347 lines
15 KiB
C
347 lines
15 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/*
|
|
* EMPTY IS NOT ABSENT — the one bit the serve owes the client about a parameter
|
|
* that carried no characters.
|
|
*
|
|
* THE DEFECT. parse_call_arguments dropped any value that trimmed to empty, so
|
|
* `edit` performing a deletion — `new_string:""`, which is what a deletion IS —
|
|
* left the serve as `{"path":…,"old_string":…}` with the member gone. At the
|
|
* client a gone member is an ABSENT one (the client's schema code
|
|
* argument_is_absent), absent is the signature of a call cut short, and the client
|
|
* charges a cut-short call against a per-task budget of 6 that ENDS the task
|
|
* (LOOP_GUARD_TRUNCATED_CALL_BUDGET). So a correct deletion was corrected,
|
|
* re-asked, re-emitted identically, and eventually abandoned the task. Deleting
|
|
* a line of code is an ordinary operation.
|
|
*
|
|
* THE DISCRIMINATOR, and why it is `closed` and not "was the value empty". The
|
|
* two events that produce zero value characters are:
|
|
* • the model wrote `<parameter=k></parameter>` — it CLOSED the parameter, so
|
|
* the emptiness is the argument; and
|
|
* • the turn ended inside the value region — no terminator was ever written,
|
|
* so nothing about the argument is known.
|
|
* That is a fact about the bytes, at parameter granularity, and it holds with no
|
|
* decoding grammar running at all (PANTHEON_TOOL_GRAMMAR is off by default).
|
|
* tg_complete() is the same question one level up, about the whole call, and the
|
|
* two layer: test_tool_call_truncation.c owns that half.
|
|
*
|
|
* WHAT THIS DOES NOT UNDO. The empty-path wedge was `{"path":""}` reaching a `read`,
|
|
* and that shape is deliberately reachable again — it is what the model emitted.
|
|
* Hiding it as `{}` is what made "the model sent an empty path" and "the model
|
|
* never sent a path" the same bytes. Whether an empty value is USABLE is a
|
|
* question about the parameter (`path` never, `new_string` always) and is
|
|
* answered at the client, which has the tool's meaning; it cannot be answered
|
|
* about a member that never arrived.
|
|
*
|
|
* Pure C string work — no engine/model/CUDA deps, fully local.
|
|
*/
|
|
|
|
#include "lib/test.h"
|
|
#include "../src/serve/tool_calling.h"
|
|
#include "../src/serve/json_reader.h"
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* The client's real shapes for the tools this contract turns on, plus one non-string
|
|
* parameter — an empty value on an `integer` must still produce valid JSON. */
|
|
static const char *EMPTY_ARG_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\"]}}},"
|
|
"{\"type\":\"function\",\"function\":{\"name\":\"edit_lines\","
|
|
"\"parameters\":{\"type\":\"object\",\"properties\":{"
|
|
"\"path\":{\"type\":\"string\"},\"start_line\":{\"type\":\"integer\"},"
|
|
"\"new_text\":{\"type\":\"string\"}},"
|
|
"\"required\":[\"path\",\"start_line\"]}}}]";
|
|
|
|
/* Parse `raw` and hand back the first call's name + arguments (both owned by the
|
|
* caller), along with the diagnosis. `*n` is the call count. */
|
|
static char *parse_one(const char *raw, const jr_value *tools, int *n,
|
|
char **name_out, tc_parse_error *err)
|
|
{
|
|
tc_tool_call *calls = NULL;
|
|
*n = -1;
|
|
*name_out = NULL;
|
|
if (tc_parse_tool_calls_diagnosed(raw, tools, TC_DIALECT_QWEN_XML, &calls, n,
|
|
err, NULL) != 0)
|
|
return NULL;
|
|
char *args = NULL;
|
|
if (*n > 0) {
|
|
args = calls[0].arguments ? strdup(calls[0].arguments) : NULL;
|
|
*name_out = calls[0].name ? strdup(calls[0].name) : NULL;
|
|
}
|
|
tc_tool_calls_free(calls, *n);
|
|
return args;
|
|
}
|
|
|
|
/* ============================================================
|
|
* THE DELETION — the call this whole change exists for.
|
|
* ============================================================ */
|
|
|
|
/* `edit` with an empty `new_string` is a DELETION, and it must arrive as a
|
|
* present-and-empty member. Everything the client does with it turns on that:
|
|
* present-and-empty is a value it can judge, absent is a truncation it charges. */
|
|
static void test_edit_deletion_keeps_new_string_present(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_TOOLS);
|
|
const char *raw =
|
|
"<tool_call>\n<function=edit>\n"
|
|
"<parameter=path>\n/tmp/x.c\n</parameter>\n"
|
|
"<parameter=old_string>\ndead line\n</parameter>\n"
|
|
"<parameter=new_string>\n</parameter>\n"
|
|
"</function>\n</tool_call>";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 1);
|
|
ASSERT_STR_EQ(name, "edit");
|
|
ASSERT_STR_EQ(args, "{\"path\":\"/tmp/x.c\",\"old_string\":\"dead line\","
|
|
"\"new_string\":\"\"}");
|
|
ASSERT_INT_EQ(err.kind, TC_PARSE_OK);
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* The same deletion written with the whitespace padding the model actually
|
|
* emits. dup_trim reduces "\n\n" to "", so this is byte-different from the test
|
|
* above and value-identical — the padding must not change the verdict. */
|
|
static void test_edit_deletion_with_whitespace_padding(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_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 \t\n</parameter>\n"
|
|
"</function>";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 1);
|
|
ASSERT_STR_EQ(args, "{\"path\":\"/tmp/x.c\",\"old_string\":\"dead line\","
|
|
"\"new_string\":\"\"}");
|
|
ASSERT_INT_EQ(err.kind, TC_PARSE_OK);
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* `write` with empty `content` — creating an empty file — is the same contract on
|
|
* the two-argument tool, and it is the shape that used to arrive as `write` with
|
|
* `content` MISSING, the exact fixture the client's truncated-call detector was built
|
|
* from. */
|
|
static void test_write_empty_content_keeps_the_member(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_TOOLS);
|
|
const char *raw =
|
|
"<function=write>\n"
|
|
"<parameter=path>\n/tmp/empty.txt\n</parameter>\n"
|
|
"<parameter=content>\n</parameter>\n"
|
|
"</function>";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 1);
|
|
ASSERT_STR_EQ(args, "{\"path\":\"/tmp/empty.txt\",\"content\":\"\"}");
|
|
ASSERT_INT_EQ(err.kind, TC_PARSE_OK);
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* ============================================================
|
|
* THE OTHER SIDE — an UNCLOSED empty value is still dropped, and still named.
|
|
* ============================================================ */
|
|
|
|
/* The turn ended inside the value: no `</parameter>`, no content. Nothing is
|
|
* known about this argument, so nothing is claimed — the member stays out AND the
|
|
* call is reported abandoned. This is the half of the old behaviour that was
|
|
* always right, and it is what keeps the fix from being "emit empties". */
|
|
static void test_unclosed_empty_value_is_still_dropped(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_TOOLS);
|
|
const char *raw =
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n\n</function>";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 0); /* not emitted at all */
|
|
ASSERT_INT_EQ(err.kind, TC_PARSE_CALL_ABANDONED);
|
|
ASSERT_STR_EQ(err.function_name, "read");
|
|
ASSERT_STR_EQ(err.parameter_name, "path");
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* An unclosed empty value SIDE BY SIDE with a good one: `path` arrived and is
|
|
* kept, `content` never closed and is dropped — the pre-existing truncation
|
|
* shape, which the client names through the schema's required list. The empty
|
|
* rule must not turn "we never heard about content" into `content:""`. */
|
|
static void test_unclosed_empty_beside_a_good_value(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_TOOLS);
|
|
const char *raw =
|
|
"<function=write>\n"
|
|
"<parameter=path>\n/tmp/x.c\n</parameter>\n"
|
|
"<parameter=content>\n";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 0); /* abandoned → not emitted */
|
|
ASSERT_INT_EQ(err.kind, TC_PARSE_CALL_ABANDONED);
|
|
ASSERT_STR_EQ(err.parameter_name, "content");
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* ============================================================
|
|
* SHAPES — the empty value must survive every wrapper, and stay valid JSON.
|
|
* ============================================================ */
|
|
|
|
/* The forced `<value></value>` wrapper closes the value too, so it is the same
|
|
* deliberate emission and keeps the member. It is a SEPARATE close from
|
|
* `</parameter>`: the live MiniCPM quirk truncates `</parameter>` to
|
|
* "</paramet>", and a value the model plainly finished must not become absent
|
|
* because of a malformed outer tag. */
|
|
static void test_value_wrapper_empty_inner_keeps_the_member(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_TOOLS);
|
|
const char *raw =
|
|
"<function=read>\n<parameter=path>\n<value>\n</value>\n</paramet>\n"
|
|
"</function>";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 1);
|
|
ASSERT_STR_EQ(args, "{\"path\":\"\"}");
|
|
ASSERT_INT_EQ(err.kind, TC_PARSE_OK);
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* A closed empty value on a NON-STRING parameter. write_typed_value must not
|
|
* splice `"start_line":` followed by nothing — an empty integer falls back to a
|
|
* JSON string, so the arguments object still parses. Asserted by parsing it. */
|
|
static void test_empty_value_on_a_typed_parameter_is_valid_json(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_TOOLS);
|
|
const char *raw =
|
|
"<function=edit_lines>\n"
|
|
"<parameter=path>\n/tmp/x.c\n</parameter>\n"
|
|
"<parameter=start_line>\n</parameter>\n"
|
|
"</function>";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 1);
|
|
ASSERT_STR_EQ(args, "{\"path\":\"/tmp/x.c\",\"start_line\":\"\"}");
|
|
jr_value *parsed = jr_parse(args);
|
|
ASSERT_NOT_NULL(parsed); /* it is JSON, not a fragment */
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(parsed, "start_line")), "");
|
|
jr_free(parsed);
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* ============================================================
|
|
* THE NEIGHBOUR THAT MUST STILL LOOK WRONG — the empty-path wedge.
|
|
* ============================================================ */
|
|
|
|
/* `{"path":""}` on a `read` is the wedge, and it still reaches the client as
|
|
* `{"path":""}`. That is the point: the client can see an empty path and refuse
|
|
* it (loop_guard_args_void, file_tools' require_string). Emitted as `{}` it was
|
|
* indistinguishable from a `read` that never sent a path — the same collision,
|
|
* one layer down. */
|
|
static void test_empty_path_still_arrives_as_an_empty_path(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_TOOLS);
|
|
const char *raw =
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n\n</parameter>\n"
|
|
"</function>\n</tool_call>";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 1);
|
|
ASSERT_STR_EQ(name, "read");
|
|
ASSERT_STR_EQ(args, "{\"path\":\"\"}");
|
|
ASSERT_INT_EQ(err.kind, TC_PARSE_OK); /* it is a call, not a parse failure */
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* A call with NO parameters at all is still `{}` — the empty rule is about a
|
|
* parameter that was opened and closed, not about an empty argument bag. */
|
|
static void test_no_parameters_is_still_an_empty_object(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_TOOLS);
|
|
const char *raw = "<function=read>\n</function>";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 1);
|
|
ASSERT_STR_EQ(args, "{}");
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* A healthy call is byte-identical to what it always was. */
|
|
static void test_good_values_are_untouched(void) {
|
|
jr_value *tools = jr_parse(EMPTY_ARG_TOOLS);
|
|
const char *raw =
|
|
"<function=edit>\n"
|
|
"<parameter=path>\n/tmp/x.c\n</parameter>\n"
|
|
"<parameter=old_string>\nold\n</parameter>\n"
|
|
"<parameter=new_string>\nnew\n</parameter>\n"
|
|
"</function>";
|
|
|
|
int n = 0; char *name = NULL; tc_parse_error err = {0};
|
|
char *args = parse_one(raw, tools, &n, &name, &err);
|
|
ASSERT_INT_EQ(n, 1);
|
|
ASSERT_STR_EQ(args, "{\"path\":\"/tmp/x.c\",\"old_string\":\"old\","
|
|
"\"new_string\":\"new\"}");
|
|
ASSERT_INT_EQ(err.kind, TC_PARSE_OK);
|
|
|
|
free(args); free(name);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
}
|
|
|
|
/* ============================================================
|
|
* Main
|
|
* ============================================================ */
|
|
|
|
int main(void) {
|
|
printf("\n empty tool arguments — empty is present, absent is not\n\n");
|
|
|
|
RUN_TEST(edit_deletion_keeps_new_string_present);
|
|
RUN_TEST(edit_deletion_with_whitespace_padding);
|
|
RUN_TEST(write_empty_content_keeps_the_member);
|
|
RUN_TEST(unclosed_empty_value_is_still_dropped);
|
|
RUN_TEST(unclosed_empty_beside_a_good_value);
|
|
RUN_TEST(value_wrapper_empty_inner_keeps_the_member);
|
|
RUN_TEST(empty_value_on_a_typed_parameter_is_valid_json);
|
|
RUN_TEST(empty_path_still_arrives_as_an_empty_path);
|
|
RUN_TEST(no_parameters_is_still_an_empty_object);
|
|
RUN_TEST(good_values_are_untouched);
|
|
|
|
TEST_REPORT();
|
|
return __ft_failed > 0 ? 1 : 0;
|
|
}
|