mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(agent): replay reasoning_content across native tool-call turns to prevent DeepSeek 400s (#2918)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ fn call(id: &str) -> ConversationMessage {
|
||||
name: "t".into(),
|
||||
arguments: "{}".into(),
|
||||
}],
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ mod tests {
|
||||
name: name.into(),
|
||||
arguments: "{}".into(),
|
||||
}],
|
||||
reasoning_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>),
|
||||
|
||||
Reference in New Issue
Block a user