fix(agent): decode element-form calls for offered tools - #31
Conversation
Told to call tools inside <tool_call> tags, DeepSeek V4 writes its native invoke XML there. The tagged grammar claimed the block, found no JSON body, and dropped the call as malformed, though the same invoke parses bare. When a tag body opens with a named invoke, decode it with invoke_xml. The anchor keeps an invoke quoted inside other body text from executing. Refs tinyhumansai/openhuman#6722
Adds a closed JSON tag body quoting an invoke (fails without the anchor) and a <tool_call><function=…> body (dropped on main). Narrows the decode_body doc to what the anchor guarantees: only the first invoke is anchored, as on the bare path.
DeepSeek V4 Flash wrote the opener as the fence info string, ```<tool_call>, and never closed the fence. The info string read as a language, so the unclosed fence protected the call to end of text as an example and it was dropped. A fence whose info string opens with a complete call tag (tag-family opener, named invoke, bare <invoke>) is now a call fence. A language-tagged fence still protects its contents. Refs tinyhumansai/openhuman#6722
opens_with_call_tag reused NAMED_INVOKE_OPEN_RE, which also accepts <function …> and any XML namespace, so an XSLT fence such as ```<xsl:function name="f"> became a call. Only a tag-family opener or an (optionally DSML-prefixed) <invoke> now marks a call fence.
DeepSeek V4 Flash writes some calls as plain elements under the Python code dialect: <todo><todos>[…]</todos></todo>. No grammar read the form, so the call was dropped and the turn ended on narration. A new registry-gated grammar claims <NAME>…</NAME> when NAME is an offered tool with a registry entry and the body is child elements only. It decodes to a call when every child is a parameter and every JSON- looking value parses, and reports a MalformedBlock otherwise. Anything else stays in the text. In a stream, a partial opener of an eligible tool is held back so the markup is not released as text. Refs tinyhumansai/openhuman#6722
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: Comment |
Tiny Sweeper review
|
|
Review of 1. Known-tool + registry gate: sound, and strict. 2. Stream hold-back: the partial opener is fine, but a complete opener stalls the rest of the stream.
3. Prose inside a known tool's tags is not claimed, which matches the controls. Two edges to note:
Minor: a prose value inside a parameter child is taken as a string ( |
An element opener with no closer held the whole rest of the stream until flush. Hold only while the body is still a viable child-only prefix, so a mis-closed <todo>…</tool_call> releases text live. Reserved names (tool_call, invoke, …) are no longer parameter children, so a malformed element wrapping a real <tool_call> leaves that call to its own grammar instead of swallowing it.
|
Addressed in
|
|
Re-checked at
Two small notes, neither blocking:
|
|
Correction to my count above: the "348/348" included 2 local probe tests ( |
Summary
Under the Python code dialect, DeepSeek V4 Flash writes some calls as plain elements:
<todo>\n<todos>\n[{…}, …]\n</todos>\n</todo>. That is the tool name as the tag and each parameter as a child. No grammar read this form, so the call was dropped and the turn ended on narration.This PR adds a new
grammar/element.rs, placed last inGRAMMARS, plusCallSource::Element(the enum is#[non_exhaustive]). Because any tag could be a tool name, it is gated hard. A block is claimed only when:<NAME>has no attributes, NAME is an offered tool with a P-Format registry entry, and NAME is not a tag another grammar owns;</NAME>is present;A claimed block decodes to a call when every child is a parameter of NAME and every
[/{value is valid JSON. Other values are read the same wayinvoke_xmlreads them. Otherwise the block isDecoded::Malformed, so the host gets aMalformedBlock { source: Element, .. }diagnostic instead of a silent drop. A block that fails the gate is left in the text untouched.In a stream, a trailing partial
<na…that could still become an eligible tool's opener is held back. Tool names are not staticopeners(), so without this the scrubber released<todoas visible text before its>arrived. An opener whose closer hasn't arrived is held only while its body is still a viable child-only prefix. A mis-closed<todo>…</todos></tool_call>therefore releases the rest of the stream live instead of stalling it until flush (10622c4). Reserved names (tool_call,invoke,function, …) are never parameter children, so a malformed element wrapping a real<tool_call>leaves that call to its own grammar.Widened surface: as before, a call outside any language-tagged fence executes, and that includes inline-code examples. Element form widens this from the fixed call markers to any offered tool name used as a tag. Under the registry gate, a model writing
<todo><todos>[…]</todos></todo>as an illustration will dispatch it.Registry gate: parameter names reach the parser only through the registry, which the harness passes for the P-Format/code dialects. The Xml dialect never sees element calls; the module carries a
ponytail:note naming that ceiling.Known, intentional gap: in the user's record 27, the leading todo block closes with
</todos>\n</tool_call>instead of</todo>; the model mismatched the closer. The matching-closer gate correctly declines it, so that todo is not recovered. The same record'sGITHUB_SEARCH_REPOSITORIEScall still is, via #29/#30.Related issue
Refs tinyhumansai/openhuman#6722. Verified on the real user records with a registry built from their recorded schemas (counts only): five todo records that previously gave 0 calls now give 1 call each (
todo,Element) with 0 diagnostics. Record 27 gives its invoke call and, as intended, not its mis-closed todo. Prose and<div>give 0. A code call plus a todo element gives both, in order.API or behavior changes
CallSource::Element(the enum is#[non_exhaustive], so this is not breaking).MalformedBlock. Before this change it was plain text.Validation
Commands actually run, with their outcome:
cargo fmt --all -- --check: cleancargo clippy --all-targets --all-features -- -D warnings: ran without--all-features(cargo clippy --all-targets -- -D warnings): cleancargo build --all-targets --all-features: not run locally with--all-features; CI runs itcargo test --all-features: rancargo test(default features): all pass (113 + 346 + 20 + 1)Also ran
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps: clean.Tests
The new
src/parse/test/element.rshas 10 tests.Fixtures:
<todo>wrapping a real<tool_call>→ the inner call survives, with noMalformedBlock;```<tool_call>wrapped invoke →[todo, tool_search]in order;MalformedBlock;MalformedBlockfrom bothparse_textandStreamScrubber, with the markup not shown.Controls:
todonot offered → 0 calls, text kept;<div><p>x</p></div>→ 0 calls, no diagnostic;<todo>→ 0 calls, no diagnostic, text kept;```xmlfence → 0 calls.Revert check: with
Elementremoved fromGRAMMARS, the first 5 element fixtures fail and all 5 controls pass. With the stream hold made unconditional, only the stall test fails. With reserved names allowed as children, only the survive test fails.Documentation
grammar/element.rshas module docs that cover the gate, the malformed rule and the registry ceiling.Checklist
#[allow(...)],#[ignore], or relaxed lints.envcontents in the diff or the descriptionReview status:
tinysweeper/reviewtimed out at both heads,5f7161dand10622c4(900s, 'No code was reviewed'), so tinysweeper has never reviewed this PR. It is advisory, not a required check. The fleet reviewer reviewed5f7161dand re-checked10622c4(346/346 on a clean target dir).