mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 06:32:17 +00:00
feat(runtime): recover nested XML tool call parameters
(cherry picked from commit 336240b28996bcd4c6a823d5d0a45efe4a6aaba3)
This commit is contained in:
@@ -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. `<tool_use>{"name":"tool","arguments":{...}}</tool_use>` — Llama 3.1+ variant
|
||||
/// 14. `<function=tool><parameter=name>value</parameter></function>` — 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<ToolCall> {
|
||||
@@ -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<serde_json::Value> {
|
||||
use regex_lite::Regex;
|
||||
|
||||
let re = Regex::new(r#"(?s)<parameter=([A-Za-z0-9_.:-]+)>\s*(.*?)\s*</parameter>"#).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#"<function=shell_exec><parameter=command>python3 "/tmp/run.py" --flag value</parameter></function>"#;
|
||||
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#"<tool_call>
|
||||
<function=shell_exec>
|
||||
<parameter=command>python3 "/tmp/poll.py" --job-id "abc123"</parameter>
|
||||
</function>
|
||||
</tool_call>"#;
|
||||
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<CompletionResponse, LlmError> {
|
||||
let call = self.call_count.fetch_add(1, Ordering::Relaxed);
|
||||
if call == 0 {
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "<tool_call><function=web_search><parameter=query>rust async</parameter></function></tool_call>".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<dyn LlmDriver> = 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("<tool_call>"),
|
||||
"Response should not contain raw tool_call tags, got: {:?}",
|
||||
result.response
|
||||
);
|
||||
assert!(
|
||||
!result.response.contains("<function="),
|
||||
"Response should not contain raw function tags, got: {:?}",
|
||||
result.response
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.response
|
||||
.contains("Recovered nested XML tool call successfully."),
|
||||
"Expected final response text, got: {:?}",
|
||||
result.response
|
||||
);
|
||||
assert!(
|
||||
result.iterations >= 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]
|
||||
|
||||
Reference in New Issue
Block a user