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]