From 2a64d8e11fa3dd8bc2037da16f13f83f99dda483 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Sun, 29 Mar 2026 20:57:59 -0400 Subject: [PATCH] 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 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 --- src/agent/api/mod.rs | 47 ++++++++++++++++++++++++++++++++++---------- src/agent/runner.rs | 31 +++-------------------------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/agent/api/mod.rs b/src/agent/api/mod.rs index 6ac0fc1..302ac4a 100644 --- a/src/agent/api/mod.rs +++ b/src/agent/api/mod.rs @@ -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. `...` +/// 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, ) -> 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, diff --git a/src/agent/runner.rs b/src/agent/runner.rs index f5fce7a..0c022b1 100644 --- a/src/agent/runner.rs +++ b/src/agent/runner.rs @@ -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 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);