move leaked tool call recovery into build_response_message

Tool call parsing was only in runner.rs, so subconscious agents
(poc-memory agent run) never recovered leaked tool calls from
models that emit <tool_call> as content text (e.g. Qwen via Crane).

Move the recovery into build_response_message where both code paths
share it. Leaked tool calls are promoted to structured tool_calls
and the content is cleaned, so all consumers see them uniformly.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-03-29 20:57:59 -04:00
parent 39b07311e6
commit 2a64d8e11f
2 changed files with 40 additions and 38 deletions

View file

@ -319,22 +319,49 @@ impl SseReader {
/// Build a response Message from accumulated content and tool calls.
/// Shared by both backends — the wire format differs but the internal
/// representation is the same.
///
/// If no structured tool calls came from the API but the content
/// contains leaked tool call XML (e.g. `<tool_call>...</tool_call>`
/// from models that emit tool calls as text), parse them out and
/// promote them to structured tool_calls. This way all consumers
/// see tool calls uniformly regardless of backend.
pub(crate) fn build_response_message(
content: String,
tool_calls: Vec<ToolCall>,
) -> Message {
// If the API returned structured tool calls, use them as-is.
if !tool_calls.is_empty() {
return Message {
role: Role::Assistant,
content: if content.is_empty() { None }
else { Some(MessageContent::Text(content)) },
tool_calls: Some(tool_calls),
tool_call_id: None,
name: None,
timestamp: None,
};
}
// Check for leaked tool calls in content text.
let leaked = crate::agent::parsing::parse_leaked_tool_calls(&content);
if !leaked.is_empty() {
let cleaned = crate::agent::parsing::strip_leaked_artifacts(&content);
return Message {
role: Role::Assistant,
content: if cleaned.trim().is_empty() { None }
else { Some(MessageContent::Text(cleaned)) },
tool_calls: Some(leaked),
tool_call_id: None,
name: None,
timestamp: None,
};
}
Message {
role: Role::Assistant,
content: if content.is_empty() {
None
} else {
Some(MessageContent::Text(content))
},
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
content: if content.is_empty() { None }
else { Some(MessageContent::Text(content)) },
tool_calls: None,
tool_call_id: None,
name: None,
timestamp: None,

View file

@ -329,7 +329,8 @@ impl Agent {
empty_retries = 0;
}
// Structured tool calls from the API
// Tool calls (structured from API, or recovered from content
// by build_response_message if the model leaked them as XML).
if let Some(ref tool_calls) = msg.tool_calls {
if !tool_calls.is_empty() {
self.push_message(msg.clone());
@ -341,34 +342,8 @@ impl Agent {
}
}
// No structured tool calls — check for leaked tool calls
// (Qwen sometimes outputs <tool_call> XML as text).
let text = msg.content_text().to_string();
let leaked = crate::agent::parsing::parse_leaked_tool_calls(&text);
if !leaked.is_empty() {
let _ = ui_tx.send(UiMessage::Debug(format!(
"recovered {} leaked tool call(s) from text",
leaked.len()
)));
// Strip tool call XML and thinking tokens from the message
// so they don't clutter the conversation history.
let cleaned = crate::agent::parsing::strip_leaked_artifacts(&text);
let mut clean_msg = msg.clone();
clean_msg.content = if cleaned.trim().is_empty() {
None
} else {
Some(MessageContent::Text(cleaned))
};
self.push_message(clean_msg);
for call in &leaked {
self.dispatch_tool_call(call, Some("recovered"), ui_tx, &mut ds)
.await;
}
continue;
}
// Genuinely text-only response
let text = msg.content_text().to_string();
let _ = ui_tx.send(UiMessage::Activity(String::new()));
self.push_message(msg);