From 9f3e1616709671588f7984df3cba937e093033de Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Fri, 29 May 2026 17:12:48 +0530 Subject: [PATCH] fix(agent): replay reasoning_content across native tool-call turns to prevent DeepSeek 400s (#2918) --- src/openhuman/agent/dispatcher.rs | 11 ++++- src/openhuman/agent/dispatcher_tests.rs | 49 +++++++++++++++++++ .../agent/harness/session/runtime_tests.rs | 2 + src/openhuman/agent/harness/session/turn.rs | 6 +++ .../agent/harness/session/turn_tests.rs | 5 +- src/openhuman/agent/harness/token_budget.rs | 10 +++- src/openhuman/agent/tests.rs | 6 +++ src/openhuman/context/manager_tests.rs | 1 + src/openhuman/context/microcompact.rs | 1 + src/openhuman/context/pipeline.rs | 1 + .../context/segment_recap_summarizer.rs | 4 +- src/openhuman/context/summarizer.rs | 4 +- src/openhuman/context/summarizer_tests.rs | 2 + src/openhuman/inference/provider/traits.rs | 2 + 14 files changed, 98 insertions(+), 6 deletions(-) diff --git a/src/openhuman/agent/dispatcher.rs b/src/openhuman/agent/dispatcher.rs index 6019cbb9d..7c635d17b 100644 --- a/src/openhuman/agent/dispatcher.rs +++ b/src/openhuman/agent/dispatcher.rs @@ -550,11 +550,18 @@ impl ToolDispatcher for NativeToolDispatcher { .into_iter() .flat_map(|i| match &history[i] { ConversationMessage::Chat(chat) => vec![chat.clone()], - ConversationMessage::AssistantToolCalls { text, tool_calls } => { - let payload = serde_json::json!({ + ConversationMessage::AssistantToolCalls { + text, + tool_calls, + reasoning_content, + } => { + let mut payload = serde_json::json!({ "content": text, "tool_calls": tool_calls, }); + if let Some(rc) = reasoning_content { + payload["reasoning_content"] = serde_json::Value::String(rc.clone()); + } vec![ChatMessage::assistant(payload.to_string())] } ConversationMessage::ToolResults(results) => results diff --git a/src/openhuman/agent/dispatcher_tests.rs b/src/openhuman/agent/dispatcher_tests.rs index d6b1ee516..d9fbc8deb 100644 --- a/src/openhuman/agent/dispatcher_tests.rs +++ b/src/openhuman/agent/dispatcher_tests.rs @@ -269,6 +269,7 @@ fn assistant_tool_calls(id: &str) -> ConversationMessage { name: "shell".into(), arguments: "{}".into(), }], + reasoning_content: None, } } @@ -398,9 +399,57 @@ fn assistant_tool_calls_multi(ids: &[&str]) -> ConversationMessage { arguments: "{}".into(), }) .collect(), + reasoning_content: None, } } +#[test] +fn native_dispatcher_serializes_reasoning_content_for_tool_call_turns() { + let dispatcher = NativeToolDispatcher; + let history = vec![ + ConversationMessage::AssistantToolCalls { + text: Some("calling tools".into()), + tool_calls: vec![crate::openhuman::inference::provider::ToolCall { + id: "tc-1".into(), + name: "shell".into(), + arguments: "{}".into(), + }], + reasoning_content: Some("chain-of-thought replay blob".into()), + }, + tool_results("tc-1"), + ]; + + let out = dispatcher.to_provider_messages(&history); + assert_eq!(out.len(), 2); + assert_eq!(out[0].role, "assistant"); + + let payload: serde_json::Value = + serde_json::from_str(&out[0].content).expect("assistant payload should be valid JSON"); + assert_eq!( + payload + .get("reasoning_content") + .and_then(serde_json::Value::as_str), + Some("chain-of-thought replay blob") + ); +} + +#[test] +fn native_dispatcher_omits_reasoning_content_when_absent() { + let dispatcher = NativeToolDispatcher; + let history = vec![assistant_tool_calls("tc-1"), tool_results("tc-1")]; + + let out = dispatcher.to_provider_messages(&history); + assert_eq!(out.len(), 2); + assert_eq!(out[0].role, "assistant"); + + let payload: serde_json::Value = + serde_json::from_str(&out[0].content).expect("assistant payload should be valid JSON"); + assert!( + payload.get("reasoning_content").is_none(), + "reasoning_content should be omitted when absent" + ); +} + fn tool_results_multi(ids: &[&str]) -> ConversationMessage { use crate::openhuman::inference::provider::ToolResultMessage; ConversationMessage::ToolResults( diff --git a/src/openhuman/agent/harness/session/runtime_tests.rs b/src/openhuman/agent/harness/session/runtime_tests.rs index 3ebd50378..c5504a4ec 100644 --- a/src/openhuman/agent/harness/session/runtime_tests.rs +++ b/src/openhuman/agent/harness/session/runtime_tests.rs @@ -137,10 +137,12 @@ fn sanitizers_and_tool_call_helpers_cover_fallback_paths() { ConversationMessage::AssistantToolCalls { text: None, tool_calls: vec![], + reasoning_content: None, }, ConversationMessage::AssistantToolCalls { text: None, tool_calls: vec![], + reasoning_content: None, }, ]; assert_eq!(Agent::count_iterations(&history), 3); diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 82f77208d..7eb01e2fc 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -1016,6 +1016,12 @@ impl Agent { Some(text.clone()) }, tool_calls: persisted_tool_calls, + reasoning_content: response + .reasoning_content + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string), }); // Persist the transcript **right after** the provider diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 5a06e420b..5d7b5a4c6 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -391,6 +391,7 @@ fn trim_history_snaps_past_orphaned_tool_results() { name: "shell".into(), arguments: "{}".into(), }], + reasoning_content: None, }, // ...orphaning this result at the head of the kept window. ConversationMessage::ToolResults(vec![ToolResultMessage { @@ -799,7 +800,9 @@ async fn turn_runs_full_tool_cycle_with_context_and_hooks() { assert!(agent.last_memory_context.as_deref() == Some("[Injected]\n")); assert!(agent.history.iter().any(|message| matches!( message, - ConversationMessage::AssistantToolCalls { text, tool_calls } + ConversationMessage::AssistantToolCalls { + text, tool_calls, .. + } if text.as_deref().is_some_and(|value| value.contains("preface")) && tool_calls.len() == 1 ))); assert!(agent.history.iter().any(|message| matches!( diff --git a/src/openhuman/agent/harness/token_budget.rs b/src/openhuman/agent/harness/token_budget.rs index fd961d53a..bf321737c 100644 --- a/src/openhuman/agent/harness/token_budget.rs +++ b/src/openhuman/agent/harness/token_budget.rs @@ -33,9 +33,16 @@ pub fn estimate_chat_message_tokens(msg: &ChatMessage) -> usize { pub fn estimate_conversation_message_tokens(msg: &ConversationMessage) -> usize { match msg { ConversationMessage::Chat(chat) => estimate_chat_message_tokens(chat), - ConversationMessage::AssistantToolCalls { text, tool_calls } => { + ConversationMessage::AssistantToolCalls { + text, + tool_calls, + reasoning_content, + } => { let body = text.as_deref().unwrap_or_default(); let mut total = estimate_tokens(body); + if let Some(reasoning) = reasoning_content.as_deref() { + total = total.saturating_add(estimate_tokens(reasoning)); + } for call in tool_calls { total = total.saturating_add(estimate_tokens(&call.name)); total = total.saturating_add(estimate_tokens(&call.arguments)); @@ -240,6 +247,7 @@ mod tests { name: "echo".into(), arguments: "{\"value\":\"x\"}".into(), }], + reasoning_content: None, }; assert!(estimate_conversation_message_tokens(&msg) > 0); } diff --git a/src/openhuman/agent/tests.rs b/src/openhuman/agent/tests.rs index f6372ae0f..b54640bed 100644 --- a/src/openhuman/agent/tests.rs +++ b/src/openhuman/agent/tests.rs @@ -1177,6 +1177,7 @@ fn conversation_message_serialization_roundtrip() { name: "shell".into(), arguments: "{}".into(), }], + reasoning_content: Some("thinking".into()), }, ConversationMessage::ToolResults(vec![ToolResultMessage { tool_call_id: "tc1".into(), @@ -1199,14 +1200,17 @@ fn conversation_message_serialization_roundtrip() { ConversationMessage::AssistantToolCalls { text: a_text, tool_calls: a_calls, + reasoning_content: a_reasoning, }, ConversationMessage::AssistantToolCalls { text: b_text, tool_calls: b_calls, + reasoning_content: b_reasoning, }, ) => { assert_eq!(a_text, b_text); assert_eq!(a_calls.len(), b_calls.len()); + assert_eq!(a_reasoning, b_reasoning); } (ConversationMessage::ToolResults(a), ConversationMessage::ToolResults(b)) => { assert_eq!(a.len(), b.len()); @@ -1299,6 +1303,7 @@ fn xml_dispatcher_converts_history_to_provider_messages() { name: "shell".into(), arguments: "{}".into(), }], + reasoning_content: None, }, ConversationMessage::ToolResults(vec![ToolResultMessage { tool_call_id: "tc1".into(), @@ -1339,6 +1344,7 @@ fn native_dispatcher_converts_tool_results_to_tool_messages() { arguments: "{}".into(), }, ], + reasoning_content: None, }, ConversationMessage::ToolResults(vec![ ToolResultMessage { diff --git a/src/openhuman/context/manager_tests.rs b/src/openhuman/context/manager_tests.rs index 2622cacef..1b125b747 100644 --- a/src/openhuman/context/manager_tests.rs +++ b/src/openhuman/context/manager_tests.rs @@ -15,6 +15,7 @@ fn call(id: &str) -> ConversationMessage { name: "t".into(), arguments: "{}".into(), }], + reasoning_content: None, } } diff --git a/src/openhuman/context/microcompact.rs b/src/openhuman/context/microcompact.rs index 084e93c8a..d7c76f587 100644 --- a/src/openhuman/context/microcompact.rs +++ b/src/openhuman/context/microcompact.rs @@ -116,6 +116,7 @@ mod tests { name: name.into(), arguments: "{}".into(), }], + reasoning_content: None, } } diff --git a/src/openhuman/context/pipeline.rs b/src/openhuman/context/pipeline.rs index 1e97507c2..68156b04c 100644 --- a/src/openhuman/context/pipeline.rs +++ b/src/openhuman/context/pipeline.rs @@ -270,6 +270,7 @@ mod tests { name: "t".into(), arguments: "{}".into(), }], + reasoning_content: None, } } diff --git a/src/openhuman/context/segment_recap_summarizer.rs b/src/openhuman/context/segment_recap_summarizer.rs index 4649e9603..08f37b5cb 100644 --- a/src/openhuman/context/segment_recap_summarizer.rs +++ b/src/openhuman/context/segment_recap_summarizer.rs @@ -208,7 +208,9 @@ impl Summarizer for SegmentRecapSummarizer { fn conversation_message_approx_bytes(msg: &ConversationMessage) -> usize { match msg { ConversationMessage::Chat(m) => m.content.len(), - ConversationMessage::AssistantToolCalls { text, tool_calls } => { + ConversationMessage::AssistantToolCalls { + text, tool_calls, .. + } => { text.as_deref().map_or(0, str::len) + tool_calls .iter() diff --git a/src/openhuman/context/summarizer.rs b/src/openhuman/context/summarizer.rs index fb5607fd8..54541576f 100644 --- a/src/openhuman/context/summarizer.rs +++ b/src/openhuman/context/summarizer.rs @@ -288,7 +288,9 @@ fn render_transcript(msgs: &[ConversationMessage]) -> String { ConversationMessage::Chat(m) => { let _ = writeln!(&mut out, "[{i}] {}: {}", m.role, m.content); } - ConversationMessage::AssistantToolCalls { text, tool_calls } => { + ConversationMessage::AssistantToolCalls { + text, tool_calls, .. + } => { if let Some(t) = text.as_deref() { if !t.is_empty() { let _ = writeln!(&mut out, "[{i}] assistant: {t}"); diff --git a/src/openhuman/context/summarizer_tests.rs b/src/openhuman/context/summarizer_tests.rs index 413ad9b4a..f5f7635e8 100644 --- a/src/openhuman/context/summarizer_tests.rs +++ b/src/openhuman/context/summarizer_tests.rs @@ -19,6 +19,7 @@ fn call(id: &str) -> ConversationMessage { name: "t".into(), arguments: "{}".into(), }], + reasoning_content: None, } } @@ -217,6 +218,7 @@ fn transcript_renders_all_message_variants() { name: "shell".into(), arguments: r#"{"cmd":"ls"}"#.into(), }], + reasoning_content: None, }, result("1", "file.txt"), ]; diff --git a/src/openhuman/inference/provider/traits.rs b/src/openhuman/inference/provider/traits.rs index 9fd2a4e59..5af662d74 100644 --- a/src/openhuman/inference/provider/traits.rs +++ b/src/openhuman/inference/provider/traits.rs @@ -169,6 +169,8 @@ pub enum ConversationMessage { AssistantToolCalls { text: Option, tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_content: Option, }, /// Results of tool executions, fed back to the LLM. ToolResults(Vec),