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 /src | |
| 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.
Diffstat (limited to 'src')
| -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 |
7 files changed, 20 insertions, 16 deletions
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) |
