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);