summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-12 00:21:23 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-12 00:21:23 +0900
commit98b6a2aeedd591058c1d81e401d24f3ded162926 (patch)
tree74cbb38af825526e4fc90b150feed14426d727b4
parentce550a127b7dc972651bd15b9f09979a2c38688f (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.md2
-rw-r--r--doc/Design.md4
-rw-r--r--doc/Specifications.md1
-rw-r--r--plans/RequirementTraceability.md1
-rw-r--r--src/Agent.App/Configuration/AgentConfiguration.cs2
-rw-r--r--src/Agent.App/Configuration/ConfigurationLoader.cs5
-rw-r--r--src/Agent.Core/Ollama/OllamaClient.cs5
-rw-r--r--src/Agent.Core/Ollama/OllamaModels.cs7
-rw-r--r--src/Agent.Core/Ollama/OllamaSettings.cs2
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs7
-rw-r--r--src/Agent.Core/Runtime/PlannerService.cs8
-rw-r--r--tests/Agent.Core.Tests/ConfigurationTests.cs30
-rw-r--r--tests/Agent.Core.Tests/ObservabilityTests.cs7
-rw-r--r--tests/Agent.Core.Tests/OllamaClientTests.cs54
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs13
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));
}
}