diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
| commit | b07b41cd435991cd254957af79055080c85b346e (patch) | |
| tree | 27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.Core/Runtime | |
| parent | 2fefd29e86c3b926d0cf4a362251d3f119688612 (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')
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntime.cs | 165 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntimeSettings.cs | 10 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/IAgentRuntimeObserver.cs | 19 |
3 files changed, 194 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; + } +} diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs new file mode 100644 index 0000000..9bc04b9 --- /dev/null +++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs @@ -0,0 +1,10 @@ +namespace Agent.Core.Runtime; + +public sealed record AgentRuntimeSettings( + string UserSystemPrompt, + string Locale, + IReadOnlyList<string> Skills, + string SandboxCapabilitySummary, + int MaxIterations = 128, + int SameFailureLimit = 5, + string VisionUnsupportedMessage = "The selected model does not support image input."); diff --git a/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs new file mode 100644 index 0000000..e4e26e3 --- /dev/null +++ b/src/Agent.Core/Runtime/IAgentRuntimeObserver.cs @@ -0,0 +1,19 @@ +using Agent.Core.Ollama; +using Agent.Core.Tools; + +namespace Agent.Core.Runtime; + +public interface IAgentRuntimeObserver +{ + void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages); + void LlmResponse(Guid taskId, int iteration, OllamaResult result); + void ToolResult(Guid taskId, string toolName, ToolExecutionResult result); +} + +public sealed class NullAgentRuntimeObserver : IAgentRuntimeObserver +{ + public static NullAgentRuntimeObserver Instance { get; } = new(); + public void LlmRequest(Guid taskId, int iteration, IReadOnlyList<OllamaMessage> messages) { } + public void LlmResponse(Guid taskId, int iteration, OllamaResult result) { } + public void ToolResult(Guid taskId, string toolName, ToolExecutionResult result) { } +} |
