fix(agent): replay reasoning_content across native tool-call turns to prevent DeepSeek 400s (#2918)

This commit is contained in:
YellowSnnowmann
2026-05-29 17:12:48 +05:30
committed by GitHub
parent 63e64f20e9
commit 9f3e161670
14 changed files with 98 additions and 6 deletions
+9 -2
View File
@@ -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
+49
View File
@@ -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(
@@ -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);
@@ -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
@@ -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!(
+9 -1
View File
@@ -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);
}
+6
View File
@@ -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 {
+1
View File
@@ -15,6 +15,7 @@ fn call(id: &str) -> ConversationMessage {
name: "t".into(),
arguments: "{}".into(),
}],
reasoning_content: None,
}
}
+1
View File
@@ -116,6 +116,7 @@ mod tests {
name: name.into(),
arguments: "{}".into(),
}],
reasoning_content: None,
}
}
+1
View File
@@ -270,6 +270,7 @@ mod tests {
name: "t".into(),
arguments: "{}".into(),
}],
reasoning_content: None,
}
}
@@ -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()
+3 -1
View File
@@ -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}");
@@ -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"),
];
@@ -169,6 +169,8 @@ pub enum ConversationMessage {
AssistantToolCalls {
text: Option<String>,
tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
},
/// Results of tool executions, fed back to the LLM.
ToolResults(Vec<ToolResultMessage>),