From 1fac813169c4a5048f441fec19dbf114c75d6233 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 7 May 2026 05:34:54 +0000 Subject: [PATCH 1/4] fix(llm): route DeepSeek and Gemini through dedicated rig-core clients (#3201, #3225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek thinking-mode (#3201) and Gemini API-key tool calling (#3225) both failed deterministically on the second LLM turn: - DeepSeek: HTTP 400 "The reasoning_content in the thinking mode must be passed back to the API" - Gemini: HTTP 400 "Function call is missing a thought_signature in functionCall parts" Both providers were configured in `providers.json` as `protocol: "open_ai_completions"`, which dispatched them through rig-core's generic OpenAI client. That client silently strips `reasoning_content` from assistant messages and `thought_signature` from tool calls when serializing the next turn — so the field that the upstream API requires to be echoed back was never sent. rig-core actually has dedicated `deepseek::Client` and `gemini::Client` implementations that handle the round-trip correctly: - `deepseek.rs:500-527` — captures `reasoning_content` from the response and writes it back onto the last assistant message in the next request. - `gemini/completion.rs:1016` — round-trips `thought_signature` on every `ToolCall` via `ToolCall.signature`. Add `ProviderProtocol::DeepSeek` and `ProviderProtocol::Gemini` variants plus matching factory branches in `create_registry_provider`, and update the two registry entries to use them. Both factories follow the same shape as `create_anthropic_from_registry` and wrap the rig-core client in our existing `RigAdapter` — no new HTTP code, no agent-loop changes. The `default_base_url` for both entries is now empty so the dedicated clients use their built-in endpoints (`api.deepseek.com` / `generativelanguage.googleapis.com`) rather than the OpenAI-compat shims. Co-Authored-By: Claude Opus 4.7 (1M context) --- providers.json | 12 ++--- src/llm/mod.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++ src/llm/registry.rs | 37 ++++++++++++++++ 3 files changed, 147 insertions(+), 6 deletions(-) diff --git a/providers.json b/providers.json index 517e2a264f1..2d7fd0050d3 100644 --- a/providers.json +++ b/providers.json @@ -246,13 +246,13 @@ "aliases": [ "deep_seek" ], - "protocol": "open_ai_completions", - "default_base_url": "https://api.deepseek.com/v1", + "protocol": "deep_seek", + "default_base_url": "", "api_key_env": "DEEPSEEK_API_KEY", "api_key_required": true, "model_env": "DEEPSEEK_MODEL", "default_model": "deepseek-chat", - "description": "DeepSeek inference API", + "description": "DeepSeek inference API (preserves reasoning_content for thinking-mode models)", "setup": { "kind": "api_key", "secret_name": "llm_deepseek_api_key", @@ -325,13 +325,13 @@ "google_gemini", "google" ], - "protocol": "open_ai_completions", - "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", + "protocol": "gemini", + "default_base_url": "", "api_key_env": "GEMINI_API_KEY", "api_key_required": true, "model_env": "GEMINI_MODEL", "default_model": "gemini-2.5-flash", - "description": "Google Gemini (via OpenAI-compatible endpoint)", + "description": "Google Gemini native API (preserves thought_signature on tool calls)", "setup": { "kind": "api_key", "secret_name": "llm_gemini_api_key", diff --git a/src/llm/mod.rs b/src/llm/mod.rs index b0fdbd00268..3feea12bd4d 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -187,6 +187,8 @@ fn create_registry_provider( ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config), ProviderProtocol::Anthropic => create_anthropic_from_registry(config), ProviderProtocol::Ollama => create_ollama_from_registry(config), + ProviderProtocol::DeepSeek => create_deepseek_from_registry(config), + ProviderProtocol::Gemini => create_gemini_from_registry(config), ProviderProtocol::GithubCopilot => { let provider = github_copilot::GithubCopilotProvider::new(config, request_timeout_secs)?; @@ -424,6 +426,108 @@ fn create_ollama_from_registry( Ok(Arc::new(adapter)) } +/// Build a DeepSeek provider via rig-core's dedicated DeepSeek client. +/// +/// Routing through this client (rather than the generic OpenAI-compat path) +/// is what makes thinking-mode tool calling work: rig-core's DeepSeek +/// implementation captures `reasoning_content` from each response and writes +/// it back onto the assistant message in the next request. Without that +/// round-trip the API rejects the second turn with HTTP 400 ("The +/// reasoning_content in the thinking mode must be passed back to the API"). +/// See #3201. +fn create_deepseek_from_registry( + config: &RegistryProviderConfig, +) -> Result, LlmError> { + use rig::providers::deepseek; + + let api_key = config + .api_key + .as_ref() + .map(|k| k.expose_secret().to_string()) + .ok_or_else(|| LlmError::AuthFailed { + provider: config.provider_id.clone(), + })?; + + let client: deepseek::Client = if config.base_url.is_empty() { + deepseek::Client::new(&api_key) + } else { + deepseek::Client::builder() + .api_key(&api_key) + .base_url(&config.base_url) + .build() + } + .map_err(|e| LlmError::RequestFailed { + provider: config.provider_id.clone(), + reason: format!("Failed to create DeepSeek client: {e}"), + })?; + + let model = client.completion_model(&config.model); + + tracing::debug!( + provider = %config.provider_id, + model = %config.model, + base_url = if config.base_url.is_empty() { "default" } else { &config.base_url }, + "Using DeepSeek provider (preserves reasoning_content across turns)" + ); + + Ok(Arc::new( + RigAdapter::new(model, &config.model) + .with_unsupported_params(config.unsupported_params.clone()), + )) +} + +/// Build a Gemini provider via rig-core's dedicated Gemini client. +/// +/// Routing through this client (rather than the generic OpenAI-compat path +/// at `/v1beta/openai`) is what makes Gemini thinking-mode tool calling +/// work: rig-core's Gemini implementation round-trips `thought_signature` +/// on each `functionCall`. Without that round-trip the API rejects the +/// next turn with HTTP 400 ("Function call is missing a thought_signature +/// in functionCall parts"). See #3225. +/// +/// This is API-key auth only (`GEMINI_API_KEY`). Users on Gemini OAuth go +/// through the separate `gemini_oauth` backend. +fn create_gemini_from_registry( + config: &RegistryProviderConfig, +) -> Result, LlmError> { + use rig::providers::gemini; + + let api_key = config + .api_key + .as_ref() + .map(|k| k.expose_secret().to_string()) + .ok_or_else(|| LlmError::AuthFailed { + provider: config.provider_id.clone(), + })?; + + let client: gemini::Client = if config.base_url.is_empty() { + gemini::Client::new(&api_key) + } else { + gemini::Client::builder() + .api_key(&api_key) + .base_url(&config.base_url) + .build() + } + .map_err(|e| LlmError::RequestFailed { + provider: config.provider_id.clone(), + reason: format!("Failed to create Gemini client: {e}"), + })?; + + let model = client.completion_model(&config.model); + + tracing::debug!( + provider = %config.provider_id, + model = %config.model, + base_url = if config.base_url.is_empty() { "default" } else { &config.base_url }, + "Using Gemini provider (preserves thought_signature across turns)" + ); + + Ok(Arc::new( + RigAdapter::new(model, &config.model) + .with_unsupported_params(config.unsupported_params.clone()), + )) +} + /// Create an OpenAI Codex provider with OAuth authentication. /// /// This is async because it needs to ensure authentication before diff --git a/src/llm/registry.rs b/src/llm/registry.rs index 9e2ee7f5a81..609d544f9cf 100644 --- a/src/llm/registry.rs +++ b/src/llm/registry.rs @@ -39,6 +39,14 @@ pub enum ProviderProtocol { Ollama, /// GitHub Copilot API (OpenAI-compatible with token exchange). GithubCopilot, + /// DeepSeek API. Routes through rig-core's dedicated DeepSeek client, + /// which round-trips `reasoning_content` for thinking-mode models — + /// the generic OpenAI client strips it. (#3201) + DeepSeek, + /// Google Gemini native API. Routes through rig-core's dedicated Gemini + /// client, which round-trips `thought_signature` on tool calls — + /// the OpenAI-compat shim strips it. (#3225) + Gemini, } /// How the setup wizard should collect credentials for this provider. @@ -478,6 +486,35 @@ mod tests { } } + /// Regression for #3201 / #3225 — DeepSeek and Gemini must NOT use the + /// generic `OpenAiCompletions` protocol. The OpenAI-compat path goes + /// through rig-core's OpenAI client, which strips `reasoning_content` + /// (DeepSeek) and `thought_signature` (Gemini), breaking multi-turn + /// tool calling for thinking-mode models. They must route through the + /// dedicated rig-core providers via `DeepSeek` / `Gemini` protocol. + #[test] + fn deepseek_and_gemini_use_dedicated_protocol_not_openai_compat() { + let providers: Vec = + serde_json::from_str(include_str!("../../providers.json")).unwrap(); + let by_id = |id: &str| providers.iter().find(|p| p.id == id).cloned(); + + let deepseek = by_id("deepseek").expect("deepseek entry must exist"); + assert_eq!( + deepseek.protocol, + ProviderProtocol::DeepSeek, + "deepseek must use DeepSeek protocol — OpenAiCompletions strips \ + reasoning_content and breaks thinking-mode tool calling (#3201)", + ); + + let gemini = by_id("gemini").expect("gemini entry must exist"); + assert_eq!( + gemini.protocol, + ProviderProtocol::Gemini, + "gemini must use Gemini protocol — OpenAiCompletions strips \ + thought_signature and breaks tool calling on thinking models (#3225)", + ); + } + #[test] fn test_openai_compatible_providers_have_base_url() { let providers: Vec = From 7632b2a562ef3d74f0afbf391220999dfc983696 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 7 May 2026 05:47:28 +0000 Subject: [PATCH 2/4] fix(llm): also route OpenRouter through dedicated rig-core client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same bug class as DeepSeek (#3201) and Gemini (#3225): OpenRouter was configured as `protocol: "open_ai_completions"`, dispatching it through rig-core's generic OpenAI client. That client strips OpenRouter's `reasoning`, `reasoning_details` (Summary / Encrypted / Text), and per-tool-call signatures when serializing the next turn — breaking tool calling for every thinking-mode model OpenRouter exposes (Claude with thinking, OpenAI o-series, DeepSeek-R1, Gemini 2.5+, Qwen QwQ, …). rig-core's dedicated OpenRouter client (`openrouter/completion.rs`) round-trips all of it correctly, including the `reasoning_details` -> `tool_call.signature` mapping at lines 290-308. Add `ProviderProtocol::OpenRouter`, a `create_openrouter_from_registry` factory that preserves OpenRouter attribution headers (`HTTP-Referer`, `X-Title`), and update the registry entry. Extend the regression test to lock the routing for all three providers. Co-Authored-By: Claude Opus 4.7 (1M context) --- providers.json | 6 ++-- src/llm/mod.rs | 72 +++++++++++++++++++++++++++++++++++++++++++++ src/llm/registry.rs | 34 ++++++++++++++++----- 3 files changed, 102 insertions(+), 10 deletions(-) diff --git a/providers.json b/providers.json index 2d7fd0050d3..a91a63bd92f 100644 --- a/providers.json +++ b/providers.json @@ -124,13 +124,13 @@ "aliases": [ "open_router" ], - "protocol": "open_ai_completions", - "default_base_url": "https://openrouter.ai/api/v1", + "protocol": "open_router", + "default_base_url": "", "api_key_env": "OPENROUTER_API_KEY", "api_key_required": true, "model_env": "OPENROUTER_MODEL", "default_model": "openai/gpt-4o", - "description": "OpenRouter multi-provider gateway (200+ models)", + "description": "OpenRouter multi-provider gateway (200+ models, preserves reasoning across turns)", "setup": { "kind": "api_key", "secret_name": "llm_openrouter_api_key", diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 3feea12bd4d..f72d42add61 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -189,6 +189,7 @@ fn create_registry_provider( ProviderProtocol::Ollama => create_ollama_from_registry(config), ProviderProtocol::DeepSeek => create_deepseek_from_registry(config), ProviderProtocol::Gemini => create_gemini_from_registry(config), + ProviderProtocol::OpenRouter => create_openrouter_from_registry(config), ProviderProtocol::GithubCopilot => { let provider = github_copilot::GithubCopilotProvider::new(config, request_timeout_secs)?; @@ -476,6 +477,77 @@ fn create_deepseek_from_registry( )) } +/// Build an OpenRouter provider via rig-core's dedicated OpenRouter client. +/// +/// Routing through this client (rather than the generic OpenAI-compat path) +/// preserves OpenRouter's `reasoning`, `reasoning_details`, and per-tool-call +/// signatures across turns. The generic OpenAI client strips all of them, so +/// any thinking-mode model accessed via OpenRouter (Claude with thinking, +/// OpenAI o-series, DeepSeek-R1, Gemini 2.5+, Qwen QwQ, …) loses its +/// reasoning artifacts on the assistant message and the next request fails +/// the same way as #3201 / #3225. +fn create_openrouter_from_registry( + config: &RegistryProviderConfig, +) -> Result, LlmError> { + use rig::providers::openrouter; + + let api_key = config + .api_key + .as_ref() + .map(|k| k.expose_secret().to_string()) + .ok_or_else(|| LlmError::AuthFailed { + provider: config.provider_id.clone(), + })?; + + // OpenRouter attribution headers (`HTTP-Referer`, `X-Title`) and any other + // user-configured extras must follow the request through. + let mut extra_headers = reqwest::header::HeaderMap::new(); + for (key, value) in &config.extra_headers { + let name = match reqwest::header::HeaderName::from_bytes(key.as_bytes()) { + Ok(n) => n, + Err(e) => { + tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid name"); + continue; + } + }; + let val = match reqwest::header::HeaderValue::from_str(value) { + Ok(v) => v, + Err(e) => { + tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid value"); + continue; + } + }; + extra_headers.insert(name, val); + } + + let mut builder = openrouter::Client::builder().api_key(&api_key); + if !config.base_url.is_empty() { + builder = builder.base_url(&config.base_url); + } + if !extra_headers.is_empty() { + builder = builder.http_headers(extra_headers); + } + + let client: openrouter::Client = builder.build().map_err(|e| LlmError::RequestFailed { + provider: config.provider_id.clone(), + reason: format!("Failed to create OpenRouter client: {e}"), + })?; + + let model = client.completion_model(&config.model); + + tracing::debug!( + provider = %config.provider_id, + model = %config.model, + base_url = if config.base_url.is_empty() { "default" } else { &config.base_url }, + "Using OpenRouter provider (preserves reasoning + signatures across turns)" + ); + + Ok(Arc::new( + RigAdapter::new(model, &config.model) + .with_unsupported_params(config.unsupported_params.clone()), + )) +} + /// Build a Gemini provider via rig-core's dedicated Gemini client. /// /// Routing through this client (rather than the generic OpenAI-compat path diff --git a/src/llm/registry.rs b/src/llm/registry.rs index 609d544f9cf..f3df14ec666 100644 --- a/src/llm/registry.rs +++ b/src/llm/registry.rs @@ -47,6 +47,13 @@ pub enum ProviderProtocol { /// client, which round-trips `thought_signature` on tool calls — /// the OpenAI-compat shim strips it. (#3225) Gemini, + /// OpenRouter (multi-model gateway). Routes through rig-core's dedicated + /// OpenRouter client, which round-trips `reasoning`, `reasoning_details` + /// (Summary / Encrypted / Text), and per-tool-call signatures — + /// the generic OpenAI client strips all of them, breaking thinking-mode + /// tool calling on every reasoning model OpenRouter exposes (Claude with + /// thinking, OpenAI o-series, DeepSeek-R1, Gemini 2.5+, Qwen QwQ, …). + OpenRouter, } /// How the setup wizard should collect credentials for this provider. @@ -486,14 +493,17 @@ mod tests { } } - /// Regression for #3201 / #3225 — DeepSeek and Gemini must NOT use the - /// generic `OpenAiCompletions` protocol. The OpenAI-compat path goes - /// through rig-core's OpenAI client, which strips `reasoning_content` - /// (DeepSeek) and `thought_signature` (Gemini), breaking multi-turn - /// tool calling for thinking-mode models. They must route through the - /// dedicated rig-core providers via `DeepSeek` / `Gemini` protocol. + /// Regression for #3201 / #3225 and the OpenRouter generalisation: + /// providers whose APIs return reasoning artifacts (DeepSeek's + /// `reasoning_content`, Gemini's `thought_signature`, OpenRouter's + /// `reasoning_details` + signatures) must NOT use the generic + /// `OpenAiCompletions` protocol. The OpenAI-compat path goes through + /// rig-core's OpenAI client, which strips those fields, breaking + /// multi-turn tool calling for every thinking-mode model these + /// providers expose. They must route through the dedicated rig-core + /// clients which round-trip the artifacts on the next request. #[test] - fn deepseek_and_gemini_use_dedicated_protocol_not_openai_compat() { + fn reasoning_aware_providers_use_dedicated_protocol_not_openai_compat() { let providers: Vec = serde_json::from_str(include_str!("../../providers.json")).unwrap(); let by_id = |id: &str| providers.iter().find(|p| p.id == id).cloned(); @@ -513,6 +523,16 @@ mod tests { "gemini must use Gemini protocol — OpenAiCompletions strips \ thought_signature and breaks tool calling on thinking models (#3225)", ); + + let openrouter = by_id("openrouter").expect("openrouter entry must exist"); + assert_eq!( + openrouter.protocol, + ProviderProtocol::OpenRouter, + "openrouter must use OpenRouter protocol — OpenAiCompletions \ + strips reasoning_details and tool-call signatures, breaking \ + every thinking-mode model OpenRouter exposes (Claude with \ + thinking, OpenAI o-series, DeepSeek-R1, Gemini 2.5+, Qwen QwQ)", + ); } #[test] From 31297866fd1261fb5cfc6e081a2eece5ee51269c Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 7 May 2026 05:55:17 +0000 Subject: [PATCH 3/4] ci: trigger refs/pull/3326/head regeneration From 3a0b3a5d07677e9873434ca950adcae230a2b9b6 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 7 May 2026 16:24:55 +0000 Subject: [PATCH 4/4] fix(llm): round-trip reasoning + signatures through RigAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #3326. The original PR routed DeepSeek, Gemini, and OpenRouter through rig-core's dedicated clients, but the fix only worked inside rig-core. IronClaw's RigAdapter sits between the agent loop and rig-core and was dropping AssistantContent::Reasoning (DeepSeek `reasoning_content`) and per-tool-call `signature` (Gemini `thought_signature`) on the response → IronClaw conversion. On the next request it rebuilt rig messages without either field, so the provider rejected the follow-up turn — same HTTP 400s the PR claimed to fix. Round-trip both fields end-to-end: - Add `ChatMessage::reasoning` and `ToolCall.signature` (skip-serialized when None) plus `ToolCompletionResponse.reasoning` to carry artifacts out of the provider. - Update `RigAdapter::extract_response` to capture both, and `convert_messages` to push `AssistantContent::Reasoning` and `ToolCall.signature` back when rebuilding rig messages on the next turn. - Plumb response.reasoning through dispatcher, job worker, container worker, routine engine, and the orchestrator-worker proxy via a new `ChatMessage::with_reasoning` builder. Other review fixes: - Set `can_list_models: false` for Gemini in providers.json — setup wizard and web list-models handler don't speak native Gemini, so exposing the button reliably falls back to manual entry. - Add `extra_headers_env: OPENROUTER_EXTRA_HEADERS` so users of the built-in `openrouter` backend can configure HTTP-Referer / X-Title attribution headers. - New `sanitize_gemini_base_url` discards the legacy `…/v1beta/openai` shim URL persisted by pre-3225 installs in `llm_builtin_overrides[gemini].base_url`. Without this, upgraded users would hit `…/v1beta/openai/v1beta/models/...:generateContent`. - Include `provider_id` in extra-header warning logs (OpenRouter + OpenAI-compat). Regression tests: - `reasoning_and_signature_round_trip_through_chat_message` simulates the 2-turn tool loop end-to-end. - `chat_message_with_reasoning_drops_empty_input` locks the empty-input contract so we don't echo `reasoning_content: ""`. - `sanitize_gemini_base_url_*` cover legacy-shim discard, empty input, and custom-endpoint pass-through. Co-Authored-By: Claude Opus 4.7 (1M context) --- providers.json | 3 +- src/agent/agent_loop.rs | 1 + src/agent/agentic_loop.rs | 25 ++- src/agent/dispatcher.rs | 32 +++- src/agent/routine_engine.rs | 16 +- src/agent/session.rs | 5 + src/agent/thread_ops.rs | 2 + src/bridge/llm_adapter.rs | 7 + src/bridge/router.rs | 1 + src/channels/web/openai_compat.rs | 3 + src/llm/anthropic_oauth.rs | 3 + src/llm/bedrock.rs | 14 ++ src/llm/codex_chatgpt.rs | 3 + src/llm/failover.rs | 2 + src/llm/gemini_oauth.rs | 4 + src/llm/github_copilot.rs | 3 + src/llm/mod.rs | 131 ++++++++++++++- src/llm/nearai_chat.rs | 9 ++ src/llm/openai_codex_provider.rs | 8 + src/llm/provider.rs | 49 +++++- src/llm/reasoning.rs | 21 +++ src/llm/response_cache.rs | 1 + src/llm/rig_adapter.rs | 245 +++++++++++++++++++++++++++-- src/orchestrator/api.rs | 1 + src/testing/mod.rs | 1 + src/tools/builder/core.rs | 1 + src/worker/api.rs | 7 + src/worker/container.rs | 14 +- src/worker/job.rs | 18 ++- tests/admin_tool_policy_e2e.rs | 1 + tests/openai_compat_integration.rs | 4 + tests/provider_chaos.rs | 3 + tests/support/trace_llm.rs | 3 + 33 files changed, 590 insertions(+), 51 deletions(-) diff --git a/providers.json b/providers.json index a91a63bd92f..8ee6460b5a1 100644 --- a/providers.json +++ b/providers.json @@ -129,6 +129,7 @@ "api_key_env": "OPENROUTER_API_KEY", "api_key_required": true, "model_env": "OPENROUTER_MODEL", + "extra_headers_env": "OPENROUTER_EXTRA_HEADERS", "default_model": "openai/gpt-4o", "description": "OpenRouter multi-provider gateway (200+ models, preserves reasoning across turns)", "setup": { @@ -337,7 +338,7 @@ "secret_name": "llm_gemini_api_key", "key_url": "https://aistudio.google.com/app/apikey", "display_name": "Google Gemini", - "can_list_models": true + "can_list_models": false } }, { diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 8d08cdfecaa..561f9ca6c45 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -2223,6 +2223,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/src/agent/agentic_loop.rs b/src/agent/agentic_loop.rs index c2b1022e207..3c4b6a571ed 100644 --- a/src/agent/agentic_loop.rs +++ b/src/agent/agentic_loop.rs @@ -127,6 +127,7 @@ pub trait LoopDelegate: Send + Sync { tool_calls: Vec, content: Option, reason_ctx: &mut ReasoningContext, + reasoning: Option, ) -> Result, Error>; /// Called when the LLM expresses tool intent without actually calling a tool. @@ -253,6 +254,7 @@ pub async fn run_agentic_loop( RespondResult::ToolCalls { tool_calls, content, + reasoning: _, } => { let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect(); tracing::debug!( @@ -307,6 +309,7 @@ pub async fn run_agentic_loop( RespondResult::ToolCalls { tool_calls, content, + reasoning, } => { // If the response was truncated, tool call parameters are likely // incomplete. Discard them and tell the LLM to try a different @@ -345,7 +348,7 @@ pub async fn run_agentic_loop( reason_ctx.last_tool_batch_all_failed = false; if let Some(outcome) = delegate - .execute_tool_calls(tool_calls, content, reason_ctx) + .execute_tool_calls(tool_calls, content, reason_ctx, reasoning) .await? { return Ok(outcome); @@ -434,6 +437,7 @@ mod tests { result: RespondResult::ToolCalls { tool_calls: calls, content: None, + reasoning: None, }, usage: zero_usage(), finish_reason: FinishReason::ToolUse, @@ -529,6 +533,7 @@ mod tests { _tool_calls: Vec, _content: Option, reason_ctx: &mut ReasoningContext, + _reasoning: Option, ) -> Result, crate::error::Error> { self.tool_exec_count.fetch_add(1, Ordering::SeqCst); reason_ctx @@ -577,6 +582,7 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }; let delegate = MockDelegate::new(vec![ tool_calls_output(vec![tool_call]), @@ -688,6 +694,7 @@ mod tests { _: Vec, _: Option, _: &mut ReasoningContext, + _: Option, ) -> Result, crate::error::Error> { Ok(None) } @@ -748,6 +755,7 @@ mod tests { _: Vec, _: Option, _: &mut ReasoningContext, + _: Option, ) -> Result, crate::error::Error> { Ok(None) } @@ -866,11 +874,13 @@ mod tests { name: "memory_write".to_string(), arguments: serde_json::json!({}), // empty — truncated reasoning: None, + signature: None, }; let truncated_output = RespondOutput { result: RespondResult::ToolCalls { tool_calls: vec![truncated_tool_call], content: Some("I'll write the report.".to_string()), + reasoning: None, }, usage: zero_usage(), finish_reason: FinishReason::Length, // response was truncated @@ -918,8 +928,10 @@ mod tests { name: "memory_write".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }], content: None, + reasoning: None, }, usage: zero_usage(), finish_reason: FinishReason::Length, @@ -962,6 +974,7 @@ mod tests { name: "echo".into(), arguments: serde_json::json!({"msg": "hi"}), reasoning: None, + signature: None, }]; let fp = DuplicateToolCallTracker::fingerprint(&calls); // Tool succeeded — count stays at 0 @@ -977,6 +990,7 @@ mod tests { name: "http_get".into(), arguments: serde_json::json!({"url": "https://example.com"}), reasoning: None, + signature: None, }]; let fp = DuplicateToolCallTracker::fingerprint(&calls); assert_eq!(tracker.record_with_fingerprint(fp, true), 1); @@ -992,6 +1006,7 @@ mod tests { name: "http_get".into(), arguments: serde_json::json!({"url": "https://example.com"}), reasoning: None, + signature: None, }]; let fp = DuplicateToolCallTracker::fingerprint(&calls); assert_eq!(tracker.record_with_fingerprint(fp, true), 1); @@ -1010,12 +1025,14 @@ mod tests { name: "http_get".into(), arguments: serde_json::json!({"url": "https://a.com"}), reasoning: None, + signature: None, }]; let calls_b = vec![ToolCall { id: "c1".into(), name: "http_get".into(), arguments: serde_json::json!({"url": "https://b.com"}), reasoning: None, + signature: None, }]; let fp_a = DuplicateToolCallTracker::fingerprint(&calls_a); let fp_b = DuplicateToolCallTracker::fingerprint(&calls_b); @@ -1033,12 +1050,14 @@ mod tests { name: "echo".into(), arguments: serde_json::json!({"a": 1, "b": 2}), reasoning: None, + signature: None, }]; let calls_b = vec![ToolCall { id: "c1".into(), name: "echo".into(), arguments: serde_json::json!({"b": 2, "a": 1}), reasoning: None, + signature: None, }]; assert_eq!( DuplicateToolCallTracker::fingerprint(&calls_a), @@ -1055,6 +1074,7 @@ mod tests { name: "http_get".to_string(), arguments: serde_json::json!({"url": "https://broken.example.com"}), reasoning: None, + signature: None, }; // 3 identical failing tool calls, then text response let mut delegate = MockDelegate::new(vec![ @@ -1103,6 +1123,7 @@ mod tests { name: "http_get".to_string(), arguments: serde_json::json!({"url": "https://broken.example.com"}), reasoning: None, + signature: None, }; // 5 identical failing tool calls, then text response let mut delegate = MockDelegate::new(vec![ @@ -1140,6 +1161,7 @@ mod tests { name: "http_get".to_string(), arguments: serde_json::json!({"url": "https://broken.example.com"}), reasoning: None, + signature: None, }; // 2 failing calls, then a text continuation, then 2 more of the same failing calls // The text response in the middle should reset the streak, so we never hit 3. @@ -1193,6 +1215,7 @@ mod tests { _: Vec, _: Option, reason_ctx: &mut ReasoningContext, + _reasoning: Option, ) -> Result, crate::error::Error> { self.tool_exec_count.fetch_add(1, Ordering::SeqCst); reason_ctx.messages.push(ChatMessage::user("tool error")); diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 86c6964f535..53cab168e08 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -766,6 +766,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> { tool_calls: Vec, content: Option, reason_ctx: &mut ReasoningContext, + reasoning: Option, ) -> Result, Error> { // Extract and sanitize the narrative before consuming `content`. let narrative = content @@ -782,12 +783,12 @@ impl<'a> LoopDelegate for ChatDelegate<'a> { // Add the assistant message with tool_calls to context. // OpenAI protocol requires this before tool-result messages. - reason_ctx - .messages - .push(ChatMessage::assistant_with_tool_calls( - content, - tool_calls.clone(), - )); + // Carry reasoning so the next request can echo it back — required for + // DeepSeek thinking-mode and Gemini 2.5+ to validate the chain (#3201, #3225). + reason_ctx.messages.push( + ChatMessage::assistant_with_tool_calls(content, tool_calls.clone()) + .with_reasoning(reasoning), + ); // Execute tools and add results to context let _ = self @@ -1888,6 +1889,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -1930,6 +1932,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -1973,6 +1976,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }); } @@ -1984,12 +1988,14 @@ mod tests { name: "tool_activate".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }, ToolCall { id: crate::llm::generate_tool_call_id(0, 1), name: "approval_tool".to_string(), arguments: serde_json::json!({"target": "danger"}), reasoning: None, + signature: None, }, ], input_tokens: 0, @@ -1997,6 +2003,7 @@ mod tests { finish_reason: FinishReason::ToolUse, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -2334,12 +2341,14 @@ mod tests { name: "http".to_string(), arguments: serde_json::json!({"url": "https://example.com"}), reasoning: None, + signature: None, }, ToolCall { id: "call_3".to_string(), name: "echo".to_string(), arguments: serde_json::json!({"message": "done"}), reasoning: None, + signature: None, }, ], selected_auth_prompt: Some(crate::agent::session::PendingAuthPrompt::new( @@ -2843,6 +2852,7 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({"message": "hi"}), reasoning: None, + signature: None, }], ), ChatMessage::tool_result("call_1", "echo", "hi"), @@ -2936,12 +2946,14 @@ mod tests { name: "http".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }, ToolCall { id: "c2".to_string(), name: "echo".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }, ], ), @@ -2976,6 +2988,7 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }], ), ChatMessage::tool_result("c1", "echo", "done"), @@ -3097,6 +3110,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }); } // Tools available: always call one. @@ -3107,12 +3121,14 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({"message": "looping"}), reasoning: None, + signature: None, }], input_tokens: 0, output_tokens: 5, finish_reason: FinishReason::ToolUse, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -3306,6 +3322,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }); } // Always call a tool that does not exist in the registry. @@ -3316,12 +3333,14 @@ mod tests { name: "nonexistent_tool".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }], input_tokens: 0, output_tokens: 5, finish_reason: FinishReason::ToolUse, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -3372,6 +3391,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index 9a30823ac2c..53dae89ef4a 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -1937,11 +1937,17 @@ async fn execute_lightweight_with_tools( ); } - // LLM returned tool calls: add assistant message and execute tools - messages.push(ChatMessage::assistant_with_tool_calls( - response.content.clone(), - response.tool_calls.clone(), - )); + // LLM returned tool calls: add assistant message and execute tools. + // Carry reasoning so the next request can echo it — required for + // DeepSeek thinking-mode and Gemini 2.5+ to validate the chain + // (#3201, #3225). + messages.push( + ChatMessage::assistant_with_tool_calls( + response.content.clone(), + response.tool_calls.clone(), + ) + .with_reasoning(response.reasoning.clone()), + ); // Execute tools sequentially for tc in response.tool_calls { diff --git a/src/agent/session.rs b/src/agent/session.rs index 2e6c92ec00b..2d537b8cca6 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -553,6 +553,7 @@ impl Thread { name: tc.name.clone(), arguments: tc.parameters.clone(), reasoning: None, + signature: None, }) .collect(); @@ -1525,6 +1526,7 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"q": "test"}), reasoning: None, + signature: None, }; let messages = vec![ ChatMessage::user("Find test"), @@ -1556,6 +1558,7 @@ mod tests { name: "http".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }; let messages = vec![ ChatMessage::user("Fetch URL"), @@ -1622,12 +1625,14 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"q": "data"}), reasoning: None, + signature: None, }; let tc2 = ToolCall { id: "call_b".to_string(), name: "write".to_string(), arguments: serde_json::json!({"path": "out.txt"}), reasoning: None, + signature: None, }; let messages = vec![ ChatMessage::user("Find and save"), diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index 0468c686533..8200e893916 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -2662,6 +2662,7 @@ fn rebuild_chat_messages_from_db( .get("rationale") .and_then(|v| v.as_str()) .map(String::from), + signature: None, }) .collect(); @@ -2804,6 +2805,7 @@ mod tests { finish_reason: crate::llm::FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/src/bridge/llm_adapter.rs b/src/bridge/llm_adapter.rs index eebcf8f18a2..f9216ed85b2 100644 --- a/src/bridge/llm_adapter.rs +++ b/src/bridge/llm_adapter.rs @@ -403,6 +403,7 @@ fn thread_msg_to_chat(msg: &ThreadMessage) -> ChatMessage { tool_call_id: msg.action_call_id.clone(), name: msg.action_name.clone(), tool_calls: None, + reasoning: None, }; // Convert action calls if present (assistant message with tool calls) @@ -415,6 +416,7 @@ fn thread_msg_to_chat(msg: &ThreadMessage) -> ChatMessage { name: c.action_name.clone(), arguments: c.parameters.clone(), reasoning: None, + signature: None, }) .collect(), ); @@ -685,6 +687,7 @@ mod tests { finish_reason: crate::llm::FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -851,6 +854,7 @@ mod tests { finish_reason: crate::llm::FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -1576,12 +1580,14 @@ And also check the token price:\n\ "project_id": "{{call-1.project_id}}" }), reasoning: None, + signature: None, }], input_tokens: 10, output_tokens: 10, finish_reason: crate::llm::FinishReason::ToolUse, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -1686,6 +1692,7 @@ And also check the token price:\n\ finish_reason: crate::llm::FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/src/bridge/router.rs b/src/bridge/router.rs index 53824063490..33b27de8f5f 100644 --- a/src/bridge/router.rs +++ b/src/bridge/router.rs @@ -7104,6 +7104,7 @@ mod tests { finish_reason: crate::llm::FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/src/channels/web/openai_compat.rs b/src/channels/web/openai_compat.rs index 3121c62562e..d59cd02687b 100644 --- a/src/channels/web/openai_compat.rs +++ b/src/channels/web/openai_compat.rs @@ -232,6 +232,7 @@ pub fn convert_messages(messages: &[OpenAiMessage]) -> Result, arguments: serde_json::from_str(&tc.function.arguments) .unwrap_or(serde_json::Value::Object(Default::default())), reasoning: None, + signature: None, }) .collect(); Ok(ChatMessage::assistant_with_tool_calls( @@ -249,6 +250,7 @@ pub fn convert_messages(messages: &[OpenAiMessage]) -> Result, tool_call_id: None, name: m.name.clone(), tool_calls: None, + reasoning: None, }), } }) @@ -956,6 +958,7 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"query": "rust"}), reasoning: None, + signature: None, }]; let converted = convert_tool_calls_to_openai(&calls); diff --git a/src/llm/anthropic_oauth.rs b/src/llm/anthropic_oauth.rs index 97748602e56..6dd20e875f5 100644 --- a/src/llm/anthropic_oauth.rs +++ b/src/llm/anthropic_oauth.rs @@ -350,6 +350,7 @@ impl LlmProvider for AnthropicOAuthProvider { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens, cache_creation_input_tokens: response.usage.cache_creation_input_tokens, + reasoning: None, cache_read_input_tokens: response.usage.cache_read_input_tokens, }) } @@ -580,6 +581,7 @@ fn extract_response_content(response: &AnthropicResponse) -> (Option, Ve name: name.clone(), arguments: input.clone(), reasoning: None, + signature: None, }); } } @@ -629,6 +631,7 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"q": "test"}), reasoning: None, + signature: None, }]; let messages = vec![ ChatMessage::user("Search for test"), diff --git a/src/llm/bedrock.rs b/src/llm/bedrock.rs index 4326cabbb8e..cb37713e108 100644 --- a/src/llm/bedrock.rs +++ b/src/llm/bedrock.rs @@ -206,6 +206,7 @@ impl LlmProvider for BedrockProvider { output_tokens, finish_reason: map_stop_reason(response.stop_reason()), cache_creation_input_tokens: 0, + reasoning: None, cache_read_input_tokens: 0, }) } @@ -578,6 +579,7 @@ fn extract_content_blocks( name: tu.name().to_string(), arguments: document_to_json(tu.input()), reasoning: None, + signature: None, }); } // Ignore reasoning, citations, images, etc. @@ -816,12 +818,14 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({"text": "hi"}), reasoning: None, + signature: None, }; let tc2 = crate::llm::provider::ToolCall { id: "call_2".to_string(), name: "time".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }; let messages = vec![ @@ -861,6 +865,7 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"query": "test"}), reasoning: None, + signature: None, }; let messages = vec![ @@ -885,6 +890,7 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }; let messages = vec![ @@ -978,6 +984,7 @@ mod tests { name: Some("echo".to_string()), tool_calls: None, content_parts: Vec::new(), + reasoning: None, }, ]; @@ -1050,12 +1057,14 @@ mod tests { name: "get_weather".to_string(), arguments: serde_json::json!({"city": "NYC"}), reasoning: None, + signature: None, }; let tc2 = crate::llm::provider::ToolCall { id: "call_def".to_string(), name: "get_time".to_string(), arguments: serde_json::json!({"tz": "EST"}), reasoning: None, + signature: None, }; let messages = vec![ @@ -1218,6 +1227,7 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({"text": "hi"}), reasoning: None, + signature: None, }; let mut messages = vec![ @@ -1262,6 +1272,7 @@ mod tests { name: "get_weather".to_string(), arguments: serde_json::json!({"city": "NYC"}), reasoning: None, + signature: None, }; let mut messages = vec![ @@ -1299,6 +1310,7 @@ mod tests { name: "time".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }; let mut messages = vec![ @@ -1336,6 +1348,7 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"q": "test"}), reasoning: None, + signature: None, }; let mut messages = vec![ @@ -1391,6 +1404,7 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }; let mut messages = vec![ diff --git a/src/llm/codex_chatgpt.rs b/src/llm/codex_chatgpt.rs index e7dcf40da0a..adc7755cd38 100644 --- a/src/llm/codex_chatgpt.rs +++ b/src/llm/codex_chatgpt.rs @@ -733,6 +733,7 @@ impl LlmProvider for CodexChatGptProvider { name: tc.name, arguments: args, reasoning: None, + signature: None, } }) .collect(); @@ -755,6 +756,7 @@ impl LlmProvider for CodexChatGptProvider { finish_reason, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -827,6 +829,7 @@ mod tests { name: "search".to_string(), arguments: json!({"query": "rust"}), reasoning: None, + signature: None, }; let msg = ChatMessage::assistant_with_tool_calls(Some("thinking...".into()), vec![tc]); let items = CodexChatGptProvider::message_to_input_items(&msg); diff --git a/src/llm/failover.rs b/src/llm/failover.rs index a23934d1e73..1642a739ae8 100644 --- a/src/llm/failover.rs +++ b/src/llm/failover.rs @@ -423,6 +423,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }))), } } @@ -833,6 +834,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } diff --git a/src/llm/gemini_oauth.rs b/src/llm/gemini_oauth.rs index 9e035be99c4..3766e6b4422 100644 --- a/src/llm/gemini_oauth.rs +++ b/src/llm/gemini_oauth.rs @@ -1939,6 +1939,7 @@ impl GeminiOauthProvider { name, arguments: args, reasoning: None, + signature: None, }); } } @@ -2162,6 +2163,7 @@ impl LlmProvider for GeminiOauthProvider { tool_calls, cache_read_input_tokens: response.cache_read_input_tokens, cache_creation_input_tokens: response.cache_creation_input_tokens, + reasoning: None, }) } } @@ -2763,6 +2765,7 @@ mod tests { name: "read_file".to_string(), arguments: serde_json::json!({"path": "/tmp/x"}), reasoning: None, + signature: None, }], ), ChatMessage::tool_result("call_1", "read_file", r#"{"output":"hello"}"#), @@ -2802,6 +2805,7 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }], ), ChatMessage::tool_result("call_1", "echo", r#"{"output":"ok"}"#), diff --git a/src/llm/github_copilot.rs b/src/llm/github_copilot.rs index 88be16c9cf0..5868b04f0e3 100644 --- a/src/llm/github_copilot.rs +++ b/src/llm/github_copilot.rs @@ -347,6 +347,7 @@ impl LlmProvider for GithubCopilotProvider { .map(|u| u.completion_tokens) .unwrap_or(0), cache_creation_input_tokens: 0, + reasoning: None, cache_read_input_tokens: 0, }) } @@ -604,6 +605,7 @@ fn extract_choice_content(choice: &OpenAiChoice) -> (Option, Vec n, Err(e) => { - tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid name"); + tracing::warn!( + provider = %config.provider_id, + header = %key, + error = %e, + "Skipping extra header: invalid name", + ); continue; } }; let val = match reqwest::header::HeaderValue::from_str(value) { Ok(v) => v, Err(e) => { - tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid value"); + tracing::warn!( + provider = %config.provider_id, + header = %key, + error = %e, + "Skipping extra header: invalid value", + ); continue; } }; @@ -500,20 +510,32 @@ fn create_openrouter_from_registry( })?; // OpenRouter attribution headers (`HTTP-Referer`, `X-Title`) and any other - // user-configured extras must follow the request through. + // user-configured extras must follow the request through. The `http` crate + // normalizes header names to lowercase internally, so configuring + // `HTTP-Referer` or `X-Title` (canonical OpenRouter spelling) parses fine. let mut extra_headers = reqwest::header::HeaderMap::new(); for (key, value) in &config.extra_headers { let name = match reqwest::header::HeaderName::from_bytes(key.as_bytes()) { Ok(n) => n, Err(e) => { - tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid name"); + tracing::warn!( + provider = %config.provider_id, + header = %key, + error = %e, + "Skipping extra header: invalid name", + ); continue; } }; let val = match reqwest::header::HeaderValue::from_str(value) { Ok(v) => v, Err(e) => { - tracing::warn!(header = %key, error = %e, "Skipping extra header: invalid value"); + tracing::warn!( + provider = %config.provider_id, + header = %key, + error = %e, + "Skipping extra header: invalid value", + ); continue; } }; @@ -572,12 +594,20 @@ fn create_gemini_from_registry( provider: config.provider_id.clone(), })?; - let client: gemini::Client = if config.base_url.is_empty() { + // Pre-3201/3225 installs persisted the OpenAI-shim URL + // (`https://generativelanguage.googleapis.com/v1beta/openai`) under + // `llm_builtin_overrides[gemini].base_url`. Passing that to rig-core's + // native Gemini client would produce + // `…/v1beta/openai/v1beta/models/{model}:generateContent` and break every + // request. Discard any persisted shim URL and use the native default. + let base_url = sanitize_gemini_base_url(&config.base_url); + + let client: gemini::Client = if base_url.is_empty() { gemini::Client::new(&api_key) } else { gemini::Client::builder() .api_key(&api_key) - .base_url(&config.base_url) + .base_url(&base_url) .build() } .map_err(|e| LlmError::RequestFailed { @@ -590,7 +620,7 @@ fn create_gemini_from_registry( tracing::debug!( provider = %config.provider_id, model = %config.model, - base_url = if config.base_url.is_empty() { "default" } else { &config.base_url }, + base_url = if base_url.is_empty() { "default" } else { &base_url }, "Using Gemini provider (preserves thought_signature across turns)" ); @@ -600,6 +630,29 @@ fn create_gemini_from_registry( )) } +/// Discard pre-3225 OpenAI-shim Gemini URLs (`…/v1beta/openai`). +/// +/// Returns the empty string to signal "use rig-core's native default" when the +/// configured base URL is the legacy shim. Other URLs (custom proxies, region +/// endpoints, etc.) pass through unchanged. +fn sanitize_gemini_base_url(base_url: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return String::new(); + } + let lower = trimmed.to_ascii_lowercase(); + if lower.ends_with("/v1beta/openai") || lower.ends_with("/v1/openai") { + tracing::warn!( + stale_base_url = %base_url, + "Ignoring legacy OpenAI-shim base URL for native Gemini provider; \ + using rig-core default. Clear `llm_builtin_overrides[gemini].base_url` \ + in settings to silence this warning." + ); + return String::new(); + } + trimmed.to_string() +} + /// Create an OpenAI Codex provider with OAuth authentication. /// /// This is async because it needs to ensure authentication before @@ -1290,4 +1343,66 @@ mod tests { "https://api.example.com/custom" ); } + + /// Regression for #3225: pre-PR, the configure UI/setup default for + /// Gemini was the OpenAI shim URL ending in `/v1beta/openai`. Once + /// `ProviderProtocol::Gemini` switches to rig-core's native client + /// (which appends `/v1beta/models/{model}:generateContent`), passing + /// the persisted shim URL through would produce + /// `…/v1beta/openai/v1beta/models/...` and break every Gemini call. + /// `sanitize_gemini_base_url` must strip those legacy values. + #[test] + fn sanitize_gemini_base_url_strips_legacy_openai_shim() { + // The exact string the old configure UI persisted. + assert_eq!( + sanitize_gemini_base_url("https://generativelanguage.googleapis.com/v1beta/openai"), + "", + "legacy OpenAI-shim base URL must be discarded so rig-core's \ + native default takes over", + ); + // With trailing slash (also seen in saved overrides). + assert_eq!( + sanitize_gemini_base_url("https://generativelanguage.googleapis.com/v1beta/openai/"), + "", + ); + // Case-insensitive on the suffix match. + assert_eq!( + sanitize_gemini_base_url("https://Generativelanguage.googleapis.com/V1beta/OpenAI"), + "", + ); + // The alternate `/v1/openai` shape (some adapters used this). + assert_eq!( + sanitize_gemini_base_url("https://example.com/v1/openai"), + "", + ); + } + + /// Empty/whitespace-only input must still be treated as "use the default", + /// not get accidentally upgraded to a real URL. + #[test] + fn sanitize_gemini_base_url_passes_through_empty() { + assert_eq!(sanitize_gemini_base_url(""), ""); + assert_eq!(sanitize_gemini_base_url(" "), ""); + } + + /// Custom proxies / region endpoints / native Gemini bases must + /// pass through unchanged (modulo trailing-slash trimming). + #[test] + fn sanitize_gemini_base_url_preserves_custom_endpoints() { + // Native default base (rig-core would also use this). + assert_eq!( + sanitize_gemini_base_url("https://generativelanguage.googleapis.com"), + "https://generativelanguage.googleapis.com", + ); + // Custom proxy. + assert_eq!( + sanitize_gemini_base_url("https://gemini-proxy.internal.example.com"), + "https://gemini-proxy.internal.example.com", + ); + // Trailing slash gets trimmed. + assert_eq!( + sanitize_gemini_base_url("https://gemini-proxy.internal.example.com/"), + "https://gemini-proxy.internal.example.com", + ); + } } diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index b9830164385..3a686f35d21 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -653,6 +653,7 @@ impl LlmProvider for NearAiChatProvider { name: tc.function.name, arguments, reasoning: None, + signature: None, } }) .collect(); @@ -693,6 +694,7 @@ impl LlmProvider for NearAiChatProvider { output_tokens, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } @@ -1357,12 +1359,14 @@ mod tests { name: "list_issues".to_string(), arguments: serde_json::json!({"owner": "foo", "repo": "bar"}), reasoning: None, + signature: None, }, ToolCall { id: "call_2".to_string(), name: "search".to_string(), arguments: serde_json::json!({"query": "test"}), reasoning: None, + signature: None, }, ]; @@ -1476,6 +1480,7 @@ mod tests { name: "test".to_string(), arguments: serde_json::json!({"key": "value"}), reasoning: None, + signature: None, }; let msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]); let chat_msg: ChatCompletionMessage = msg.into(); @@ -1727,6 +1732,7 @@ mod tests { name: tc.function.name, arguments, reasoning: None, + signature: None, } }) .collect(); @@ -1784,6 +1790,7 @@ mod tests { name: tc.function.name, arguments, reasoning: None, + signature: None, } }) .collect(); @@ -1884,6 +1891,7 @@ mod tests { name: tc.function.name, arguments, reasoning: None, + signature: None, } }) .collect(); @@ -2703,6 +2711,7 @@ mod tests { name: "test".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }], ); let chat_msg: ChatCompletionMessage = msg.into(); diff --git a/src/llm/openai_codex_provider.rs b/src/llm/openai_codex_provider.rs index 1e0df402cff..9b0de35cbe7 100644 --- a/src/llm/openai_codex_provider.rs +++ b/src/llm/openai_codex_provider.rs @@ -327,6 +327,7 @@ impl LlmProvider for OpenAiCodexProvider { finish_reason, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } @@ -680,6 +681,7 @@ fn parse_sse_response(body: &str) -> Result { name: state.name, arguments, reasoning: None, + signature: None, }); } else { // Fallback: extract directly from the item @@ -706,6 +708,7 @@ fn parse_sse_response(body: &str) -> Result { name, arguments, reasoning: None, + signature: None, }); } } @@ -784,6 +787,7 @@ fn parse_sse_response(body: &str) -> Result { name: state.name, arguments, reasoning: None, + signature: None, }); } } @@ -880,12 +884,14 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"query": "test"}), reasoning: None, + signature: None, }, ToolCall { id: "call_2".to_string(), name: "read".to_string(), arguments: serde_json::json!({"path": "/tmp"}), reasoning: None, + signature: None, }, ]; let msg = @@ -1242,6 +1248,7 @@ data: {"type":"response.completed","response":{"status":"completed","usage":{"in name: "mcp.server.search".to_string(), arguments: serde_json::json!({"q": "test"}), reasoning: None, + signature: None, }]; let msg = ChatMessage::assistant_with_tool_calls(None, tool_calls); let items = super::convert_message(&msg, 0); @@ -1293,6 +1300,7 @@ data: {"type":"response.completed","response":{"status":"completed","usage":{"in name: "mcp_server_search".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }; if let Some(original) = name_map.get(&tc.name) { tc.name = original.clone(); diff --git a/src/llm/provider.rs b/src/llm/provider.rs index 3daffdc3a37..bd617886908 100644 --- a/src/llm/provider.rs +++ b/src/llm/provider.rs @@ -78,6 +78,14 @@ pub struct ChatMessage { /// to appear on the assistant message preceding tool result messages). #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, + /// Provider-emitted reasoning artifacts (DeepSeek's `reasoning_content`, + /// Gemini's `thought_signature` parts, OpenRouter's `reasoning_details`) + /// captured from the previous response. Required to be echoed back on + /// the next request — DeepSeek thinking-mode and Gemini 2.5+ both reject + /// the next turn with HTTP 400 when the prior assistant message had + /// reasoning that was dropped (#3201, #3225). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, } impl ChatMessage { @@ -90,6 +98,7 @@ impl ChatMessage { tool_call_id: None, name: None, tool_calls: None, + reasoning: None, } } @@ -102,6 +111,7 @@ impl ChatMessage { tool_call_id: None, name: None, tool_calls: None, + reasoning: None, } } @@ -116,6 +126,7 @@ impl ChatMessage { tool_call_id: None, name: None, tool_calls: None, + reasoning: None, } } @@ -128,6 +139,7 @@ impl ChatMessage { tool_call_id: None, name: None, tool_calls: None, + reasoning: None, } } @@ -147,9 +159,24 @@ impl ChatMessage { } else { Some(tool_calls) }, + reasoning: None, } } + /// Attach provider-emitted reasoning artifacts to an assistant message. + /// + /// Required for thinking-mode tool calling on DeepSeek (`reasoning_content`), + /// Gemini 2.5+ (`thought_signature`), and OpenRouter (`reasoning_details`). + /// The provider rejects the next turn with HTTP 400 when the prior + /// assistant message had reasoning that wasn't echoed back. See #3201, #3225. + /// + /// Empty / whitespace-only reasoning is dropped (treated as None) so we + /// don't send `reasoning_content: ""` and trip strict-mode validators. + pub fn with_reasoning(mut self, reasoning: Option) -> Self { + self.reasoning = reasoning.filter(|r| !r.trim().is_empty()); + self + } + /// Create a tool result message. pub fn tool_result( tool_call_id: impl Into, @@ -163,6 +190,7 @@ impl ChatMessage { tool_call_id: Some(tool_call_id.into()), name: Some(name.into()), tool_calls: None, + reasoning: None, } } } @@ -235,12 +263,13 @@ pub struct CompletionResponse { } /// Why the completion finished. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum FinishReason { Stop, Length, ToolUse, ContentFilter, + #[default] Unknown, } @@ -262,6 +291,14 @@ pub struct ToolCall { /// or derived from the shared response content as a fallback. #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, + /// Provider-emitted per-tool-call cryptographic signature (Gemini's + /// `thought_signature`, Anthropic's reasoning signature). Required to be + /// echoed on the next request — Gemini 2.5+ rejects tool-loop turns with + /// HTTP 400 ("Function call is missing a thought_signature in + /// functionCall parts") when the prior tool call's signature was dropped. + /// See #3225. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, } /// Generate a tool-call ID that satisfies all providers. @@ -393,6 +430,12 @@ pub struct ToolCompletionResponse { pub cache_read_input_tokens: u32, /// Tokens written to the provider's server-side prompt cache (Anthropic). pub cache_creation_input_tokens: u32, + /// Provider-emitted reasoning content (DeepSeek `reasoning_content`, + /// Gemini `thought_signature` parts, OpenRouter `reasoning_details`). + /// Callers MUST attach this to the assistant `ChatMessage` they store + /// for the next turn — otherwise the provider rejects the follow-up with + /// HTTP 400 (#3201, #3225). `None` when the model produced no reasoning. + pub reasoning: Option, } /// Metadata about a model returned by the provider's API. @@ -805,6 +848,7 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }; let mut messages = vec![ ChatMessage::user("hello"), @@ -849,6 +893,7 @@ mod tests { name: "echo".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }; let mut messages = vec![ ChatMessage::user("test"), @@ -875,12 +920,14 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"q": "test"}), reasoning: None, + signature: None, }; let tc2 = ToolCall { id: "call_sel_2".to_string(), name: "http".to_string(), arguments: serde_json::json!({"url": "https://example.com"}), reasoning: None, + signature: None, }; let mut messages = vec![ ChatMessage::system("You are a helpful assistant."), diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs index 8b54477e3d4..adf225a0e7e 100644 --- a/src/llm/reasoning.rs +++ b/src/llm/reasoning.rs @@ -429,6 +429,12 @@ pub enum RespondResult { ToolCalls { tool_calls: Vec, content: Option, + /// Provider-emitted reasoning artifacts (DeepSeek `reasoning_content`, + /// Gemini reasoning, OpenRouter `reasoning_details`). Must be attached + /// to the assistant `ChatMessage` the caller pushes into context for + /// the next turn — otherwise the provider rejects the follow-up + /// request with HTTP 400. See #3201, #3225. + reasoning: Option, }, } @@ -819,6 +825,7 @@ Respond in JSON format: let pre_truncated = truncate_at_tool_tags(&c); clean_response(&pre_truncated) }); + let provider_reasoning = response.reasoning; // Populate per-tool reasoning from the shared narrative when the // provider did not supply per-tool rationale. let tool_calls: Vec = response @@ -845,6 +852,7 @@ Respond in JSON format: result: RespondResult::ToolCalls { tool_calls, content: narrative, + reasoning: provider_reasoning, }, usage, finish_reason: response.finish_reason, @@ -872,6 +880,10 @@ Respond in JSON format: } else { Some(cleaned) }, + // XML-tag-recovered tool calls don't come with native + // reasoning artifacts — those would have been on the + // structured tool_calls path instead. + reasoning: response.reasoning, }, usage, finish_reason: response.finish_reason, @@ -1640,6 +1652,7 @@ pub(crate) fn recover_tool_calls_from_content( name: name.to_string(), arguments, reasoning: None, + signature: None, }); continue; } @@ -1655,6 +1668,7 @@ pub(crate) fn recover_tool_calls_from_content( name: name.to_string(), arguments: serde_json::Value::Object(Default::default()), reasoning: None, + signature: None, }); } } @@ -1693,6 +1707,7 @@ pub(crate) fn recover_tool_calls_from_content( name: name.to_string(), arguments, reasoning: None, + signature: None, }); remaining = &args_start[bracket_end + 1..]; continue; @@ -1705,6 +1720,7 @@ pub(crate) fn recover_tool_calls_from_content( name: name.to_string(), arguments: serde_json::Value::Object(Default::default()), reasoning: None, + signature: None, }); remaining = after_name; } @@ -3398,6 +3414,7 @@ That's my plan."#; finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -3549,6 +3566,7 @@ That's my plan."#; RespondResult::ToolCalls { tool_calls, content, + reasoning: _, } => { assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].name, "tool_list"); @@ -3582,6 +3600,7 @@ That's my plan."#; RespondResult::ToolCalls { tool_calls, content, + reasoning: _, } => { assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls[0].name, "tool_list"); @@ -3743,12 +3762,14 @@ That's my plan."#; name: "memory_write".to_string(), arguments: serde_json::json!({}), reasoning: None, + signature: None, }], input_tokens: 5000, output_tokens: 1024, finish_reason: self.finish_reason, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/src/llm/response_cache.rs b/src/llm/response_cache.rs index d7746f606b8..6a9f4f259bf 100644 --- a/src/llm/response_cache.rs +++ b/src/llm/response_cache.rs @@ -381,6 +381,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/src/llm/rig_adapter.rs b/src/llm/rig_adapter.rs index 3ddb5b1f6db..1dcb0cf7db1 100644 --- a/src/llm/rig_adapter.rs +++ b/src/llm/rig_adapter.rs @@ -211,16 +211,38 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec (Option, Vec = Vec::new(); + if !msg.content.is_empty() { + contents.push(AssistantContent::text(&msg.content)); + } + contents.push(AssistantContent::Reasoning(rig::message::Reasoning::new( + reasoning, + ))); + if let Ok(many) = OneOrMany::many(contents) { + history.push(RigMessage::Assistant { + id: None, + content: many, + }); + } } else { // Skip empty assistant messages — these occur when thinking-tag stripping // leaves a blank response; sending "content": "" causes 400 on strict @@ -356,13 +399,29 @@ fn convert_tool_choice(choice: Option<&str>) -> Option { } } -/// Extract text and tool calls from a rig-core completion response. +/// Extract text, tool calls, and provider-emitted reasoning artifacts from a +/// rig-core completion response. +/// +/// The returned `reasoning` is the concatenation of every +/// `AssistantContent::Reasoning` chunk in the response. Callers MUST attach it +/// to the assistant `ChatMessage` they store for the next turn — DeepSeek's +/// thinking mode and Gemini 2.5+ both reject the next request with HTTP 400 +/// when the prior message had reasoning that wasn't echoed back. See #3201, +/// #3225, and the rig-core deepseek client source where +/// `last_reasoning_content` is round-tripped onto the last assistant message +/// of the next request. fn extract_response( choice: &OneOrMany, _usage: &RigUsage, -) -> (Option, Vec, FinishReason) { +) -> ( + Option, + Vec, + FinishReason, + Option, +) { let mut text_parts: Vec = Vec::new(); let mut tool_calls: Vec = Vec::new(); + let mut reasoning_parts: Vec = Vec::new(); for content in choice.iter() { match content { @@ -376,9 +435,17 @@ fn extract_response( name: tc.function.name.clone(), arguments: tc.function.arguments.clone(), reasoning: None, + // Capture Gemini `thought_signature` (and any other + // per-tool-call signatures) so the next turn can echo + // them. Without this, Gemini 2.5+ rejects the next + // request with HTTP 400. See #3225. + signature: tc.signature.clone(), }); } - // Reasoning and Image variants are not mapped to IronClaw types + AssistantContent::Reasoning(r) if !r.reasoning.is_empty() => { + reasoning_parts.push(r.reasoning.join("\n")); + } + // Image variants are not mapped to IronClaw types _ => {} } } @@ -389,13 +456,19 @@ fn extract_response( Some(text_parts.join("")) }; + let reasoning = if reasoning_parts.is_empty() { + None + } else { + Some(reasoning_parts.join("\n")) + }; + let finish = if !tool_calls.is_empty() { FinishReason::ToolUse } else { FinishReason::Stop }; - (text, tool_calls, finish) + (text, tool_calls, finish, reasoning) } /// Saturate u64 to u32 for token counts. @@ -595,7 +668,8 @@ where .await .map_err(|e| map_rig_error(&self.model_name, e))?; - let (text, _tool_calls, finish) = extract_response(&response.choice, &response.usage); + let (text, _tool_calls, finish, _reasoning) = + extract_response(&response.choice, &response.usage); let resp = CompletionResponse { content: text.unwrap_or_default(), @@ -655,7 +729,8 @@ where .await .map_err(|e| map_rig_error(&self.model_name, e))?; - let (text, mut tool_calls, finish) = extract_response(&response.choice, &response.usage); + let (text, mut tool_calls, finish, reasoning) = + extract_response(&response.choice, &response.usage); // Normalize tool call names: some proxies prepend "proxy_" prefixes. for tc in &mut tool_calls { @@ -678,6 +753,7 @@ where finish_reason: finish, cache_read_input_tokens: saturate_u32(response.usage.cached_input_tokens), cache_creation_input_tokens: extract_cache_creation(&response.raw_response), + reasoning, }; if resp.cache_read_input_tokens > 0 { @@ -1540,6 +1616,7 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"query": "test"}), reasoning: None, + signature: None, }; let msg = ChatMessage::assistant_with_tool_calls(Some("thinking".to_string()), vec![tc]); let messages = vec![msg]; @@ -1568,6 +1645,7 @@ mod tests { tool_call_id: None, name: Some("search".to_string()), tool_calls: None, + reasoning: None, }]; let (_preamble, history) = convert_messages(&messages); match &history[0] { @@ -1705,7 +1783,7 @@ mod tests { fn test_extract_response_text_only() { let content = OneOrMany::one(AssistantContent::text("Hello world")); let usage = RigUsage::new(); - let (text, calls, finish) = extract_response(&content, &usage); + let (text, calls, finish, _reasoning) = extract_response(&content, &usage); assert_eq!(text, Some("Hello world".to_string())); assert!(calls.is_empty()); assert_eq!(finish, FinishReason::Stop); @@ -1716,7 +1794,7 @@ mod tests { let tc = AssistantContent::tool_call("call_1", "search", serde_json::json!({"q": "test"})); let content = OneOrMany::one(tc); let usage = RigUsage::new(); - let (text, calls, finish) = extract_response(&content, &usage); + let (text, calls, finish, _reasoning) = extract_response(&content, &usage); assert!(text.is_none()); assert_eq!(calls.len(), 1); assert_eq!(calls[0].name, "search"); @@ -1730,6 +1808,7 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"query": "test"}), reasoning: None, + signature: None, }; let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])]; let (_preamble, history) = convert_messages(&messages); @@ -1762,6 +1841,7 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"query": "test"}), reasoning: None, + signature: None, }; let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])]; let (_preamble, history) = convert_messages(&messages); @@ -1796,6 +1876,7 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"query": "test"}), reasoning: None, + signature: None, }; let assistant_msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]); let tool_result_msg = ChatMessage { @@ -1805,6 +1886,7 @@ mod tests { tool_call_id: None, name: Some("search".to_string()), tool_calls: None, + reasoning: None, }; let messages = vec![assistant_msg, tool_result_msg]; let (_preamble, history) = convert_messages(&messages); @@ -2116,12 +2198,14 @@ mod tests { name: "search".to_string(), arguments: serde_json::json!({"q": "rust"}), reasoning: None, + signature: None, }; let tc2 = IronToolCall { id: "call_b".to_string(), name: "fetch".to_string(), arguments: serde_json::json!({"url": "https://example.com"}), reasoning: None, + signature: None, }; let assistant = ChatMessage::assistant_with_tool_calls(None, vec![tc1, tc2]); let result_a = ChatMessage::tool_result("call_a", "search", "search results"); @@ -2201,6 +2285,7 @@ mod tests { role: crate::llm::Role::Assistant, content: String::new(), tool_calls: None, + reasoning: None, tool_call_id: None, name: None, content_parts: vec![], @@ -2221,6 +2306,7 @@ mod tests { role: crate::llm::Role::Assistant, content: String::new(), tool_calls: None, + reasoning: None, tool_call_id: None, name: None, content_parts: vec![], @@ -2481,4 +2567,133 @@ mod tests { other => panic!("Expected ContextLengthExceeded, got: {other:?}"), } } + + /// Regression for #3201 / #3225 (the high-severity gap in PR #3326): + /// the dedicated rig-core DeepSeek/Gemini/OpenRouter clients only fix the + /// reasoning round-trip *inside* rig-core. IronClaw's RigAdapter sits + /// between the agent loop and rig-core, and previously dropped both + /// `AssistantContent::Reasoning` (DeepSeek `reasoning_content`) and + /// per-tool-call `signature` (Gemini `thought_signature`) on the response → + /// IronClaw conversion. On the next request it rebuilt rig messages + /// without either field, so the provider rejected the follow-up turn. + /// + /// This test simulates a 2-turn tool loop: + /// 1. extract a rig response carrying both reasoning and a signed tool call + /// 2. round-trip those onto an IronClaw `ChatMessage` + /// 3. convert that message back into rig format + /// 4. assert both reasoning and signature appear on the rebuilt rig message + /// + /// If any layer drops a field, the next turn would fail with HTTP 400. + #[test] + fn reasoning_and_signature_round_trip_through_chat_message() { + // --- Turn 1: provider returned reasoning + signed tool call --- + let rig_response = OneOrMany::many(vec![ + AssistantContent::Reasoning(rig::message::Reasoning::new( + "Let me check the weather first.", + )), + AssistantContent::ToolCall( + rig::message::ToolCall::new( + "call_abc123".to_string(), + ToolFunction::new( + "get_weather".to_string(), + serde_json::json!({"city": "London"}), + ), + ) + .with_signature(Some("thought-sig-deadbeef".to_string())), + ), + ]) + .unwrap(); + let usage = RigUsage::new(); + let (text, tool_calls, finish, reasoning) = extract_response(&rig_response, &usage); + + assert_eq!(finish, FinishReason::ToolUse); + assert_eq!(text, None); + assert_eq!( + reasoning.as_deref(), + Some("Let me check the weather first."), + "extract_response must capture AssistantContent::Reasoning so the \ + next request can echo DeepSeek's reasoning_content (#3201)", + ); + assert_eq!(tool_calls.len(), 1); + assert_eq!( + tool_calls[0].signature.as_deref(), + Some("thought-sig-deadbeef"), + "extract_response must capture ToolCall.signature so the next \ + request can echo Gemini's thought_signature (#3225)", + ); + + // --- IronClaw stores the assistant message + tool result --- + let assistant = ChatMessage::assistant_with_tool_calls(text, tool_calls) + .with_reasoning(reasoning.clone()); + let tool_result = + ChatMessage::tool_result("call_abc123", "get_weather", "{\"temp_c\": 14}"); + + // --- Turn 2: IronClaw rebuilds the rig request from stored messages --- + let messages = vec![ + ChatMessage::user("What's the weather?"), + assistant, + tool_result, + ]; + let (_preamble, history) = convert_messages(&messages); + + // The rebuilt rig assistant message must carry both reasoning and + // signature; otherwise the dedicated DeepSeek/Gemini/OpenRouter rig + // clients would emit an empty `reasoning_content` / unsigned + // `functionCall` and the API would reject with HTTP 400. + let assistant_msg = history + .iter() + .find(|m| matches!(m, RigMessage::Assistant { .. })) + .expect("rebuilt rig history should contain the assistant message"); + let RigMessage::Assistant { content, .. } = assistant_msg else { + unreachable!() + }; + + let mut found_reasoning = false; + let mut found_signed_tool_call = false; + for c in content.iter() { + match c { + AssistantContent::Reasoning(r) => { + assert_eq!(r.reasoning, vec!["Let me check the weather first."]); + found_reasoning = true; + } + AssistantContent::ToolCall(tc) => { + assert_eq!( + tc.signature.as_deref(), + Some("thought-sig-deadbeef"), + "rebuilt rig tool call must carry the original \ + thought_signature (#3225)", + ); + found_signed_tool_call = true; + } + _ => {} + } + } + assert!( + found_reasoning, + "convert_messages must emit AssistantContent::Reasoning when \ + ChatMessage carries reasoning — without this, DeepSeek thinking \ + mode rejects the next turn (#3201)", + ); + assert!( + found_signed_tool_call, + "convert_messages must propagate ToolCall.signature when \ + rebuilding rig tool calls — without this, Gemini 2.5+ rejects \ + the next turn (#3225)", + ); + } + + /// `with_reasoning` must drop empty/whitespace-only strings rather than + /// echoing `reasoning_content: ""` (some strict-mode providers reject + /// empty reasoning fields, and an empty echo carries no signal anyway). + #[test] + fn chat_message_with_reasoning_drops_empty_input() { + let msg = ChatMessage::assistant("hi").with_reasoning(Some(String::new())); + assert!(msg.reasoning.is_none()); + let msg = ChatMessage::assistant("hi").with_reasoning(Some(" ".to_string())); + assert!(msg.reasoning.is_none()); + let msg = ChatMessage::assistant("hi").with_reasoning(None); + assert!(msg.reasoning.is_none()); + let msg = ChatMessage::assistant("hi").with_reasoning(Some("real".to_string())); + assert_eq!(msg.reasoning.as_deref(), Some("real")); + } } diff --git a/src/orchestrator/api.rs b/src/orchestrator/api.rs index ebafdb2cdd4..49f567acab5 100644 --- a/src/orchestrator/api.rs +++ b/src/orchestrator/api.rs @@ -256,6 +256,7 @@ async fn llm_complete_with_tools( finish_reason: format_finish_reason(resp.finish_reason), cache_read_input_tokens: resp.cache_read_input_tokens, cache_creation_input_tokens: resp.cache_creation_input_tokens, + reasoning: resp.reasoning, })) } diff --git a/src/testing/mod.rs b/src/testing/mod.rs index 0c14be46af0..04f3022fc78 100644 --- a/src/testing/mod.rs +++ b/src/testing/mod.rs @@ -233,6 +233,7 @@ impl LlmProvider for StubLlm { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index 0d729ec2dcd..68b17b4d615 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -770,6 +770,7 @@ Create alongside the .wasm file to grant capabilities: RespondResult::ToolCalls { tool_calls, content, + reasoning: _, } => { tools_executed = true; diff --git a/src/worker/api.rs b/src/worker/api.rs index 9bfda52cd97..fc364ccf41e 100644 --- a/src/worker/api.rs +++ b/src/worker/api.rs @@ -80,6 +80,12 @@ pub struct ProxyToolCompletionResponse { pub cache_read_input_tokens: u32, #[serde(default)] pub cache_creation_input_tokens: u32, + /// Provider-emitted reasoning content that must be echoed on the next + /// turn (#3201, #3225). The orchestrator forwards it back to the + /// container worker, which attaches it to the assistant `ChatMessage` + /// before the next LLM call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, } /// Completion result for the worker to report when done. @@ -268,6 +274,7 @@ impl WorkerHttpClient { finish_reason: parse_finish_reason(&proxy_resp.finish_reason), cache_read_input_tokens: proxy_resp.cache_read_input_tokens, cache_creation_input_tokens: proxy_resp.cache_creation_input_tokens, + reasoning: proxy_resp.reasoning, }) } diff --git a/src/worker/container.rs b/src/worker/container.rs index 31e9a29ef6d..a1e1d4d8043 100644 --- a/src/worker/container.rs +++ b/src/worker/container.rs @@ -510,6 +510,7 @@ impl LoopDelegate for ContainerDelegate { tool_calls: Vec, content: Option, reason_ctx: &mut ReasoningContext, + reasoning: Option, ) -> Result, crate::error::Error> { { let mut recovery = self.recovery_state.lock().await; @@ -527,13 +528,12 @@ impl LoopDelegate for ContainerDelegate { .await; } - // Add assistant message with tool_calls (OpenAI protocol) - reason_ctx - .messages - .push(ChatMessage::assistant_with_tool_calls( - content, - tool_calls.clone(), - )); + // Add assistant message with tool_calls (OpenAI protocol). + // Carry reasoning for the next turn — see #3201, #3225. + reason_ctx.messages.push( + ChatMessage::assistant_with_tool_calls(content, tool_calls.clone()) + .with_reasoning(reasoning), + ); // Execute tools sequentially (container context — no parallel execution) let mut tool_failure_count: usize = 0; diff --git a/src/worker/job.rs b/src/worker/job.rs index caf9434d605..45e28eb8b8e 100644 --- a/src/worker/job.rs +++ b/src/worker/job.rs @@ -967,6 +967,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."# } else { Some(action.reasoning.clone()) }, + signature: None, }], )); @@ -1503,6 +1504,7 @@ impl<'a> LoopDelegate for JobDelegate<'a> { result: RespondResult::ToolCalls { tool_calls, content: reasoning_text, + reasoning: None, }, usage: crate::llm::TokenUsage::default(), finish_reason: crate::llm::FinishReason::ToolUse, @@ -1671,6 +1673,7 @@ impl<'a> LoopDelegate for JobDelegate<'a> { tool_calls: Vec, content: Option, reason_ctx: &mut ReasoningContext, + reasoning: Option, ) -> Result, crate::error::Error> { { let mut recovery = self.recovery_state.lock().await; @@ -1732,13 +1735,13 @@ impl<'a> LoopDelegate for JobDelegate<'a> { ); } - // Add assistant message with tool_calls (OpenAI protocol) - reason_ctx - .messages - .push(ChatMessage::assistant_with_tool_calls( - content, - tool_calls.clone(), - )); + // Add assistant message with tool_calls (OpenAI protocol). + // Carry reasoning for the next turn — DeepSeek thinking-mode and + // Gemini 2.5+ reject the follow-up with HTTP 400 otherwise (#3201, #3225). + reason_ctx.messages.push( + ChatMessage::assistant_with_tool_calls(content, tool_calls.clone()) + .with_reasoning(reasoning), + ); // Convert to ToolSelections let selections: Vec = tool_calls @@ -1816,6 +1819,7 @@ fn selections_to_tool_calls(selections: &[ToolSelection]) -> Vec { } else { Some(s.reasoning.clone()) }, + signature: None, }) .collect() } diff --git a/tests/admin_tool_policy_e2e.rs b/tests/admin_tool_policy_e2e.rs index dbfc8dd6882..91de69b542a 100644 --- a/tests/admin_tool_policy_e2e.rs +++ b/tests/admin_tool_policy_e2e.rs @@ -70,6 +70,7 @@ mod tests { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs index 69f91b0fc32..30ebe84f2cd 100644 --- a/tests/openai_compat_integration.rs +++ b/tests/openai_compat_integration.rs @@ -97,12 +97,14 @@ impl LlmProvider for MockLlmProvider { name: tool.name.clone(), arguments: serde_json::json!({"test": true}), reasoning: None, + signature: None, }], input_tokens: 15, output_tokens: 8, finish_reason: FinishReason::ToolUse, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } else { Ok(ToolCompletionResponse { @@ -113,6 +115,7 @@ impl LlmProvider for MockLlmProvider { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -168,6 +171,7 @@ impl LlmProvider for FixedModelProvider { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } diff --git a/tests/provider_chaos.rs b/tests/provider_chaos.rs index b6fa4c4136b..390034fb4c3 100644 --- a/tests/provider_chaos.rs +++ b/tests/provider_chaos.rs @@ -119,6 +119,7 @@ impl LlmProvider for FlakeyProvider { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -214,6 +215,7 @@ impl LlmProvider for GarbageProvider { finish_reason: FinishReason::Unknown, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } @@ -274,6 +276,7 @@ impl LlmProvider for ReliableProvider { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } } diff --git a/tests/support/trace_llm.rs b/tests/support/trace_llm.rs index 34514b654de..61968337074 100644 --- a/tests/support/trace_llm.rs +++ b/tests/support/trace_llm.rs @@ -739,6 +739,7 @@ impl LlmProvider for TraceLlm { finish_reason: FinishReason::Stop, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }), TraceResponse::ToolCalls { tool_calls, @@ -752,6 +753,7 @@ impl LlmProvider for TraceLlm { name: tc.name, arguments: tc.arguments, reasoning: None, + signature: None, }) .collect(); Ok(ToolCompletionResponse { @@ -762,6 +764,7 @@ impl LlmProvider for TraceLlm { finish_reason: FinishReason::ToolUse, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + reasoning: None, }) } TraceResponse::UserInput { .. } => Err(LlmError::RequestFailed {