using System.Collections.Concurrent; using System.Text.Json; using Agent.Core.Sessions; using Agent.Planner; namespace Agent.Core.Tools; public interface ITaskUpdateHandler { void BeginTask(Guid taskId, Guid sessionId); ToolExecutionResult Execute(Guid taskId, JsonElement arguments); void EndTask(Guid taskId); } public sealed class TaskGraphToolHandler(int maxTasks = 32, SessionStore? sessions = null) : ITaskUpdateHandler { private readonly ConcurrentDictionary _fallbackGraphs = new(); public void BeginTask(Guid taskId, Guid sessionId) { TaskGraph graph = new(maxTasks); if (sessions is not null && sessions.TryGet(sessionId, out AgentSession? session) && session is not null) { if (!session.TaskGraphs.TryAdd(taskId, graph)) throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists in session '{sessionId}'."); } else { if (!_fallbackGraphs.TryAdd(taskId, graph)) throw new InvalidOperationException($"TaskGraph for '{taskId}' already exists."); } } public TaskGraph? GetGraph(Guid taskId, Guid? sessionId = null) { if (sessionId is not null && sessions is not null && sessions.TryGet(sessionId.Value, out AgentSession? session) && session is not null) return session.TaskGraphs.TryGetValue(taskId, out TaskGraph? g) ? g : null; return _fallbackGraphs.TryGetValue(taskId, out TaskGraph? fallback) ? fallback : null; } public ToolExecutionResult Execute(Guid taskId, JsonElement arguments) { TaskGraph? graph = ResolveGraph(taskId) ?? throw new InvalidOperationException("TaskGraph scope is not active."); string operation = arguments.GetProperty("operation").GetString()!; switch (operation) { case "add": graph.AddTask( RequiredString(arguments, "description"), Budget(arguments), Estimate(arguments), Ids(arguments, "depends_on"), requiresExternalEffect: OptionalBool(arguments, "requires_external_effect"), id: OptionalId(arguments, "task_id")); break; case "add_dependency": Guid taskIdValue = RequiredId(arguments, "task_id"); Guid[] dependencies = Ids(arguments, "depends_on"); if (dependencies.Length == 0) throw new ToolValidationException("depends_on must contain at least one task id."); foreach (Guid dependency in dependencies) graph.AddDependency(taskIdValue, dependency); break; case "set_state": graph.SetState(RequiredId(arguments, "task_id"), ParseState(RequiredString(arguments, "state"))); break; case "set_estimate": graph.SetEstimate(RequiredId(arguments, "task_id"), Budget(arguments), Estimate(arguments)); break; default: throw new ToolValidationException($"Unsupported task_update operation '{operation}'."); } string output = JsonSerializer.Serialize(new { tasks = graph.Tasks.Select(static task => new { task_id = task.Id, description = task.Description, depends_on = task.Dependencies, state = task.State.ToString().ToLowerInvariant(), expected_tokens = task.ExpectedTokens, requires_external_effect = task.RequiresExternalEffect }), critical_path = graph.CriticalPath.TaskIds, critical_path_tokens = graph.CriticalPath.CumulativeTokenCost, total_token_budget = graph.TotalTokenBudget }); return new ToolExecutionResult(true, output, false); } public void EndTask(Guid taskId) { // Remove from fallback dict; session-owned graphs persist for context summary. _fallbackGraphs.TryRemove(taskId, out _); } private TaskGraph? ResolveGraph(Guid taskId) { // Search all sessions first, then fallback. if (sessions is not null) { foreach (AgentSession? session in sessions.Sessions) { if (session is not null && session.TaskGraphs.TryGetValue(taskId, out TaskGraph? g)) return g; } } return _fallbackGraphs.TryGetValue(taskId, out TaskGraph? fallback) ? fallback : null; } private static bool OptionalBool(JsonElement arguments, string property) { if (!arguments.TryGetProperty(property, out JsonElement value)) return false; return value.ValueKind == JsonValueKind.True; } private static TokenBudgetClass Budget(JsonElement arguments) => TokenBudgetClassExtensions.Parse(RequiredString(arguments, "budget_class")); private static TokenEstimate Estimate(JsonElement arguments) => new( RequiredInt64(arguments, "optimistic_tokens"), RequiredInt64(arguments, "most_likely_tokens"), RequiredInt64(arguments, "pessimistic_tokens")); private static string RequiredString(JsonElement arguments, string property) => arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString()) ? value.GetString()! : throw new ToolValidationException($"{property} is required for this operation."); private static long RequiredInt64(JsonElement arguments, string property) => arguments.TryGetProperty(property, out JsonElement value) && value.ValueKind == JsonValueKind.Number ? value.GetInt64() : throw new ToolValidationException($"{property} is required for this operation."); private static Guid RequiredId(JsonElement arguments, string property) => OptionalId(arguments, property) ?? throw new ToolValidationException($"{property} must be a UUID for this operation."); private static Guid? OptionalId(JsonElement arguments, string property) { if (!arguments.TryGetProperty(property, out JsonElement value) || value.ValueKind == JsonValueKind.Null) return null; return value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out Guid id) ? id : throw new ToolValidationException($"{property} must be a UUID."); } private static Guid[] Ids(JsonElement arguments, string property) { if (!arguments.TryGetProperty(property, out JsonElement value)) return []; var ids = new List(); foreach (JsonElement item in value.EnumerateArray()) ids.Add(Guid.TryParse(item.GetString(), out Guid id) ? id : throw new ToolValidationException($"{property} must contain only UUIDs.")); return [.. ids]; } private static PlannerTaskState ParseState(string state) => state switch { "queued" => PlannerTaskState.Queued, "planning" => PlannerTaskState.Planning, "running" => PlannerTaskState.Running, "completed" => PlannerTaskState.Completed, "failed" => PlannerTaskState.Failed, "cancelled" => PlannerTaskState.Cancelled, _ => throw new ToolValidationException($"Unknown task state '{state}'.") }; }