diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs
index f773def4..b30e3073 100644
--- a/crates/openfang-runtime/src/agent_loop.rs
+++ b/crates/openfang-runtime/src/agent_loop.rs
@@ -2140,6 +2140,7 @@ pub async fn run_agent_loop_streaming(
/// 11. `Action: tool\nAction Input: {"key":"value"}` — ReAct-style (LM Studio, GPT-OSS)
/// 12. `tool_name\n{"key":"value"}` — bare name + JSON on next line (Llama 4 Scout)
/// 13. `{"name":"tool","arguments":{...}}` — Llama 3.1+ variant
+/// 14. `value` — nested XML parameter style
///
/// Validates tool names against available tools and returns synthetic `ToolCall` entries.
fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Vec {
@@ -2177,13 +2178,16 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
continue;
}
- // Parse JSON input
+ // Parse JSON input, or fall back to nested XML parameter blocks.
let input: serde_json::Value = match serde_json::from_str(json_body) {
Ok(v) => v,
- Err(e) => {
- warn!(tool = tool_name, error = %e, "Failed to parse text-based tool call JSON — skipping");
- continue;
- }
+ Err(json_err) => match parse_xml_parameter_blocks(json_body) {
+ Some(v) => v,
+ None => {
+ warn!(tool = tool_name, error = %json_err, "Failed to parse text-based tool call payload — skipping");
+ continue;
+ }
+ },
};
info!(
@@ -2751,6 +2755,42 @@ fn parse_json_tool_call_object(
Some((name.to_string(), args))
}
+fn unescape_xml_entities(text: &str) -> String {
+ text.replace(""", "\"")
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("'", "'")
+}
+
+fn parse_xml_parameter_blocks(text: &str) -> Option {
+ use regex_lite::Regex;
+
+ let re = Regex::new(r#"(?s)\s*(.*?)\s*"#).unwrap();
+ let mut params = serde_json::Map::new();
+
+ for caps in re.captures_iter(text) {
+ let Some(name) = caps.get(1).map(|m| m.as_str().trim()) else {
+ continue;
+ };
+ if name.is_empty() {
+ continue;
+ }
+
+ let raw_value = caps.get(2).map(|m| m.as_str()).unwrap_or_default();
+ let value_text = unescape_xml_entities(raw_value).trim().to_string();
+ let value =
+ serde_json::from_str(&value_text).unwrap_or(serde_json::Value::String(value_text));
+ params.insert(name.to_string(), value);
+ }
+
+ if params.is_empty() {
+ None
+ } else {
+ Some(serde_json::Value::Object(params))
+ }
+}
+
/// Parse the custom arrow syntax used by some Ollama models:
/// `{tool => "name", args => {--key "value"}}` or `{tool => "name", args => {"key":"value"}}`
fn parse_arrow_syntax_tool_call(
@@ -3639,6 +3679,44 @@ mod tests {
assert!(calls[0].id.starts_with("recovered_"));
}
+ #[test]
+ fn test_recover_text_tool_calls_xml_parameters() {
+ let tools = vec![ToolDefinition {
+ name: "shell_exec".into(),
+ description: "Execute".into(),
+ input_schema: serde_json::json!({}),
+ }];
+ let text = r#"python3 "/tmp/run.py" --flag value"#;
+ let calls = recover_text_tool_calls(text, &tools);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell_exec");
+ assert_eq!(
+ calls[0].input["command"],
+ r#"python3 "/tmp/run.py" --flag value"#
+ );
+ }
+
+ #[test]
+ fn test_recover_text_tool_calls_xml_parameters_with_wrapper() {
+ let tools = vec![ToolDefinition {
+ name: "shell_exec".into(),
+ description: "Execute".into(),
+ input_schema: serde_json::json!({}),
+ }];
+ let text = r#"
+
+python3 "/tmp/poll.py" --job-id "abc123"
+
+"#;
+ let calls = recover_text_tool_calls(text, &tools);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].name, "shell_exec");
+ assert_eq!(
+ calls[0].input["command"],
+ r#"python3 "/tmp/poll.py" --job-id "abc123""#
+ );
+ }
+
#[test]
fn test_recover_text_tool_calls_unknown_tool() {
let tools = vec![ToolDefinition {
@@ -4405,6 +4483,56 @@ mod tests {
}
}
+ /// Mock driver that emits nested XML parameter-style tool calls as plain text.
+ struct NestedXmlTextToolCallDriver {
+ call_count: AtomicU32,
+ }
+
+ impl NestedXmlTextToolCallDriver {
+ fn new() -> Self {
+ Self {
+ call_count: AtomicU32::new(0),
+ }
+ }
+ }
+
+ #[async_trait]
+ impl LlmDriver for NestedXmlTextToolCallDriver {
+ async fn complete(
+ &self,
+ _request: CompletionRequest,
+ ) -> Result {
+ let call = self.call_count.fetch_add(1, Ordering::Relaxed);
+ if call == 0 {
+ Ok(CompletionResponse {
+ content: vec![ContentBlock::Text {
+ text: "rust async".to_string(),
+ provider_metadata: None,
+ }],
+ stop_reason: StopReason::EndTurn,
+ tool_calls: vec![],
+ usage: TokenUsage {
+ input_tokens: 18,
+ output_tokens: 10,
+ },
+ })
+ } else {
+ Ok(CompletionResponse {
+ content: vec![ContentBlock::Text {
+ text: "Recovered nested XML tool call successfully.".to_string(),
+ provider_metadata: None,
+ }],
+ stop_reason: StopReason::EndTurn,
+ tool_calls: vec![],
+ usage: TokenUsage {
+ input_tokens: 24,
+ output_tokens: 8,
+ },
+ })
+ }
+ }
+ }
+
#[async_trait]
impl LlmDriver for TextToolCallDriver {
async fn complete(
@@ -4518,6 +4646,81 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_nested_xml_text_tool_call_recovery_e2e() {
+ let memory = openfang_memory::MemorySubstrate::open_in_memory(0.01).unwrap();
+ let agent_id = openfang_types::agent::AgentId::new();
+ let mut session = openfang_memory::session::Session {
+ id: openfang_types::agent::SessionId::new(),
+ agent_id,
+ messages: Vec::new(),
+ context_window_tokens: 0,
+ label: None,
+ };
+ let manifest = test_manifest();
+ let driver: Arc = Arc::new(NestedXmlTextToolCallDriver::new());
+
+ let tools = vec![ToolDefinition {
+ name: "web_search".into(),
+ description: "Search the web".into(),
+ input_schema: serde_json::json!({
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"}
+ }
+ }),
+ }];
+
+ let result = run_agent_loop(
+ &manifest,
+ "Search for rust async programming",
+ &mut session,
+ &memory,
+ driver,
+ &tools,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+ .await
+ .expect("Agent loop should recover nested XML tool calls");
+
+ assert!(
+ !result.response.contains(""),
+ "Response should not contain raw tool_call tags, got: {:?}",
+ result.response
+ );
+ assert!(
+ !result.response.contains("= 2,
+ "Should have at least 2 iterations (tool call + final response), got: {}",
+ result.iterations
+ );
+ }
+
/// Mock driver that returns NO text-based tool calls — just normal text.
/// Verifies recovery does NOT interfere with normal flow.
#[tokio::test]