using Agent.Core.Ollama; using Agent.Core.Prompts; using Agent.Core.Runtime; using Agent.Core.Sessions; using Agent.Core.Skills; using Agent.Core.Tasks; using Agent.Core.Tools; using System.Text.Json; namespace Agent.Core.Tests; public sealed class PromptAndRuntimeTests { [Fact] public void Prompt_order_is_fixed_and_request_is_last() { var availableSkills = new List { new("root", "test-skill", "A test skill.", "skill instructions") }; IReadOnlyList messages = PromptComposer.Compose(new PromptContext( "fixed", "user system", "ja", availableSkills, [], "capability", "graph", "summary", [new ExecutionResult(Guid.NewGuid(), "file_read", true, "verified content")], [new ConversationMessage("user", "old"), new ConversationMessage("assistant", "reply")], "original request with C:/exact/path", "new request", true, ["image"])); // Check that the skill catalog (name + description) is present, not the full instructions. Assert.Contains(messages, m => m.Content.Contains("test-skill") && m.Content.Contains("A test skill.")); Assert.DoesNotContain(messages, m => m.Content == "skill instructions"); Assert.Contains(messages, m => m.Role == "system" && m.Content.Contains("requires a verified external effect", StringComparison.Ordinal)); Assert.Contains(messages, m => m.Role == "system" && m.Content.Contains("fenced JSON example does not perform", StringComparison.Ordinal)); Assert.Contains(messages, m => m.Role == "system" && m.Content.Contains("do not perform Queued Tasks", StringComparison.Ordinal)); Assert.Contains(messages, m => m.Role == "system" && m.Content.Contains("file_read (success): verified content", StringComparison.Ordinal)); Assert.Contains(messages, m => m.Role == "system" && m.Content.Contains("original request with C:/exact/path", StringComparison.Ordinal)); Assert.Equal("system", messages[0].Role); Assert.Equal("user", messages[^1].Role); Assert.Contains("CURRENT RUNNING PLANNER TASK ONLY", messages[^1].Content, StringComparison.Ordinal); Assert.Contains("original request with C:/exact/path", messages[^1].Content, StringComparison.Ordinal); Assert.Equal(["image"], messages[^1].Images); } [Fact] public async Task Runtime_continues_session_and_accumulates_usage() { var sessions = new SessionStore(); var registry = new TaskRegistry(sessions); var ollama = new RecordingOllamaClient(); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("custom", "ja", [], "sandbox")); TaskSnapshot first = registry.Enqueue("first", null, 0.5); Assert.True(registry.Transition(first.TaskId, AgentTaskState.Planning)); Assert.True(registry.Transition(first.TaskId, AgentTaskState.Running)); await runtime.ExecuteAsync(first.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(first.TaskId, null, out TaskSnapshot? completed)); Assert.True(completed!.State == AgentTaskState.Completed, completed.Result); Assert.Equal(6, completed.InputTokens); Assert.Equal(4, completed.OutputTokens); TaskSnapshot second = registry.Enqueue("second", first.SessionId, null); Assert.True(registry.Transition(second.TaskId, AgentTaskState.Planning)); Assert.True(registry.Transition(second.TaskId, AgentTaskState.Running)); await runtime.ExecuteAsync(second.TaskId, TestContext.Current.CancellationToken); // 4 calls: Plan + Run for each ExecuteAsync (Skills empty, so no skill selection call) Assert.Equal(4, ollama.Calls.Count); // Second Run (index 3) should contain session conversation from first task Assert.Contains(ollama.Calls[3], message => message.Role == "user" && message.Content == "first"); // First Run returns answer-2 (Plan=answer-1, Run=answer-2 due to call ordering) Assert.Contains(ollama.Calls[3], message => message.Role == "assistant" && message.Content == "answer-2"); // The task-scoped temperature is propagated to Planning and Run calls. Assert.Equal(0.5, ollama.Temperatures[0]); Assert.Equal(0.5, ollama.Temperatures[1]); } [Fact] public void Session_store_is_process_local() { SessionStore first = new(); Guid id = first.Create().SessionId; SessionStore restarted = new(); Assert.False(restarted.TryGet(id, out _)); } [Fact] public async Task Non_vision_model_rejects_images_with_selected_locale_message_before_chat() { TaskRegistry registry = new(); var ollama = new NonVisionOllamaClient(); const string localized = "選択したモデルは画像入力に対応していません。"; var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "ja", [], "sandbox", VisionUnsupportedMessage: localized)); TaskSnapshot task = registry.Enqueue("画像を確認", null, null, ["base64-image"]); registry.Transition(task.TaskId, AgentTaskState.Planning); registry.Transition(task.TaskId, AgentTaskState.Running); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); Assert.Equal(AgentTaskState.Failed, failed!.State); Assert.Equal(localized, failed.Result); Assert.Equal(0, ollama.ChatCalls); } [Fact] public async Task Runtime_dispatches_native_tool_call_then_completes_only_after_effect_evidence() { TaskRegistry registry = new(); FakeInternalToolBackend backend = new(); var dispatcher = new InternalToolDispatcher( new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend); var ollama = new ToolCallingOllamaClient(); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "ja", [], "sandbox"), dispatcher); TaskSnapshot task = registry.Enqueue("fileを修正", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); registry.Transition(task.TaskId, AgentTaskState.Running); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); Assert.True(completed!.State == AgentTaskState.Completed, $"{completed.Result}; backend={backend.Calls.Count}:{string.Join(',', backend.Calls.Select(x => x.Name))}; ollama={ollama.CallCount}"); Assert.Equal("修正しました。", completed.Result); Assert.Single(backend.Calls); Assert.Equal("file_edit", backend.Calls[0].Name); Assert.Equal(2, ollama.CallCount); Assert.Equal(InternalToolCatalog.All.Count, ollama.ToolCount); Assert.True(ollama.ThinkingPreservedForToolContinuation); } [Fact] public async Task Runtime_requires_the_planned_effect_tool_not_an_unrelated_effect() { TaskRegistry registry = new(); FakeInternalToolBackend backend = new(); var dispatcher = new InternalToolDispatcher( new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend); var runtime = new AgentRuntime(registry, new MismatchedThenRequiredEffectOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); TaskSnapshot task = registry.Enqueue("run build", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); Assert.Equal(AgentTaskState.Completed, completed!.State); Assert.Equal(["process_run"], backend.Calls.Select(static call => call.Name)); } [Fact] public async Task Runtime_requires_read_tool_evidence_for_a_read_task() { TaskRegistry registry = new(); FakeInternalToolBackend backend = new(); var dispatcher = new InternalToolDispatcher( new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend); var client = new RequiredReadOllamaClient(); var runtime = new AgentRuntime(registry, client, new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); TaskSnapshot task = registry.Enqueue("read file", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); Assert.Equal(AgentTaskState.Completed, completed!.State); Assert.Equal(3, client.RunCalls); Assert.Equal(["file_read"], backend.Calls.Select(static call => call.Name)); } [Fact] public async Task Runtime_reaches_terminal_failed_state_when_resource_cleanup_fails() { TaskRegistry registry = new(); var backend = new CleanupFailingBackend(); var dispatcher = new InternalToolDispatcher(new DenyByDefaultPolicy(), backend); var runtime = new AgentRuntime(registry, new RecordingOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); TaskSnapshot task = registry.Enqueue("analyze", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); registry.Transition(task.TaskId, AgentTaskState.Running); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); Assert.Equal(AgentTaskState.Failed, failed!.State); Assert.Contains("cleanup failed", failed.Result, StringComparison.OrdinalIgnoreCase); Assert.Equal(2, backend.EndCalls); } [Fact] public async Task Runtime_cannot_complete_with_an_unresolved_tool_failure() { TaskRegistry registry = new(); var dispatcher = new InternalToolDispatcher( new DenyByDefaultPolicy(new HashSet(["process_run"], StringComparer.Ordinal)), new FailingProcessBackend()); var runtime = new AgentRuntime(registry, new FailingThenFinalOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox", MaxIterations: 10, SameFailureLimit: 2), dispatcher); TaskSnapshot task = registry.Enqueue("run build", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); registry.Transition(task.TaskId, AgentTaskState.Running); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); Assert.Equal(AgentTaskState.Failed, failed!.State); Assert.Contains("required operation", failed.Result, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Runtime_does_not_retry_a_sandbox_policy_denial() { TaskRegistry registry = new(); var backend = new UnauthorizedBackend(); var dispatcher = new InternalToolDispatcher( new DenyByDefaultPolicy(new HashSet(["file_search"], StringComparer.Ordinal)), backend); var ollama = new FileSearchOllamaClient(); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); TaskSnapshot task = registry.Enqueue("research regional curry", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); registry.Transition(task.TaskId, AgentTaskState.Running); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); Assert.Equal(AgentTaskState.Failed, failed!.State); Assert.Contains("Replan did not change", failed.Result, StringComparison.OrdinalIgnoreCase); Assert.Equal("planning.replan_unchanged", failed.LastErrorCode); Assert.Contains("Replan did not change", failed.LastError, StringComparison.OrdinalIgnoreCase); Assert.True(ollama.CallCount >= 1, $"Expected at least 1 call, got {ollama.CallCount}"); Assert.True(backend.CallCount >= 1, $"Expected at least 1 backend call, got {backend.CallCount}"); } [Fact] public async Task Policy_denial_replanning_is_limited_to_three_changed_plans() { TaskRegistry registry = new(); var ollama = new ChangingPolicyPlanOllamaClient(); var backend = new ChangingPolicyBackend(); var dispatcher = new InternalToolDispatcher( new DenyByDefaultPolicy(new HashSet(["file_search"], StringComparer.Ordinal)), backend); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); TaskSnapshot task = registry.Enqueue("search", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); Assert.Equal(AgentTaskState.Failed, failed!.State); Assert.Equal(3, failed.Replans); Assert.Equal(4, backend.CallCount); Assert.Contains("3 replans", failed.Result, StringComparison.Ordinal); } [Fact] public async Task Repeated_identical_policy_denial_stops_after_one_replan() { TaskRegistry registry = new(); var ollama = new ChangingPolicyPlanOllamaClient(sameDeny: true); var backend = new ChangingPolicyBackend(); var dispatcher = new InternalToolDispatcher( new DenyByDefaultPolicy(new HashSet(["file_search"], StringComparer.Ordinal)), backend); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); TaskSnapshot task = registry.Enqueue("search", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); Assert.Equal(AgentTaskState.Failed, failed!.State); Assert.Equal(1, failed.Replans); Assert.Equal(2, backend.CallCount); Assert.Contains("same sandbox policy denial", failed.Result, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Invalid_structured_plan_retries_once_without_disabling_configured_thinking() { TaskRegistry registry = new(); var ollama = new ThinkRetryOllamaClient(succeedOnRetry: true); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("execution-only-marker", "en", [], "sandbox")); TaskSnapshot task = registry.Enqueue("analyze", null, 0.7); registry.Transition(task.TaskId, AgentTaskState.Planning); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); Assert.Equal(AgentTaskState.Completed, completed!.State); Assert.Equal([null, null], ollama.ThinkOverrides); Assert.All(ollama.Temperatures, value => Assert.Equal(0.7, value)); Assert.Contains(ollama.PlanningMessages[0], message => message.Role == "system" && message.Content.Contains("There is no file_write Tool", StringComparison.Ordinal)); Assert.Contains(ollama.PlanningMessages[0], message => message.Role == "system" && message.Content.Contains("one implementation-and-verification Task", StringComparison.Ordinal)); Assert.Contains(ollama.PlanningMessages[1], message => message.Role == "system" && message.Content.Contains("Do not call or execute any Tool", StringComparison.Ordinal)); Assert.DoesNotContain(ollama.PlanningMessages.SelectMany(static messages => messages), message => message.Content.Contains("execution-only-marker", StringComparison.Ordinal)); } [Fact] public async Task Invalid_structured_plan_after_retry_fails_without_running_tools() { TaskRegistry registry = new(); var ollama = new ThinkRetryOllamaClient(succeedOnRetry: false); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox")); TaskSnapshot task = registry.Enqueue("change a file", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); Assert.Equal(AgentTaskState.Failed, failed!.State); Assert.Contains("one corrected retry", failed.Result, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, ollama.RunCalls); } [Fact] public async Task Split_coding_plan_is_rejected_and_retried_with_specific_correction() { TaskRegistry registry = new(); var ollama = new ThinkRetryOllamaClient(succeedOnRetry: true, semanticSplitFirst: true); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox")); TaskSnapshot task = registry.Enqueue("implement and test", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); Assert.Equal(AgentTaskState.Completed, completed!.State); Assert.Contains(ollama.PlanningMessages[1], message => message.Role == "system" && message.Content.Contains("cannot depend on a separate file_edit Task", StringComparison.Ordinal)); } [Fact] public async Task Failed_context_summary_does_not_delete_original_context() { var sessions = new SessionStore(); AgentSession session = sessions.Create(); for (int index = 0; index < 10; index++) session.Append(new ConversationMessage("user", $"constraint-{index}")); var registry = new TaskRegistry(sessions); var runtime = new AgentRuntime(registry, new SummaryOllamaClient(summarySucceeds: false), new AgentRuntimeSettings("", "en", [], "sandbox", NumCtx: 100, ContextSummaryThreshold: 0.8)); TaskSnapshot task = registry.Enqueue("analyze", session.SessionId, null); registry.Transition(task.TaskId, AgentTaskState.Planning); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.Null(session.Summary); Assert.Contains(session.Conversation, message => message.Content == "constraint-0"); } [Fact] public async Task Successful_context_summary_preserves_required_categories_before_compaction() { TaskRegistry registry = new(); var ollama = new SummaryOllamaClient(summarySucceeds: true); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox", NumCtx: 100, ContextSummaryThreshold: 0.8, ConversationKeepLastN: 2)); TaskSnapshot task = registry.Enqueue("analyze", null, null); Assert.True(registry.Sessions.TryGet(task.SessionId, out AgentSession? session)); for (int i = 0; i < 10; i++) session!.Append(new ConversationMessage("user", $"constraint-{i}")); registry.Transition(task.TaskId, AgentTaskState.Planning); await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); Assert.Equal("preserved constraints", session!.Summary); Assert.True(session.Conversation.Count <= 4); string summaryPrompt = string.Join("\n", ollama.SummaryRequest!.Select(static message => message.Content)); Assert.Contains("incomplete Tasks", summaryPrompt, StringComparison.Ordinal); Assert.Contains("security denials", summaryPrompt, StringComparison.Ordinal); Assert.Contains("verification results", summaryPrompt, StringComparison.Ordinal); Assert.Contains("constraint-0", summaryPrompt, StringComparison.Ordinal); } [Fact] public async Task Running_task_becomes_cancelled_only_after_cleanup_finishes() { TaskRegistry registry = new(); var backend = new BlockingCleanupBackend(); var dispatcher = new InternalToolDispatcher(new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend); var runtime = new AgentRuntime(registry, new CancellableOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); TaskSnapshot task = registry.Enqueue("wait", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); Task execution = runtime.ExecuteAsync(task.TaskId, registry.GetCancellationToken(task.TaskId)); await backend.Begun.Task.WaitAsync(TestContext.Current.CancellationToken); Assert.True(registry.Cancel(task.TaskId)); await backend.CleanupStarted.Task.WaitAsync(TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? stopping)); Assert.NotEqual(AgentTaskState.Cancelled, stopping!.State); backend.AllowCleanup.TrySetResult(); await execution; Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? cancelled)); Assert.Equal(AgentTaskState.Cancelled, cancelled!.State); } [Fact] public async Task Cancellation_cleanup_failure_becomes_failed() { TaskRegistry registry = new(); var backend = new BlockingCleanupBackend(failCleanup: true); var dispatcher = new InternalToolDispatcher(new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend); var runtime = new AgentRuntime(registry, new CancellableOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); TaskSnapshot task = registry.Enqueue("wait", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); Task execution = runtime.ExecuteAsync(task.TaskId, registry.GetCancellationToken(task.TaskId)); await backend.Begun.Task.WaitAsync(TestContext.Current.CancellationToken); Assert.True(registry.Cancel(task.TaskId)); await backend.CleanupStarted.Task.WaitAsync(TestContext.Current.CancellationToken); backend.AllowCleanup.TrySetResult(); await execution; Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); Assert.Equal(AgentTaskState.Failed, failed!.State); Assert.Equal("cleanup_failed", failed.LastErrorCode); } [Fact] public async Task Task_progress_current_task_and_critical_path_follow_the_graph() { TaskRegistry registry = new(); var ollama = new TwoTaskProgressOllamaClient(); var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "en", [], "sandbox")); TaskSnapshot task = registry.Enqueue("two steps", null, null); registry.Transition(task.TaskId, AgentTaskState.Planning); Task execution = runtime.ExecuteAsync(task.TaskId, registry.GetCancellationToken(task.TaskId)); await ollama.SecondTaskStarted.Task.WaitAsync(TestContext.Current.CancellationToken); Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? progress)); Assert.Equal(1, progress!.CompletedTasks); Assert.Equal(2, progress.TotalTasks); Assert.Equal("second", progress.CurrentTask); Assert.True(progress.CriticalPathTokens > 0); ollama.AllowSecondTask.TrySetResult(); await execution; Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); Assert.Equal(2, completed!.CompletedTasks); Assert.Equal(2, completed.TotalTasks); Assert.Null(completed.CurrentTask); } private sealed class RecordingOllamaClient : IOllamaClient { public List> Calls { get; } = []; public List Temperatures { get; } = []; public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) { Calls.Add(messages); Temperatures.Add(temperatureOverride); return Task.FromResult(new OllamaResult($"answer-{Calls.Count}", 3, 2)); } public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) { // Return a minimal single-task plan for Planning phase. Calls.Add(messages); Temperatures.Add(temperatureOverride); string planJson = """{"tasks":[{"description":"task","dependencies":[],"budget_class":"4K","optimistic_tokens":512,"most_likely_tokens":2048,"pessimistic_tokens":4096,"requires_external_effect":false}]}"""; return Task.FromResult(new OllamaResult(planJson, 3, 2)); } public Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { Calls.Add(messages); Temperatures.Add(temperatureOverride); return Task.FromResult(new OllamaResult($"answer-{Calls.Count}", 3, 2)); } } private sealed class ToolCallingOllamaClient : IOllamaClient { public int CallCount { get; private set; } public int ToolCount { get; private set; } public bool ThinkingPreservedForToolContinuation { get; private set; } public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("修正しました。", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) { // Return a plan with requires_external_effect=true for file edit task. string planJson = """{"tasks":[{"description":"fileを修正","dependencies":[],"budget_class":"4K","optimistic_tokens":512,"most_likely_tokens":2048,"pessimistic_tokens":4096,"requires_external_effect":true,"required_tools":["file_edit"]}]}"""; return Task.FromResult(new OllamaResult(planJson, 1, 1)); } public Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { CallCount++; ToolCount = tools.Count; if (CallCount == 1) { using JsonDocument document = JsonDocument.Parse("""{"path":"x","operation":"overwrite","content":"new"}"""); return Task.FromResult(new OllamaResult("", 2, 1, [new OllamaToolCall(new OllamaFunctionCall("file_edit", document.RootElement.Clone()))], Thinking: "private tool reasoning")); } ThinkingPreservedForToolContinuation = messages.Any(message => message.Role == "assistant" && message.Thinking == "private tool reasoning" && message.ToolCalls?.Count == 1); return Task.FromResult(new OllamaResult("修正しました。", 2, 2)); } } private sealed class MismatchedThenRequiredEffectOllamaClient : IOllamaClient { private int _runCalls; public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("done", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("""{"tasks":[{"description":"run build","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":true,"required_tools":["process_run"]}]}""", 1, 1)); public Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { int call = Interlocked.Increment(ref _runCalls); if (call == 1) { using JsonDocument arguments = JsonDocument.Parse("""{"path":"x","operation":"overwrite","content":"new"}"""); return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("file_edit", arguments.RootElement.Clone()))])); } if (call == 2) { Assert.Contains(messages, message => message.Role == "system" && message.Content.Contains("process_run", StringComparison.Ordinal)); using JsonDocument arguments = JsonDocument.Parse("""{"executable":"x","args":[],"cwd":"."}"""); return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("process_run", arguments.RootElement.Clone()))])); } return Task.FromResult(new OllamaResult("done", 1, 1)); } } private sealed class RequiredReadOllamaClient : IOllamaClient { public int RunCalls { get; private set; } public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("done", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("""{"tasks":[{"description":"read file","dependencies":[],"budget_class":"1K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false,"required_tools":["file_read"]}]}""", 1, 1)); public Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { RunCalls++; if (RunCalls == 1) return Task.FromResult(new OllamaResult("I will read it.", 1, 1)); if (RunCalls == 2) { using JsonDocument arguments = JsonDocument.Parse("""{"path":"x","mode":"whole"}"""); return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("file_read", arguments.RootElement.Clone()))])); } return Task.FromResult(new OllamaResult("read complete", 1, 1)); } } private sealed class NonVisionOllamaClient : IOllamaClient { public int ChatCalls { get; private set; } public Task SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult(false); public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) { ChatCalls++; return Task.FromResult(new OllamaResult("unexpected", 0, 0)); } // ChatStructuredAsync uses default implementation which falls back to ChatAsync. // Vision check happens before Planning, so ChatAsync should never be called. } private sealed class CleanupFailingBackend : IInternalToolBackend { public int EndCalls { get; private set; } public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask; public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) => Task.FromResult(new ToolExecutionResult(true, "unused", false)); public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) { EndCalls++; throw new IOException("cleanup failed"); } } private sealed class FailingProcessBackend : IInternalToolBackend { public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask; public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) => Task.FromResult(new ToolExecutionResult(false, "exit 1", true, "process:1")); public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class FailingThenFinalOllamaClient : IOllamaClient { private int _calls; public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("done", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) { string planJson = """{"tasks":[{"description":"run build","dependencies":[],"budget_class":"4K","optimistic_tokens":512,"most_likely_tokens":2048,"pessimistic_tokens":4096,"requires_external_effect":true,"required_tools":["process_run"]}]}"""; return Task.FromResult(new OllamaResult(planJson, 1, 1)); } public Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { if (_calls++ > 0) return Task.FromResult(new OllamaResult("done", 1, 1)); using JsonDocument arguments = JsonDocument.Parse("""{"executable":"x","args":[],"cwd":"."}"""); return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("process_run", arguments.RootElement.Clone()))])); } } private sealed class UnauthorizedBackend : IInternalToolBackend { public int CallCount { get; private set; } public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) => Task.CompletedTask; public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) { CallCount++; throw new UnauthorizedAccessException("'C:\\Users' is not approved for Read."); } public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class FileSearchOllamaClient : IOllamaClient { public int CallCount { get; private set; } public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("unexpected", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) { string planJson = """{"tasks":[{"description":"research regional curry","dependencies":[],"budget_class":"4K","optimistic_tokens":512,"most_likely_tokens":2048,"pessimistic_tokens":4096,"requires_external_effect":true,"required_tools":["file_edit"]}]}"""; return Task.FromResult(new OllamaResult(planJson, 1, 1)); } public Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { CallCount++; using JsonDocument arguments = JsonDocument.Parse("""{"root":"C:\\Users","mode":"name","query":"*.txt"}"""); return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("file_search", arguments.RootElement.Clone()))])); } } private sealed class ThinkRetryOllamaClient(bool succeedOnRetry, bool semanticSplitFirst = false) : IOllamaClient { public List ThinkOverrides { get; } = []; public List Temperatures { get; } = []; public List> PlanningMessages { get; } = []; public int RunCalls { get; private set; } public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("done", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) { ThinkOverrides.Add(thinkOverride); Temperatures.Add(temperatureOverride); PlanningMessages.Add(messages); string content = ThinkOverrides.Count == 1 && semanticSplitFirst ? """{"tasks":[{"description":"edit","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":true,"required_tools":["file_edit"]},{"description":"verify and correct","dependencies":[0],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":true,"required_tools":["file_edit","process_run"]}]}""" : ThinkOverrides.Count == 2 && succeedOnRetry ? """ Here is the requested plan. ```json {"tasks":[{"description":"analyze","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]} ``` The JSON above is the complete plan. """ : "not-json"; return Task.FromResult(new OllamaResult(content, 1, 1)); } public Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { RunCalls++; Temperatures.Add(temperatureOverride); return Task.FromResult(new OllamaResult("done", 1, 1)); } } private sealed class ChangingPolicyPlanOllamaClient(bool sameDeny = false) : IOllamaClient { private int _plans; private int _runs; public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("unused", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) { int plan = Interlocked.Increment(ref _plans); string json = $$"""{"tasks":[{"description":"search-{{plan}}","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":true,"required_tools":["file_edit"]}]}"""; return Task.FromResult(new OllamaResult(json, 1, 1)); } public Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { int run = Interlocked.Increment(ref _runs); string root = sameDeny ? "C:/denied/same" : $"C:/denied/{run}"; using JsonDocument arguments = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(root)}},"mode":"name","query":"*.txt"}"""); return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("file_search", arguments.RootElement.Clone()))])); } } private sealed class ChangingPolicyBackend : IInternalToolBackend { public int CallCount { get; private set; } public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) { CallCount++; string root = arguments.GetProperty("root").GetString()!; return Task.FromResult(new ToolExecutionResult(false, $"denied: {root}", false, root, RetryAllowed: false)); } } private sealed class SummaryOllamaClient(bool summarySucceeds) : IOllamaClient { public IReadOnlyList? SummaryRequest { get; private set; } public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("done", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) { if (format.GetProperty("properties").TryGetProperty("summary", out _)) { SummaryRequest = messages.ToArray(); if (!summarySucceeds) throw new InvalidOperationException("summary failed"); return Task.FromResult(new OllamaResult("""{"summary":"preserved constraints"}""", 1, 1)); } return Task.FromResult(new OllamaResult("""{"tasks":[{"description":"analyze","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]}""", 1, 1)); } public Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("done", 1, 1, PromptEvalCount: 90)); } private sealed class CancellableOllamaClient : IOllamaClient { public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("done", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("""{"tasks":[{"description":"wait","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]}""", 1, 1)); public async Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); throw new InvalidOperationException("unreachable"); } } private sealed class TwoTaskProgressOllamaClient : IOllamaClient { private int _runCalls; public TaskCompletionSource SecondTaskStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource AllowSecondTask { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task ChatAsync(IReadOnlyList messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("done", 1, 1)); public Task ChatStructuredAsync(IReadOnlyList messages, double? temperatureOverride, JsonElement format, bool? thinkOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("""{"tasks":[{"description":"first","dependencies":[],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false},{"description":"second","dependencies":[0],"budget_class":"4K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30,"requires_external_effect":false}]}""", 1, 1)); public async Task ChatWithToolsAsync(IReadOnlyList messages, double? temperatureOverride, IReadOnlyList tools, CancellationToken cancellationToken) { if (Interlocked.Increment(ref _runCalls) == 2) { SecondTaskStarted.TrySetResult(); await AllowSecondTask.Task.WaitAsync(cancellationToken); } return new OllamaResult("done", 1, 1); } } private sealed class BlockingCleanupBackend(bool failCleanup = false) : IInternalToolBackend { public TaskCompletionSource Begun { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource CleanupStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource AllowCleanup { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task BeginTaskAsync(Guid taskId, Guid sessionId, CancellationToken cancellationToken) { Begun.TrySetResult(); return Task.CompletedTask; } public Task ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) => Task.FromResult(new ToolExecutionResult(true, "unused", false)); public async Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) { CleanupStarted.TrySetResult(); await AllowCleanup.Task.WaitAsync(cancellationToken); if (failCleanup) throw new InvalidOperationException("cleanup failed"); } } }