/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* Copyright (C) 2026 David (Corey) Herrera */ /* * TRUNCATED TOOL CALLS — the grammar and the parser must agree about where a * parameter value ended, and a call the grammar never closed must be visibly * incomplete rather than a call with an argument quietly missing. * * This is the only test that links BOTH src/serve/tool_grammar.c and * src/serve/tool_calling.c, because the defect it covers exists only in the gap * between them. Neither half is wrong on its own: * * • the grammar's value terminator was "\n\n", plus a guard that * refused to let it complete on an empty value by consuming its chars as * CONTENT instead; * • the parser's value terminator is strstr(vstart, "") — any * occurrence, no padding required. * * On the one emission the model actually produces when it gives up — a value of * a single "\n" — those two rules disagree about a byte sequence both of them * saw. The guard ate the terminator's leading "\n" as content, which made * "" break the match and be swallowed as ordinary value text, so the * walk stayed inside TG_VALUE for the rest of the turn. The parser found that * same "" immediately, read the value as "", and dropped the key. * The client got `read {}` / `write {"path":…}` with no `content` — a * complete-looking call missing a required argument — while the serve's own * grammar state said the call had never finished. * * Measured: 9/9 live truncated calls were "grammar ACCEPTS char-for-char, * tg_complete == NO", both controls complete. * * Every defect case here is paired with the healthy neighbour it must not be * confused with, and the sharpest of those neighbours is a LEGAL EMPTY VALUE: * `edit`'s new_string:"" is a deletion (the client's schema code * argument_is_absent), and it is byte-for-byte identical to the truncation up to * the point where the model closes the call. Only the close separates them, * which is exactly why the verdict is tg_complete() and not "was the value * empty". * * Pure C string/FSM work — no engine/model/CUDA deps, fully local. */ #include "lib/test.h" #include "../src/serve/tool_calling.h" #include "../src/serve/tool_grammar.h" #include "../src/serve/json_reader.h" #include #include #include /* The client's real shapes, trimmed to the tools that appear in the captured raws: * `read` (1 required arg — the `{}` case), `write` (2 — the "path kept, content * lost" case) and `edit` (3 — where new_string:"" is a legal deletion). */ static const char *TRUNC_TOOLS = "[{\"type\":\"function\",\"function\":{\"name\":\"read\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}}," "{\"type\":\"function\",\"function\":{\"name\":\"write\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"path\":{\"type\":\"string\"},\"content\":{\"type\":\"string\"}}," "\"required\":[\"path\",\"content\"]}}}," "{\"type\":\"function\",\"function\":{\"name\":\"edit\"," "\"parameters\":{\"type\":\"object\",\"properties\":{" "\"path\":{\"type\":\"string\"},\"old_string\":{\"type\":\"string\"}," "\"new_string\":{\"type\":\"string\"}}," "\"required\":[\"path\",\"old_string\",\"new_string\"]}}}]"; /* What the DECODER saw, for one raw emission. */ typedef struct { bool accepted; /* every char was an allowed continuation (nothing masked) */ bool complete; /* tg_complete — the call closed */ bool in_call; /* tg_in_call — the walk is inside the skeleton */ } decode_verdict; /* Walk `text` through the grammar one char at a time in FORCED mode * (tool_choice=required, which is how the orchestrator runs), asking * tg_token_allowed BEFORE each advance so `accepted` really means "the mask * would never have blocked this". `accepted` is the load-bearing half: it is * what makes a failing case a GRAMMAR/PARSER DISAGREEMENT rather than merely * text the grammar rejected. */ static decode_verdict decode(const tg_grammar *g, const char *text) { decode_verdict v = { true, false, false }; tg_state *st = tg_state_new_forced(g, NULL); if (!st) { v.accepted = false; return v; } tg_reasoning_end(st); for (const char *p = text; *p; p++) { char one[2] = { *p, '\0' }; if (!tg_token_allowed(st, one)) v.accepted = false; tg_advance(st, one); } v.complete = tg_complete(st); v.in_call = tg_in_call(st); tg_state_free(st); return v; } /* Parse `text` and return the FIRST call's arguments JSON (owned by the caller), * or NULL when no call came out. `*n_out` reports how many calls were parsed so * a test can tell "no call" from "a call with nothing in it". */ static char *parse_first_arguments(const char *text, const jr_value *tools, int *n_out, char **name_out) { tc_tool_call *calls = NULL; int n = 0; char *args = NULL; if (tc_parse_tool_calls(text, tools, TC_DIALECT_QWEN_XML, &calls, &n) != 0) n = 0; *n_out = n; if (name_out) *name_out = NULL; if (n > 0) { args = calls[0].arguments ? strdup(calls[0].arguments) : NULL; if (name_out && calls[0].name) *name_out = strdup(calls[0].name); } tc_tool_calls_free(calls, n); return args; } /* ============================================================ * THE TWO SHAPES THAT REACHED THE CLIENT AS BROKEN CALLS * ============================================================ */ /* VERBATIM from the live serve log (tool_arg_truncation.md, log:274) — the raw * that reached the client as `read {}`. Note the close carries NO padding newlines: * "", which the old grammar could not match either. * * The assertions in order are the whole finding: the grammar never objected to a * single character (so no amount of masking was going to stop this), the call * nonetheless never closed, and the parser turned it into a named call whose one * required argument is gone. */ static void test_live_read_empty_path(void) { jr_value *tools = jr_parse(TRUNC_TOOLS); tg_grammar *g = tg_build(tools); const char *raw = "\n\n\n\n" "\n"; decode_verdict v = decode(g, raw); ASSERT_TRUE(v.accepted); /* the grammar accepted it char-for-char */ ASSERT_FALSE(v.complete); /* …and it is NOT a finished call */ ASSERT_TRUE(v.in_call); /* …and the walk is inside the skeleton */ /* What the parser makes of the same bytes: a `read` whose `path` is the * empty string the model actually closed around. It used to be `{}` — the * argument dropped and therefore ABSENT, which is a claim about a member * that was in fact present. Either way shipping it is the defect, and the * three assertions above are what the serve refuses on (openai_chat.c * collect_tool_calls, asserted on the wire in test_chat_request.c). The * empty `path` that survives that gate being off is the empty-path wedge's shape, * and it is the client's void-argument guard that answers it. */ int n = 0; char *name = NULL; char *args = parse_first_arguments(raw, tools, &n, &name); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(name, "read"); ASSERT_STR_EQ(args, "{\"path\":\"\"}"); free(args); free(name); tg_free(g); jr_free(tools); } /* The 2-required-argument shape: `path` survives, `content` arrives EMPTY, and * the model stops without closing the call. * * This is the residue the parameter-level empty-value rule cannot see. `content` * was closed — `` is right there — so as bytes it is a deliberate * empty value and the parser now keeps it. What is truncated is the CALL, which * only the grammar can say (`v.complete` false, asserted below), and that is the * gate this shape is caught by. */ static void test_live_write_loses_content(void) { jr_value *tools = jr_parse(TRUNC_TOOLS); tg_grammar *g = tg_build(tools); const char *raw = "\n" "\n/tmp/x.c\n\n" "\n\n\n"; decode_verdict v = decode(g, raw); ASSERT_TRUE(v.accepted); ASSERT_FALSE(v.complete); ASSERT_TRUE(v.in_call); int n = 0; char *name = NULL; char *args = parse_first_arguments(raw, tools, &n, &name); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(name, "write"); /* `content` is EMPTY, not gone — and the difference matters, because gone is * a claim the bytes do not support. */ ASSERT_STR_EQ(args, "{\"path\":\"/tmp/x.c\",\"content\":\"\"}"); free(args); free(name); tg_free(g); jr_free(tools); } /* The end token landing inside a value (three captured raws end this way). The * value here is NOT empty, so no empty-value rule of any kind would catch it — * only "the call never closed" does. */ static void test_end_token_inside_a_value(void) { jr_value *tools = jr_parse(TRUNC_TOOLS); tg_grammar *g = tg_build(tools); const char *raw = "\n" "\n/tmp/x.c\n\n" "\n<|im_end|>"; decode_verdict v = decode(g, raw); ASSERT_TRUE(v.accepted); ASSERT_FALSE(v.complete); ASSERT_TRUE(v.in_call); tg_free(g); jr_free(tools); } /* ============================================================ * THE NEIGHBOURS THAT MUST NOT MOVE * ============================================================ */ /* CONTROL — a healthy multi-parameter call is complete and keeps both arguments. * Without this the suite would "pass" under a grammar that never completes * anything, which is precisely the failure mode being fixed. */ static void test_healthy_multi_parameter_call(void) { jr_value *tools = jr_parse(TRUNC_TOOLS); tg_grammar *g = tg_build(tools); const char *raw = "\n\n" "\n/tmp/x.c\n\n" "\nhello\n\n" "\n"; decode_verdict v = decode(g, raw); ASSERT_TRUE(v.accepted); ASSERT_TRUE(v.complete); int n = 0; char *name = NULL; char *args = parse_first_arguments(raw, tools, &n, &name); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(name, "write"); ASSERT_STR_EQ(args, "{\"path\":\"/tmp/x.c\",\"content\":\"hello\"}"); free(args); free(name); tg_free(g); jr_free(tools); } /* THE TRAP — a LEGAL EMPTY VALUE. `edit` with new_string:"" is a deletion, and * The client's schema layer is explicit that an empty member is present, not absent * (argument_is_absent, the client's schema code). Its bytes are identical * to test_live_read_empty_path's right up to the close, so any rule of the form * "an empty value means the model gave up" turns this correct call into an error. * * It must be COMPLETE: the model closed the parameter and then closed the call. * That is the whole difference, and it is the only one available. */ static void test_legal_empty_value_completes(void) { jr_value *tools = jr_parse(TRUNC_TOOLS); tg_grammar *g = tg_build(tools); const char *raw = "\n" "\n/tmp/x.c\n\n" "\ndead line\n\n" "\n\n\n" ""; decode_verdict v = decode(g, raw); ASSERT_TRUE(v.accepted); ASSERT_TRUE(v.complete); /* NOT a truncation → never gated */ /* And the deletion SURVIVES: `new_string` reaches the client present and * empty, which is the one shape the client can read as a deletion. Dropped, it * arrived absent — indistinguishable from a call cut short before * `new_string`, charged against the truncated-call budget, corrected, and * re-asked until the task was abandoned over a call that was right every * time (the client's schema code argument_is_absent, reducer.c * LOOP_GUARD_TRUNCATED_CALL_BUDGET). */ int n = 0; char *name = NULL; char *args = parse_first_arguments(raw, tools, &n, &name); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(name, "edit"); ASSERT_STR_EQ(args, "{\"path\":\"/tmp/x.c\",\"old_string\":\"dead line\"," "\"new_string\":\"\"}"); free(args); free(name); tg_free(g); jr_free(tools); } /* The naive early close stays REJECTED — the model cannot skip a required * argument by closing the call. This is the guard that already worked * (tool_grammar.c TG_BODY, commit 5baeeec) and the one thing in this area the * measurement confirmed was doing its job; the value-side change must not have * loosened it. */ static void test_naive_early_close_still_rejected(void) { jr_value *tools = jr_parse(TRUNC_TOOLS); tg_grammar *g = tg_build(tools); /* No parameters at all. */ decode_verdict bare = decode(g, "\n"); ASSERT_FALSE(bare.accepted); ASSERT_FALSE(bare.complete); /* One of two required arguments, then close. */ decode_verdict half = decode(g, "\n\n/tmp/x.c\n\n"); ASSERT_FALSE(half.accepted); ASSERT_FALSE(half.complete); tg_free(g); jr_free(tools); } /* ============================================================ * THE MECHANISM ITSELF — the close is no longer swallowed * ============================================================ */ /* The direct statement of the fix, with no parser involved. * * Feed a value and its close, and then ask what may follow. Once "" * has been consumed the walk is at a BODY BOUNDARY, where the only legal * continuations are another "" — so ordinary prose * must be MASKED. Under the old guard the close had been absorbed as value text * and the walk was still in free-text VALUE, where prose is allowed; that single * difference is the whole divergence. * * Both the padded and unpadded closes are checked, because the live raws use the * unpadded one. */ static void test_close_returns_the_walk_to_the_skeleton(void) { jr_value *tools = jr_parse(TRUNC_TOOLS); tg_grammar *g = tg_build(tools); static const char *const openers[] = { "\n\n/tmp/x.c\n\n", /* padded */ "\n\n/tmp/x.c", /* unpadded */ "\n\n\n", /* empty */ }; for (size_t i = 0; i < sizeof(openers) / sizeof(openers[0]); i++) { tg_state *st = tg_state_new_forced(g, NULL); ASSERT_NOT_NULL(st); tg_reasoning_end(st); tg_advance(st, openers[i]); ASSERT_TRUE(tg_in_call(st)); /* At a body boundary: prose is off-grammar… */ ASSERT_FALSE(tg_token_allowed(st, "The bash tool seems to be stale")); /* …and the two real continuations are not. */ ASSERT_TRUE(tg_token_allowed(st, "")); ASSERT_TRUE(tg_token_allowed(st, "\n")); ASSERT_FALSE(tg_complete(st)); /* the CALL is still open */ tg_state_free(st); } tg_free(g); jr_free(tools); } /* The value boundary the grammar uses is the one the parser uses, across the * padding variants the model actually emits. Each of these closes the call, and * the parser reads the same value out of each — which is the property that makes * "the grammar did not complete" mean "the model truncated" and nothing else. */ static void test_value_boundary_agrees_with_the_parser(void) { jr_value *tools = jr_parse(TRUNC_TOOLS); tg_grammar *g = tg_build(tools); static const char *const shapes[] = { "\n\n/tmp/x.c\n\n", "\n\n/tmp/x.c", "\n\n/tmp/x.c\n", "\n\n/tmp/x.c\n", }; for (size_t i = 0; i < sizeof(shapes) / sizeof(shapes[0]); i++) { decode_verdict v = decode(g, shapes[i]); ASSERT_TRUE(v.accepted); ASSERT_TRUE(v.complete); int n = 0; char *name = NULL; char *args = parse_first_arguments(shapes[i], tools, &n, &name); ASSERT_INT_EQ(n, 1); ASSERT_STR_EQ(args, "{\"path\":\"/tmp/x.c\"}"); free(args); free(name); } tg_free(g); jr_free(tools); } /* ============================================================ * Main * ============================================================ */ int main(void) { printf("\n truncated tool calls — grammar/parser agreement + the close gate\n\n"); RUN_TEST(live_read_empty_path); RUN_TEST(live_write_loses_content); RUN_TEST(end_token_inside_a_value); RUN_TEST(healthy_multi_parameter_call); RUN_TEST(legal_empty_value_completes); RUN_TEST(naive_early_close_still_rejected); RUN_TEST(close_returns_the_walk_to_the_skeleton); RUN_TEST(value_boundary_agrees_with_the_parser); TEST_REPORT(); return __ft_failed > 0 ? 1 : 0; }