From b07b41cd435991cd254957af79055080c85b346e Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 9 Aug 2026 21:08:20 +0900 Subject: feat: AI Agent Sandbox MCP Server v1を実装 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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まで生成可能にした。 --- src/Agent.Core/Runtime/AgentRuntime.cs | 165 +++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/Agent.Core/Runtime/AgentRuntime.cs (limited to 'src/Agent.Core/Runtime/AgentRuntime.cs') 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(StringComparer.Ordinal); + IReadOnlyDictionary requiredToolCalls = RequiredToolCalls(task.Request); + var successfulToolCalls = new Dictionary(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 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 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 RequiredToolCalls(string request) + { + var result = new Dictionary(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; + } +} -- cgit v1.2.3