using System.Text.Json; using Agent.Core.Ollama; using Agent.Core.Localization; using Agent.Core.Prompts; using Agent.Core.Sessions; using Agent.Core.Skills; using Agent.Core.Tasks; using Agent.Core.Tools; using Agent.Planner; namespace Agent.Core.Runtime; public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, PlannerService? plannerService = null, LocaleCatalog? locale = null) { private readonly InternalToolDispatcher _tools = toolDispatcher ?? new( new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), new FakeInternalToolBackend()); private readonly IAgentRuntimeObserver _observer = observer ?? NullAgentRuntimeObserver.Instance; private readonly PlannerService _planner = plannerService ?? new(ollama, settings); /// /// Plan → Run lifecycle for a single MCP Task. /// 1. Planning: generate Initial TaskGraph via structured output. /// 2. Running: execute ready Tasks (dependencies satisfied) in critical-path / creation order. /// Sandbox Task Resource is applied at RunAsync time, not PlanAsync time. /// public async Task ExecuteAsync(Guid taskId, CancellationToken cancellationToken) { if (!registry.TryGet(taskId, null, out TaskSnapshot? task) || task is null) return; if (!registry.Sessions.TryGet(task.SessionId, out AgentSession? session) || session is null) { registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.session_not_found", "Session not found.")); return; } try { var counters = new ExecutionCounters(); // Vision capability check before any Ollama call. if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false) throw new InvalidOperationException(settings.VisionUnsupportedMessage); // Phase 1: Planning — generate TaskGraph via structured output. TaskGraph graph; try { graph = await _planner.PlanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, task.Images, (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "planning_failed", ex.Message); registry.Transition(taskId, AgentTaskState.Failed, result: Message("runtime.planning_failed", $"Planning failed: {ex.Message}", ex.Message)); return; } // Phase 2: Running — execute ready Tasks sequentially. registry.Transition(taskId, AgentTaskState.Running, "running"); await RunAsync(taskId, task, session, graph, counters, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); if (cleanupFailure is null) { registry.Transition(taskId, AgentTaskState.Cancelled, result: Message("runtime.cancelled", "Cancelled.")); } else { registry.UpdateDiagnostics(taskId, null, null, "cleanup_failed", cleanupFailure); registry.Transition(taskId, AgentTaskState.Failed, result: cleanupFailure); } } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false); registry.TryGet(taskId, null, out TaskSnapshot? failedSnapshot); string baseResult = failedSnapshot?.LastErrorCode is not null and not "context_summary_failed" ? failedSnapshot.LastError ?? ex.Message : ex.Message; string result = cleanupFailure is null ? baseResult : $"{baseResult} {cleanupFailure}"; string errorCode = cleanupFailure is not null ? "cleanup_failed" : failedSnapshot?.LastErrorCode ?? "execution_failed"; string errorMessage = cleanupFailure is not null ? result : failedSnapshot?.LastError ?? result; registry.UpdateDiagnostics(taskId, null, null, errorCode, errorMessage); registry.Transition(taskId, AgentTaskState.Failed, result: result); } } /// /// Executes ready Tasks (dependencies satisfied) in critical-path then creation order. /// Each PlannerTask is executed as a single LLM chat-with-tools iteration loop. /// Sandbox Task Resource (BeginTask/EndTask) wraps the entire RunAsync phase. /// private async Task RunAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, ExecutionCounters counters, CancellationToken cancellationToken) { // Apply Sandbox Task Resource at RunAsync time. await _tools.BeginTaskAsync(taskId, task.SessionId, cancellationToken).ConfigureAwait(false); var finalContents = new List(); const int maxReplans = 3; var denyFingerprints = new HashSet(StringComparer.Ordinal); try { UpdateTaskProgress(taskId, graph, null, counters); // Execute tasks in ready order: dependencies satisfied → critical path → creation order. while (true) { PlannerTask? next = SelectNextReadyTask(graph); if (next is null) { if (graph.Tasks.All(static item => item.State == PlannerTaskState.Completed)) break; throw new InvalidOperationException("TaskGraph has incomplete Tasks but no executable Task."); } graph.SetState(next.Id, PlannerTaskState.Running); UpdateTaskProgress(taskId, graph, next.Description, counters); try { string content = await ExecutePlannerTaskAsync(taskId, task, session, graph, next, counters, cancellationToken).ConfigureAwait(false); graph.SetState(next.Id, PlannerTaskState.Completed); finalContents.Add(content); UpdateTaskProgress(taskId, graph, null, counters); } catch (PolicyDeniedException ex) { graph.SetState(next.Id, PlannerTaskState.Failed); string localizedDenial = LocalizePolicyDenial(ex); registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, ex.ErrorCode, localizedDenial); if (!denyFingerprints.Add(ex.Fingerprint)) { string repeated = Message("runtime.policy_repeated", $"The same sandbox policy denial occurred again: {ex.Message}"); registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy.repeated", repeated); throw new InvalidOperationException(repeated); } if (counters.Replans >= maxReplans) { string exhausted = Message("runtime.policy_replan_exhausted", $"Sandbox policy denial remained unresolved after {maxReplans} replans: {ex.Message}", maxReplans.ToString(System.Globalization.CultureInfo.InvariantCulture)); registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "policy.replan_exhausted", exhausted); throw new InvalidOperationException(exhausted); } string previousSignature = GraphSignature(graph); counters.Replans++; registry.Transition(taskId, AgentTaskState.Planning, "replan"); graph = await _planner.ReplanAsync(taskId, task.SessionId, session, task.Request, task.Temperature, ex.Message, (phase, messages, result, error) => RecordLlmAttempt(task, counters, phase, messages, result, error), cancellationToken).ConfigureAwait(false); if (string.Equals(previousSignature, GraphSignature(graph), StringComparison.Ordinal)) { string unchanged = Message("runtime.replan_unchanged", "Replan did not change the incomplete TaskGraph."); registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "planning.replan_unchanged", unchanged); throw new InvalidOperationException(unchanged); } if (!registry.Transition(taskId, AgentTaskState.Running, "replan")) throw new InvalidOperationException("Task state could not return to running after replan."); UpdateTaskProgress(taskId, graph, null, counters); continue; } } if (finalContents.Count == 0) throw new InvalidOperationException("TaskGraph completed without producing a result."); string finalContent = string.Join("\n\n", finalContents); string? cleanupFailure = await EndTaskSafelyAsync(taskId, true, CancellationToken.None).ConfigureAwait(false); if (cleanupFailure is not null) throw new InvalidOperationException(cleanupFailure); session.Append(new ConversationMessage("user", task.Request)); session.Append(new ConversationMessage("assistant", finalContent)); registry.Transition(taskId, AgentTaskState.Completed, result: finalContent); } catch { // Re-throw without double-cleanup; ExecuteAsync's catch handles EndTaskSafelyAsync. throw; } } /// /// Selects the next ready Task: dependencies satisfied, not yet completed/failed. /// Priority: critical path first, then creation order. /// private static PlannerTask? SelectNextReadyTask(TaskGraph graph) { var completedIds = graph.Tasks .Where(static t => t.State == PlannerTaskState.Completed) .Select(static t => t.Id) .ToHashSet(); // Critical path task IDs in order. var criticalPath = new HashSet(graph.CriticalPath.TaskIds); // Ready = not completed/failed/running and all dependencies completed. var ready = graph.Tasks .Where(t => t.State is not (PlannerTaskState.Completed or PlannerTaskState.Failed or PlannerTaskState.Running)) .Where(t => t.Dependencies.All(depId => completedIds.Contains(depId))) .ToList(); if (ready.Count == 0) return null; // Prefer critical path tasks, then creation order (Tasks list is in insertion order). return ready.FirstOrDefault(t => criticalPath.Contains(t.Id)) ?? ready[0]; } /// /// Executes a single PlannerTask: LLM chat-with-tools iteration loop. /// Uses PlannerTask.RequiresExternalEffect (not keyword heuristic) for ToolUseGuard. /// private async Task ExecutePlannerTaskAsync(Guid taskId, TaskSnapshot task, AgentSession session, TaskGraph graph, PlannerTask plannerTask, ExecutionCounters counters, CancellationToken cancellationToken) { // Progressive Disclosure Stage 2: LLM Skill Selection. IReadOnlyList selectedSkills = await SelectSkillsAsync(task, counters, cancellationToken).ConfigureAwait(false); var messages = PromptComposer.Compose(new PromptContext( FixedPrompt.Load(), settings.UserSystemPrompt, settings.Locale, settings.Skills, selectedSkills, settings.SandboxCapabilitySummary, BuildTaskGraphSummary(graph, plannerTask), session.Summary ?? string.Empty, session.ExecutionResults, session.Conversation, task.Request, plannerTask.Description, plannerTask.RequiresExternalEffect, task.Images)).ToList(); var guard = new ToolUseGuard(settings.SameFailureLimit); bool requiresExternalEffect = plannerTask.RequiresExternalEffect; bool externalEffectEvidence = false; var unresolvedToolFailures = new Dictionary(StringComparer.Ordinal); string? finalContent = null; while (counters.Iterations < settings.MaxIterations) { int iteration = counters.Iterations++; registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, plannerTask.Id, "running", iteration, counters.Replans, settings.Model); _observer.LlmRequest(callContext, messages); OllamaResult result; try { result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { _observer.LlmFailure(callContext, "ollama_call_failed", ex.Message); throw; } _observer.LlmResponse(callContext, result); registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); // Record usage in TaskGraph for CPM tracking. graph.AddUsage(plannerTask.Id, result.InputTokens, result.OutputTokens); // Context Summary: compact old conversation/results when context utilization exceeds threshold. // Usage = latest prompt_eval_count / num_ctx (NOT cumulative InputTokens). await TryContextSummaryAsync(taskId, task, session, result.PromptEvalCount, counters, cancellationToken).ConfigureAwait(false); IReadOnlyList calls = result.ToolCalls ?? []; if (calls.Count > 0) { messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls)); string? failureFingerprint = null; foreach (OllamaToolCall call in calls) { ToolExecutionResult toolResult; bool outOfScopeEffect = IsOutOfScopeEffectCall(call, plannerTask.RequiredEffectTools); if (outOfScopeEffect) { toolResult = new(false, $"The '{call.Function.Name}' external effect belongs to another Planner Task. Perform only the current Task; required effect Tool(s): {string.Join(", ", plannerTask.RequiredEffectTools.Order(StringComparer.Ordinal))}.", false, $"task_scope:{plannerTask.Id}:{call.Function.Name}"); } else try { toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false); } catch (UnauthorizedAccessException ex) { toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}", RetryAllowed: false); } catch (Exception ex) when (ex is ToolValidationException or ArgumentException or PlannerValidationException or FileNotFoundException) { toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}"); } externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect && plannerTask.RequiredEffectTools.Contains(call.Function.Name); // task_update is a metadata tool for graph bookkeeping; its failures should not block completion. bool isMetadataTool = call.Function.Name == "task_update"; if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name); else if (!isMetadataTool && !outOfScopeEffect) unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output; // Record ExecutionResult in session for context summary. session.AppendExecutionResult(new ExecutionResult(taskId, call.Function.Name, toolResult.Success, toolResult.Output)); _observer.ToolResult(callContext, call.Function.Name, toolResult); if (!toolResult.Success && !toolResult.RetryAllowed && !isMetadataTool) throw new PolicyDeniedException(toolResult.FailureFingerprint ?? $"policy:{call.Function.Name}:{toolResult.Output}", toolResult.Output, toolResult.ErrorCode ?? "policy_denied", toolResult.ErrorCategory, toolResult.TargetPath, toolResult.RequiredPermission, toolResult.Resolution); failureFingerprint = toolResult.FailureFingerprint ?? failureFingerprint; messages.Add(new OllamaMessage("tool", toolResult.Output, ToolName: call.Function.Name)); } ToolUseGuardResult callGuard = guard.Evaluate(requiresExternalEffect, true, externalEffectEvidence, failureFingerprint); if (callGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(callGuard.Observation); if (callGuard.Decision == ToolUseGuardDecision.Retry) messages.Add(new OllamaMessage("system", callGuard.Observation + " Required effect Tool(s) for the current Task: " + string.Join(", ", plannerTask.RequiredEffectTools.Order(StringComparer.Ordinal)) + ".")); continue; } if (unresolvedToolFailures.Count > 0) { string unresolvedFingerprint = string.Join(";", unresolvedToolFailures.OrderBy(static item => item.Key, StringComparer.Ordinal) .Select(static item => $"{item.Key}={item.Value}")); ToolUseGuardResult unresolvedGuard = guard.Evaluate(true, false, false, unresolvedFingerprint); if (unresolvedGuard.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(unresolvedGuard.Observation); messages.Add(new OllamaMessage("system", "Completion is not allowed while tool failures remain unresolved. Retry or correct: " + string.Join("; ", unresolvedToolFailures.Select(static item => $"{item.Key}={item.Value}")))); continue; } ToolUseGuardResult completion = guard.Evaluate(requiresExternalEffect, false, externalEffectEvidence); if (completion.Decision == ToolUseGuardDecision.Retry) { messages.Add(new OllamaMessage("system", completion.Observation)); continue; } if (completion.Decision == ToolUseGuardDecision.Fail) throw new InvalidOperationException(completion.Observation); finalContent = result.Content; break; } if (finalContent is null) throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations}."); return finalContent; } private static bool IsOutOfScopeEffectCall(OllamaToolCall call, IReadOnlySet requiredEffectTools) { bool createsExternalEffect = call.Function.Name is "file_edit" or "process_run" || call.Function.Name == "dir" && call.Function.Arguments.TryGetProperty("operation", out JsonElement operation) && operation.GetString() is "mkdir" or "rmdir"; return createsExternalEffect && !requiredEffectTools.Contains(call.Function.Name); } /// /// Builds a TaskGraph summary for the prompt context (Design §9.2 item 6). /// Shows the current task position within the overall graph. /// private static string BuildTaskGraphSummary(TaskGraph graph, PlannerTask currentTask) { var sb = new System.Text.StringBuilder(); sb.AppendLine("Current TaskGraph:"); for (int i = 0; i < graph.Tasks.Count; i++) { PlannerTask t = graph.Tasks[i]; string marker = t.Id == currentTask.Id ? "→ " : " "; sb.AppendLine($"{marker}[{t.State}] {t.Description}"); } sb.AppendLine($"Critical path tokens: {graph.CriticalPath.CumulativeTokenCost:F0}"); return sb.ToString(); } /// /// Context Summary: compacts old Conversation/ExecutionResults when context utilization exceeds threshold. /// Usage = latest prompt_eval_count / num_ctx (Design §9.3). NOT cumulative InputTokens. /// Preserves: user constraints, incomplete tasks, completed task results, failure causes, security denials. /// private async Task TryContextSummaryAsync(Guid taskId, TaskSnapshot task, AgentSession session, long promptEvalCount, ExecutionCounters counters, CancellationToken cancellationToken) { if (settings.NumCtx <= 0) return; double utilization = (double)promptEvalCount / settings.NumCtx; if (utilization < settings.ContextSummaryThreshold) return; string source = JsonSerializer.Serialize(new { previous_summary = session.Summary, conversation = session.Conversation, execution_results = session.ExecutionResults }); var messages = new List { new("system", "Summarize context without chain-of-thought. Preserve user constraints, incomplete Tasks, important completed results, changed files, verification results, failure reasons, retry counts, and security denials. Return JSON with a non-empty 'summary' string."), new("user", source) }; using JsonDocument schema = JsonDocument.Parse("""{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}"""); AgentLlmCallContext? callContext = null; try { if (counters.Iterations >= settings.MaxIterations) return; int iteration = counters.Iterations++; callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, taskId, null, "context_summary", iteration, counters.Replans, settings.Model); _observer.LlmRequest(callContext, messages); OllamaResult result = await ollama.ChatStructuredAsync(messages, task.Temperature, schema.RootElement.Clone(), false, cancellationToken).ConfigureAwait(false); _observer.LlmResponse(callContext, result); registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); using JsonDocument response = JsonDocument.Parse(result.Content); string? summary = response.RootElement.GetProperty("summary").GetString(); if (string.IsNullOrWhiteSpace(summary)) return; session.SetSummary(summary); session.CompactConversation(settings.ConversationKeepLastN); session.CompactExecutionResults(settings.ExecutionResultsKeepLastN); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { if (callContext is not null) _observer.LlmFailure(callContext, "context_summary_failed", ex.Message); registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans, "context_summary_failed", ex.Message); } } private async Task> SelectSkillsAsync(TaskSnapshot task, ExecutionCounters counters, CancellationToken cancellationToken) { if (settings.Skills.Count == 0) return []; string catalog = string.Join("\n", settings.Skills.Select(static s => $"- {s.Name}: {s.Description}")); var selectionMessages = new List { new("system", $"Select skills relevant to the user request. Available skills:\n{catalog}\n\nReturn a JSON object with a 'selected_skills' array containing skill names. Select only relevant skills. If none are relevant, return an empty array."), new("user", task.Request) }; string schema = """{"type":"object","properties":{"selected_skills":{"type":"array","items":{"type":"string"}}},"required":["selected_skills"]}"""; using JsonDocument formatDoc = JsonDocument.Parse(schema); OllamaResult result; AgentLlmCallContext? callContext = null; try { if (counters.Iterations >= settings.MaxIterations) return []; int iteration = counters.Iterations++; callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null, "skill_selection", iteration, counters.Replans, settings.Model); _observer.LlmRequest(callContext, selectionMessages); result = await ollama.ChatStructuredAsync(selectionMessages, task.Temperature, formatDoc.RootElement.Clone(), cancellationToken).ConfigureAwait(false); _observer.LlmResponse(callContext, result); registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens); registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans); } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { if (callContext is not null) _observer.LlmFailure(callContext, "skill_selection_failed", ex.Message); return []; } return ParseSelectedSkills(result.Content); } private void RecordLlmAttempt(TaskSnapshot task, ExecutionCounters counters, string phase, IReadOnlyList messages, OllamaResult? result, Exception? error) { if (counters.Iterations >= settings.MaxIterations) throw new InvalidOperationException($"Agent exceeded the Task iteration limit of {settings.MaxIterations} during {phase}."); counters.Iterations++; int iteration = counters.Iterations - 1; var callContext = new AgentLlmCallContext(Guid.NewGuid(), task.SessionId, task.TaskId, null, phase, iteration, counters.Replans, settings.Model); _observer.LlmRequest(callContext, messages); if (result is not null) { _observer.LlmResponse(callContext, result); registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens); } if (error is not null) _observer.LlmFailure(callContext, "ollama_call_failed", error.Message); registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans); } private void UpdateTaskProgress(Guid taskId, TaskGraph graph, string? currentTask, ExecutionCounters counters) { int completed = graph.Tasks.Count(static item => item.State == PlannerTaskState.Completed); registry.UpdateProgress(taskId, completed, graph.Count, currentTask, graph.CriticalPath.CumulativeTokenCost); registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); } private static string GraphSignature(TaskGraph graph) { IReadOnlyDictionary descriptions = graph.Tasks.ToDictionary(static item => item.Id, static item => item.Description); return string.Join("\n", graph.Tasks .Where(static item => item.State != PlannerTaskState.Completed) .Select(item => $"{item.Description}|{item.RequiresExternalEffect}|{string.Join(',', item.Dependencies.Select(id => descriptions.GetValueOrDefault(id, id.ToString())).Order(StringComparer.Ordinal))}")); } private sealed class ExecutionCounters { public int Iterations { get; set; } public int Replans { get; set; } } private string Message(string key, string fallback, string? detail = null) { if (locale is null) return fallback; return detail is null ? locale.Get(key) : locale.Format(key, new Dictionary { ["message"] = detail }); } private sealed class PolicyDeniedException(string fingerprint, string message, string errorCode, string? errorCategory, string? targetPath, string? requiredPermission, string? resolution) : Exception(message) { public string Fingerprint { get; } = fingerprint; public string ErrorCode { get; } = errorCode; public string? ErrorCategory { get; } = errorCategory; public string? TargetPath { get; } = targetPath; public string? RequiredPermission { get; } = requiredPermission; public string? Resolution { get; } = resolution; } private string LocalizePolicyDenial(PolicyDeniedException error) { if (locale is null) return error.Message; return locale.Format("runtime.policy_denied", new Dictionary { ["code"] = error.ErrorCode, ["path"] = error.TargetPath ?? "-", ["permission"] = error.RequiredPermission ?? "-", ["resolution"] = error.Resolution ?? "-" }); } private IReadOnlyList ParseSelectedSkills(string content) { try { using JsonDocument doc = JsonDocument.Parse(content); if (!doc.RootElement.TryGetProperty("selected_skills", out JsonElement skillsElement)) return []; var selectedNames = new HashSet(StringComparer.Ordinal); foreach (JsonElement item in skillsElement.EnumerateArray()) { string? name = item.GetString(); if (!string.IsNullOrWhiteSpace(name)) selectedNames.Add(name); } var valid = new List(); foreach (AgentSkill skill in settings.Skills) { if (selectedNames.Contains(skill.Name)) { valid.Add(skill); if (valid.Count >= 10) break; } } return valid; } catch { return []; } } private async Task EndTaskSafelyAsync(Guid taskId, bool success, CancellationToken cancellationToken) { try { await _tools.EndTaskAsync(taskId, success, cancellationToken).ConfigureAwait(false); return null; } catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { return $"Task resource cleanup failed: {ex.Message}"; } } }