diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-12 00:21:23 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-12 00:21:23 +0900 |
| commit | 98b6a2aeedd591058c1d81e401d24f3ded162926 (patch) | |
| tree | 74cbb38af825526e4fc90b150feed14426d727b4 | |
| parent | ce550a127b7dc972651bd15b9f09979a2c38688f (diff) | |
fix: implement native Ollama thinking semantics
Model Ollama chat thinking separately from final content, preserve it only across assistant tool-call continuation, and keep private reasoning out of user results, session context, and logs.
Accept native boolean and low/medium/high/max think values, normalize YAML booleans, and serialize explicit overrides using the native top-level chat field.
Stop forcing think=false for structured planning, skill selection, and context summaries; corrected retries now retain the configured thinking mode.
Update the specification, design, audit report, and requirement traceability for OLL-14.
Add request/response, configuration, observability, retry, and tool-continuation coverage. Verified with 92 passing Agent.Core tests and a deepseek-v4-flash:cloud think:true MCP operational run.
| -rw-r--r-- | doc/AuditReport.md | 2 | ||||
| -rw-r--r-- | doc/Design.md | 4 | ||||
| -rw-r--r-- | doc/Specifications.md | 1 | ||||
| -rw-r--r-- | plans/RequirementTraceability.md | 1 | ||||
| -rw-r--r-- | src/Agent.App/Configuration/AgentConfiguration.cs | 2 | ||||
| -rw-r--r-- | src/Agent.App/Configuration/ConfigurationLoader.cs | 5 | ||||
| -rw-r--r-- | src/Agent.Core/Ollama/OllamaClient.cs | 5 | ||||
| -rw-r--r-- | src/Agent.Core/Ollama/OllamaModels.cs | 7 | ||||
| -rw-r--r-- | src/Agent.Core/Ollama/OllamaSettings.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntime.cs | 7 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/PlannerService.cs | 8 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/ConfigurationTests.cs | 30 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/ObservabilityTests.cs | 7 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/OllamaClientTests.cs | 54 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/PromptAndRuntimeTests.cs | 13 |
15 files changed, 123 insertions, 25 deletions
diff --git a/doc/AuditReport.md b/doc/AuditReport.md index b1f782f..c98694e 100644 --- a/doc/AuditReport.md +++ b/doc/AuditReport.md @@ -12,7 +12,7 @@ | policy deny | 初回failedと最大3回replanが混在 | 同一呼出しへ再投入しない条件付きreplanへ統一。同一deny、計画未変更、実行不能、replan失敗でfailed | | Skill形式 | READMEにlegacy互換記述 | `<skill-name>/SKILL.md`だけに統一 | | Task完了 | 実行Taskなしでもcompletedになり得た | TaskGraph全件completedだけを完了条件とし、実行不能は理由付きfailedへ変更 | -| Planner fallback | 空/不正planから曖昧なfallback graphを生成 | `think=false`で1回だけ再試行し、再失敗はplanning failureへ変更。planning中のTool callを禁止する明示promptを追加 | +| Planner fallback | 空/不正planから曖昧なfallback graphを生成 | 設定された`think`を維持して訂正instruction付きで1回だけ再試行し、再失敗はplanning failureへ変更。planning中のTool callを禁止する明示promptを追加 | | Tool証跡 | 読取Taskや別Taskの副作用で完了できた | Taskごとの`required_tools`を設計し、必要Tool成功まで完了不可、範囲外副作用はdispatchもsession投入もしない | | task_update | LLMが状態遷移可能 | Tool catalogには維持し、Task追加/dependency追加/estimate変更だけに限定 | | cancel | CancellationToken通知時点でterminal | Task Resource cleanup完了後だけcancelled。cleanup失敗はfailed | diff --git a/doc/Design.md b/doc/Design.md index 68c7dbc..9ad4ae3 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -241,7 +241,7 @@ top_k, top_p, min_p, typical_p, tfs_z, repeat_last_n, repeat_penalty, presence_penalty, frequency_penalty, mirostat, mirostat_tau, mirostat_eta, stop ``` -Chat API top-levelとして `model`, `think`, `keep_alive`, `format`, `logprobs`, `top_logprobs` を別型で管理する。Ollama API変更時は既知型を更新し、未知optionは `extra_options` で一時対応できる。 +Chat API top-levelとして `model`, `think`, `keep_alive`, `format`, `logprobs`, `top_logprobs` を別型で管理する。`think`はbooleanまたはOllamaが定義するlevel (`low` / `medium` / `high` / `max`) を受け付ける。responseでは最終回答の`message.content`と推論過程の`message.thinking`を分離して取り込み、tool call継続時だけassistant messageの`thinking`をOllamaへ戻す。thinking本文を利用者向け結果、Conversation、Execution Result、LLM Log、Audit Logへ保存・出力しない。Ollama API変更時は既知型を更新し、未知optionは `extra_options` で一時対応できる。 ### 6.3 temperature override ```text effective_temperature = agent_run.temperature ?? config.ollama.options.temperature @@ -379,7 +379,7 @@ topological sort後にforward passで各Taskの累積expected token costを計ç® - Policy denyは同一Tool呼出しへ再投入しない。許可済み能力だけで代替できる可能性がある場合、`running -> planning -> running` と遷移してreplanを最大3回まで試行できる。 - completed TaskのID、状態、usageを保持する。failed Taskをdependency解決済みとして扱わない。 - 同一deny fingerprintの再発、計画未変更、実行可能Taskなし、structured replan失敗は即Failedとする。全Taskがcompletedの場合だけAgent Taskをcompletedにする。 -- structured planが空または不正な場合は`think=false`で1回だけ再試行し、それでも失敗した場合はplanning failureとする。外部効果要否が不明なfallback graphは作成しない。 +- structured planが空または不正な場合は、設定された`think`を維持したまま訂正instruction付きで1回だけ再試行し、それでも失敗した場合はplanning failureとする。外部効果要否が不明なfallback graphは作成しない。 - 利用者が指定したabsolute path、executable、cwd、引数配列、operation、literal値は、それを使用するTask descriptionへそのまま保持する。後続Taskの操作を先行Taskへ混在させない。 ### 8.5 Ollama usage集計 1 Ollama callの `prompt_eval_count` をTask inputへ、`eval_count` をTask outputへ加算する。tool実行そのものにはtoken costを加算しない。context summary生成に使ったtokenはSession maintenance Taskとして別集計する。 diff --git a/doc/Specifications.md b/doc/Specifications.md index 92dc176..9f6e9b8 100644 --- a/doc/Specifications.md +++ b/doc/Specifications.md @@ -213,6 +213,7 @@ command省略時、および先頭引数がoptionの場合は `serve` としてæ | OLL-11 | Tool Guard | 実行が必要なTaskでtool callが行われない場合、未実行を検出して再試行する。 | | OLL-12 | Retry | Ollama通信失敗は既定3回まで再試行する。 | | OLL-13 | Usage | `prompt_eval_count` と `eval_count` をTask実測tokenとして記録する。 | +| OLL-14 | Thinking | `think`はOllama native Chat APIのbooleanまたは対応levelを使用する。推論過程と最終回答を分離し、推論本文を利用者向け結果・Conversation・Execution Result・Logへ出力しない。tool call継続に必要なassistant message内でのみOllamaへ返送する。 | Vision入力は設定可能な画像数、1画像あたりのsize、request合計sizeの上限を持ち、上限超過をOllama呼出し前に拒否しなければならない。具体的な既定値は設計および設定schemaで定義する。 diff --git a/plans/RequirementTraceability.md b/plans/RequirementTraceability.md index b7a84ef..6025755 100644 --- a/plans/RequirementTraceability.md +++ b/plans/RequirementTraceability.md @@ -11,6 +11,7 @@ | OLL-01〜05 | `OllamaClient`, `OllamaSettingsFactory`, typed/extra options | `OllamaClientTests`, `ConfigurationTests` | PASS | | OLL-06〜09 | Task temperature、固定model/prompt、Vision capability | `PromptAndRuntimeTests`, `VisionTests` | PASS | | OLL-10〜13 | native Tool call、ToolUseGuard、retry、全phase token集計 | `InternalToolTests`, `PromptAndRuntimeTests`, DeepSeek Operational E2E | PASS | +| OLL-14 | native `think`値、thinking/content分離、tool継続、推論本文非出力 | `OllamaClientTests`, `ConfigurationTests`, `ObservabilityTests`, 2026-08-12 DeepSeek think:true Operational E2E | PASS | | AGT-01〜06 | `AgentRuntime`, `InternalToolDispatcher`, MCP公開境界 | completion/replan/iteration/catalog tests、stdio/HTTP E2E | PASS | | PLN-01〜06 | `TaskGraph`, `PlannerService`, structural `task_update` | Planner 10件、structured-plan retry、Task固有Tool証跡test | PASS | | SES-01〜06 | `SessionStore`、会話/graph/result保持、structured要約 | summary成功/失敗、session/runtime tests | PASS | diff --git a/src/Agent.App/Configuration/AgentConfiguration.cs b/src/Agent.App/Configuration/AgentConfiguration.cs index 362b889..c5f720b 100644 --- a/src/Agent.App/Configuration/AgentConfiguration.cs +++ b/src/Agent.App/Configuration/AgentConfiguration.cs @@ -35,7 +35,7 @@ public sealed class OllamaConfiguration { public string Endpoint { get; set; } = "http://127.0.0.1:11434"; public string Model { get; set; } = string.Empty; - public bool Think { get; set; } = true; + public object Think { get; set; } = true; public string KeepAlive { get; set; } = "5m"; public OllamaGenerationOptions Options { get; set; } = new(); public Dictionary<string, object?> ExtraOptions { get; set; } = new(StringComparer.Ordinal); diff --git a/src/Agent.App/Configuration/ConfigurationLoader.cs b/src/Agent.App/Configuration/ConfigurationLoader.cs index efb3db1..26c56e1 100644 --- a/src/Agent.App/Configuration/ConfigurationLoader.cs +++ b/src/Agent.App/Configuration/ConfigurationLoader.cs @@ -53,6 +53,11 @@ public static partial class ConfigurationLoader if (value.Agent.MaxIterations <= 0 || value.Agent.SameFailureLimit <= 0) Throw("agent iteration limits must be positive."); if (!Uri.TryCreate(value.Ollama.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme is not ("http" or "https")) Throw("ollama.endpoint must be an absolute HTTP(S) URI."); if (string.IsNullOrWhiteSpace(value.Ollama.Model)) Throw("ollama.model is required."); + if (value.Ollama.Think is string booleanText && bool.TryParse(booleanText, out bool booleanThink)) + value.Ollama.Think = booleanThink; + if (value.Ollama.Think is not bool && + (value.Ollama.Think is not string thinkLevel || thinkLevel is not ("low" or "medium" or "high" or "max"))) + Throw("ollama.think must be true, false, low, medium, high, or max."); if (value.Ollama.Options.NumCtx is <= 0) Throw("ollama.options.num_ctx must be positive."); if (value.Ollama.Options.Temperature is < 0 or > 2) Throw("ollama.options.temperature must be in 0..2."); if (value.Planner.MaxTasks is < 1 or > 32) Throw("planner.max_tasks must be in 1..32."); diff --git a/src/Agent.Core/Ollama/OllamaClient.cs b/src/Agent.Core/Ollama/OllamaClient.cs index 428bca9..fea5dfb 100644 --- a/src/Agent.Core/Ollama/OllamaClient.cs +++ b/src/Agent.Core/Ollama/OllamaClient.cs @@ -57,7 +57,7 @@ public sealed class OllamaClient : IOllamaClient Model = _settings.Model, Messages = messages, Stream = false, - Think = thinkOverride ?? _settings.Think, + Think = thinkOverride is null ? _settings.Think : thinkOverride.Value, KeepAlive = _settings.KeepAlive, Options = options, Tools = tools?.Select(static tool => new OllamaToolDefinition("function", new OllamaFunctionDefinition(tool.Name, tool.Description, tool.Parameters))).ToArray(), @@ -85,7 +85,8 @@ public sealed class OllamaClient : IOllamaClient ?? throw new OllamaException("Ollama returned an empty response.", false); if (!string.IsNullOrWhiteSpace(result.Error)) throw new OllamaException(result.Error, false); if (result.Message is null) throw new OllamaException("Ollama response has no message.", false); - return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, result.Message.ToolCalls ?? [], result.PromptEvalCount); + return new OllamaResult(result.Message.Content, result.PromptEvalCount, result.EvalCount, + result.Message.ToolCalls ?? [], result.PromptEvalCount, result.Message.Thinking); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) { diff --git a/src/Agent.Core/Ollama/OllamaModels.cs b/src/Agent.Core/Ollama/OllamaModels.cs index f6ff092..9e6103e 100644 --- a/src/Agent.Core/Ollama/OllamaModels.cs +++ b/src/Agent.Core/Ollama/OllamaModels.cs @@ -9,7 +9,8 @@ public sealed record OllamaMessage( [property: JsonPropertyName("content")] string Content, [property: JsonPropertyName("images"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<string>? Images = null, [property: JsonPropertyName("tool_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolName = null, - [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<OllamaToolCall>? ToolCalls = null); + [property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<OllamaToolCall>? ToolCalls = null, + [property: JsonPropertyName("thinking"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Thinking = null); public sealed record OllamaToolCall([property: JsonPropertyName("function")] OllamaFunctionCall Function); public sealed record OllamaFunctionCall( @@ -29,7 +30,7 @@ public sealed class OllamaChatRequest [JsonPropertyName("model")] public required string Model { get; init; } [JsonPropertyName("messages")] public required IReadOnlyList<OllamaMessage> Messages { get; init; } [JsonPropertyName("stream")] public bool Stream { get; init; } - [JsonPropertyName("think")] public bool Think { get; init; } + [JsonPropertyName("think")] public required object Think { get; init; } [JsonPropertyName("keep_alive")] public required string KeepAlive { get; init; } [JsonPropertyName("options")] public required IReadOnlyDictionary<string, System.Text.Json.JsonElement> Options { get; init; } [JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList<OllamaToolDefinition>? Tools { get; init; } @@ -44,4 +45,4 @@ public sealed class OllamaChatResponse [JsonPropertyName("error")] public string? Error { get; init; } } -public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null, long PromptEvalCount = 0); +public sealed record OllamaResult(string Content, long InputTokens, long OutputTokens, IReadOnlyList<OllamaToolCall>? ToolCalls = null, long PromptEvalCount = 0, string? Thinking = null); diff --git a/src/Agent.Core/Ollama/OllamaSettings.cs b/src/Agent.Core/Ollama/OllamaSettings.cs index 94ab3ef..9f33208 100644 --- a/src/Agent.Core/Ollama/OllamaSettings.cs +++ b/src/Agent.Core/Ollama/OllamaSettings.cs @@ -5,6 +5,6 @@ namespace Agent.Core.Ollama; public sealed record OllamaSettings( Uri Endpoint, string Model, - bool Think, + object Think, string KeepAlive, IReadOnlyDictionary<string, JsonElement> Options); diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs index 31a4414..5ca9d1c 100644 --- a/src/Agent.Core/Runtime/AgentRuntime.cs +++ b/src/Agent.Core/Runtime/AgentRuntime.cs @@ -262,7 +262,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag IReadOnlyList<OllamaToolCall> calls = result.ToolCalls ?? []; if (calls.Count > 0) { - messages.Add(new OllamaMessage("assistant", result.Content, ToolCalls: calls)); + messages.Add(new OllamaMessage("assistant", result.Content, Thinking: result.Thinking, ToolCalls: calls)); string? failureFingerprint = null; foreach (OllamaToolCall call in calls) { @@ -400,7 +400,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag 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); + OllamaResult result = await ollama.ChatStructuredAsync(messages, task.Temperature, schema.RootElement.Clone(), null, cancellationToken).ConfigureAwait(false); _observer.LlmResponse(callContext, result); registry.AddUsage(taskId, result.InputTokens, result.OutputTokens); registry.UpdateDiagnostics(taskId, counters.Iterations, counters.Replans); @@ -444,8 +444,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag OllamaResult? result = null; try { - bool? thinkOverride = attempt == 0 ? null : false; - result = await ollama.ChatStructuredAsync(attemptMessages, task.Temperature, formatDoc.RootElement.Clone(), thinkOverride, cancellationToken).ConfigureAwait(false); + result = await ollama.ChatStructuredAsync(attemptMessages, task.Temperature, formatDoc.RootElement.Clone(), null, cancellationToken).ConfigureAwait(false); _observer.LlmResponse(callContext, result); registry.AddUsage(task.TaskId, result.InputTokens, result.OutputTokens); registry.UpdateDiagnostics(task.TaskId, counters.Iterations, counters.Replans); diff --git a/src/Agent.Core/Runtime/PlannerService.cs b/src/Agent.Core/Runtime/PlannerService.cs index bc96ec7..3591259 100644 --- a/src/Agent.Core/Runtime/PlannerService.cs +++ b/src/Agent.Core/Runtime/PlannerService.cs @@ -218,8 +218,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se OllamaResult? result = null; try { - bool? thinkOverride = attempt == 0 ? null : false; - result = await _ollama.ChatStructuredAsync(attemptMessages, temperature, formatDoc.RootElement.Clone(), thinkOverride, cancellationToken).ConfigureAwait(false); + result = await _ollama.ChatStructuredAsync(attemptMessages, temperature, formatDoc.RootElement.Clone(), null, cancellationToken).ConfigureAwait(false); if (!TryParseSelectedSkills(result.Content, out IReadOnlyList<AgentSkill> selected)) throw new PlannerValidationException("Ollama returned an invalid skill selection object."); observeAttempt?.Invoke("skill_selection", attemptMessages, result, null); @@ -250,8 +249,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se OllamaResult? result = null; try { - bool? thinkOverride = attempt == 0 ? null : false; - result = await _ollama.ChatStructuredAsync(attemptMessages, temperature, PlanSchema.Element, thinkOverride, cancellationToken).ConfigureAwait(false); + result = await _ollama.ChatStructuredAsync(attemptMessages, temperature, PlanSchema.Element, null, cancellationToken).ConfigureAwait(false); PlanResult? plan = ParsePlan(result.Content); if (plan is null || plan.Tasks.Count == 0) throw new PlannerValidationException("Ollama returned an empty or invalid structured plan."); @@ -274,7 +272,7 @@ public sealed class PlannerService(IOllamaClient ollama, AgentRuntimeSettings se } } } - throw new PlannerValidationException($"Structured {phase} failed after think=false retry: {lastFailure?.Message}"); + throw new PlannerValidationException($"Structured {phase} failed after one corrected retry: {lastFailure?.Message}"); } private bool TryParseSelectedSkills(string content, out IReadOnlyList<AgentSkill> selected) diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs index 64b2d85..e588825 100644 --- a/tests/Agent.Core.Tests/ConfigurationTests.cs +++ b/tests/Agent.Core.Tests/ConfigurationTests.cs @@ -37,6 +37,36 @@ public sealed class ConfigurationTests Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); } + [Theory] + [InlineData("true")] + [InlineData("false")] + [InlineData("low")] + [InlineData("medium")] + [InlineData("high")] + [InlineData("max")] + public void Ollama_think_accepts_native_boolean_and_level_values(string value) + { + using var directory = new TestDirectory(); + string config = CreateValidConfiguration(directory); + System.IO.File.WriteAllText(config, System.IO.File.ReadAllText(config).Replace(" model: model", $" model: model\n think: {value}", StringComparison.Ordinal)); + + AgentConfiguration result = ConfigurationLoader.Load(config); + + Assert.Equal(value is "true" or "false" ? bool.Parse(value) : value, result.Ollama.Think); + } + + [Fact] + public void Ollama_think_rejects_unknown_level() + { + using var directory = new TestDirectory(); + string config = CreateValidConfiguration(directory); + System.IO.File.WriteAllText(config, System.IO.File.ReadAllText(config).Replace(" model: model", " model: model\n think: extreme", StringComparison.Ordinal)); + + ConfigurationException error = Assert.Throws<ConfigurationException>(() => ConfigurationLoader.Load(config)); + + Assert.Contains("ollama.think", error.Message, StringComparison.Ordinal); + } + [Fact] public void CommandLineLanguageOverrideIsCaptured() { diff --git a/tests/Agent.Core.Tests/ObservabilityTests.cs b/tests/Agent.Core.Tests/ObservabilityTests.cs index 8231ab9..3dcfdf6 100644 --- a/tests/Agent.Core.Tests/ObservabilityTests.cs +++ b/tests/Agent.Core.Tests/ObservabilityTests.cs @@ -23,8 +23,8 @@ public sealed class ObservabilityTests var context = new AgentLlmCallContext(callId, sessionId, taskId, plannerTaskId, "replan", 7, 2, "model-a"); using (var observer = new JsonLinesRuntimeObserver(directory.Path, 1024 * 1024, 2)) { - observer.LlmRequest(context, [new OllamaMessage("user", "request")]); - observer.LlmResponse(context, new OllamaResult("response", 11, 13)); + observer.LlmRequest(context, [new OllamaMessage("assistant", "request", Thinking: "private request reasoning")]); + observer.LlmResponse(context, new OllamaResult("response", 11, 13, Thinking: "private response reasoning")); observer.ToolResult(context, "file_read", new ToolExecutionResult(true, "ok", false)); observer.LlmFailure(context, "planning_failed", "invalid plan"); } @@ -45,6 +45,9 @@ public sealed class ObservabilityTests Assert.Equal("file_read", entries[2].GetProperty("tool_name").GetString()); Assert.Equal("llm.failure", entries[3].GetProperty("event_name").GetString()); Assert.Equal("planning_failed", entries[3].GetProperty("error_code").GetString()); + string log = File.ReadAllText(Path.Combine(directory.Path, "llm.jsonl")); + Assert.DoesNotContain("private request reasoning", log, StringComparison.Ordinal); + Assert.DoesNotContain("private response reasoning", log, StringComparison.Ordinal); } [Fact] diff --git a/tests/Agent.Core.Tests/OllamaClientTests.cs b/tests/Agent.Core.Tests/OllamaClientTests.cs index 94ce8cd..87a3e20 100644 --- a/tests/Agent.Core.Tests/OllamaClientTests.cs +++ b/tests/Agent.Core.Tests/OllamaClientTests.cs @@ -38,6 +38,60 @@ public sealed class OllamaClientTests } [Fact] + public async Task Thinking_is_separated_from_final_content_and_not_discarded() + { + var handler = new StubHandler((_, _) => Task.FromResult(Json(HttpStatusCode.OK, + """{"message":{"role":"assistant","thinking":"private reasoning","content":"final answer"},"prompt_eval_count":3,"eval_count":5}"""))); + var client = new OllamaClient(new HttpClient(handler), + new OllamaSettings(new Uri("http://127.0.0.1:11434"), "test-model", true, "0", new Dictionary<string, JsonElement>())); + + OllamaResult result = await client.ChatAsync([new OllamaMessage("user", "hello")], null, TestContext.Current.CancellationToken); + + Assert.Equal("final answer", result.Content); + Assert.Equal("private reasoning", result.Thinking); + } + + [Fact] + public async Task Thinking_level_and_assistant_thinking_history_follow_native_chat_schema() + { + string? body = null; + var handler = new StubHandler(async (request, _) => + { + body = await request.Content!.ReadAsStringAsync(); + return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"done"}}"""); + }); + var client = new OllamaClient(new HttpClient(handler), + new OllamaSettings(new Uri("http://127.0.0.1:11434"), "test-model", "high", "0", new Dictionary<string, JsonElement>())); + + await client.ChatAsync([new OllamaMessage("assistant", "", ToolCalls: [], Thinking: "prior reasoning")], null, + TestContext.Current.CancellationToken); + + using JsonDocument document = JsonDocument.Parse(body!); + Assert.Equal("high", document.RootElement.GetProperty("think").GetString()); + Assert.Equal("prior reasoning", document.RootElement.GetProperty("messages")[0].GetProperty("thinking").GetString()); + } + + [Fact] + public async Task Explicit_false_override_is_serialized_as_boolean_false() + { + string? body = null; + var handler = new StubHandler(async (request, _) => + { + body = await request.Content!.ReadAsStringAsync(); + return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"{}"}}"""); + }); + var client = new OllamaClient(new HttpClient(handler), + new OllamaSettings(new Uri("http://127.0.0.1:11434"), "test-model", "high", "0", new Dictionary<string, JsonElement>())); + using JsonDocument schema = JsonDocument.Parse("""{"type":"object"}"""); + + await client.ChatStructuredAsync([new OllamaMessage("user", "json")], null, schema.RootElement.Clone(), false, + TestContext.Current.CancellationToken); + + using JsonDocument document = JsonDocument.Parse(body!); + Assert.False(document.RootElement.GetProperty("think").GetBoolean()); + } + + [Fact] public async Task Transient_failure_retries_three_times_with_configured_backoff() { int attempts = 0; diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs index 6dad528..082ea9b 100644 --- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs +++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs @@ -124,6 +124,7 @@ public sealed class PromptAndRuntimeTests 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] @@ -270,7 +271,7 @@ public sealed class PromptAndRuntimeTests } [Fact] - public async Task Invalid_structured_plan_retries_once_with_think_disabled() + public async Task Invalid_structured_plan_retries_once_without_disabling_configured_thinking() { TaskRegistry registry = new(); var ollama = new ThinkRetryOllamaClient(succeedOnRetry: true); @@ -282,7 +283,7 @@ public sealed class PromptAndRuntimeTests Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); Assert.Equal(AgentTaskState.Completed, completed!.State); - Assert.Equal([null, false], ollama.ThinkOverrides); + 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)); @@ -305,7 +306,7 @@ public sealed class PromptAndRuntimeTests Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); Assert.Equal(AgentTaskState.Failed, failed!.State); - Assert.Contains("think=false retry", failed.Result, StringComparison.OrdinalIgnoreCase); + Assert.Contains("one corrected retry", failed.Result, StringComparison.OrdinalIgnoreCase); Assert.Equal(0, ollama.RunCalls); } @@ -453,6 +454,7 @@ public sealed class PromptAndRuntimeTests { public int CallCount { get; private set; } public int ToolCount { get; private set; } + public bool ThinkingPreservedForToolContinuation { get; private set; } public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) => Task.FromResult(new OllamaResult("修正しました。", 1, 1)); public Task<OllamaResult> ChatStructuredAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, JsonElement format, CancellationToken cancellationToken) @@ -468,8 +470,11 @@ public sealed class PromptAndRuntimeTests 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()))])); + 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)); } } |
