mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(agent): parse Claude-native <invoke name> attribute tool calls (#3622)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Claude Opus 4.8
Steven Enamakel
parent
0adbed247d
commit
93c7b3d6a3
@@ -475,6 +475,114 @@ fn parse_tool_calls_invoke_tag_with_json_body() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Item 3b — parse_tool_calls: Claude-native <invoke name="…"> attribute form
|
||||
// with nested <parameter name="…"> children (issue #3493).
|
||||
// Claude-family models ignore the injected <tool_call>{json} template
|
||||
// and emit their trained syntax; the parser must recover it instead of
|
||||
// leaking the raw markup as assistant text.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_invoke_attribute_form_single_param() {
|
||||
use crate::openhuman::agent::harness::parse::parse_tool_calls;
|
||||
|
||||
let input =
|
||||
"Sure.\n<invoke name=\"echo\">\n<parameter name=\"value\">hi</parameter>\n</invoke>\ndone";
|
||||
let (text, calls) = parse_tool_calls(input);
|
||||
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"attribute-form <invoke> should parse one call"
|
||||
);
|
||||
assert_eq!(calls[0].name, "echo");
|
||||
assert_eq!(calls[0].arguments, serde_json::json!({"value": "hi"}));
|
||||
// Surrounding prose preserved; raw <invoke> markup must not leak.
|
||||
assert!(text.contains("Sure."), "text before tag preserved");
|
||||
assert!(text.contains("done"), "text after tag preserved");
|
||||
assert!(
|
||||
!text.contains("<invoke"),
|
||||
"raw <invoke> markup must not surface in assistant text"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("<parameter"),
|
||||
"raw <parameter> markup must not surface in assistant text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_invoke_attribute_form_multiple_params_scalar_policy() {
|
||||
use crate::openhuman::agent::harness::parse::parse_tool_calls;
|
||||
|
||||
// Multiple <parameter> children. Scalar policy: a value that parses as JSON
|
||||
// (number, bool) becomes that JSON type; anything else stays a string. A
|
||||
// parameter with an empty name is skipped (it cannot key an argument).
|
||||
let input = concat!(
|
||||
"<invoke name=\"search\">\n",
|
||||
"<parameter name=\"query\">rust parsers</parameter>\n",
|
||||
"<parameter name=\"limit\">5</parameter>\n",
|
||||
"<parameter name=\"fuzzy\">true</parameter>\n",
|
||||
"<parameter name=\"\">ignored</parameter>\n",
|
||||
"</invoke>"
|
||||
);
|
||||
let (_text, calls) = parse_tool_calls(input);
|
||||
|
||||
assert_eq!(calls.len(), 1, "should parse one call");
|
||||
assert_eq!(calls[0].name, "search");
|
||||
assert_eq!(
|
||||
calls[0].arguments,
|
||||
serde_json::json!({"query": "rust parsers", "limit": 5, "fuzzy": true})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_invoke_attribute_form_missing_close_tag_is_text() {
|
||||
use crate::openhuman::agent::harness::parse::parse_tool_calls;
|
||||
|
||||
// No closing </invoke>: nothing to dispatch. The block is left as text
|
||||
// rather than silently dropped.
|
||||
let input = "before\n<invoke name=\"echo\">\n<parameter name=\"v\">hi</parameter>";
|
||||
let (text, calls) = parse_tool_calls(input);
|
||||
|
||||
assert_eq!(calls.len(), 0, "unterminated <invoke> yields no calls");
|
||||
assert!(text.contains("before"), "preceding text preserved");
|
||||
assert!(
|
||||
text.contains("<invoke"),
|
||||
"unterminated block left as text, not dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_invoke_attribute_form_missing_name_is_text() {
|
||||
use crate::openhuman::agent::harness::parse::parse_tool_calls;
|
||||
|
||||
// Attribute form without a `name` attribute cannot name a tool → no call.
|
||||
let input = "<invoke foo=\"bar\">\n<parameter name=\"v\">hi</parameter>\n</invoke>";
|
||||
let (_text, calls) = parse_tool_calls(input);
|
||||
|
||||
assert_eq!(calls.len(), 0, "missing name attribute yields no calls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_mixed_tool_call_json_and_invoke_attribute() {
|
||||
use crate::openhuman::agent::harness::parse::parse_tool_calls;
|
||||
|
||||
// A canonical <tool_call>{json} block and a Claude-native attribute-form
|
||||
// <invoke> block in the same response are both recovered, earliest first.
|
||||
let input = concat!(
|
||||
"<tool_call>{\"name\":\"first\",\"arguments\":{\"a\":1}}</tool_call>\n",
|
||||
"<invoke name=\"second\">\n<parameter name=\"b\">two</parameter>\n</invoke>"
|
||||
);
|
||||
let (_text, calls) = parse_tool_calls(input);
|
||||
|
||||
assert_eq!(calls.len(), 2, "both tag forms parsed");
|
||||
assert_eq!(calls[0].name, "first");
|
||||
assert_eq!(calls[0].arguments, serde_json::json!({"a": 1}));
|
||||
assert_eq!(calls[1].name, "second");
|
||||
assert_eq!(calls[1].arguments, serde_json::json!({"b": "two"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_markdown_fence_yaml_like_json_body() {
|
||||
use crate::openhuman::agent::harness::parse::parse_tool_calls;
|
||||
|
||||
@@ -183,6 +183,92 @@ pub(crate) fn matching_tool_call_close_tag(open_tag: &str) -> Option<&'static st
|
||||
}
|
||||
}
|
||||
|
||||
/// `<invoke` prefix shared by the bare (`<invoke>`) and Claude-native
|
||||
/// attribute (`<invoke name="…">`) forms.
|
||||
const INVOKE_PREFIX: &str = "<invoke";
|
||||
|
||||
/// Locate the earliest Claude-native attribute-form `<invoke …>` open tag
|
||||
/// (issue #3493). Matches `<invoke` only when the next character is whitespace
|
||||
/// — i.e. attributes follow. The bare `<invoke>` form (next char `>`) is
|
||||
/// intentionally skipped here; it is recognised as a literal tag with a JSON
|
||||
/// body via [`TOOL_CALL_OPEN_TAGS`], preserving back-compat.
|
||||
fn find_invoke_attr_tag(haystack: &str) -> Option<usize> {
|
||||
let mut from = 0;
|
||||
while let Some(rel) = haystack[from..].find(INVOKE_PREFIX) {
|
||||
let idx = from + rel;
|
||||
let after = &haystack[idx + INVOKE_PREFIX.len()..];
|
||||
match after.chars().next() {
|
||||
Some(c) if c.is_whitespace() => return Some(idx),
|
||||
_ => from = idx + INVOKE_PREFIX.len(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Scalar policy for `<parameter>` values: a value that parses as JSON
|
||||
/// (number, bool, null, array, object) is kept as that JSON type; anything
|
||||
/// else — the common case of bare text — stays a string. Mirrors the tolerant
|
||||
/// arg handling in [`parse_arguments_value`].
|
||||
fn parameter_scalar_value(raw: &str) -> serde_json::Value {
|
||||
let trimmed = raw.trim();
|
||||
match serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
Ok(
|
||||
value @ (serde_json::Value::Number(_)
|
||||
| serde_json::Value::Bool(_)
|
||||
| serde_json::Value::Null
|
||||
| serde_json::Value::Array(_)
|
||||
| serde_json::Value::Object(_)),
|
||||
) => value,
|
||||
_ => serde_json::Value::String(trimmed.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Claude-native attribute-form invoke block whose text begins
|
||||
/// immediately after the `<invoke` prefix (at the attributes). Returns the
|
||||
/// recovered call and the number of bytes consumed up to and including the
|
||||
/// closing `</invoke>`. `None` when the `name` attribute or the closing tag is
|
||||
/// missing — the caller then leaves the markup as text rather than dropping it.
|
||||
fn parse_invoke_attribute_block(after_prefix: &str) -> Option<(ParsedToolCall, usize)> {
|
||||
static INVOKE_NAME_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r#"name\s*=\s*"([^"]*)""#).unwrap());
|
||||
static PARAMETER_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"(?s)<parameter\s+name\s*=\s*"([^"]*)"\s*>(.*?)</parameter>"#).unwrap()
|
||||
});
|
||||
|
||||
let open_end = after_prefix.find('>')?;
|
||||
let attrs = &after_prefix[..open_end];
|
||||
let name = INVOKE_NAME_RE
|
||||
.captures(attrs)
|
||||
.and_then(|c| c.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.filter(|n| !n.is_empty())?;
|
||||
|
||||
let body = &after_prefix[open_end + 1..];
|
||||
let close_rel = body.find("</invoke>")?;
|
||||
let inner = &body[..close_rel];
|
||||
|
||||
let mut arguments = serde_json::Map::new();
|
||||
for cap in PARAMETER_RE.captures_iter(inner) {
|
||||
// Groups 1 (name) and 2 (value) are mandatory in the pattern, so a
|
||||
// captured match always has both — index access is safe.
|
||||
let key = cap[1].trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
arguments.insert(key.to_string(), parameter_scalar_value(&cap[2]));
|
||||
}
|
||||
|
||||
let consumed = open_end + 1 + close_rel + "</invoke>".len();
|
||||
Some((
|
||||
ParsedToolCall {
|
||||
name,
|
||||
arguments: serde_json::Value::Object(arguments),
|
||||
id: None,
|
||||
},
|
||||
consumed,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn extract_first_json_value_with_end(input: &str) -> Option<(serde_json::Value, usize)> {
|
||||
let trimmed = input.trim_start();
|
||||
let trim_offset = input.len().saturating_sub(trimmed.len());
|
||||
@@ -439,7 +525,48 @@ pub(crate) fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>)
|
||||
}
|
||||
|
||||
// Fall back to XML-style tool-call tag parsing.
|
||||
while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
|
||||
loop {
|
||||
let literal = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS);
|
||||
let invoke_attr = find_invoke_attr_tag(remaining);
|
||||
|
||||
// Choose the earliest-positioned recognised open tag. The bare
|
||||
// `<invoke>` literal and the attribute form `<invoke …>` never collide
|
||||
// at one offset (one is followed by `>`, the other by whitespace), so a
|
||||
// simple index comparison disambiguates them (issue #3493).
|
||||
let use_invoke_attr = match (invoke_attr, literal.as_ref()) {
|
||||
(Some(i), Some((l, _))) => i < *l,
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if use_invoke_attr {
|
||||
let start = invoke_attr.expect("use_invoke_attr implies Some");
|
||||
let before = &remaining[..start];
|
||||
if !before.trim().is_empty() {
|
||||
text_parts.push(before.trim().to_string());
|
||||
}
|
||||
|
||||
let after_prefix = &remaining[start + INVOKE_PREFIX.len()..];
|
||||
if let Some((parsed, consumed)) = parse_invoke_attribute_block(after_prefix) {
|
||||
calls.push(parsed);
|
||||
remaining = &after_prefix[consumed..];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unparseable attribute-form block (no `name`/no close tag): leave
|
||||
// it and the rest as text instead of silently dropping content.
|
||||
tracing::warn!(
|
||||
body_chars = after_prefix.chars().count(),
|
||||
"[agent_parse] malformed <invoke> attribute block: missing name or close tag"
|
||||
);
|
||||
remaining = &remaining[start..];
|
||||
break;
|
||||
}
|
||||
|
||||
let Some((start, open_tag)) = literal else {
|
||||
break;
|
||||
};
|
||||
|
||||
// Everything before the tag is text.
|
||||
let before = &remaining[..start];
|
||||
if !before.trim().is_empty() {
|
||||
|
||||
Reference in New Issue
Block a user