Skip to content

Commit b9b20d8

Browse files
senamakelmedullabot
andcommitted
fix(tool_calling): land the GLM sibling-drop fix, and make the module usable by a host
Two halves, both discovered by actually wiring OpenHuman onto this module. 1. The GLM sibling drop (re-lands #104 against main) #104 merged into #102's branch, but #102 had already merged to main - so the fix never reached main. `main` today carries `tool_calling` with the `#[ignore]`d test and no fix. `parse_tool_calls_with_pformat` walks `<tool_call>`-family tags. Once ANY tag yields a p-format call the walk never falls back to the canonical parse, so every remaining tag has only the JSON path. A GLM body (`shell/command>ls -la`) is not JSON, so the call was silently dropped. The fallback runs ONLY when the JSON path found nothing: GLM's `name/key>value` shape can occur inside a JSON string value (`{"command": "cat a/b>c"}`), and an unconditional fallback would count that body twice, making the agent run the same tool twice. Routing through `parse_tool_calls` instead is also wrong - it forbids the argument-key aliases for a bare top-level object, but a `<tool_call>` tag IS an explicit marker where they apply, so that would trade one silent drop for another. Both pinned by tests. 2. Host-consumability fixes The first real consumer could not compile against this module: - `extract_json_values` was unreachable, and it is not a test helper: pulling the first JSON object out of model prose is how a host checks a required-output contract, which has nothing to do with tool calls. Now exported, along with `parse_arguments_value`, `parse_glm_style_tool_calls`, `parse_tool_call_value` and `parse_tool_calls_from_json_value`, all of which host tests exercise directly. - `parse_tool_call_value` was `#[cfg(test)]`, inherited from a host where it was test-only. It is a reasonable primitive - parse one JSON value as a tool call - so it is un-gated and documented rather than duplicated host-side. - `build_registry` took `&Value`. A host tool trait that RETURNS a schema by value - the common shape - then has to collect into a temporary just to hand out references. It takes `Borrow<Value>` now, so both forms work. Without these a host either shadows the code it just stopped owning, or loses the tests that covered it. OpenHuman keeps all 62 of its parser tests working against the crate. 1823 lib tests pass, no ignores. Clippy and fmt clean. Co-authored-by: Medulla <medulla@tinyhumans.ai>
1 parent 311199c commit b9b20d8

4 files changed

Lines changed: 116 additions & 19 deletions

File tree

‎src/harness/tool_calling/mod.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,15 @@
4141
pub(crate) mod parse;
4242
pub(crate) mod pformat;
4343

44-
pub use parse::{ParsedToolCall, parse_tool_calls, parse_tool_calls_with_pformat};
44+
// The two entry points, plus the building blocks a host legitimately reaches
45+
// for on its own. `extract_json_values` in particular is not a test helper:
46+
// pulling the first JSON object out of model prose is how a host checks a
47+
// required-output contract, which has nothing to do with tool calls.
48+
pub use parse::{
49+
ParsedToolCall, extract_json_values, parse_arguments_value, parse_glm_style_tool_calls,
50+
parse_tool_call_value, parse_tool_calls, parse_tool_calls_from_json_value,
51+
parse_tool_calls_with_pformat,
52+
};
4553
pub use pformat::{
4654
PFormatParamType, PFormatRegistry, PFormatToolParams, build_registry, parse_call,
4755
render_signature, render_signature_from_schema,

‎src/harness/tool_calling/parse.rs‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ fn first_args_by_keys(obj: &serde_json::Value) -> serde_json::Value {
4242
parse_arguments_value(None)
4343
}
4444

45-
#[cfg(test)]
45+
/// Parse a single JSON value as a tool call, honouring the argument-key
46+
/// aliases.
47+
///
48+
/// The permissive entry point: callers reach a value through an explicit
49+
/// tool-call marker (a `tool_calls` array, a `<tool_call>` tag, a fenced
50+
/// block), which is what licenses the aliases. Do not use it on arbitrary
51+
/// model output — see the module docs.
4652
pub fn parse_tool_call_value(value: &serde_json::Value) -> Option<ParsedToolCall> {
4753
// Default to the permissive (tagged) behaviour: callers that reach a
4854
// value through an explicit tool-call marker (`tool_calls` array,
@@ -1001,9 +1007,34 @@ pub fn parse_tool_calls_with_pformat(
10011007
} else {
10021008
// Re-parse this tag body with the canonical JSON logic so a body
10031009
// holding several calls contributes all of them.
1010+
//
1011+
// Deliberately the *permissive* (alias-honouring) path rather than
1012+
// `parse_tool_calls`: a `<tool_call>` tag is an explicit tool-call
1013+
// marker, so the `args`/`parameters`/`input` aliases apply here.
1014+
// `parse_tool_calls` forbids them for a bare top-level object, so
1015+
// routing through it would silently drop an aliased tagged call.
1016+
let mut from_body: Vec<ParsedToolCall> = Vec::new();
10041017
for value in extract_json_values(body) {
1005-
combined.extend(parse_tool_calls_from_json_value(&value));
1018+
from_body.extend(parse_tool_calls_from_json_value(&value));
10061019
}
1020+
1021+
// A tag body need not be JSON at all. GLM emits its own grammar
1022+
// (`shell/command>ls -la`), and once ANY tag in the response yields
1023+
// a p-format call this walk never falls back to the canonical
1024+
// result — so without this the GLM call is dropped and nothing
1025+
// reports it. Tried only when the JSON path found nothing, so a
1026+
// well-formed JSON body can never be double-counted.
1027+
if from_body.is_empty() {
1028+
from_body.extend(parse_glm_style_tool_calls(body).into_iter().map(
1029+
|(name, arguments, _raw)| ParsedToolCall {
1030+
name,
1031+
arguments,
1032+
id: None,
1033+
},
1034+
));
1035+
}
1036+
1037+
combined.extend(from_body);
10071038
}
10081039

10091040
remaining = &after_open[close_idx + close_tag.len()..];

‎src/harness/tool_calling/parse_test.rs‎

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -399,21 +399,12 @@ use crate::harness::tool_calling::{PFormatRegistry, PFormatToolParams};
399399

400400
/// A p-format tag alongside a GLM-style sibling.
401401
///
402-
/// **Known pre-existing limitation, inherited from the code this was ported
403-
/// from — `#[ignore]`d rather than deleted so it stays visible.**
404-
///
405402
/// Once any tag yields a p-format call, the walk stops falling back to the
406-
/// canonical parse, and the remaining path handles JSON only. A GLM body
407-
/// (`shell/command>ls`) is not JSON, so that call is silently dropped: the
408-
/// agent loses a tool invocation it asked for and nothing reports it.
409-
///
410-
/// Verified against the pre-port original, which fails this identically — so
411-
/// it is not a regression from the relocation or from the tag-walk rewrite.
412-
/// Fixing it means routing the non-p-format branch through the full grammar
413-
/// set rather than `extract_json_values`, which is a behaviour change and
414-
/// belongs in its own change with its own review.
403+
/// canonical parse, so every remaining tag is on its own. A GLM body
404+
/// (`shell/command>ls -la`) is not JSON, so before the GLM fallback existed
405+
/// this call was silently dropped — the agent lost a tool invocation it had
406+
/// asked for and nothing reported it.
415407
#[test]
416-
#[ignore = "pre-existing: a GLM sibling tag is dropped once a p-format tag matches"]
417408
fn a_pformat_tag_does_not_suppress_a_sibling_glm_tag() {
418409
let mut reg = PFormatRegistry::new();
419410
reg.insert(
@@ -467,3 +458,65 @@ fn a_pformat_tag_does_not_suppress_a_sibling_fenced_json_tag() {
467458
"the fenced-JSON sibling was dropped — got {names:?}"
468459
);
469460
}
461+
462+
/// A JSON body that ALSO looks like GLM's `name/key>value` grammar must not
463+
/// yield the call twice.
464+
///
465+
/// The GLM fallback runs only when the JSON path found nothing, and this is
466+
/// what pins that ordering. If it ever ran unconditionally, a body containing
467+
/// a `/` and a `>` inside a string value would be counted once as JSON and
468+
/// again as GLM — the agent would execute the same tool twice.
469+
#[test]
470+
fn a_json_body_is_not_double_counted_by_the_glm_fallback() {
471+
let mut reg = PFormatRegistry::new();
472+
reg.insert(
473+
"echo".to_string(),
474+
PFormatToolParams::from_schema(&serde_json::json!({
475+
"type": "object",
476+
"properties": { "value": { "type": "string" } }
477+
})),
478+
);
479+
480+
let response = concat!(
481+
"<tool_call>echo[hello]</tool_call>\n",
482+
"<tool_call>{\"name\": \"shell\", \"arguments\": {\"command\": \"cat a/b>c\"}}</tool_call>"
483+
);
484+
let (_narrative, calls) = parse_tool_calls_with_pformat(response, &reg);
485+
let shell_calls = calls.iter().filter(|c| c.name == "shell").count();
486+
assert_eq!(
487+
shell_calls,
488+
1,
489+
"the JSON body was counted twice: {:?}",
490+
calls.iter().map(|c| c.name.as_str()).collect::<Vec<_>>()
491+
);
492+
}
493+
494+
/// A tagged body may use the argument-key aliases.
495+
///
496+
/// A `<tool_call>` tag is an explicit tool-call marker, so `args` /
497+
/// `parameters` / `input` are honoured inside it — unlike a bare top-level
498+
/// object, where they are refused so a plain JSON answer cannot read as a
499+
/// call. This pins that the tag path keeps the permissive behaviour: routing
500+
/// it through `parse_tool_calls` instead would silently drop this call.
501+
#[test]
502+
fn a_tagged_body_still_honours_argument_key_aliases() {
503+
let mut reg = PFormatRegistry::new();
504+
reg.insert(
505+
"echo".to_string(),
506+
PFormatToolParams::from_schema(&serde_json::json!({
507+
"type": "object",
508+
"properties": { "value": { "type": "string" } }
509+
})),
510+
);
511+
512+
let response = concat!(
513+
"<tool_call>echo[hello]</tool_call>\n",
514+
"<tool_call>{\"name\": \"shell\", \"args\": {\"command\": \"ls\"}}</tool_call>"
515+
);
516+
let (_narrative, calls) = parse_tool_calls_with_pformat(response, &reg);
517+
let shell = calls
518+
.iter()
519+
.find(|c| c.name == "shell")
520+
.expect("the aliased tagged call must survive");
521+
assert_eq!(shell.arguments["command"], "ls");
522+
}

‎src/harness/tool_calling/pformat.rs‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,19 @@ pub type PFormatRegistry = HashMap<String, PFormatToolParams>;
150150
/// type is its own vocabulary, and requiring it here would make this module
151151
/// depend on the very thing it exists to stay independent of. Hosts keep a
152152
/// one-line adapter over their own tool slice.
153-
pub fn build_registry<'a, I, N>(tools: I) -> PFormatRegistry
153+
///
154+
/// The schema is `Borrow<Value>` rather than `&Value` so a host whose tool
155+
/// trait *returns* a schema by value — the common shape — can map straight
156+
/// into this without collecting into a temporary first.
157+
pub fn build_registry<I, N, S>(tools: I) -> PFormatRegistry
154158
where
155-
I: IntoIterator<Item = (N, &'a Value)>,
159+
I: IntoIterator<Item = (N, S)>,
156160
N: Into<String>,
161+
S: std::borrow::Borrow<Value>,
157162
{
158163
tools
159164
.into_iter()
160-
.map(|(name, schema)| (name.into(), PFormatToolParams::from_schema(schema)))
165+
.map(|(name, schema)| (name.into(), PFormatToolParams::from_schema(schema.borrow())))
161166
.collect()
162167
}
163168

0 commit comments

Comments
 (0)