summaryrefslogtreecommitdiff
path: root/src/Agent.Core/Runtime/AgentRuntime.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
commitb07b41cd435991cd254957af79055080c85b346e (patch)
tree27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.Core/Runtime/AgentRuntime.cs
parent2fefd29e86c3b926d0cf4a362251d3f119688612 (diff)
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'src/Agent.Core/Runtime/AgentRuntime.cs')
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs165
1 files changed, 165 insertions, 0 deletions
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
new file mode 100644
index 0000000..c2fa568
--- /dev/null
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -0,0 +1,165 @@
+using Agent.Core.Ollama;
+using Agent.Core.Prompts;
+using Agent.Core.Sessions;
+using Agent.Core.Tasks;
+using Agent.Core.Tools;
+
+namespace Agent.Core.Runtime;
+
+public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, AgentRuntimeSettings settings, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = 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;
+
+ 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: "Session not found.");
+ return;
+ }
+
+ try
+ {
+ await _tools.BeginTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
+ if (task.Images.Count > 0 && await ollama.SupportsVisionAsync(cancellationToken).ConfigureAwait(false) is false)
+ throw new InvalidOperationException(settings.VisionUnsupportedMessage);
+ var messages = PromptComposer.Compose(new PromptContext(
+ FixedPrompt.Load(),
+ settings.UserSystemPrompt,
+ settings.Locale,
+ settings.Skills,
+ settings.SandboxCapabilitySummary,
+ "Current task: " + task.Request,
+ string.Empty,
+ session.Conversation,
+ task.Request,
+ task.Images)).ToList();
+ var guard = new ToolUseGuard(settings.SameFailureLimit);
+ bool requiresExternalEffect = RequiresExternalEffect(task.Request);
+ bool externalEffectEvidence = false;
+ var unresolvedToolFailures = new Dictionary<string, string>(StringComparer.Ordinal);
+ IReadOnlyDictionary<string, int> requiredToolCalls = RequiredToolCalls(task.Request);
+ var successfulToolCalls = new Dictionary<string, int>(StringComparer.Ordinal);
+ string? finalContent = null;
+ for (int iteration = 0; iteration < settings.MaxIterations; iteration++)
+ {
+ _observer.LlmRequest(taskId, iteration, messages);
+ OllamaResult result = await ollama.ChatWithToolsAsync(messages, task.Temperature, InternalToolCatalog.All, cancellationToken).ConfigureAwait(false);
+ _observer.LlmResponse(taskId, iteration, result);
+ registry.AddUsage(taskId, result.InputTokens, result.OutputTokens);
+ IReadOnlyList<OllamaToolCall> 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;
+ try
+ {
+ toolResult = await _tools.DispatchAsync(taskId, call.Function.Name, call.Function.Arguments, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is ToolValidationException or ArgumentException or UnauthorizedAccessException)
+ {
+ toolResult = new(false, ex.Message, false, $"{call.Function.Name}:{ex.GetType().Name}:{ex.Message}");
+ }
+ externalEffectEvidence |= toolResult.Success && toolResult.ExternalEffect;
+ if (toolResult.Success) unresolvedToolFailures.Remove(call.Function.Name);
+ else unresolvedToolFailures[call.Function.Name] = toolResult.FailureFingerprint ?? toolResult.Output;
+ if (toolResult.Success) successfulToolCalls[call.Function.Name] = successfulToolCalls.GetValueOrDefault(call.Function.Name) + 1;
+ _observer.ToolResult(taskId, call.Function.Name, toolResult);
+ 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);
+ continue;
+ }
+
+ if (unresolvedToolFailures.Count > 0)
+ {
+ 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;
+ }
+ string[] missingCalls = requiredToolCalls
+ .Where(requirement => successfulToolCalls.GetValueOrDefault(requirement.Key) < requirement.Value)
+ .Select(requirement => $"{requirement.Key}: {successfulToolCalls.GetValueOrDefault(requirement.Key)}/{requirement.Value}")
+ .ToArray();
+ if (missingCalls.Length > 0)
+ {
+ messages.Add(new OllamaMessage("system", "Completion is not allowed because the request explicitly requires successful tool calls that are still missing: " + string.Join("; ", missingCalls)));
+ 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 iteration limit of {settings.MaxIterations}.");
+ 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 (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
+ string result = cleanupFailure is null ? "Cancelled." : $"Cancelled. {cleanupFailure}";
+ registry.Transition(taskId, AgentTaskState.Cancelled, result: result);
+ }
+ catch (Exception ex) when (ex is not (OutOfMemoryException or StackOverflowException or AccessViolationException))
+ {
+ string? cleanupFailure = await EndTaskSafelyAsync(taskId, false, CancellationToken.None).ConfigureAwait(false);
+ string result = cleanupFailure is null ? ex.Message : $"{ex.Message} {cleanupFailure}";
+ registry.Transition(taskId, AgentTaskState.Failed, result: result);
+ }
+ }
+
+ private async Task<string?> 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}";
+ }
+ }
+
+ private static bool RequiresExternalEffect(string request)
+ {
+ string normalized = request.ToLowerInvariant();
+ string[] indicators = ["edit", "modify", "change", "fix", "write", "delete", "create", "build", "test", "run", "修正", "変更", "編集", "作成", "削除", "ビルド", "テスト", "実行"];
+ return indicators.Any(normalized.Contains);
+ }
+
+ private static IReadOnlyDictionary<string, int> RequiredToolCalls(string request)
+ {
+ var result = new Dictionary<string, int>(StringComparer.Ordinal);
+ foreach (InternalToolDefinition tool in InternalToolCatalog.All)
+ {
+ int count = 0;
+ int offset = 0;
+ while ((offset = request.IndexOf(tool.Name, offset, StringComparison.OrdinalIgnoreCase)) >= 0)
+ {
+ count++;
+ offset += tool.Name.Length;
+ }
+ if (count > 0) result[tool.Name] = count;
+ }
+ return result;
+ }
+}