405 lines
17 KiB
C
405 lines
17 KiB
C
/* 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</parameter>\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, "</parameter>") — 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
|
|
* "</parameter>" 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 "</parameter>" 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 <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* 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:
|
|
* "</parameter></function>", 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 =
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n\n"
|
|
"</parameter></function>\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 — `</parameter>` 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 =
|
|
"<function=write>\n"
|
|
"<parameter=path>\n/tmp/x.c\n</parameter>\n"
|
|
"<parameter=content>\n\n</parameter>\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 =
|
|
"<function=write>\n"
|
|
"<parameter=path>\n/tmp/x.c\n</parameter>\n"
|
|
"<parameter=content>\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 =
|
|
"<tool_call>\n<function=write>\n"
|
|
"<parameter=path>\n/tmp/x.c\n</parameter>\n"
|
|
"<parameter=content>\nhello\n</parameter>\n"
|
|
"</function>\n</tool_call>";
|
|
|
|
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 =
|
|
"<function=edit>\n"
|
|
"<parameter=path>\n/tmp/x.c\n</parameter>\n"
|
|
"<parameter=old_string>\ndead line\n</parameter>\n"
|
|
"<parameter=new_string>\n\n</parameter>\n"
|
|
"</function>";
|
|
|
|
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, "<function=read>\n</function>");
|
|
ASSERT_FALSE(bare.accepted);
|
|
ASSERT_FALSE(bare.complete);
|
|
|
|
/* One of two required arguments, then close. */
|
|
decode_verdict half = decode(g,
|
|
"<function=write>\n<parameter=path>\n/tmp/x.c\n</parameter>\n</function>");
|
|
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 "</parameter>"
|
|
* has been consumed the walk is at a BODY BOUNDARY, where the only legal
|
|
* continuations are another "<parameter=" or "</function>" — 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[] = {
|
|
"<function=read>\n<parameter=path>\n/tmp/x.c\n</parameter>\n", /* padded */
|
|
"<function=read>\n<parameter=path>\n/tmp/x.c</parameter>", /* unpadded */
|
|
"<function=read>\n<parameter=path>\n\n</parameter>", /* 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, "</function>"));
|
|
ASSERT_TRUE(tg_token_allowed(st, "\n<parameter=path>"));
|
|
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[] = {
|
|
"<function=read>\n<parameter=path>\n/tmp/x.c\n</parameter>\n</function>",
|
|
"<function=read>\n<parameter=path>\n/tmp/x.c</parameter></function>",
|
|
"<function=read>\n<parameter=path>\n/tmp/x.c\n</parameter></function>",
|
|
"<function=read>\n<parameter=path>\n/tmp/x.c</parameter>\n</function>",
|
|
};
|
|
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;
|
|
}
|