diff --git a/Cargo.toml b/Cargo.toml index 1d9cbb04..a6dd03da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,9 +24,10 @@ futures = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" -# `regex` strips markup / data-URIs / whitespace from oversized tool payloads -# in `harness::handoff`. Already resolved in OpenHuman's kernel profile, so -# this adds no package there. +# `regex` has two consumers: the prompt-guided tool-call parsers in +# `harness::tool_calling`, and the markup / data-URI / whitespace stripping +# applied to oversized payloads in `harness::handoff`. Already resolved in +# OpenHuman's kernel profile, so it adds no package there. regex = "1" thiserror = "2" tracing = "0.1" diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 356329ea..87a86439 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -43,6 +43,7 @@ pub mod subagent; pub mod summarization; pub mod testkit; pub mod tool; +pub mod tool_calling; #[cfg(feature = "tools")] pub mod tools; pub mod usage; diff --git a/src/harness/tool_calling/mod.rs b/src/harness/tool_calling/mod.rs new file mode 100644 index 00000000..70105007 --- /dev/null +++ b/src/harness/tool_calling/mod.rs @@ -0,0 +1,48 @@ +//! Recovering tool calls from model output. +//! +//! A model that supports native tool use hands back structured calls and none +//! of this is needed. Everything else — prompt-guided models, local models, +//! providers whose native mode is unavailable or disabled — emits tool calls as +//! *text*, in whatever shape the model was trained to produce. This module +//! turns that text back into calls. +//! +//! ## Why it is this forgiving +//! +//! Each accommodation here exists because a model actually produced it and the +//! alternative was dropping a well-formed call and burning an agent iteration. +//! Concretely, the parsers accept `` tags in several spellings, +//! fenced `tool_call` blocks, bare JSON objects, Anthropic-style +//! `` XML, and the compact positional +//! [`pformat`] syntax. +//! +//! The permissiveness is bounded on purpose, and the boundary is worth knowing +//! before widening anything: +//! +//! * **Argument keys are aliased; tool names are not.** A model drifting from +//! `arguments` to `args`/`parameters`/`params`/`input` still yields a usable +//! call. The *name* stays strict, because loosening it risks reading a plain +//! JSON answer as a tool call in the whole-response path — turning an ordinary +//! reply into a phantom invocation. +//! * **The generic `input` alias is only honoured behind an explicit marker** +//! (a `tool_calls` array, a `` tag, a fenced block). Untagged text +//! does not get it. +//! * **[`pformat`] refuses to invent argument names for an unknown tool**, so a +//! model cannot tunnel arbitrary JSON through by guessing a tool name that +//! does not exist. +//! +//! ## What the host still owns +//! +//! This module takes **schemas**, never a tool trait object. A host's tool type +//! is its own vocabulary, and depending on it here would defeat the point — so +//! [`pformat::build_registry`] takes `(name, schema)` pairs and the host keeps a +//! one-line adapter over its own tool slice. Dispatch and execution stay host-side +//! too: this module answers "what did the model ask for", never "what happens next". + +pub(crate) mod parse; +pub(crate) mod pformat; + +pub use parse::{ParsedToolCall, parse_tool_calls, parse_tool_calls_with_pformat}; +pub use pformat::{ + PFormatParamType, PFormatRegistry, PFormatToolParams, build_registry, parse_call, + render_signature, render_signature_from_schema, +}; diff --git a/src/harness/tool_calling/parse.rs b/src/harness/tool_calling/parse.rs new file mode 100644 index 00000000..9682e605 --- /dev/null +++ b/src/harness/tool_calling/parse.rs @@ -0,0 +1,1027 @@ +use regex::Regex; +use std::borrow::Cow; +use std::sync::LazyLock; + +#[derive(Debug, Clone)] +pub struct ParsedToolCall { + pub name: String, + pub arguments: serde_json::Value, + /// Provider-assigned call id when the call came from a native + /// tool-use response. `None` for prompt-guided (XML-parsed) + /// tool calls — progress emitters synthesise a fallback id. + pub id: Option, +} + +pub fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value { + match raw { + Some(serde_json::Value::String(s)) => serde_json::from_str::(s) + .unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new())), + Some(value) => value.clone(), + None => serde_json::Value::Object(serde_json::Map::new()), + } +} + +/// Object keys that may carry the tool **arguments**, in priority order. +/// Models drift from the canonical `arguments` to `args`/`parameters`/etc.; +/// accepting these recovers an otherwise well-formed call (with a correct +/// `name`) instead of dropping it and burning an agent iteration +/// (bug-report-2026-05-26 A3). The tool **name** is deliberately left +/// strict — widening it would risk misreading a plain JSON answer as a +/// tool call in the whole-response parse path. +const TOOL_ARG_KEYS: &[&str] = &["arguments", "args", "parameters", "params", "input"]; + +/// Normalized arguments for the first present key among [`TOOL_ARG_KEYS`] +/// (via [`parse_arguments_value`], which tolerates both stringified and +/// object JSON). Empty-object default when none are present. +fn first_args_by_keys(obj: &serde_json::Value) -> serde_json::Value { + for key in TOOL_ARG_KEYS { + if let Some(v) = obj.get(*key) { + return parse_arguments_value(Some(v)); + } + } + parse_arguments_value(None) +} + +#[cfg(test)] +pub fn parse_tool_call_value(value: &serde_json::Value) -> Option { + // Default to the permissive (tagged) behaviour: callers that reach a + // value through an explicit tool-call marker (`tool_calls` array, + // `` tags, ```tool_call blocks) accept the arg-key aliases. + parse_tool_call_value_aliased(value, true) +} + +/// Parse a single JSON value as a tool call. +/// +/// `allow_arg_aliases` controls whether the generic argument-key aliases in +/// [`TOOL_ARG_KEYS`] (notably the very generic `input`) are honoured for a +/// **bare** `{ "name": .., .. }` object. The whole-response fallback path +/// (`parse_tool_calls` on a top-level JSON object) passes `false`: there, a +/// normal model reply such as `{"name":"Alice","input":"hi"}` must not have +/// its `input` slurped into tool arguments and routed to execution +/// (bug-report-2026-05-26 A3 follow-up). The `function`-wrapped shape stays +/// permissive regardless — the `function` key is an unambiguous tool-call +/// marker. +fn parse_tool_call_value_aliased( + value: &serde_json::Value, + allow_arg_aliases: bool, +) -> Option { + if let Some(function) = value.get("function") { + let name = function + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if !name.is_empty() { + let arguments = first_args_by_keys(function); + return Some(ParsedToolCall { + name, + arguments, + id: None, + }); + } + } + + let name = value + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + if name.is_empty() { + return None; + } + + let arguments = if allow_arg_aliases { + first_args_by_keys(value) + } else { + // Whole-response bare-object fallback: require the canonical + // `arguments` key as an explicit tool-call marker. A plain JSON reply + // that merely carries a `name` (e.g. {"name":"Alice","input":…}) must + // stay plain text, not be dispatched as a tool call just because its + // name happens to match a registered tool (CodeRabbit, #2683). Tagged + // contexts (``/``, `tool_calls` array, `function` + // wrapper) reach this fn with `allow_arg_aliases = true` and keep the + // permissive behaviour. + parse_arguments_value(Some(value.get("arguments")?)) + }; + Some(ParsedToolCall { + name, + arguments, + id: None, + }) +} + +pub fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec { + // Tagged contexts (callers reach here via an explicit tool-call marker) + // accept the argument-key aliases. + parse_tool_calls_from_json_value_aliased(value, true) +} + +/// Like [`parse_tool_calls_from_json_value`], but lets the caller forbid +/// generic arg-key aliases on a **bare** singleton/array object. The +/// `tool_calls`-keyed envelope always stays permissive — that key is an +/// unambiguous tool-call marker even on the whole-response path. +pub fn parse_tool_calls_from_json_value_aliased( + value: &serde_json::Value, + allow_arg_aliases: bool, +) -> Vec { + let mut calls = Vec::new(); + + if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) { + for call in tool_calls { + // `tool_calls` entries are explicitly tool-call shaped → widen. + if let Some(parsed) = parse_tool_call_value_aliased(call, true) { + calls.push(parsed); + } + } + + if !calls.is_empty() { + return calls; + } + } + + if let Some(array) = value.as_array() { + for item in array { + if let Some(parsed) = parse_tool_call_value_aliased(item, allow_arg_aliases) { + calls.push(parsed); + } + } + return calls; + } + + if let Some(parsed) = parse_tool_call_value_aliased(value, allow_arg_aliases) { + calls.push(parsed); + } + + calls +} + +const TOOL_CALL_OPEN_TAGS: [&str; 4] = ["", "", "", ""]; + +pub fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> { + tags.iter() + .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag))) + .min_by_key(|(idx, _)| *idx) +} + +pub fn matching_tool_call_close_tag(open_tag: &str) -> Option<&'static str> { + match open_tag { + "" => Some(""), + "" => Some(""), + "" => Some(""), + "" => Some(""), + _ => None, + } +} + +/// A tool_call-family tag — ``, ``, `` — in ANY +/// open/close variant, tolerating sentinel-token pipes, a slash, and whitespace +/// leaked into the markers (`<|tool_call>`, ``, `<|tool_call|>`, +/// ``, …). Deliberately excludes `` (plural JSON key) +/// and `` (its own attribute parser). Used only to *locate* tags; +/// open/close is decided by pairing, not by this pattern. +static TOOL_CALL_TAG_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)<[|/\s]*tool[_-]?call[|/\s]*>").unwrap()); + +/// Repair `` markers a weak model garbled by leaking its native +/// `<|…|>` sentinel-token pipes into the tags — e.g. `<|tool_call>call:{…}` +/// instead of `{…}`. Such shapes match no grammar, so the +/// whole call is dropped as narrative text and the tool never runs (observed +/// with a small Composio-toolkit sub-agent). +/// +/// Format-agnostic by construction: find every tool_call-family tag (any +/// pipe/slash garble) via [`TOOL_CALL_TAG_RE`] and pair them positionally — +/// 1st = open, 2nd = close, 3rd = open, … — rewriting each pair to canonical +/// `BODY` (and stripping a leading `call:` the model +/// sometimes emits). This sidesteps the unreliable per-tag open/close guess a +/// string-replace map needs. A trailing unpaired tag is left verbatim. Cheap +/// `Borrowed` no-op unless a *piped* tag is actually present, so well-formed +/// output — which the base parser already handles — and P-Format pipe args are +/// untouched. +fn normalize_garbled_tool_call_tags(s: &str) -> Cow<'_, str> { + // Garbling always leaks a `|` into a tag; no `|` anywhere → nothing to do. + if !s.contains('|') { + return Cow::Borrowed(s); + } + let tags: Vec<(usize, usize)> = TOOL_CALL_TAG_RE + .find_iter(s) + .map(|m| (m.start(), m.end())) + .collect(); + // Need at least one open/close pair, and at least one tag must actually be + // garbled (contain a pipe) — otherwise the base parser handles it verbatim, + // and P-Format `name[a|b]` args (pipes in the BODY, not the tags) are left + // alone. + if tags.len() < 2 || !tags.iter().any(|&(a, b)| s[a..b].contains('|')) { + return Cow::Borrowed(s); + } + let mut out = String::with_capacity(s.len()); + let mut cursor = 0usize; + for pair in tags.chunks_exact(2) { + let (open_start, open_end) = pair[0]; + let (close_start, close_end) = pair[1]; + out.push_str(&s[cursor..open_start]); // text before the open tag, verbatim + out.push_str(""); + // Strip the `call:` prefix, then try to recover a Kimi-family + // `NAME{…}` argument-sentinel body into canonical JSON (#5119). When + // the body is already canonical JSON / P-Format the recovery is a no-op + // and the stripped body flows through unchanged. + let stripped = strip_call_prefix(&s[open_end..close_start]); + match recover_sentinel_tool_call_body(stripped) { + Some(recovered) => { + // Recovered a Kimi-family `NAME{…}` sentinel body into canonical + // JSON. body_chars only (never the body itself — it may carry + // tool arguments with user data); stable `[agent_parse]` prefix + // so it aggregates with the other harness log families. + tracing::debug!( + body_chars = recovered.chars().count(), + "[agent_parse] recovered Kimi-family sentinel tool-call body into canonical JSON (#5119)" + ); + out.push_str(&recovered) + } + None => { + // A body still carrying the `<|"|>` arg-quote sentinel that + // recovery could NOT normalize is a new Kimi garble variant. + // Surface it (body_chars only — never the body: it may carry + // user data) so operators debugging a future unrecovered variant + // get a signal instead of a silently dropped tool call. + if stripped.contains(ARG_QUOTE_SENTINEL) { + tracing::warn!( + body_chars = stripped.chars().count(), + "[agent_parse] unrecovered Kimi-family sentinel tool-call body; passing through as text (#5119)" + ); + } + out.push_str(stripped) + } + } + out.push_str(""); + cursor = close_end; + } + // Trailing text, plus any final unpaired tag, verbatim. + out.push_str(&s[cursor..]); + Cow::Owned(out) +} + +/// Strip a leading `call:` some models emit right after the open tag, plus +/// surrounding whitespace, so the JSON / P-Format body underneath parses. +fn strip_call_prefix(body: &str) -> &str { + let trimmed = body.trim(); + trimmed + .strip_prefix("call:") + .map(str::trim_start) + .unwrap_or(trimmed) +} + +/// The Kimi-K2-family argument-quote sentinel that leaks in place of a real `"` +/// around string values (`[<|"|>INBOX<|"|>]` instead of `["INBOX"]`). It is the +/// body-level sibling of the tag garble [`normalize_garbled_tool_call_tags`] +/// already repairs; see [`recover_sentinel_tool_call_body`]. +const ARG_QUOTE_SENTINEL: &str = "<|\"|>"; + +/// Recover a Kimi-K2-family garbled tool-call **body** into canonical +/// `{"name":…,"arguments":…}` JSON (#5119). +/// +/// The managed `burst`/`chat` tiers are Kimi-K2-family models; in text mode they +/// sometimes render a call as `NAME{…}` — the action name before a JSON-ish +/// argument object with **unquoted keys** and the `<|"|>` sentinel in place of +/// string quotes — e.g. `GMAIL_FETCH_EMAILS{label_ids:[<|"|>INBOX<|"|>],max_results:1}`. +/// After [`normalize_garbled_tool_call_tags`] fixes the surrounding tags this +/// body still matches neither the JSON nor the P-Format grammar, so the call is +/// dropped as narrative text and the tool never runs (the turn then loops). +/// +/// Recovery: replace the `<|"|>` sentinels with real quotes, split the leading +/// action name off the `{…}` object, quote the object's bare keys, and re-emit +/// as `{"name":"NAME","arguments":{…}}` for the existing JSON parser. Returns +/// `None` — leaving the body untouched — whenever the shape does not match: a +/// canonical JSON body (`{…}`, empty name), a P-Format body (`NAME[…]`, no `{`), +/// or the already-handled `call:{"name":…}` form all fall through unchanged. +fn recover_sentinel_tool_call_body(body: &str) -> Option { + let repaired = body.replace(ARG_QUOTE_SENTINEL, "\""); + let trimmed = repaired.trim(); + + // Shape must be `NAME{…}`: a bare action identifier immediately followed by + // a brace object. A body already starting with `{` yields an empty name and + // is left to the JSON parser; a `NAME[…]` P-Format body has no `{`. + let brace = trimmed.find('{')?; + let name = trimmed[..brace].trim(); + if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return None; + } + let object = trimmed[brace..].trim_end(); + if !object.starts_with('{') || !object.ends_with('}') { + return None; + } + + // Kimi renders object keys unquoted (`{label_ids: …}`); quote them so the + // result is strict JSON, then confirm it actually parses as an object before + // committing to the rewrite. + let quoted = quote_bare_json_object_keys(object); + let arguments: serde_json::Value = serde_json::from_str("ed).ok()?; + if !arguments.is_object() { + return None; + } + + serde_json::to_string(&serde_json::json!({ "name": name, "arguments": arguments })).ok() +} + +/// Quote every **bare** object key (`{label_ids: …}` → `{"label_ids": …}`) in a +/// JSON-ish string, tracking string context so a `:` or identifier inside a +/// value never triggers a spurious rewrite. Bare literal values +/// (`true`/`false`/`null`/numbers) are left untouched — they are valid JSON — +/// and already-quoted keys pass through unchanged. +fn quote_bare_json_object_keys(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 16); + let mut in_string = false; + let mut escaped = false; + // Innermost container: `true` = object, `false` = array. A bare token may + // only be a key inside an object. + let mut in_object: Vec = Vec::new(); + // True right after a structural `{` or `,` — the only positions where a bare + // key may begin. + let mut expect_key = false; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if in_string { + out.push(c); + if escaped { + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + in_string = false; + } + continue; + } + match c { + '"' => { + in_string = true; + expect_key = false; + out.push(c); + } + '{' | ',' => { + if c == '{' { + in_object.push(true); + } + expect_key = *in_object.last().unwrap_or(&false); + out.push(c); + } + '[' => { + in_object.push(false); + expect_key = false; + out.push(c); + } + '}' | ']' => { + in_object.pop(); + expect_key = false; + out.push(c); + } + c if c.is_whitespace() => out.push(c), // keep looking for a key + c if expect_key && (c.is_ascii_alphabetic() || c == '_') => { + out.push('"'); + out.push(c); + while let Some(&nc) = chars.peek() { + if nc.is_ascii_alphanumeric() || nc == '_' { + out.push(nc); + chars.next(); + } else { + break; + } + } + out.push('"'); + expect_key = false; + } + _ => { + expect_key = false; + out.push(c); + } + } + } + out +} + +/// ``) and Claude-native +/// attribute (``) forms. +const INVOKE_PREFIX: &str = "` open tag +/// (issue #3493). Matches `` 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 { + 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 `` 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::(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 ``. `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 = + LazyLock::new(|| Regex::new(r#"name\s*=\s*"([^"]*)""#).unwrap()); + static PARAMETER_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)(.*?)"#).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("")?; + 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 + "".len(); + Some(( + ParsedToolCall { + name, + arguments: serde_json::Value::Object(arguments), + id: None, + }, + consumed, + )) +} + +pub 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()); + + for (byte_idx, ch) in trimmed.char_indices() { + if ch != '{' && ch != '[' { + continue; + } + + let slice = &trimmed[byte_idx..]; + let mut stream = serde_json::Deserializer::from_str(slice).into_iter::(); + if let Some(Ok(value)) = stream.next() { + let consumed = stream.byte_offset(); + if consumed > 0 { + return Some((value, trim_offset + byte_idx + consumed)); + } + } + } + + None +} + +pub fn strip_leading_close_tags(mut input: &str) -> &str { + loop { + let trimmed = input.trim_start(); + if !trimmed.starts_with("') else { + return ""; + }; + input = &trimmed[close_end + 1..]; + } +} + +/// Extract JSON values from a string. +/// +/// # Security Warning +/// +/// This function extracts ANY JSON objects/arrays from the input. It MUST only +/// be used on content that is already trusted to be from the LLM, such as +/// content inside `` tags where the LLM has explicitly indicated intent +/// to make a tool call. Do NOT use this on raw user input or content that +/// could contain prompt injection payloads. +pub fn extract_json_values(input: &str) -> Vec { + let mut values = Vec::new(); + let trimmed = input.trim(); + if trimmed.is_empty() { + return values; + } + + if let Ok(value) = serde_json::from_str::(trimmed) { + values.push(value); + return values; + } + + let char_positions: Vec<(usize, char)> = trimmed.char_indices().collect(); + let mut idx = 0; + while idx < char_positions.len() { + let (byte_idx, ch) = char_positions[idx]; + if ch == '{' || ch == '[' { + let slice = &trimmed[byte_idx..]; + let mut stream = + serde_json::Deserializer::from_str(slice).into_iter::(); + if let Some(Ok(value)) = stream.next() { + let consumed = stream.byte_offset(); + if consumed > 0 { + values.push(value); + let next_byte = byte_idx + consumed; + while idx < char_positions.len() && char_positions[idx].0 < next_byte { + idx += 1; + } + continue; + } + } + } + idx += 1; + } + + values +} + +/// Find the end position of a JSON object by tracking balanced braces. +pub fn find_json_end(input: &str) -> Option { + let trimmed = input.trim_start(); + let offset = input.len() - trimmed.len(); + + if !trimmed.starts_with('{') { + return None; + } + + let mut depth = 0; + let mut in_string = false; + let mut escape_next = false; + + for (i, ch) in trimmed.char_indices() { + if escape_next { + escape_next = false; + continue; + } + + match ch { + '\\' if in_string => escape_next = true, + '"' => in_string = !in_string, + '{' if !in_string => depth += 1, + '}' if !in_string => { + depth -= 1; + if depth == 0 { + return Some(offset + i + ch.len_utf8()); + } + } + _ => {} + } + } + + None +} + +/// Parse GLM-style tool calls from response text. +/// GLM uses proprietary formats like: +/// - `browser_open/url>https://example.com` +/// - `shell/command>ls -la` +/// - `http_request/url>https://api.example.com` +pub fn map_glm_tool_alias(tool_name: &str) -> &str { + match tool_name { + "browser_open" | "browser" | "web_search" | "shell" | "bash" => "shell", + "http_request" | "http" => "http_request", + _ => tool_name, + } +} + +pub fn build_curl_command(url: &str) -> Option { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return None; + } + + if url.chars().any(char::is_whitespace) { + return None; + } + + let escaped = url.replace('\'', "'\\''"); + Some(format!("curl -s '{}'", escaped)) +} + +pub fn parse_glm_style_tool_calls(text: &str) -> Vec<(String, serde_json::Value, Option)> { + let mut calls = Vec::new(); + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Format: tool_name/param>value or tool_name/{json} + if let Some(pos) = line.find('/') { + let tool_part = &line[..pos]; + let rest = &line[pos + 1..]; + + if tool_part.chars().all(|c| c.is_alphanumeric() || c == '_') { + let tool_name = map_glm_tool_alias(tool_part); + + if let Some(gt_pos) = rest.find('>') { + let param_name = rest[..gt_pos].trim(); + let value = rest[gt_pos + 1..].trim(); + + let arguments = match tool_name { + "shell" => { + if param_name == "url" { + let Some(command) = build_curl_command(value) else { + continue; + }; + serde_json::json!({"command": command}) + } else if value.starts_with("http://") || value.starts_with("https://") + { + if let Some(command) = build_curl_command(value) { + serde_json::json!({"command": command}) + } else { + serde_json::json!({"command": value}) + } + } else { + serde_json::json!({"command": value}) + } + } + "http_request" => { + serde_json::json!({"url": value, "method": "GET"}) + } + _ => serde_json::json!({param_name: value}), + }; + + calls.push((tool_name.to_string(), arguments, Some(line.to_string()))); + continue; + } + + if rest.starts_with('{') + && let Ok(json_args) = serde_json::from_str::(rest) + { + calls.push((tool_name.to_string(), json_args, Some(line.to_string()))); + } + } + } + + // Plain URL + if let Some(command) = build_curl_command(line) { + calls.push(( + "shell".to_string(), + serde_json::json!({"command": command}), + Some(line.to_string()), + )); + } + } + + calls +} + +/// Parse tool calls from an LLM response that uses XML-style function calling. +/// +/// Expected format (common with system-prompt-guided tool use): +/// ```text +/// +/// {"name": "shell", "arguments": {"command": "ls"}} +/// +/// ``` +/// +/// Also accepts common tag variants (``, ``) for model +/// compatibility. +/// +/// Also supports JSON with `tool_calls` array from OpenAI-format responses. +pub fn parse_tool_calls(response: &str) -> (String, Vec) { + let normalized = normalize_garbled_tool_call_tags(response); + let response = normalized.as_ref(); + let mut text_parts = Vec::new(); + let mut calls = Vec::new(); + let mut remaining = response; + + // First, try to parse as OpenAI-style JSON response with tool_calls array + // This handles providers like Minimax that return tool_calls in native JSON format + if let Ok(json_value) = serde_json::from_str::(response.trim()) { + // Whole-response parse: a bare top-level object/array is NOT an + // explicit tool-call marker, so forbid the generic arg-key aliases + // here (a plain `{"name":..,"input":..}` answer must stay text). + // The `tool_calls`-keyed envelope is still honoured (it carries its + // own marker) — handled inside the `_aliased` helper. + calls = parse_tool_calls_from_json_value_aliased(&json_value, false); + if !calls.is_empty() { + // If we found tool_calls, extract any content field as text + if let Some(content) = json_value.get("content").and_then(|v| v.as_str()) + && !content.trim().is_empty() + { + text_parts.push(content.trim().to_string()); + } + return (text_parts.join("\n"), calls); + } + } + + // Fall back to XML-style tool-call tag parsing. + 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 + // `` literal and the attribute form `` 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 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() { + text_parts.push(before.trim().to_string()); + } + + let Some(close_tag) = matching_tool_call_close_tag(open_tag) else { + break; + }; + + let after_open = &remaining[start + open_tag.len()..]; + if let Some(close_idx) = after_open.find(close_tag) { + let inner = &after_open[..close_idx]; + let mut parsed_any = false; + let json_values = extract_json_values(inner); + for value in json_values { + let parsed_calls = parse_tool_calls_from_json_value(&value); + if !parsed_calls.is_empty() { + parsed_any = true; + calls.extend(parsed_calls); + } + } + + if !parsed_any { + // body_chars only (never the body itself — it may carry tool + // arguments with user data). Stable `[agent_parse]` prefix so + // it aggregates with the other harness log families. Surfaces + // how often the model emits an unparseable tool-call tag + // (bug-report-2026-05-26 A3). + tracing::warn!( + body_chars = inner.chars().count(), + "[agent_parse] malformed JSON: expected tool-call object in tag body" + ); + } + + remaining = &after_open[close_idx + close_tag.len()..]; + } else { + if let Some(json_end) = find_json_end(after_open) + && let Ok(value) = + serde_json::from_str::(&after_open[..json_end]) + { + let parsed_calls = parse_tool_calls_from_json_value(&value); + if !parsed_calls.is_empty() { + calls.extend(parsed_calls); + remaining = strip_leading_close_tags(&after_open[json_end..]); + continue; + } + } + + if let Some((value, consumed_end)) = extract_first_json_value_with_end(after_open) { + let parsed_calls = parse_tool_calls_from_json_value(&value); + if !parsed_calls.is_empty() { + calls.extend(parsed_calls); + remaining = strip_leading_close_tags(&after_open[consumed_end..]); + continue; + } + } + + remaining = &remaining[start..]; + break; + } + } + + // If XML tags found nothing, try markdown code blocks with tool_call language. + // Models behind OpenRouter sometimes output ```tool_call ... ``` or hybrid + // ```tool_call ... instead of structured API calls or XML tags. + if calls.is_empty() { + static MD_TOOL_CALL_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?s)```(?:tool[_-]?call|invoke)\s*\n(.*?)(?:```|||)", + ) + .unwrap() + }); + let mut md_text_parts: Vec = Vec::new(); + let mut last_end = 0; + + for cap in MD_TOOL_CALL_RE.captures_iter(response) { + let full_match = cap.get(0).unwrap(); + let before = &response[last_end..full_match.start()]; + if !before.trim().is_empty() { + md_text_parts.push(before.trim().to_string()); + } + let inner = &cap[1]; + let json_values = extract_json_values(inner); + for value in json_values { + let parsed_calls = parse_tool_calls_from_json_value(&value); + calls.extend(parsed_calls); + } + last_end = full_match.end(); + } + + if !calls.is_empty() { + let after = &response[last_end..]; + if !after.trim().is_empty() { + md_text_parts.push(after.trim().to_string()); + } + text_parts = md_text_parts; + remaining = ""; + } + } + + // GLM-style tool calls (browser_open/url>https://..., shell/command>ls, etc.) + if calls.is_empty() { + let glm_calls = parse_glm_style_tool_calls(remaining); + if !glm_calls.is_empty() { + let mut cleaned_text = remaining.to_string(); + for (name, args, raw) in &glm_calls { + calls.push(ParsedToolCall { + name: name.clone(), + arguments: args.clone(), + id: None, + }); + if let Some(r) = raw { + cleaned_text = cleaned_text.replace(r, ""); + } + } + if !cleaned_text.trim().is_empty() { + text_parts.push(cleaned_text.trim().to_string()); + } + remaining = ""; + } + } + + // SECURITY: We do NOT fall back to extracting arbitrary JSON from the response + // here. That would enable prompt injection attacks where malicious content + // (e.g., in emails, files, or web pages) could include JSON that mimics a + // tool call. Tool calls MUST be explicitly wrapped in either: + // 1. OpenAI-style JSON with a "tool_calls" array + // 2. OpenHuman tool-call tags (, , ) + // 3. Markdown code blocks with tool_call/toolcall/tool-call language + // 4. Explicit GLM line-based call formats (e.g. `shell/command>...`) + // This ensures only the LLM's intentional tool calls are executed. + + // Remaining text after last tool call + if !remaining.trim().is_empty() { + text_parts.push(remaining.trim().to_string()); + } + + (text_parts.join("\n"), calls) +} + +/// P-Format-aware wrapper over [`parse_tool_calls`] (issue #4465). +/// +/// The migrated tinyagents parse path +/// (the native tool-use path) kept the XML/JSON/markdown/GLM +/// grammars but dropped the legacy **P-Format** positional grammar +/// (`name[arg1|arg2]`) — even though `PFormat` is the +/// default `ToolCallFormat` +/// and ~10 builtin agent prompts still *teach* the `name[a|b]` form. A model +/// that followed its own instructions therefore emitted calls that +/// [`parse_tool_calls`] logged as "malformed `` JSON" and silently +/// dropped, so the turn continued as if no tool was called. +/// +/// This restores parity by walking the ``-family tags and, for each +/// tag body, **preferring** the registry-driven P-Format parse +/// ([`pformat::parse_call`](super::pformat::parse_call)) and +/// **falling back** to the JSON entry the canonical parser produced at the same +/// ordinal position — the exact per-tag selection the legacy +/// `PFormatToolDispatcher` performed. This makes it a strict superset of +/// [`parse_tool_calls`]: +/// +/// - An **empty** `registry` (native/JSON agents advertise no positional +/// layout, or no tools at all) short-circuits to [`parse_tool_calls`], so +/// nothing changes for non-PFormat callers. +/// - A tag body that is not a valid `name[...]` positional call (e.g. a JSON +/// `{"name":..}` body, or an unregistered tool name) leaves +/// [`pformat::parse_call`](super::pformat::parse_call) +/// returning `None`, so the canonical JSON entry is used unchanged. +pub fn parse_tool_calls_with_pformat( + response: &str, + registry: &super::pformat::PFormatRegistry, +) -> (String, Vec) { + let normalized = normalize_garbled_tool_call_tags(response); + let response = normalized.as_ref(); + // Canonical parse first: narrative text + JSON/XML/markdown/GLM calls. + let (narrative, json_calls) = parse_tool_calls(response); + + // Without a registry there is no positional layout to reconstruct — keep + // the canonical result verbatim (behaviour-neutral for non-PFormat paths). + if registry.is_empty() { + return (narrative, json_calls); + } + + // Walk the tags ourselves, preferring a P-Format body per tag and falling + // back to parsing the tag body directly with the JSON logic to preserve + // all calls produced from multi-call JSON bodies and markdown/GLM grammars. + let mut combined: Vec = Vec::new(); + let mut remaining = response; + + while !remaining.is_empty() { + let Some((open_idx, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) else { + break; + }; + let Some(close_tag) = matching_tool_call_close_tag(open_tag) else { + break; + }; + let after_open = &remaining[open_idx + open_tag.len()..]; + let Some(close_idx) = after_open.find(close_tag) else { + break; + }; + let body = &after_open[..close_idx]; + + if let Some((name, arguments)) = super::pformat::parse_call(body, registry) { + // Do NOT log the arguments — a p-format body carries tool arguments + // that may contain user data (bug-report-2026-05-26 A3 parity). + tracing::debug!( + tool = name.as_str(), + "[agent_parse] recovered P-Format tool call (name[arg|arg]) the JSON pass dropped" + ); + combined.push(ParsedToolCall { + name, + arguments, + id: None, + }); + } else { + // Re-parse this tag body with the canonical JSON logic so a body + // holding several calls contributes all of them. + for value in extract_json_values(body) { + combined.extend(parse_tool_calls_from_json_value(&value)); + } + } + + remaining = &after_open[close_idx + close_tag.len()..]; + } + + if combined.is_empty() { + // No `` tag recovered a positional call — the canonical + // result already covers JSON/XML/markdown/GLM grammars. + return (narrative, json_calls); + } + + tracing::debug!( + parsed_tool_calls = combined.len(), + "[agent_parse] P-Format-aware parse produced combined tool-call set" + ); + (narrative, combined) +} + +#[cfg(test)] +#[path = "parse_test.rs"] +mod tests; diff --git a/src/harness/tool_calling/parse_test.rs b/src/harness/tool_calling/parse_test.rs new file mode 100644 index 00000000..ccf3477b --- /dev/null +++ b/src/harness/tool_calling/parse_test.rs @@ -0,0 +1,469 @@ +use super::*; + +#[test] +fn parse_argument_helpers_cover_string_non_string_and_missing_values() { + assert_eq!( + parse_arguments_value(Some(&serde_json::json!("{\"value\":1}"))), + serde_json::json!({ "value": 1 }) + ); + assert_eq!( + parse_arguments_value(Some(&serde_json::json!("not-json"))), + serde_json::json!({}) + ); + assert_eq!( + parse_arguments_value(Some(&serde_json::json!({ "value": 2 }))), + serde_json::json!({ "value": 2 }) + ); + assert_eq!(parse_arguments_value(None), serde_json::json!({})); +} + +#[test] +fn parse_tool_call_value_supports_function_shape_flat_shape_and_invalid_names() { + let function_shape = serde_json::json!({ + "function": { + "name": "shell", + "arguments": "{\"command\":\"ls\"}" + } + }); + let parsed = parse_tool_call_value(&function_shape).expect("function call should parse"); + assert_eq!(parsed.name, "shell"); + assert_eq!(parsed.arguments, serde_json::json!({ "command": "ls" })); + + let flat_shape = serde_json::json!({ + "name": "echo", + "arguments": { "value": "hi" } + }); + let parsed = parse_tool_call_value(&flat_shape).expect("flat call should parse"); + assert_eq!(parsed.name, "echo"); + assert_eq!(parsed.arguments, serde_json::json!({ "value": "hi" })); + + assert!(parse_tool_call_value(&serde_json::json!({ "name": " " })).is_none()); + assert!(parse_tool_call_value(&serde_json::json!({ "function": {} })).is_none()); +} + +#[test] +fn parse_tool_call_value_accepts_argument_key_aliases() { + // Correct name but the model used `args`/`parameters` instead of the + // canonical `arguments` — recover the call rather than drop it and burn + // an agent iteration (bug-report-2026-05-26 A3). + let with_args = serde_json::json!({ "name": "echo", "args": { "value": "hi" } }); + let parsed = parse_tool_call_value(&with_args).expect("args alias should parse"); + assert_eq!(parsed.name, "echo"); + assert_eq!(parsed.arguments, serde_json::json!({ "value": "hi" })); + + let with_parameters = serde_json::json!({ + "function": { "name": "shell", "parameters": "{\"command\":\"ls\"}" } + }); + let parsed = parse_tool_call_value(&with_parameters).expect("parameters alias should parse"); + assert_eq!(parsed.name, "shell"); + assert_eq!(parsed.arguments, serde_json::json!({ "command": "ls" })); + + // Name stays strict: an arg alias without a recognized name key is not + // a tool call (guards the whole-response JSON parse path). + assert!(parse_tool_call_value(&serde_json::json!({ "tool": "echo", "args": {} })).is_none()); +} + +#[test] +fn whole_response_singleton_ignores_generic_arg_aliases() { + // A plain JSON answer that happens to carry a `name` plus a generic, + // object-valued `input`. Tagged contexts widen `input` into arguments… + let answer = serde_json::json!({ "name": "Alice", "input": { "value": "hi" } }); + let tagged = parse_tool_calls_from_json_value(&answer); + assert_eq!(tagged.len(), 1); + assert_eq!(tagged[0].arguments, serde_json::json!({ "value": "hi" })); + + // …but the whole-response (bare singleton) path must treat this as plain + // text, not a tool call: it carries no canonical `arguments` marker, only + // a `name` that happens to match a tool (CodeRabbit, #2683). + let whole = parse_tool_calls_from_json_value_aliased(&answer, false); + assert!( + whole.is_empty(), + "bare whole-response object without canonical `arguments` must not dispatch a tool call" + ); + + // A bare object WITH the canonical `arguments` key is still recognized on + // the whole-response path — `arguments` is the explicit tool-call marker. + let bare_call = serde_json::json!({ "name": "echo", "arguments": { "value": "hi" } }); + let calls = parse_tool_calls_from_json_value_aliased(&bare_call, false); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); + assert_eq!(calls[0].arguments, serde_json::json!({ "value": "hi" })); + + // The `tool_calls`-keyed envelope is an explicit marker and stays + // permissive even when aliases are forbidden for bare objects. + let envelope = serde_json::json!({ + "tool_calls": [ { "name": "echo", "input": { "value": "hi" } } ] + }); + let calls = parse_tool_calls_from_json_value_aliased(&envelope, false); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); + assert_eq!(calls[0].arguments, serde_json::json!({ "value": "hi" })); +} + +#[test] +fn parse_tool_calls_from_json_value_handles_tool_calls_array_arrays_and_singletons() { + let wrapped = serde_json::json!({ + "tool_calls": [ + { "name": "echo", "arguments": { "value": "one" } }, + { "function": { "name": "shell", "arguments": "{\"command\":\"pwd\"}" } } + ], + "content": "assistant text" + }); + let calls = parse_tool_calls_from_json_value(&wrapped); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "echo"); + assert_eq!(calls[1].name, "shell"); + + let array = serde_json::json!([ + { "name": "echo", "arguments": { "value": "two" } }, + { "name": " " } + ]); + let calls = parse_tool_calls_from_json_value(&array); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].arguments, serde_json::json!({ "value": "two" })); + + let single = serde_json::json!({ "name": "echo", "arguments": { "value": "three" } }); + let calls = parse_tool_calls_from_json_value(&single); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); +} + +#[test] +fn tag_and_json_extractors_cover_common_edge_cases() { + assert_eq!( + find_first_tag("hi there", &["", ""]), + Some((3, "")) + ); + assert_eq!( + matching_tool_call_close_tag(""), + Some("") + ); + assert_eq!(matching_tool_call_close_tag(""), None); + + let extracted = extract_first_json_value_with_end(" text {\"ok\":true} trailing ") + .expect("json should be found"); + assert_eq!(extracted.0, serde_json::json!({ "ok": true })); + assert!(extracted.1 > 0); + + assert_eq!( + strip_leading_close_tags(" hi "), + "hi " + ); + assert_eq!(strip_leading_close_tags("plain"), "plain"); + + let values = extract_json_values("before {\"a\":1} [1,2] after"); + assert_eq!( + values, + vec![serde_json::json!({ "a": 1 }), serde_json::json!([1, 2])] + ); + + assert_eq!( + find_json_end(" {\"a\":\"}\"}tail"), + Some(" {\"a\":\"}\"}".len()) + ); + assert_eq!(find_json_end("[1,2,3]"), None); +} + +#[test] +fn glm_helpers_parse_aliases_urls_and_commands() { + assert_eq!(map_glm_tool_alias("browser_open"), "shell"); + assert_eq!(map_glm_tool_alias("http"), "http_request"); + assert_eq!(map_glm_tool_alias("custom_tool"), "custom_tool"); + + assert_eq!( + build_curl_command("https://example.com?q=1"), + Some("curl -s 'https://example.com?q=1'".into()) + ); + assert_eq!( + build_curl_command("https://exa'mple.com"), + Some("curl -s 'https://exa'\\''mple.com'".into()) + ); + assert!(build_curl_command("ftp://example.com").is_none()); + assert!(build_curl_command("https://example.com/has space").is_none()); + + let calls = parse_glm_style_tool_calls( + "browser_open/url>https://example.com\nhttp_request/url>https://api.example.com\nplain text\nhttps://rust-lang.org", + ); + assert_eq!(calls.len(), 3); + assert_eq!(calls[0].0, "shell"); + assert_eq!(calls[1].0, "http_request"); + assert_eq!(calls[2].0, "shell"); +} + +#[test] +fn parse_tool_calls_supports_native_json_xml_markdown_and_glm_formats() { + let native = serde_json::json!({ + "content": "native text", + "tool_calls": [ + { "name": "echo", "arguments": { "value": "one" } } + ] + }) + .to_string(); + let (text, calls) = parse_tool_calls(&native); + assert_eq!(text, "native text"); + assert_eq!(calls.len(), 1); + + let xml = "before\n\n{\"name\":\"echo\",\"arguments\":{\"value\":\"two\"}}\n\nafter"; + let (text, calls) = parse_tool_calls(xml); + assert_eq!(text, "before\nafter"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].arguments, serde_json::json!({ "value": "two" })); + + let unclosed = "{\"name\":\"echo\",\"arguments\":{\"value\":\"three\"}}"; + let (text, calls) = parse_tool_calls(unclosed); + assert!(text.is_empty()); + assert_eq!(calls.len(), 1); + + let markdown = + "lead\n```tool_call\n{\"name\":\"echo\",\"arguments\":{\"value\":\"four\"}}\n```\ntrail"; + let (text, calls) = parse_tool_calls(markdown); + assert_eq!(text, "lead\ntrail"); + assert_eq!(calls.len(), 1); + + let glm = "shell/command>ls -la"; + let (text, calls) = parse_tool_calls(glm); + assert!(text.is_empty()); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "shell"); +} + +// ── lenient recovery of pipe-garbled tags ──────────────────────── + +#[test] +fn garbled_pipe_tags_with_json_body_and_call_prefix_parse() { + // Exact shape seen from a small Composio sub-agent: native `<|…|>` sentinel + // pipes leaked into the tags (`<|tool_call>` / ``) and the body + // is prefixed with `call:`. Without the normalizer this drops silently and + // the tool never runs; with it, the real call is recovered. + let garbled = r#"<|tool_call>call:{"name": "GMAIL_LIST_THREADS", "arguments": {"query": "\"University of Colorado\"", "verbose": true}}"#; + let (_text, calls) = parse_tool_calls(garbled); + assert_eq!(calls.len(), 1, "expected the garbled call to be recovered"); + assert_eq!(calls[0].name, "GMAIL_LIST_THREADS"); + assert_eq!(calls[0].arguments["query"], "\"University of Colorado\""); + assert_eq!(calls[0].arguments["verbose"], true); +} + +#[test] +fn garbled_pipe_tags_recover_multiple_parallel_calls() { + let garbled = concat!( + r#"<|tool_call>call:{"name": "GMAIL_LIST_THREADS", "arguments": {"query": "a"}}"#, + r#"<|tool_call>call:{"name": "GMAIL_LIST_THREADS", "arguments": {"query": "b"}}"#, + ); + let (_t, calls) = parse_tool_calls(garbled); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].arguments["query"], "a"); + assert_eq!(calls[1].arguments["query"], "b"); +} + +#[test] +fn normalize_leaves_clean_output_untouched() { + // No piped marker → cheap Borrowed no-op, and a canonical call still parses. + let clean = r#"{"name":"echo","arguments":{}}"#; + assert!(matches!( + normalize_garbled_tool_call_tags(clean), + std::borrow::Cow::Borrowed(_) + )); + let (_t, calls) = parse_tool_calls(clean); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); +} + +#[test] +fn normalize_repairs_open_and_close_pipe_variants() { + // Leading pipe → open; trailing pipe (no slash) → close. + assert_eq!( + normalize_garbled_tool_call_tags("<|tool_call>BODY").as_ref(), + "BODY" + ); + // Slash-bearing close variants normalize too. + assert_eq!( + normalize_garbled_tool_call_tags("b").as_ref(), + "b" + ); +} + +#[test] +fn normalize_pairs_symmetric_both_pipe_tags() { + // `<|tool_call|>` on BOTH sides — a hardcoded open/close map can't tell them + // apart; positional pairing does (1st = open, 2nd = close). + let garbled = r#"<|tool_call|>{"name":"echo","arguments":{"msg":"hi"}}<|tool_call|>"#; + let (_t, calls) = parse_tool_calls(garbled); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "echo"); + assert_eq!(calls[0].arguments["msg"], "hi"); +} + +#[test] +fn normalize_leaves_pformat_pipe_args_untouched() { + // Pipes appear in P-Format BODIES (`name[a|b]`), not the tags — the + // garbled-tag guard must not touch a well-formed positional call. + let clean = "get_weather[London|metric]"; + assert!(matches!( + normalize_garbled_tool_call_tags(clean), + std::borrow::Cow::Borrowed(_) + )); +} + +// ── #5119: recover the Kimi `NAME{…}` argument-sentinel body ───────────────── + +#[test] +fn garbled_kimi_name_brace_body_with_quote_sentinels_parses() { + // The EXACT shape observed from `integrations_agent`/`burst-v1` (Kimi-K2) on + // the post-contract retry: garbled tags PLUS a `NAME{…}` body with unquoted + // keys and the `<|"|>` argument-quote sentinel around string values. Before + // the recovery this parsed to zero tool calls (the tag fix alone left an + // unparseable body), so GMAIL_FETCH_EMAILS never ran and the turn looped. + let garbled = r#"<|tool_call>call:GMAIL_FETCH_EMAILS{label_ids:[<|"|>INBOX<|"|>],max_results:1,verbose:true}"#; + let (_text, calls) = parse_tool_calls(garbled); + assert_eq!(calls.len(), 1, "the garbled Kimi call must be recovered"); + assert_eq!(calls[0].name, "GMAIL_FETCH_EMAILS"); + assert_eq!( + calls[0].arguments["label_ids"], + serde_json::json!(["INBOX"]) + ); + assert_eq!(calls[0].arguments["max_results"], 1); + assert_eq!(calls[0].arguments["verbose"], true); +} + +#[test] +fn garbled_kimi_name_brace_body_integer_only_parses() { + // The integer-only variant (no string values → no `<|"|>` sentinel, but the + // body is still the unparseable `NAME{unquoted-keys}` shape). Observed as + // `{max_results:5}` on the staging repro. + let garbled = r#"<|tool_call>call:GMAIL_FETCH_EMAILS{max_results:5}"#; + let (_text, calls) = parse_tool_calls(garbled); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "GMAIL_FETCH_EMAILS"); + assert_eq!(calls[0].arguments["max_results"], 5); +} + +#[test] +fn recover_sentinel_body_leaves_canonical_and_pformat_untouched() { + // Canonical JSON body → empty leading name → not our shape → None. + assert!(recover_sentinel_tool_call_body(r#"{"name":"echo","arguments":{}}"#).is_none()); + // P-Format body (`NAME[…]`, no brace) → None. + assert!(recover_sentinel_tool_call_body("get_weather[London|metric]").is_none()); + // Trailing garbage after the object → not a clean `NAME{…}` → None. + assert!(recover_sentinel_tool_call_body("FOO{a:1} trailing").is_none()); +} + +#[test] +fn quote_bare_json_object_keys_respects_string_values() { + // A `,ident:` sequence INSIDE a string value must not be quoted; only + // structural keys after `{`/`,` are rewritten. + let out = quote_bare_json_object_keys(r#"{query:"from:john,to:x",n:1}"#); + assert_eq!(out, r#"{"query":"from:john,to:x","n":1}"#); + // Parses as strict JSON with the value preserved verbatim. + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["query"], "from:john,to:x"); + assert_eq!(v["n"], 1); +} + +#[test] +fn quote_bare_json_object_keys_leaves_array_literals_unquoted() { + // Bare literals inside arrays must not be quoted. A comma inside an array + // should not trigger `expect_key = true` because arrays do not have keys. + let out = quote_bare_json_object_keys(r#"{flags:[true,false],n:null}"#); + assert_eq!(out, r#"{"flags":[true,false],"n":null}"#); + // Parses as strict JSON with the values preserved as booleans and null. + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["flags"], serde_json::json!([true, false])); + assert_eq!(v["n"], serde_json::Value::Null); +} + +#[test] +fn parse_tool_calls_with_pformat_preserves_multi_call_tag_bodies() { + // A single tag body can hold multiple JSON calls (e.g., two + // adjacent objects or a {"tool_calls":[...]} envelope). The ordinal pairing + // must not drop them when re-parsing the tag body. + use crate::harness::tool_calling::PFormatRegistry; + + let registry = PFormatRegistry::new(); + let response = r#"{"name":"get_weather","arguments":{"city":"London"}}{"name":"get_time","arguments":{"tz":"UTC"}}"#; + let (_, calls) = parse_tool_calls_with_pformat(response, ®istry); + + assert_eq!( + calls.len(), + 2, + "both JSON calls in the tag body must be recovered" + ); + assert_eq!(calls[0].name, "get_weather"); + assert_eq!(calls[0].arguments["city"], "London"); + assert_eq!(calls[1].name, "get_time"); + assert_eq!(calls[1].arguments["tz"], "UTC"); +} + +// ── Regression probe: mixed p-format + non-JSON tags ───────────────────────── + +use crate::harness::tool_calling::{PFormatRegistry, PFormatToolParams}; + +/// A p-format tag alongside a GLM-style sibling. +/// +/// **Known pre-existing limitation, inherited from the code this was ported +/// from — `#[ignore]`d rather than deleted so it stays visible.** +/// +/// Once any tag yields a p-format call, the walk stops falling back to the +/// canonical parse, and the remaining path handles JSON only. A GLM body +/// (`shell/command>ls`) is not JSON, so that call is silently dropped: the +/// agent loses a tool invocation it asked for and nothing reports it. +/// +/// Verified against the pre-port original, which fails this identically — so +/// it is not a regression from the relocation or from the tag-walk rewrite. +/// Fixing it means routing the non-p-format branch through the full grammar +/// set rather than `extract_json_values`, which is a behaviour change and +/// belongs in its own change with its own review. +#[test] +#[ignore = "pre-existing: a GLM sibling tag is dropped once a p-format tag matches"] +fn a_pformat_tag_does_not_suppress_a_sibling_glm_tag() { + let mut reg = PFormatRegistry::new(); + reg.insert( + "echo".to_string(), + PFormatToolParams::from_schema(&serde_json::json!({ + "type": "object", + "properties": { "value": { "type": "string" } } + })), + ); + + let response = concat!( + "echo[hello]\n", + "shell/command>ls -la" + ); + let (_narrative, calls) = parse_tool_calls_with_pformat(response, ®); + let names: Vec<&str> = calls.iter().map(|c| c.name.as_str()).collect(); + + assert!( + names.contains(&"echo"), + "the p-format call must survive: {names:?}" + ); + assert_eq!( + calls.len(), + 2, + "the sibling non-JSON tag was dropped — got {names:?}" + ); +} + +/// The same shape, but the sibling body is JSON inside a markdown fence. +#[test] +fn a_pformat_tag_does_not_suppress_a_sibling_fenced_json_tag() { + let mut reg = PFormatRegistry::new(); + reg.insert( + "echo".to_string(), + PFormatToolParams::from_schema(&serde_json::json!({ + "type": "object", + "properties": { "value": { "type": "string" } } + })), + ); + + let response = concat!( + "echo[hello]\n", + "\n```json\n{\"name\": \"shell\", \"arguments\": {\"command\": \"ls\"}}\n```\n" + ); + let (_narrative, calls) = parse_tool_calls_with_pformat(response, ®); + let names: Vec<&str> = calls.iter().map(|c| c.name.as_str()).collect(); + + assert!(names.contains(&"echo"), "p-format call survives: {names:?}"); + assert!( + names.contains(&"shell"), + "the fenced-JSON sibling was dropped — got {names:?}" + ); +} diff --git a/src/harness/tool_calling/pformat.rs b/src/harness/tool_calling/pformat.rs new file mode 100644 index 00000000..e7ccb5ff --- /dev/null +++ b/src/harness/tool_calling/pformat.rs @@ -0,0 +1,501 @@ +//! P-Format ("Parameter-Format") tool calls — compact, positional, +//! pipe-delimited tool invocations designed to slash the token cost of +//! text-based tool calling. +//! +//! # Why +//! +//! Standard JSON tool calls are heavy on tokens for what's actually a +//! simple instruction: +//! +//! ```text +//! {"name": "get_weather", "arguments": {"location": "London", "unit": "metric"}} +//! ``` +//! +//! That's roughly 25 tokens. The same call in P-Format: +//! +//! ```text +//! get_weather[London|metric] +//! ``` +//! +//! is ~5 tokens — an 80% reduction. Across a long agent loop with many +//! tool calls per turn, that compounds dramatically. +//! +//! # Spec +//! +//! - One call per `...` tag body. +//! - Form: `name[arg1|arg2|...|argN]`. +//! - `name` is the tool's registered name (alphanumerics + `_`). +//! - Arguments are **positional**, with the order pinned to the +//! **alphabetical** sort of the JSON-schema property names. The +//! project's `serde_json` build does not enable `preserve_order`, so +//! `Map` iterates as a `BTreeMap` — alphabetical iteration is the +//! only order we can produce deterministically without flipping a +//! crate-wide feature flag, and it is stable across rebuilds and +//! workspaces. +//! - The renderer always exposes the order in the tool catalogue +//! (e.g. `get_weather[location|unit]`, `math[verbose|x|y]`), so the +//! model never has to guess which slot maps to which parameter — it +//! reads the signature line and copies that order verbatim. +//! - Empty calls: `tool_name[]` for zero-arg tools. +//! - Empty arguments: `tool_name[||value]` is three args, the first two +//! being empty strings. +//! - Escapes: `\|` → `|`, `\]` → `]`, `\\` → `\`. Other backslashes +//! pass through verbatim so URLs and Windows paths remain readable. +//! - Type coercion: schema property `type: integer | number | boolean` +//! triggers parsing the string into the matching JSON value. Failed +//! coercion falls back to a string so the model still gets *something* +//! useful into the tool argument. +//! +//! # Trade-offs +//! +//! - **Positional only** — nested objects or arrays can't be expressed +//! directly. Tools that need rich payloads should either flatten their +//! schema, accept a JSON-blob string parameter, or be invoked via the +//! legacy JSON-in-tag fallback (which the dispatcher attempts when +//! p-format parsing returns `None`). +//! - **Tool registry required at parse time** — without the schema we +//! can't reconstruct named arguments. The dispatcher caches a +//! pre-computed `name → params` map at construction time so this +//! stays fast and avoids holding a reference to the live tool slice. + +use serde_json::{Map, Value}; +use std::collections::HashMap; + +/// JSON-schema primitive type used for argument coercion. Anything we +/// don't recognise (objects, arrays, custom types) is treated as +/// `Other`, which preserves the raw string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PFormatParamType { + String, + Integer, + Number, + Boolean, + Other, +} + +impl PFormatParamType { + /// Map a JSON-schema `type` value to the coercion enum. Schemas may + /// expose `type` as either a single string (`"integer"`) or an + /// array (`["integer", "null"]`); we accept both and pick the first + /// non-`null` entry. + pub fn from_schema_type(value: Option<&Value>) -> Self { + let label = match value { + Some(Value::String(s)) => s.as_str(), + Some(Value::Array(items)) => items + .iter() + .find_map(|v| v.as_str().filter(|s| *s != "null")) + .unwrap_or(""), + _ => "", + }; + match label { + "string" => Self::String, + "integer" => Self::Integer, + "number" => Self::Number, + "boolean" => Self::Boolean, + _ => Self::Other, + } + } +} + +/// One tool's positional parameter list, as the dispatcher needs it +/// at parse time. +#[derive(Debug, Clone)] +pub struct PFormatToolParams { + /// Parameter names in declaration order. + pub names: Vec, + /// Parallel slice of JSON types for coercion. + pub types: Vec, +} + +impl PFormatToolParams { + /// Pull the ordered parameter names + types out of a tool's + /// JSON schema. Non-object schemas (rare, but possible for + /// shell-style tools) return an empty list — the renderer falls + /// back to `name[]`. + /// + /// Iteration order is alphabetical because `serde_json::Map` is + /// a `BTreeMap` in this build (no `preserve_order` feature). The + /// renderer always shows the resulting order in the tool catalogue + /// so the model — and the parser — agree on the layout. See the + /// module-level docs for the rationale. + pub fn from_schema(schema: &Value) -> Self { + let Some(props) = schema.get("properties").and_then(|p| p.as_object()) else { + return Self { + names: Vec::new(), + types: Vec::new(), + }; + }; + let mut names = Vec::with_capacity(props.len()); + let mut types = Vec::with_capacity(props.len()); + for (name, def) in props { + names.push(name.clone()); + types.push(PFormatParamType::from_schema_type(def.get("type"))); + } + Self { names, types } + } +} + +/// Pre-computed lookup of every tool's parameter list. Built once at +/// dispatcher construction time so the parser doesn't need to hold a +/// reference to the live tool list (which the host owns). +/// +/// The map preserves the spec contract: the parser refuses to invent +/// argument names for an unknown tool, so an LLM can't tunnel +/// arbitrary JSON in by guessing tool names that don't exist. +pub type PFormatRegistry = HashMap; + +/// Build a [`PFormatRegistry`] from `(name, schema)` pairs. +/// +/// Takes schemas rather than a tool trait object on purpose: a host's tool +/// type is its own vocabulary, and requiring it here would make this module +/// depend on the very thing it exists to stay independent of. Hosts keep a +/// one-line adapter over their own tool slice. +pub fn build_registry<'a, I, N>(tools: I) -> PFormatRegistry +where + I: IntoIterator, + N: Into, +{ + tools + .into_iter() + .map(|(name, schema)| (name.into(), PFormatToolParams::from_schema(schema))) + .collect() +} + +/// Render a single tool's p-format signature, e.g. `get_weather[location|unit]`. +/// +/// This signature is included in the tool catalogue within the system prompt +/// to tell the LLM exactly how to order positional arguments for a tool. +pub fn render_signature(name: &str, params: &PFormatToolParams) -> String { + if params.names.is_empty() { + format!("{name}[]") + } else { + format!("{name}[{}]", params.names.join("|")) + } +} + +/// Render a signature straight from a tool's JSON schema. +/// +/// The schema-taking counterpart to [`render_signature`], for callers that +/// have a schema but no prebuilt [`PFormatToolParams`]. +pub fn render_signature_from_schema(name: &str, schema: &Value) -> String { + render_signature(name, &PFormatToolParams::from_schema(schema)) +} + +/// Parse a single p-format call body and reconstruct named JSON arguments. +/// +/// This function: +/// 1. Locates the positional arguments within the `[...]` brackets. +/// 2. Splits them by the `|` delimiter (respecting escapes). +/// 3. Maps each positional value to its parameter name from the tool registry. +/// 4. Performs type coercion (e.g., string to integer) based on the tool's schema. +/// +/// Returns `(tool_name, args_json)` on success, or `None` if the format is invalid +/// or the tool is unknown. +pub fn parse_call(body: &str, registry: &PFormatRegistry) -> Option<(String, Value)> { + let trimmed = body.trim(); + + // Locate the opening bracket. The closing bracket must be the + // **last** character of the trimmed body — anything trailing it + // (e.g. extra whitespace, JSON, prose) means this isn't a valid + // p-format call and we leave it for the JSON fallback. + let open = trimmed.find('[')?; + if !trimmed.ends_with(']') { + return None; + } + + let name = trimmed[..open].trim(); + if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') { + return None; + } + + let inner = &trimmed[open + 1..trimmed.len() - 1]; + + // Look up the parameter spec — required so we can map positional + // values back to named JSON keys with the correct types. + let params = registry.get(name)?; + + let raw_values = split_pipes(inner); + let mut args = Map::with_capacity(params.names.len()); + for (i, raw) in raw_values.iter().enumerate() { + let Some(param_name) = params.names.get(i) else { + // Excess values: drop silently. The schema is the source + // of truth for argument count. + tracing::debug!( + tool = name, + index = i, + "[pformat] dropping excess positional argument" + ); + continue; + }; + let coerced = coerce_value( + raw, + params + .types + .get(i) + .copied() + .unwrap_or(PFormatParamType::String), + ); + args.insert(param_name.clone(), coerced); + } + + Some((name.to_string(), Value::Object(args))) +} + +/// Split a p-format argument body on unescaped `|`. Honours `\|`, +/// `\]`, and `\\` escapes. An empty body produces an empty `Vec` (NOT +/// `vec![""]`) so a tool with zero parameters parses cleanly. +fn split_pipes(input: &str) -> Vec { + if input.is_empty() { + return Vec::new(); + } + + let mut out = Vec::new(); + let mut current = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\\' { + match chars.peek() { + Some('|') => { + current.push('|'); + chars.next(); + } + Some(']') => { + current.push(']'); + chars.next(); + } + Some('\\') => { + current.push('\\'); + chars.next(); + } + _ => current.push('\\'), + } + } else if c == '|' { + out.push(std::mem::take(&mut current)); + } else { + current.push(c); + } + } + + out.push(current); + out +} + +/// Coerce a raw string argument into the JSON type the schema expects. +/// Falls back to `Value::String` for any failed coercion so the model +/// still gets a usable value into the tool argument map. +fn coerce_value(raw: &str, ty: PFormatParamType) -> Value { + match ty { + PFormatParamType::Integer => raw + .trim() + .parse::() + .map(|n| Value::Number(n.into())) + .unwrap_or_else(|_| Value::String(raw.to_string())), + PFormatParamType::Number => raw + .trim() + .parse::() + .ok() + .and_then(serde_json::Number::from_f64) + .map(Value::Number) + .unwrap_or_else(|| Value::String(raw.to_string())), + PFormatParamType::Boolean => match raw.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "1" => Value::Bool(true), + "false" | "no" | "0" => Value::Bool(false), + _ => Value::String(raw.to_string()), + }, + PFormatParamType::String | PFormatParamType::Other => Value::String(raw.to_string()), + } +} + +// ────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_registry() -> PFormatRegistry { + let mut reg = PFormatRegistry::new(); + reg.insert( + "get_weather".to_string(), + PFormatToolParams::from_schema(&json!({ + "type": "object", + "properties": { + "location": { "type": "string" }, + "unit": { "type": "string" } + } + })), + ); + reg.insert( + "shell".to_string(), + PFormatToolParams::from_schema(&json!({ + "type": "object", + "properties": { + "command": { "type": "string" } + } + })), + ); + reg.insert( + "ping".to_string(), + PFormatToolParams::from_schema(&json!({ + "type": "object", + "properties": {} + })), + ); + reg.insert( + "math".to_string(), + PFormatToolParams::from_schema(&json!({ + "type": "object", + "properties": { + "x": { "type": "integer" }, + "y": { "type": "number" }, + "verbose": { "type": "boolean" } + } + })), + ); + reg + } + + #[test] + fn renders_zero_arg_signature() { + let reg = make_registry(); + assert_eq!(render_signature("ping", ®["ping"]), "ping[]"); + } + + #[test] + fn renders_multi_arg_signature() { + let reg = make_registry(); + assert_eq!( + render_signature("get_weather", ®["get_weather"]), + "get_weather[location|unit]" + ); + } + + #[test] + fn parses_simple_call() { + let reg = make_registry(); + let (name, args) = parse_call("get_weather[London|metric]", ®).unwrap(); + assert_eq!(name, "get_weather"); + assert_eq!(args, json!({"location": "London", "unit": "metric"})); + } + + #[test] + fn parses_zero_arg_call() { + let reg = make_registry(); + let (name, args) = parse_call("ping[]", ®).unwrap(); + assert_eq!(name, "ping"); + assert_eq!(args, json!({})); + } + + #[test] + fn parses_single_arg_with_spaces() { + let reg = make_registry(); + let (name, args) = parse_call("shell[ls -la /tmp]", ®).unwrap(); + assert_eq!(name, "shell"); + assert_eq!(args, json!({"command": "ls -la /tmp"})); + } + + #[test] + fn handles_pipe_escape() { + let reg = make_registry(); + let (_, args) = parse_call(r"shell[cat foo \| grep bar]", ®).unwrap(); + assert_eq!(args, json!({"command": "cat foo | grep bar"})); + } + + #[test] + fn handles_bracket_escape() { + let reg = make_registry(); + let (_, args) = parse_call(r"shell[echo \]done\]]", ®).unwrap(); + assert_eq!(args, json!({"command": "echo ]done]"})); + } + + #[test] + fn handles_backslash_escape() { + let reg = make_registry(); + let (_, args) = parse_call(r"shell[C:\\Users\\bob]", ®).unwrap(); + assert_eq!(args, json!({"command": r"C:\Users\bob"})); + } + + #[test] + fn coerces_typed_arguments() { + let reg = make_registry(); + // Alphabetical order: verbose, x, y. The signature the model + // sees in the catalogue is `math[verbose|x|y]` so this is the + // order it would emit. + let (_, args) = parse_call("math[true|42|2.75]", ®).unwrap(); + assert_eq!(args, json!({"verbose": true, "x": 42, "y": 2.75})); + } + + #[test] + fn coercion_falls_back_to_string_on_failure() { + let reg = make_registry(); + let (_, args) = parse_call("math[maybe|notanumber|alsonotanumber]", ®).unwrap(); + assert_eq!( + args, + json!({ + "verbose": "maybe", + "x": "notanumber", + "y": "alsonotanumber" + }) + ); + } + + #[test] + fn signature_uses_alphabetical_order() { + let reg = make_registry(); + // `math` has properties (in source) {x, y, verbose} but + // BTreeMap iteration sorts to {verbose, x, y}. + assert_eq!(render_signature("math", ®["math"]), "math[verbose|x|y]"); + } + + #[test] + fn rejects_unknown_tool() { + let reg = make_registry(); + assert!(parse_call("nope[arg]", ®).is_none()); + } + + #[test] + fn rejects_missing_brackets() { + let reg = make_registry(); + assert!(parse_call("get_weather London metric", ®).is_none()); + } + + #[test] + fn rejects_trailing_garbage() { + let reg = make_registry(); + // Closing bracket isn't last char → invalid p-format, dispatcher + // should try the JSON fallback path. + assert!(parse_call("get_weather[London|metric] // comment", ®).is_none()); + } + + #[test] + fn drops_excess_positional_arguments() { + let reg = make_registry(); + // get_weather only has 2 schema params; the third value is dropped. + let (_, args) = parse_call("get_weather[London|metric|extra]", ®).unwrap(); + assert_eq!(args, json!({"location": "London", "unit": "metric"})); + } + + #[test] + fn empty_body_pipes_produce_empty_strings() { + let reg = make_registry(); + let (_, args) = parse_call("get_weather[||]", ®).unwrap(); + // 3 raw values: "", "", "". get_weather has 2 params, third is dropped. + assert_eq!(args, json!({"location": "", "unit": ""})); + } + + #[test] + fn signature_round_trips_with_parser() { + let reg = make_registry(); + let sig = render_signature("get_weather", ®["get_weather"]); + // Render uses the same identifier the parser expects. + assert!(sig.starts_with("get_weather[")); + let synthesised = "get_weather[Berlin|imperial]"; + let (name, args) = parse_call(synthesised, ®).unwrap(); + assert_eq!(name, "get_weather"); + assert_eq!(args["location"], json!("Berlin")); + assert_eq!(args["unit"], json!("imperial")); + } +}