commit f82b590e4d17bcf395c594962292546a67ee7c8a from: Isaac Meerleo date: Wed Jul 15 18:06:31 2026 UTC add canonical turn mechanics module commit - ffccea59b4d4877ec18fbcbac7f41c955416b635 commit + f82b590e4d17bcf395c594962292546a67ee7c8a blob - 09b03732599422322cc16ad000349f3775b5efef blob + fc82d725238f321e7772e907d668f823f1a322ea --- regress/Makefile +++ regress/Makefile @@ -1,5 +1,5 @@ SUBDIR= agentcfg anthropic buf conf deploy diff editor generation http imsgev journal json log markdown \ - model_window openai output print sandbox skills sse term tools turn ui_config \ - turn_txn web xmalloc + model_window openai output print sandbox skills sse term tools turn \ + turn_mechanics turn_txn ui_config web xmalloc .include blob - /dev/null blob + 12f905a99af298d8f5ca9a9f06b840832bde9dee (mode 644) --- /dev/null +++ regress/turn_mechanics/Makefile @@ -0,0 +1,5 @@ +PROG= turn_mechanics_test +SRCS= turn_mechanics_test.c turn_mechanics.c generation.c msg.c json.c \ + buf.c log.c xmalloc.c + +.include blob - /dev/null blob + 4f26ab1c8e976dcba4f063e969217872a3924c8e (mode 644) --- /dev/null +++ regress/turn_mechanics/turn_mechanics_test.c @@ -0,0 +1,1915 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include +#include + +#include +#include +#include +#include + +#include "msg.h" +#include "turn_mechanics.h" + +#include "regress.h" + +/* + * Mutation calibration, all restored: skipping missing-result validation + * produced 135 checks/2 failures; changing the batch-cap >= to > produced + * 135/8; omitting/doubling Generation data charge produced 135/39 and 135/41; + * omitting the post-Generation/post-Tool checkpoint produced 135/15 and + * 135/7; omitting nested-usage settlement produced 135/5; changing the usage + * overflow guard from > to >= produced 135/4. + */ + +#define SCRIPT_GENERATIONS_MAX 16 +#define SCRIPT_EVENTS_MAX 16 +#define SCRIPT_TOOLS_MAX 16 +#define SCRIPT_RESULTS_MAX (GENERATION_CALL_MAX + 1) + +struct scripted_event { + enum generation_kind kind; + int index; + const char *id; + const char *name; + const void *data; + size_t len; + struct generation_usage usage; +}; + +struct scripted_generation { + enum turn_tools_mode tools; + struct scripted_event events[SCRIPT_EVENTS_MAX]; + size_t nevents; + enum turn_io io; + const char *error; + int sent; + int sent_twice; + int fill_error; +}; + +struct scripted_result { + size_t ordinal; + const char *id; + const void *data; + size_t len; + int is_error; +}; + +struct scripted_tool { + struct scripted_result results[SCRIPT_RESULTS_MAX]; + size_t nresults; + const void *text; + size_t text_len; + struct generation_usage usage; + int has_usage; + enum turn_io io; + const char *error; + int fill_error; +}; + +struct observed_assistant { + const struct msg *message; + enum generation_outcome outcome; + char stop_reason[GENERATION_STOP_MAX]; + struct generation_span synthesized_text; + size_t tool_calls; +}; + +struct observed_batch { + const struct msg *assistant; + size_t count; + char id[GENERATION_CALL_MAX][GENERATION_TOOL_ID_MAX]; + char name[GENERATION_CALL_MAX][GENERATION_TOOL_NAME_MAX]; + char input[GENERATION_CALL_MAX][128]; + size_t input_len[GENERATION_CALL_MAX]; +}; + +struct fixture { + struct msglist accepted; + struct scripted_generation generations[SCRIPT_GENERATIONS_MAX]; + size_t ngenerations; + size_t generation_next; + struct scripted_tool tools[SCRIPT_TOOLS_MAX]; + size_t ntools; + size_t tool_next; + struct observed_assistant assistants[SCRIPT_GENERATIONS_MAX]; + size_t nassistants; + struct observed_batch batches[SCRIPT_TOOLS_MAX]; + size_t nbatches; + size_t projection_count[SCRIPT_GENERATIONS_MAX]; + enum turn_tools_mode request_tools[SCRIPT_GENERATIONS_MAX]; + int set_result_rc[SCRIPT_RESULTS_MAX]; + size_t nset_result_rc; + int add_text_rc; + int add_usage_rc; + int accepts; + int projections; + int sent_callbacks; + int marking_sent; + int checkpoints; + int interrupt_checkpoint; + int bad; +}; + +static void +copy_string(char *dst, size_t dstsz, const char *src) +{ + size_t len; + + if (dstsz == 0) + return; + len = strlen(src); + if (len >= dstsz) + len = dstsz - 1; + if (len > 0) + memcpy(dst, src, len); + dst[len] = '\0'; +} + +static struct msg * +text_message(enum msg_role role, const char *text) +{ + struct msg *message; + + message = msg_new(role); + msg_add_text(message, text, strlen(text)); + return (message); +} + +static size_t +message_count(const struct msglist *messages) +{ + const struct msg *message; + size_t n = 0; + + TAILQ_FOREACH(message, messages, entry) + n++; + return (n); +} + +static struct msg * +message_at(const struct msglist *messages, size_t want) +{ + struct msg *message; + size_t n = 0; + + TAILQ_FOREACH(message, messages, entry) { + if (n++ == want) + return (message); + } + return (NULL); +} + +static struct scripted_generation * +fixture_generation(struct fixture *f, enum turn_tools_mode tools) +{ + struct scripted_generation *script; + + if (f->ngenerations >= SCRIPT_GENERATIONS_MAX) + abort(); + script = &f->generations[f->ngenerations++]; + memset(script, 0, sizeof(*script)); + script->tools = tools; + script->io = TURN_IO_OK; + script->sent = 1; + return (script); +} + +static struct scripted_event * +script_event(struct scripted_generation *script, enum generation_kind kind) +{ + struct scripted_event *event; + + if (script->nevents >= SCRIPT_EVENTS_MAX) + abort(); + event = &script->events[script->nevents++]; + memset(event, 0, sizeof(*event)); + event->kind = kind; + return (event); +} + +static void +script_bytes(struct scripted_generation *script, enum generation_kind kind, + const void *data, size_t len) +{ + struct scripted_event *event; + + event = script_event(script, kind); + event->data = data; + event->len = len; +} + +static void +script_text(struct scripted_generation *script, const char *text) +{ + script_bytes(script, GENERATION_TEXT, text, strlen(text)); +} + +static void +script_done(struct scripted_generation *script, const char *reason) +{ + script_bytes(script, GENERATION_DONE, reason, strlen(reason)); +} + +static void +script_provider_error(struct scripted_generation *script, const char *error) +{ + script_bytes(script, GENERATION_ERROR, error, strlen(error)); +} + +static void +script_usage(struct scripted_generation *script, + const struct generation_usage *usage) +{ + struct scripted_event *event; + + event = script_event(script, GENERATION_USAGE); + event->usage = *usage; +} + +static void +script_tool_begin(struct scripted_generation *script, int index, + const char *id, const char *name) +{ + struct scripted_event *event; + + event = script_event(script, GENERATION_TOOL_BEGIN); + event->index = index; + event->id = id; + event->name = name; +} + +static void +script_tool_input(struct scripted_generation *script, int index, + const void *data, size_t len) +{ + struct scripted_event *event; + + event = script_event(script, GENERATION_TOOL_INPUT); + event->index = index; + event->data = data; + event->len = len; +} + +static struct scripted_tool * +fixture_tool(struct fixture *f) +{ + struct scripted_tool *tool; + + if (f->ntools >= SCRIPT_TOOLS_MAX) + abort(); + tool = &f->tools[f->ntools++]; + memset(tool, 0, sizeof(*tool)); + tool->io = TURN_IO_OK; + return (tool); +} + +static void +script_result(struct scripted_tool *tool, size_t ordinal, const char *id, + const void *data, size_t len, int is_error) +{ + struct scripted_result *result; + + if (tool->nresults >= SCRIPT_RESULTS_MAX) + abort(); + result = &tool->results[tool->nresults++]; + memset(result, 0, sizeof(*result)); + result->ordinal = ordinal; + result->id = id; + result->data = data; + result->len = len; + result->is_error = is_error; +} + +static void +fixture_projection(void *arg, struct turn_projection *projection) +{ + struct fixture *f = arg; + + f->projections++; + memset(projection, 0, sizeof(*projection)); + projection->messages = &f->accepted; + projection->message_count = message_count(&f->accepted); + projection->cache_tail_msg = -1; + projection->receipt = projection->message_count + 100; +} + +static void +fixture_projection_sent(void *arg, const struct turn_projection *projection) +{ + struct fixture *f = arg; + + if (!f->marking_sent || projection->receipt != + projection->message_count + 100) + f->bad = 1; + f->sent_callbacks++; +} + +static void +fixture_accept(void *arg, struct msg *message) +{ + struct fixture *f = arg; + + TAILQ_INSERT_TAIL(&f->accepted, message, entry); + f->accepts++; +} + +static void +fixture_assistant_accepted(void *arg, const struct turn_assistant_event *event) +{ + struct fixture *f = arg; + struct observed_assistant *observed; + + if (f->nassistants >= SCRIPT_GENERATIONS_MAX || + TAILQ_LAST(&f->accepted, msglist) != event->message) { + f->bad = 1; + return; + } + observed = &f->assistants[f->nassistants++]; + memset(observed, 0, sizeof(*observed)); + observed->message = event->message; + observed->outcome = event->outcome; + copy_string(observed->stop_reason, sizeof(observed->stop_reason), + event->stop_reason); + observed->synthesized_text = event->synthesized_text; + observed->tool_calls = event->tool_calls; +} + +static size_t +encode_begin(u_char *payload, int index, const char *id, const char *name) +{ + size_t idlen = strlen(id), namelen = strlen(name); + + memcpy(payload, &index, sizeof(index)); + memcpy(payload + sizeof(index), id, idlen + 1); + memcpy(payload + sizeof(index) + idlen + 1, name, namelen); + return (sizeof(index) + idlen + 1 + namelen); +} + +static enum generation_feed_status +fixture_feed(struct turn_generation_io *io, const struct scripted_event *event) +{ + u_char payload[sizeof(int) + GENERATION_TOOL_ID_MAX + 128]; + size_t len; + + switch (event->kind) { + case GENERATION_TOOL_BEGIN: + len = encode_begin(payload, event->index, event->id, event->name); + return (turn_generation_feed(io, event->kind, payload, len, NULL)); + case GENERATION_TOOL_INPUT: + if (event->len > sizeof(payload) - sizeof(event->index)) + abort(); + memcpy(payload, &event->index, sizeof(event->index)); + if (event->len > 0) + memcpy(payload + sizeof(event->index), event->data, + event->len); + return (turn_generation_feed(io, event->kind, payload, + sizeof(event->index) + event->len, NULL)); + case GENERATION_USAGE: + return (turn_generation_feed(io, event->kind, &event->usage, + sizeof(event->usage), NULL)); + default: + return (turn_generation_feed(io, event->kind, event->data, + event->len, NULL)); + } +} + +static enum turn_io +fixture_generate(void *arg, const struct turn_generation_request *request, + struct turn_generation_io *io, char *error, size_t errorsz) +{ + struct fixture *f = arg; + struct scripted_generation *script; + enum generation_feed_status status = GENERATION_MORE; + size_t i, slot; + + if (f->generation_next >= f->ngenerations) { + f->bad = 1; + return (TURN_IO_ERROR); + } + slot = f->generation_next; + script = &f->generations[f->generation_next++]; + f->projection_count[slot] = request->projection.message_count; + f->request_tools[slot] = request->tools; + if (request->projection.messages != &f->accepted || + request->tools != script->tools) + f->bad = 1; + if (script->sent) { + f->marking_sent = 1; + if (turn_generation_sent(io) == -1) + f->bad = 1; + if (script->sent_twice && turn_generation_sent(io) != -1) + f->bad = 1; + f->marking_sent = 0; + } + for (i = 0; i < script->nevents; i++) { + status = fixture_feed(io, &script->events[i]); + if (status == GENERATION_PROTOCOL) + break; + } + if (script->error != NULL && errorsz > 0) + (void)snprintf(error, errorsz, "%s", script->error); + if (script->fill_error && errorsz > 0) + memset(error, 'G', errorsz); + return (script->io); +} + +static enum turn_checkpoint +fixture_checkpoint(void *arg) +{ + struct fixture *f = arg; + + f->checkpoints++; + if (f->interrupt_checkpoint != 0 && + f->checkpoints == f->interrupt_checkpoint) + return (TURN_CHECKPOINT_INTERRUPT); + return (TURN_CHECKPOINT_CONTINUE); +} + +static void +observe_batch(struct fixture *f, const struct turn_tool_batch *batch) +{ + struct observed_batch *observed; + size_t i; + + if (f->nbatches >= SCRIPT_TOOLS_MAX || + TAILQ_LAST(&f->accepted, msglist) != batch->assistant) { + f->bad = 1; + return; + } + observed = &f->batches[f->nbatches++]; + memset(observed, 0, sizeof(*observed)); + observed->assistant = batch->assistant; + observed->count = batch->count; + for (i = 0; i < batch->count && i < GENERATION_CALL_MAX; i++) { + copy_string(observed->id[i], sizeof(observed->id[i]), + batch->calls[i].id); + copy_string(observed->name[i], sizeof(observed->name[i]), + batch->calls[i].name); + observed->input_len[i] = batch->calls[i].input_len; + if (batch->calls[i].input_len <= sizeof(observed->input[i])) + memcpy(observed->input[i], batch->calls[i].input, + batch->calls[i].input_len); + else + f->bad = 1; + } +} + +static enum turn_io +fixture_execute(void *arg, const struct turn_tool_batch *batch, + struct turn_tool_results *results, char *error, size_t errorsz) +{ + struct fixture *f = arg; + struct scripted_tool *script; + struct scripted_result *result; + const char *id; + size_t i; + + if (f->tool_next >= f->ntools) { + f->bad = 1; + return (TURN_IO_ERROR); + } + script = &f->tools[f->tool_next++]; + observe_batch(f, batch); + if (script->has_usage) + f->add_usage_rc = turn_tool_results_add_usage(results, + &script->usage); + for (i = 0; i < script->nresults; i++) { + result = &script->results[i]; + id = result->id; + if (id == NULL && result->ordinal < batch->count) + id = batch->calls[result->ordinal].id; + f->set_result_rc[f->nset_result_rc++] = turn_tool_result_set( + results, result->ordinal, id, result->data, result->len, + result->is_error); + } + if (script->text != NULL || script->text_len != 0) + f->add_text_rc = turn_tool_results_add_text(results, script->text, + script->text_len); + if (script->error != NULL && errorsz > 0) + (void)snprintf(error, errorsz, "%s", script->error); + if (script->fill_error && errorsz > 0) + memset(error, 'T', errorsz); + return (script->io); +} + +static void +fixture_init(struct fixture *f, struct turn_adapter *adapter, + const char *prompt) +{ + struct msg *message; + + memset(f, 0, sizeof(*f)); + TAILQ_INIT(&f->accepted); + message = text_message(ROLE_USER, prompt); + TAILQ_INSERT_TAIL(&f->accepted, message, entry); + memset(adapter, 0, sizeof(*adapter)); + adapter->arg = f; + adapter->projection = fixture_projection; + adapter->projection_sent = fixture_projection_sent; + adapter->accept = fixture_accept; + adapter->assistant_accepted = fixture_assistant_accepted; + adapter->checkpoint = fixture_checkpoint; + adapter->generate = fixture_generate; + adapter->execute = fixture_execute; +} + +static void +options_init(struct turn_options *options, enum turn_policy_kind policy, + size_t data_used, size_t tool_batch_limit) +{ + memset(options, 0, sizeof(*options)); + options->policy = policy; + options->data_limit = 1024; + options->data_used = data_used; + options->tool_batch_limit = tool_batch_limit; +} + +static void +test_normal_text_completes(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct msg *message; + struct block *block; + int rc; + + fixture_init(&f, &adapter, "question"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "answer"); + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_LEAD, 8, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0); + CHECK(result.status == TURN_STATUS_DONE && + result.failure == TURN_FAILURE_NONE && result.data_used == 14 && + result.tool_batches == 0 && result.assistant != NULL && + strcmp(result.stop_reason, "stop") == 0 && + result.error[0] == '\0'); + CHECK(result.usage.input_tokens == 0 && + result.usage.output_tokens == 0 && result.usage.cache_read == 0 && + result.usage.cache_write == 0); + CHECK(f.generation_next == 1 && f.accepts == 1 && + f.sent_callbacks == 1 && f.nassistants == 1 && + f.request_tools[0] == TURN_TOOLS_ENABLED && + f.projection_count[0] == 1 && message_count(&f.accepted) == 2); + CHECK(f.assistants[0].outcome == GENERATION_NORMAL && + f.assistants[0].tool_calls == 0 && + strcmp(f.assistants[0].stop_reason, "stop") == 0 && + f.assistants[0].synthesized_text.data == NULL && + f.assistants[0].synthesized_text.len == 0); + message = TAILQ_LAST(&f.accepted, msglist); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(message == result.assistant && message->role == ROLE_ASSISTANT && + block != NULL && block->type == BLOCK_TEXT && block->textlen == 6 && + memcmp(block->text, "answer", 6) == 0 && + TAILQ_NEXT(block, entry) == NULL); + msglist_free(&f.accepted); +} + +static void +test_subagent_normal_text_completes_without_conclusion(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "answer"); + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE && + result.failure == TURN_FAILURE_NONE && + result.conclusion_failure == TURN_FAILURE_NONE && + result.data_used == 7 && result.tool_batches == 0 && + strcmp(result.stop_reason, "stop") == 0); + CHECK(f.generation_next == 1 && f.request_tools[0] == TURN_TOOLS_ENABLED && + f.accepts == 1 && f.nassistants == 1 && f.tool_next == 0 && + message_count(&f.accepted) == 2); + msglist_free(&f.accepted); +} + +static void +test_tool_results_are_paired_before_next_generation(void) +{ + static const char input[] = "{\"path\":\"x\"}"; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_tool *tool; + struct msg *message; + struct block *block; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "working"); + script_tool_begin(generation, 7, "call-a", "read"); + script_tool_input(generation, 7, input, sizeof(input) - 1); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + script_result(tool, 0, NULL, "file", 4, 0); + tool->text = "steer"; + tool->text_len = 5; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "done"); + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0); + CHECK(result.status == TURN_STATUS_DONE && + result.failure == TURN_FAILURE_NONE && result.data_used == 33 && + result.tool_batches == 1 && strcmp(result.stop_reason, "stop") == 0); + CHECK(f.bad == 0 && f.generation_next == 2 && f.tool_next == 1 && + f.accepts == 3 && f.sent_callbacks == 2 && f.nassistants == 2 && + f.nbatches == 1 && f.nset_result_rc == 1 && + f.set_result_rc[0] == 0 && f.add_text_rc == 0); + CHECK(f.projection_count[0] == 1 && f.projection_count[1] == 3 && + f.request_tools[0] == TURN_TOOLS_ENABLED && + f.request_tools[1] == TURN_TOOLS_ENABLED); + CHECK(f.batches[0].count == 1 && + strcmp(f.batches[0].id[0], "call-a") == 0 && + strcmp(f.batches[0].name[0], "read") == 0 && + f.batches[0].input_len[0] == sizeof(input) - 1 && + memcmp(f.batches[0].input[0], input, sizeof(input) - 1) == 0); + message = message_at(&f.accepted, 2); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(message != NULL && message->role == ROLE_USER && block != NULL && + block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "call-a") == 0 && + block->result_len == 4 && memcmp(block->result, "file", 4) == 0 && + block->is_error == 0); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TEXT && block->textlen == 5 && + memcmp(block->text, "steer", 5) == 0 && + TAILQ_NEXT(block, entry) == NULL); + message = message_at(&f.accepted, 3); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(message == result.assistant && block != NULL && + block->type == BLOCK_TEXT && block->textlen == 4 && + memcmp(block->text, "done", 4) == 0); + msglist_free(&f.accepted); +} + +static void +test_invalid_generations_are_never_accepted(void) +{ + static const struct { + enum generation_invalid_cause cause; + const char *input; + size_t input_len; + } cases[] = { + { GENERATION_INVALID_EMPTY_REPLY, NULL, 0 }, + { GENERATION_INVALID_TOOL_ARGUMENTS, "[]", 2 } + }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + size_t i; + int rc; + + for (i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + if (cases[i].input != NULL) { + script_tool_begin(generation, 0, "bad", "read"); + script_tool_input(generation, 0, cases[i].input, + cases[i].input_len); + script_done(generation, "tool_use"); + } else + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_GENERATION && + result.invalid_cause == cases[i].cause && + result.assistant == NULL && result.stop_reason[0] == '\0' && + result.error[0] != '\0'); + CHECK(f.generation_next == 1 && f.sent_callbacks == 1 && + f.accepts == 0 && f.nassistants == 0 && f.tool_next == 0 && + message_count(&f.accepted) == 1); + msglist_free(&f.accepted); + } +} + +static void +test_no_argument_call_uses_canonical_empty_object(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_tool *tool; + struct msg *message; + struct block *block; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 4, "none", "ls"); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + script_result(tool, 0, NULL, "ok", 2, 0); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "done"); + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE && + result.data_used == 9 && result.tool_batches == 1 && + f.generation_next == 2 && f.tool_next == 1); + CHECK(f.batches[0].count == 1 && f.batches[0].input_len[0] == 2 && + memcmp(f.batches[0].input[0], "{}", 2) == 0); + message = message_at(&f.accepted, 1); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(block != NULL && block->type == BLOCK_TOOL_USE && + block->tool_input_len == 2 && + memcmp(block->tool_input, "{}", 2) == 0); + msglist_free(&f.accepted); +} + +static void +test_lead_length_text_completes_and_calls_continue(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_tool *tool; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "partial"); + script_done(generation, "max_tokens"); + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE && + result.data_used == 8 && strcmp(result.stop_reason, "max_tokens") == 0 && + f.generation_next == 1 && f.tool_next == 0 && f.nassistants == 1); + CHECK(f.assistants[0].outcome == GENERATION_LENGTH && + f.assistants[0].tool_calls == 0 && + strcmp(f.assistants[0].stop_reason, "max_tokens") == 0); + msglist_free(&f.accepted); + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 1, "length-call", "read"); + script_tool_input(generation, 1, "{}", 2); + script_done(generation, "length"); + tool = fixture_tool(&f); + script_result(tool, 0, NULL, "ok", 2, 0); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "after"); + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE && + result.tool_batches == 1 && f.generation_next == 2 && + f.tool_next == 1 && f.nbatches == 1 && f.accepts == 3); + CHECK(f.assistants[0].outcome == GENERATION_LENGTH && + f.assistants[0].tool_calls == 1 && + strcmp(f.batches[0].id[0], "length-call") == 0 && + f.request_tools[1] == TURN_TOOLS_ENABLED); + msglist_free(&f.accepted); +} + +static void +test_length_placeholder_is_observed_after_accept(void) +{ + static const char placeholder[] = GENERATION_LENGTH_EMPTY; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct msg *message; + struct block *block; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_done(generation, "max_tokens"); + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + message = message_at(&f.accepted, 1); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE && + result.data_used == 1 + sizeof(placeholder) - 1 && + f.nassistants == 1 && + f.assistants[0].outcome == GENERATION_LENGTH); + CHECK(block != NULL && block->type == BLOCK_TEXT && + block->textlen == sizeof(placeholder) - 1 && + memcmp(block->text, placeholder, sizeof(placeholder) - 1) == 0 && + f.assistants[0].synthesized_text.data == block->text && + f.assistants[0].synthesized_text.len == sizeof(placeholder) - 1); + msglist_free(&f.accepted); +} + +static void +test_subagent_length_pairs_dangling_calls_then_concludes(void) +{ + static const char dangling[] = + "subagent tool call was truncated and was not executed"; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct msg *message; + struct block *block; + size_t want_data; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "partial"); + script_tool_begin(generation, 2, "dangling-a", "read"); + script_tool_input(generation, 2, "{}", 2); + script_tool_begin(generation, 8, "dangling-b", "grep"); + script_tool_input(generation, 8, "{}", 2); + script_done(generation, "max_tokens"); + generation = fixture_generation(&f, TURN_TOOLS_DISABLED); + script_text(generation, "conclusion"); + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 2); + rc = turn_run(&adapter, &options, &result); + want_data = 1 + 7 + 2 + 2 + 2 * (sizeof(dangling) - 1) + 10; + + CHECK(rc == 0); + CHECK(result.status == TURN_STATUS_CONCLUDED && + result.failure == TURN_FAILURE_NONE && result.data_used == want_data && + result.tool_batches == 0 && + strcmp(result.stop_reason, "stop") == 0); + CHECK(f.bad == 0 && f.generation_next == 2 && f.tool_next == 0 && + f.nbatches == 0 && f.accepts == 3 && f.nassistants == 2 && + f.sent_callbacks == 2); + CHECK(f.request_tools[0] == TURN_TOOLS_ENABLED && + f.request_tools[1] == TURN_TOOLS_DISABLED && + f.projection_count[0] == 1 && f.projection_count[1] == 3 && + f.assistants[0].outcome == GENERATION_LENGTH && + f.assistants[0].tool_calls == 2 && + f.assistants[1].outcome == GENERATION_NORMAL && + f.assistants[1].tool_calls == 0); + message = message_at(&f.accepted, 2); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "dangling-a") == 0 && + block->is_error == 1 && block->result_len == sizeof(dangling) - 1 && + memcmp(block->result, dangling, sizeof(dangling) - 1) == 0); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "dangling-b") == 0 && + block->is_error == 1 && block->result_len == sizeof(dangling) - 1 && + memcmp(block->result, dangling, sizeof(dangling) - 1) == 0); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TEXT && + strstr(block->text, "Conclude now") != NULL && + TAILQ_NEXT(block, entry) == NULL); + message = message_at(&f.accepted, 3); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(message == result.assistant && block != NULL && + block->type == BLOCK_TEXT && block->textlen == 10 && + memcmp(block->text, "conclusion", 10) == 0); + msglist_free(&f.accepted); +} + +static void +script_tool_round(struct fixture *f, const char *id) +{ + struct scripted_generation *generation; + struct scripted_tool *tool; + + generation = fixture_generation(f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, id, "read"); + script_tool_input(generation, 0, "{}", 2); + script_done(generation, "tool_use"); + tool = fixture_tool(f); + script_result(tool, 0, NULL, "ok", 2, 0); +} + +static void +script_two_calls(struct fixture *f) +{ + struct scripted_generation *generation; + + generation = fixture_generation(f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 3, "call-a", "read"); + script_tool_input(generation, 3, "{}", 2); + script_tool_begin(generation, 9, "call-b", "grep"); + script_tool_input(generation, 9, "{}", 2); + script_done(generation, "tool_use"); +} + +static void +test_lead_cap_follows_the_final_paired_result(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct msg *message; + struct block *block; + int rc; + + fixture_init(&f, &adapter, "q"); + script_tool_round(&f, "round-a"); + script_tool_round(&f, "round-b"); + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_CAP && + result.failure == TURN_FAILURE_NONE && result.tool_batches == 2 && + result.data_used == 9 && f.generation_next == 2 && f.tool_next == 2); + CHECK(f.accepts == 4 && f.nassistants == 2 && f.nbatches == 2 && + f.sent_callbacks == 2 && message_count(&f.accepted) == 5 && + f.projection_count[0] == 1 && f.projection_count[1] == 3); + message = message_at(&f.accepted, 4); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(message != NULL && message->role == ROLE_USER && block != NULL && + block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "round-b") == 0 && + block->result_len == 2 && memcmp(block->result, "ok", 2) == 0 && + TAILQ_NEXT(block, entry) == NULL); + msglist_free(&f.accepted); +} + +static void +test_subagent_cap_gets_one_tools_disabled_conclusion(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct msg *message; + struct block *block; + int rc; + + fixture_init(&f, &adapter, "q"); + script_tool_round(&f, "round-a"); + script_tool_round(&f, "round-b"); + generation = fixture_generation(&f, TURN_TOOLS_DISABLED); + script_text(generation, "final"); + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0); + CHECK(result.status == TURN_STATUS_CONCLUDED && + result.failure == TURN_FAILURE_NONE && result.tool_batches == 2 && + result.data_used == 14 && strcmp(result.stop_reason, "stop") == 0); + CHECK(f.bad == 0 && f.generation_next == 3 && f.tool_next == 2 && + f.accepts == 5 && f.nassistants == 3 && f.nbatches == 2 && + f.sent_callbacks == 3 && message_count(&f.accepted) == 6); + CHECK(f.request_tools[0] == TURN_TOOLS_ENABLED && + f.request_tools[1] == TURN_TOOLS_ENABLED && + f.request_tools[2] == TURN_TOOLS_DISABLED && + f.projection_count[0] == 1 && f.projection_count[1] == 3 && + f.projection_count[2] == 5); + message = message_at(&f.accepted, 4); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "round-b") == 0 && + block->result_len == 2 && memcmp(block->result, "ok", 2) == 0); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TEXT && + strstr(block->text, "Conclude now") != NULL && + TAILQ_NEXT(block, entry) == NULL); + message = message_at(&f.accepted, 5); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(message == result.assistant && block != NULL && + block->type == BLOCK_TEXT && block->textlen == 5 && + memcmp(block->text, "final", 5) == 0); + msglist_free(&f.accepted); +} + +static void +test_initial_checkpoint_precedes_first_projection(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "must not run"); + script_done(generation, "stop"); + f.interrupt_checkpoint = 1; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && result.data_used == 1 && + result.assistant == NULL); + CHECK(f.checkpoints == 1 && f.projections == 0 && + f.generation_next == 0 && f.sent_callbacks == 0 && + f.accepts == 0 && f.nassistants == 0 && + message_count(&f.accepted) == 1); + msglist_free(&f.accepted); +} + +static void +test_generation_helper_misuse_is_rejected(void) +{ + enum helper_misuse { + HELPER_EVENT_BEFORE_SENT, + HELPER_SENT_TWICE, + HELPER_NO_TERMINAL + }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + int misuse, rc; + + for (misuse = HELPER_EVENT_BEFORE_SENT; + misuse <= HELPER_NO_TERMINAL; misuse++) { + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + if (misuse == HELPER_EVENT_BEFORE_SENT) { + generation->sent = 0; + script_text(generation, "x"); + script_done(generation, "stop"); + } else if (misuse == HELPER_SENT_TWICE) + generation->sent_twice = 1; + else + generation->fill_error = 1; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_ADAPTER && + result.data_used == 1 && result.assistant == NULL && + result.error[0] != '\0'); + CHECK(f.checkpoints == 2 && f.projections == 1 && + f.generation_next == 1 && f.accepts == 0 && + f.nassistants == 0 && message_count(&f.accepted) == 1 && + f.sent_callbacks == + (misuse == HELPER_EVENT_BEFORE_SENT ? 0 : 1)); + if (misuse == HELPER_NO_TERMINAL) + CHECK(result.error[0] == 'G' && + result.error[TURN_ERROR_MAX - 2] == 'G' && + result.error[TURN_ERROR_MAX - 1] == '\0'); + msglist_free(&f.accepted); + } +} + +static void +test_adapter_interrupted_results_are_authoritative(void) +{ + static const struct generation_usage nested = { 5, 6, 7, 8 }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_tool *tool; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + generation->io = TURN_IO_INTERRUPTED; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && result.data_used == 1 && + f.checkpoints == 1 && f.projections == 1 && + f.generation_next == 1 && f.sent_callbacks == 1 && f.accepts == 0); + msglist_free(&f.accepted); + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "interrupt", "read"); + script_tool_input(generation, 0, "{}", 2); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + tool->io = TURN_IO_INTERRUPTED; + tool->usage = nested; + tool->has_usage = 1; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && result.data_used == 3 && + result.tool_batches == 0 && result.assistant == NULL && + f.checkpoints == 4 && f.tool_next == 1 && f.accepts == 1); + CHECK(result.usage.input_tokens == 5 && + result.usage.output_tokens == 6 && result.usage.cache_read == 7 && + result.usage.cache_write == 8 && f.add_usage_rc == 0); + msglist_free(&f.accepted); + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "error", "read"); + script_tool_input(generation, 0, "{}", 2); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + tool->io = TURN_IO_ERROR; + tool->fill_error = 1; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_ADAPTER && result.data_used == 3 && + result.error[0] == 'T' && + result.error[TURN_ERROR_MAX - 2] == 'T' && + result.error[TURN_ERROR_MAX - 1] == '\0' && + f.checkpoints == 5 && f.tool_next == 1 && f.accepts == 1); + msglist_free(&f.accepted); +} + +static void +test_terminal_generation_is_checkpointed_before_accept(void) +{ + static const struct generation_usage usage = { 3, 4, 5, 6 }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "do not accept"); + script_usage(generation, &usage); + script_done(generation, "stop"); + f.interrupt_checkpoint = 2; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && result.data_used == 14 && + result.assistant == NULL && result.stop_reason[0] == '\0'); + CHECK(result.usage.input_tokens == 3 && + result.usage.output_tokens == 4 && result.usage.cache_read == 5 && + result.usage.cache_write == 6); + CHECK(f.generation_next == 1 && f.sent_callbacks == 1 && + f.checkpoints == 2 && f.accepts == 0 && f.nassistants == 0 && + f.tool_next == 0 && + message_count(&f.accepted) == 1); + msglist_free(&f.accepted); +} + +static void +test_accepted_assistant_is_checkpointed_before_tool_decision(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct msg *message; + struct block *block; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "pending", "read"); + script_done(generation, "tool_use"); + f.interrupt_checkpoint = 4; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && result.data_used == 3 && + result.assistant == NULL && f.checkpoints == 4); + CHECK(f.accepts == 1 && f.nassistants == 1 && f.tool_next == 0 && + f.nbatches == 0 && message_count(&f.accepted) == 2); + message = message_at(&f.accepted, 1); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(block != NULL && block->type == BLOCK_TOOL_USE && + strcmp(block->tool_id, "pending") == 0 && + block->tool_input_len == 2 && + memcmp(block->tool_input, "{}", 2) == 0); + msglist_free(&f.accepted); +} + +static void +test_accepted_results_are_checkpointed_before_next_decision(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_tool *tool; + struct msg *message; + struct block *block; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "pending-result", "read"); + script_tool_input(generation, 0, "{}", 2); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + script_result(tool, 0, NULL, "ok", 2, 0); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "must not project"); + script_done(generation, "stop"); + f.interrupt_checkpoint = 7; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && result.data_used == 5 && + result.tool_batches == 1 && result.assistant == NULL && + f.checkpoints == 7); + CHECK(f.bad == 0 && f.generation_next == 1 && f.tool_next == 1 && + f.accepts == 2 && f.nassistants == 1 && f.nbatches == 1 && + f.sent_callbacks == 1 && message_count(&f.accepted) == 3); + message = message_at(&f.accepted, 2); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "pending-result") == 0 && + block->result_len == 2 && memcmp(block->result, "ok", 2) == 0); + msglist_free(&f.accepted); +} + +static void +test_cancellation_precedes_generation_failure_classification(void) +{ + static const struct generation_usage usage = { 11, 12, 13, 14 }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + int invalid, rc; + + for (invalid = 0; invalid <= 1; invalid++) { + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + if (invalid) { + script_tool_begin(generation, 0, "bad", "read"); + script_tool_input(generation, 0, "[]", 2); + script_usage(generation, &usage); + script_done(generation, "tool_use"); + } else { + script_usage(generation, &usage); + script_provider_error(generation, "provider down"); + } + f.interrupt_checkpoint = 2; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && + result.data_used == (invalid ? 3 : 1) && + result.assistant == NULL && result.error[0] == '\0'); + CHECK(result.usage.input_tokens == 11 && + result.usage.output_tokens == 12 && + result.usage.cache_read == 13 && + result.usage.cache_write == 14); + CHECK(f.checkpoints == 2 && f.generation_next == 1 && + f.sent_callbacks == 1 && f.accepts == 0 && + f.nassistants == 0 && message_count(&f.accepted) == 1); + msglist_free(&f.accepted); + } +} + +static void +test_cancellation_precedes_tool_failure_classification(void) +{ + static const struct generation_usage nested = { 7, 8, 9, 10 }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_tool *tool; + int data_bound, rc; + + for (data_bound = 0; data_bound <= 1; data_bound++) { + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "race", "read"); + script_tool_input(generation, 0, "{}", 2); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + tool->usage = nested; + tool->has_usage = 1; + if (data_bound) + script_result(tool, 0, NULL, "x", 1, 0); + else { + tool->io = TURN_IO_ERROR; + tool->error = "tool failed"; + } + f.interrupt_checkpoint = 5; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + if (data_bound) + options.data_limit = 3; + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && result.data_used == 3 && + result.assistant == NULL && result.error[0] == '\0'); + CHECK(result.usage.input_tokens == 7 && + result.usage.output_tokens == 8 && + result.usage.cache_read == 9 && + result.usage.cache_write == 10 && f.add_usage_rc == 0); + CHECK(f.checkpoints == 5 && f.generation_next == 1 && + f.tool_next == 1 && f.accepts == 1 && f.nassistants == 1 && + message_count(&f.accepted) == 2); + if (data_bound) + CHECK(f.nset_result_rc == 1 && f.set_result_rc[0] == -1); + msglist_free(&f.accepted); + } +} + +static void +test_cancellation_precedes_conclusion_failure_fallback(void) +{ + static const struct generation_usage usage = { 1, 2, 3, 4 }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "partial"); + script_done(generation, "max_tokens"); + generation = fixture_generation(&f, TURN_TOOLS_DISABLED); + script_usage(generation, &usage); + script_provider_error(generation, "conclusion down"); + f.interrupt_checkpoint = 7; + options_init(&options, TURN_POLICY_SUBAGENT, 1, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && + result.conclusion_failure == TURN_FAILURE_NONE && + result.data_used == 8 && result.assistant == NULL && + result.stop_reason[0] == '\0' && result.error[0] == '\0'); + CHECK(result.usage.input_tokens == 1 && + result.usage.output_tokens == 2 && result.usage.cache_read == 3 && + result.usage.cache_write == 4); + CHECK(f.checkpoints == 7 && f.generation_next == 2 && + f.sent_callbacks == 2 && f.accepts == 2 && f.nassistants == 1 && + f.request_tools[1] == TURN_TOOLS_DISABLED && + message_count(&f.accepted) == 3); + msglist_free(&f.accepted); +} + +static void +test_subagent_conclusion_failure_preserves_fallback(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + const struct msg *fallback; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "partial"); + script_done(generation, "max_tokens"); + generation = fixture_generation(&f, TURN_TOOLS_DISABLED); + script_provider_error(generation, "conclusion down"); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 2); + rc = turn_run(&adapter, &options, &result); + fallback = message_at(&f.accepted, 1); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_CONCLUDED && + result.failure == TURN_FAILURE_NONE && + result.invalid_cause == GENERATION_INVALID_NONE && + result.conclusion_failure == TURN_FAILURE_PROVIDER && + result.conclusion_invalid_cause == GENERATION_INVALID_NONE && + strcmp(result.conclusion_error, "conclusion down") == 0 && + result.error[0] == '\0'); + CHECK(result.assistant == fallback && result.data_used == 8 && + result.tool_batches == 0 && + strcmp(result.stop_reason, "max_tokens") == 0); + CHECK(f.generation_next == 2 && f.sent_callbacks == 2 && + f.accepts == 2 && f.nassistants == 1 && f.tool_next == 0 && + f.request_tools[1] == TURN_TOOLS_DISABLED && + message_count(&f.accepted) == 3); + msglist_free(&f.accepted); +} + +static void +test_tools_disabled_call_is_a_conclusion_policy_failure(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + const struct msg *fallback; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "partial"); + script_done(generation, "max_tokens"); + generation = fixture_generation(&f, TURN_TOOLS_DISABLED); + script_tool_begin(generation, 0, "disallowed", "read"); + script_tool_input(generation, 0, "{}", 2); + script_done(generation, "tool_use"); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 2); + rc = turn_run(&adapter, &options, &result); + fallback = message_at(&f.accepted, 1); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_CONCLUDED && + result.failure == TURN_FAILURE_NONE && + result.invalid_cause == GENERATION_INVALID_NONE && + result.conclusion_failure == TURN_FAILURE_GENERATION && + result.conclusion_invalid_cause == GENERATION_INVALID_NONE && + strcmp(result.conclusion_error, + "tools-disabled conclusion returned a tool call") == 0); + CHECK(result.assistant == fallback && result.data_used == 10 && + result.tool_batches == 0 && + strcmp(result.stop_reason, "max_tokens") == 0); + CHECK(f.generation_next == 2 && f.sent_callbacks == 2 && + f.accepts == 2 && f.nassistants == 1 && f.tool_next == 0 && + f.request_tools[1] == TURN_TOOLS_DISABLED && + message_count(&f.accepted) == 3); + msglist_free(&f.accepted); +} + +static void +test_fixed_conclusion_guidance_is_outside_hostile_data_bound(void) +{ + static const char dangling[] = + "subagent tool call was truncated and was not executed"; + static const char bound[] = "subagent data bound exceeded"; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_tool *tool; + struct msg *message; + struct block *block; + size_t exact; + int rc; + + /* A hostile result may fill the budget; fixed guidance still appends. */ + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "at-bound", "read"); + script_tool_input(generation, 0, "{}", 2); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + script_result(tool, 0, NULL, "x", 1, 0); + generation = fixture_generation(&f, TURN_TOOLS_DISABLED); + script_provider_error(generation, "no conclusion room"); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 1); + options.data_limit = 4; + rc = turn_run(&adapter, &options, &result); + message = message_at(&f.accepted, 2); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_CONCLUDED && result.data_used == 4 && + result.conclusion_failure == TURN_FAILURE_PROVIDER && + f.set_result_rc[0] == 0 && block != NULL && + block->type == BLOCK_TOOL_RESULT && block->result_len == 1 && + memcmp(block->result, "x", 1) == 0); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TEXT && + strstr(block->text, "Conclude now") != NULL && + TAILQ_NEXT(block, entry) == NULL); + msglist_free(&f.accepted); + + /* One hostile result byte beyond that boundary is rejected. */ + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "over-bound", "read"); + script_tool_input(generation, 0, "{}", 2); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + script_result(tool, 0, NULL, "x", 1, 0); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 1); + options.data_limit = 3; + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_DATA_BOUND && result.data_used == 3 && + f.set_result_rc[0] == -1 && f.accepts == 1 && + message_count(&f.accepted) == 2); + msglist_free(&f.accepted); + + /* Dangling diagnostics use the full reason when it fits exactly. */ + exact = 1 + 2 + sizeof(dangling) - 1; + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "dangling", "read"); + script_done(generation, "max_tokens"); + generation = fixture_generation(&f, TURN_TOOLS_DISABLED); + script_provider_error(generation, "no conclusion room"); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 1); + options.data_limit = exact; + rc = turn_run(&adapter, &options, &result); + message = message_at(&f.accepted, 2); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_CONCLUDED && + result.data_used == exact && + result.conclusion_failure == TURN_FAILURE_PROVIDER); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + block->result_len == sizeof(dangling) - 1); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TEXT && + strstr(block->text, "Conclude now") != NULL && + TAILQ_NEXT(block, entry) == NULL); + msglist_free(&f.accepted); + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "dangling", "read"); + script_done(generation, "max_tokens"); + generation = fixture_generation(&f, TURN_TOOLS_DISABLED); + script_provider_error(generation, "no conclusion room"); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 1); + options.data_limit = exact - 1; + rc = turn_run(&adapter, &options, &result); + message = message_at(&f.accepted, 2); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_CONCLUDED && + result.data_used == 3 + sizeof(bound) - 1 && + result.conclusion_failure == TURN_FAILURE_PROVIDER && + f.generation_next == 2 && f.accepts == 2); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "dangling") == 0 && + block->is_error == 1 && block->result_len == sizeof(bound) - 1 && + memcmp(block->result, bound, sizeof(bound) - 1) == 0); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TEXT && + strstr(block->text, "Conclude now") != NULL && + TAILQ_NEXT(block, entry) == NULL); + msglist_free(&f.accepted); + + /* At zero room every dangling call still gets an ordered error pair. */ + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "zero-a", "read"); + script_tool_begin(generation, 1, "zero-b", "grep"); + script_done(generation, "max_tokens"); + generation = fixture_generation(&f, TURN_TOOLS_DISABLED); + script_provider_error(generation, "no conclusion room"); + options_init(&options, TURN_POLICY_SUBAGENT, 1, 1); + options.data_limit = 5; + rc = turn_run(&adapter, &options, &result); + message = message_at(&f.accepted, 2); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_CONCLUDED && result.data_used == 5 && + result.conclusion_failure == TURN_FAILURE_PROVIDER && + f.generation_next == 2 && f.accepts == 2); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "zero-a") == 0 && block->is_error == 1 && + block->result_len == 0); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "zero-b") == 0 && block->is_error == 1 && + block->result_len == 0); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TEXT && + strstr(block->text, "Conclude now") != NULL && + TAILQ_NEXT(block, entry) == NULL); + msglist_free(&f.accepted); +} + +static void +test_provider_and_adapter_errors_remain_distinct(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + int rc; + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "partial"); + script_provider_error(generation, "provider down"); + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_PROVIDER && result.data_used == 8 && + strcmp(result.error, "provider down") == 0 && + result.assistant == NULL && f.sent_callbacks == 1 && + f.accepts == 0 && f.nassistants == 0); + msglist_free(&f.accepted); + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + generation->sent = 0; + generation->io = TURN_IO_ERROR; + generation->error = "request serialization failed"; + options_init(&options, TURN_POLICY_LEAD, 1, 2); + rc = turn_run(&adapter, &options, &result); + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_ADAPTER && result.data_used == 1 && + strcmp(result.error, "request serialization failed") == 0 && + result.assistant == NULL && result.stop_reason[0] == '\0'); + CHECK(f.generation_next == 1 && f.sent_callbacks == 0 && + f.accepts == 0 && f.nassistants == 0 && + message_count(&f.accepted) == 1); + msglist_free(&f.accepted); +} + +static void +test_out_of_order_results_are_accepted_in_call_order(void) +{ + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_tool *tool; + struct msg *message; + struct block *block; + int rc; + + fixture_init(&f, &adapter, "q"); + script_two_calls(&f); + tool = fixture_tool(&f); + script_result(tool, 1, NULL, "B", 1, 1); + script_result(tool, 0, NULL, "A", 1, 0); + options_init(&options, TURN_POLICY_LEAD, 1, 1); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_CAP && + result.failure == TURN_FAILURE_NONE && result.tool_batches == 1 && + result.data_used == 7 && f.nset_result_rc == 2 && + f.set_result_rc[0] == 0 && f.set_result_rc[1] == 0); + message = message_at(&f.accepted, 2); + block = message == NULL ? NULL : TAILQ_FIRST(&message->blocks); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "call-a") == 0 && + block->result_len == 1 && memcmp(block->result, "A", 1) == 0 && + block->is_error == 0); + block = block == NULL ? NULL : TAILQ_NEXT(block, entry); + CHECK(block != NULL && block->type == BLOCK_TOOL_RESULT && + strcmp(block->tool_use_id, "call-b") == 0 && + block->result_len == 1 && memcmp(block->result, "B", 1) == 0 && + block->is_error == 1 && TAILQ_NEXT(block, entry) == NULL); + msglist_free(&f.accepted); +} + +static void +test_incomplete_or_inconsistent_result_batches_are_rejected(void) +{ + enum pairing_case { + PAIR_MISSING, + PAIR_EXTRA, + PAIR_DUPLICATE, + PAIR_MISMATCH, + PAIR_MISMATCH_IO_ERROR, + PAIR_DATA_IO_ERROR + }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_tool *tool; + enum turn_failure want_failure; + int rc, pair; + + for (pair = PAIR_MISSING; pair <= PAIR_DATA_IO_ERROR; pair++) { + fixture_init(&f, &adapter, "q"); + script_two_calls(&f); + tool = fixture_tool(&f); + switch (pair) { + case PAIR_MISSING: + script_result(tool, 0, NULL, "A", 1, 0); + break; + case PAIR_EXTRA: + script_result(tool, 2, "extra", "X", 1, 0); + break; + case PAIR_DUPLICATE: + script_result(tool, 0, NULL, "A", 1, 0); + script_result(tool, 0, NULL, "again", 5, 0); + break; + case PAIR_MISMATCH: + script_result(tool, 0, "wrong-id", "X", 1, 0); + break; + case PAIR_MISMATCH_IO_ERROR: + script_result(tool, 0, "wrong-id", "X", 1, 0); + tool->io = TURN_IO_ERROR; + tool->error = "setter rejected result"; + break; + case PAIR_DATA_IO_ERROR: + script_result(tool, 0, NULL, "X", 1, 0); + tool->io = TURN_IO_ERROR; + tool->error = "setter rejected result"; + break; + } + options_init(&options, TURN_POLICY_LEAD, 1, 2); + want_failure = TURN_FAILURE_TOOL_RESULTS; + if (pair == PAIR_DATA_IO_ERROR) { + options.data_limit = 5; + want_failure = TURN_FAILURE_DATA_BOUND; + } + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.status == TURN_STATUS_FAILED && + result.failure == want_failure && + result.tool_batches == 0 && result.data_used == 5 && + result.assistant == NULL && result.error[0] != '\0'); + CHECK(f.accepts == 1 && f.nassistants == 1 && f.tool_next == 1 && + f.nbatches == 1 && message_count(&f.accepted) == 2); + if (pair == PAIR_MISSING) + CHECK(f.nset_result_rc == 1 && f.set_result_rc[0] == 0); + else if (pair == PAIR_DUPLICATE) + CHECK(f.nset_result_rc == 2 && f.set_result_rc[0] == 0 && + f.set_result_rc[1] == -1); + else + CHECK(f.nset_result_rc == 1 && f.set_result_rc[0] == -1); + msglist_free(&f.accepted); + } +} + +static void +test_turn_data_and_usage_are_accumulated_once(void) +{ + struct generation_usage first = { 1, 2, 3, 4 }; + struct generation_usage second = { 10, 20, 30, 40 }; + struct generation_usage nested = { 100, 200, 300, 400 }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_tool *tool; + int rc; + + fixture_init(&f, &adapter, "seed"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "aa"); + script_tool_begin(generation, 0, "account", "read"); + script_tool_input(generation, 0, "{}", 2); + script_usage(generation, &first); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + tool->usage = nested; + tool->has_usage = 1; + script_result(tool, 0, NULL, "rrrr", 4, 0); + tool->text = "ss"; + tool->text_len = 2; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "bbb"); + script_usage(generation, &second); + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_LEAD, 5, 2); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_DONE && + result.failure == TURN_FAILURE_NONE && result.data_used == 18 && + result.tool_batches == 1); + CHECK(result.usage.input_tokens == 111 && + result.usage.output_tokens == 222 && + result.usage.cache_read == 333 && + result.usage.cache_write == 444); + CHECK(f.generation_next == 2 && f.tool_next == 1 && f.accepts == 3 && + f.sent_callbacks == 2 && f.nassistants == 2 && f.nbatches == 1 && + f.set_result_rc[0] == 0 && f.add_text_rc == 0 && + f.add_usage_rc == 0); + msglist_free(&f.accepted); +} + +static void +test_nested_usage_obeys_the_whole_turn_bound(void) +{ + struct generation_usage near = { GENERATION_USAGE_MAX - 1, 0, 0, 0 }; + struct generation_usage full = { GENERATION_USAGE_MAX, 0, 0, 0 }; + struct generation_usage one = { 1, 0, 0, 0 }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_tool *tool; + int overflow, rc; + + for (overflow = 0; overflow <= 1; overflow++) { + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "usage-bound", "read"); + script_tool_input(generation, 0, "{}", 2); + script_usage(generation, overflow ? &full : &near); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + tool->usage = one; + tool->has_usage = 1; + script_result(tool, 0, NULL, "ok", 2, 0); + options_init(&options, TURN_POLICY_LEAD, 1, 1); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && + result.usage.input_tokens == GENERATION_USAGE_MAX && + result.usage.output_tokens == 0 && + result.usage.cache_read == 0 && + result.usage.cache_write == 0); + if (overflow) { + CHECK(result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_USAGE_BOUND && + result.data_used == 3 && result.tool_batches == 0 && + f.add_usage_rc == -1 && f.set_result_rc[0] == -1 && + f.accepts == 1); + } else { + CHECK(result.status == TURN_STATUS_CAP && + result.failure == TURN_FAILURE_NONE && + result.data_used == 5 && result.tool_batches == 1 && + f.add_usage_rc == 0 && f.set_result_rc[0] == 0 && + f.accepts == 2); + } + msglist_free(&f.accepted); + } +} + +int +main(void) +{ + test_normal_text_completes(); + test_subagent_normal_text_completes_without_conclusion(); + test_tool_results_are_paired_before_next_generation(); + test_invalid_generations_are_never_accepted(); + test_no_argument_call_uses_canonical_empty_object(); + test_lead_length_text_completes_and_calls_continue(); + test_length_placeholder_is_observed_after_accept(); + test_subagent_length_pairs_dangling_calls_then_concludes(); + test_lead_cap_follows_the_final_paired_result(); + test_subagent_cap_gets_one_tools_disabled_conclusion(); + test_initial_checkpoint_precedes_first_projection(); + test_generation_helper_misuse_is_rejected(); + test_adapter_interrupted_results_are_authoritative(); + test_terminal_generation_is_checkpointed_before_accept(); + test_accepted_assistant_is_checkpointed_before_tool_decision(); + test_accepted_results_are_checkpointed_before_next_decision(); + test_cancellation_precedes_generation_failure_classification(); + test_cancellation_precedes_tool_failure_classification(); + test_cancellation_precedes_conclusion_failure_fallback(); + test_subagent_conclusion_failure_preserves_fallback(); + test_tools_disabled_call_is_a_conclusion_policy_failure(); + test_fixed_conclusion_guidance_is_outside_hostile_data_bound(); + test_provider_and_adapter_errors_remain_distinct(); + test_out_of_order_results_are_accepted_in_call_order(); + test_incomplete_or_inconsistent_result_batches_are_rejected(); + test_turn_data_and_usage_are_accumulated_once(); + test_nested_usage_obeys_the_whole_turn_bound(); + REGRESS_END(); +} blob - /dev/null blob + 18f0d987fe56d92ab02525c5e9c8bf451f0f7272 (mode 644) --- /dev/null +++ src/common/turn_mechanics.c @@ -0,0 +1,753 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#include +#include + +#include +#include +#include + +#include "xmalloc.h" +#include "buf.h" +#include "msg.h" +#include "generation.h" +#include "turn_mechanics.h" + +struct turn_state { + const struct turn_adapter *adapter; + const struct turn_options *options; + struct turn_result *result; + size_t data_used; + size_t tool_batches; + struct generation_usage usage; +}; + +struct turn_generation_io { + struct turn_state *turn; + struct generation *generation; + struct turn_projection projection; + int sent; + int terminal; + int failed; + char error[TURN_ERROR_MAX]; +}; + +struct turn_generation_step { + struct generation *generation; + struct generation_result result; +}; + +struct turn_result_slot { + void *data; + size_t len; + int is_error; + int set; +}; + +struct turn_tool_results { + struct turn_state *turn; + const struct turn_tool_batch *batch; + struct turn_result_slot slots[GENERATION_CALL_MAX]; + struct buf text; + struct generation_usage pending_usage; + size_t pending_data; + int invalid; + enum turn_failure failure; + char error[TURN_ERROR_MAX]; +}; + +static const char turn_subagent_dangling[] = + "subagent tool call was truncated and was not executed"; +static const char turn_subagent_bound[] = "subagent data bound exceeded"; +static const char turn_subagent_conclude[] = + "You have reached the subagent tool-round bound or another subagent " + "execution bound. Do not call tools. " + "Conclude now with the most useful concise final report possible, " + "including uncertainty and unfinished checks."; + +static void +turn_strerror(char *dst, size_t dstsz, const char *src) +{ + size_t len; + + if (dstsz == 0) + return; + if (src == NULL) + src = ""; + len = strlen(src); + if (len >= dstsz) + len = dstsz - 1; + if (len > 0) + memcpy(dst, src, len); + dst[len] = '\0'; +} + +static void +turn_result_account(struct turn_state *turn) +{ + turn->result->data_used = turn->data_used; + turn->result->tool_batches = turn->tool_batches; + turn->result->usage = turn->usage; +} + +static void +turn_fail(struct turn_state *turn, enum turn_failure failure, + enum generation_invalid_cause invalid_cause, const char *error) +{ + turn->result->status = TURN_STATUS_FAILED; + turn->result->failure = failure; + turn->result->invalid_cause = invalid_cause; + turn->result->assistant = NULL; + turn->result->stop_reason[0] = '\0'; + turn_result_account(turn); + turn_strerror(turn->result->error, sizeof(turn->result->error), error); +} + +static void +turn_interrupt(struct turn_state *turn) +{ + turn->result->status = TURN_STATUS_INTERRUPTED; + turn->result->failure = TURN_FAILURE_NONE; + turn->result->invalid_cause = GENERATION_INVALID_NONE; + turn->result->assistant = NULL; + turn->result->stop_reason[0] = '\0'; + turn->result->error[0] = '\0'; + turn->result->conclusion_failure = TURN_FAILURE_NONE; + turn->result->conclusion_invalid_cause = GENERATION_INVALID_NONE; + turn->result->conclusion_error[0] = '\0'; + turn_result_account(turn); +} + +static int +turn_usage_add(struct generation_usage *total, + const struct generation_usage *usage) +{ + struct generation_usage sum = *total; + +#define ADD_USAGE(field) do { \ + if (usage->field < 0 || \ + usage->field > GENERATION_USAGE_MAX - sum.field) \ + return (-1); \ + sum.field += usage->field; \ +} while (0) + ADD_USAGE(input_tokens); + ADD_USAGE(output_tokens); + ADD_USAGE(cache_read); + ADD_USAGE(cache_write); +#undef ADD_USAGE + *total = sum; + return (0); +} + +static int +turn_checkpoint(struct turn_state *turn) +{ + enum turn_checkpoint checkpoint; + + if (turn->adapter->checkpoint == NULL) + return (0); + checkpoint = turn->adapter->checkpoint(turn->adapter->arg); + if (checkpoint == TURN_CHECKPOINT_CONTINUE) + return (0); + if (checkpoint == TURN_CHECKPOINT_INTERRUPT) { + turn_interrupt(turn); + return (1); + } + turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE, + "invalid Turn checkpoint result"); + return (-1); +} + +int +turn_generation_sent(struct turn_generation_io *io) +{ + if (io == NULL) + return (-1); + if (io->sent || io->terminal || io->failed) { + io->failed = 1; + turn_strerror(io->error, sizeof(io->error), + io->sent ? "Projection marked sent twice" : + "Projection marked sent after Generation events"); + return (-1); + } + io->sent = 1; + if (io->turn->adapter->projection_sent != NULL) + io->turn->adapter->projection_sent(io->turn->adapter->arg, + &io->projection); + return (0); +} + +enum generation_feed_status +turn_generation_feed(struct turn_generation_io *io, enum generation_kind kind, + const void *data, size_t len, struct generation_event *event) +{ + enum generation_feed_status status; + const char *error; + + if (io == NULL || io->failed) + return (GENERATION_PROTOCOL); + if (!io->sent) { + io->failed = 1; + turn_strerror(io->error, sizeof(io->error), + "Generation event before Projection was sent"); + return (GENERATION_PROTOCOL); + } + status = generation_feed(io->generation, kind, data, len, event); + if (status == GENERATION_PROTOCOL) { + io->failed = 1; + error = generation_error(io->generation); + turn_strerror(io->error, sizeof(io->error), error != NULL ? error : + "invalid Generation event"); + } else if (status == GENERATION_TERMINAL) + io->terminal = 1; + return (status); +} + +static void +turn_tool_results_invalidate(struct turn_tool_results *results, + enum turn_failure failure, const char *error) +{ + if (results->invalid) + return; + results->invalid = 1; + results->failure = failure; + turn_strerror(results->error, sizeof(results->error), error); +} + +static int +turn_tool_results_room(struct turn_tool_results *results, size_t len) +{ + size_t room; + + room = results->turn->options->data_limit - results->turn->data_used; + if (results->pending_data > room || len > room - results->pending_data) { + turn_tool_results_invalidate(results, TURN_FAILURE_DATA_BOUND, + "tool results exceeded the Turn data bound"); + return (-1); + } + return (0); +} + +int +turn_tool_result_set(struct turn_tool_results *results, size_t ordinal, + const char *tool_use_id, const void *data, size_t len, int is_error) +{ + struct turn_result_slot *slot; + const struct turn_tool_call *call; + + if (results == NULL || results->invalid) + return (-1); + if (ordinal >= results->batch->count) { + turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS, + "Tool Adapter returned an extra result"); + return (-1); + } + slot = &results->slots[ordinal]; + call = &results->batch->calls[ordinal]; + if (slot->set) { + turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS, + "Tool Adapter returned a duplicate result"); + return (-1); + } + if (tool_use_id == NULL || strcmp(tool_use_id, call->id) != 0) { + turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS, + "Tool Adapter returned a mismatched result"); + return (-1); + } + if ((len > 0 && data == NULL) || (is_error != 0 && is_error != 1)) { + turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS, + "Tool Adapter returned an invalid result"); + return (-1); + } + if (turn_tool_results_room(results, len) == -1) + return (-1); + if (len > 0) { + slot->data = xmalloc(len); + memcpy(slot->data, data, len); + } + slot->len = len; + slot->is_error = is_error; + slot->set = 1; + results->pending_data += len; + return (0); +} + +int +turn_tool_results_add_text(struct turn_tool_results *results, + const void *data, size_t len) +{ + if (results == NULL || results->invalid) + return (-1); + if (len > 0 && data == NULL) { + turn_tool_results_invalidate(results, TURN_FAILURE_TOOL_RESULTS, + "Tool Adapter returned invalid text"); + return (-1); + } + if (turn_tool_results_room(results, len) == -1) + return (-1); + if (len > 0) + buf_add(&results->text, data, len); + results->pending_data += len; + return (0); +} + +int +turn_tool_results_add_usage(struct turn_tool_results *results, + const struct generation_usage *usage) +{ + struct generation_usage pending, total; + + if (results == NULL || results->invalid || usage == NULL) + return (-1); + pending = results->pending_usage; + total = results->turn->usage; + if (turn_usage_add(&pending, usage) == -1 || + turn_usage_add(&total, &pending) == -1) { + turn_tool_results_invalidate(results, TURN_FAILURE_USAGE_BOUND, + "nested Provider usage exceeded the Turn usage bound"); + return (-1); + } + results->pending_usage = pending; + return (0); +} + +static void +turn_tool_results_account_usage(struct turn_tool_results *results) +{ + /* add_usage() already checked this sum against the current aggregate. */ + if (turn_usage_add(&results->turn->usage, + &results->pending_usage) == -1) { + turn_tool_results_invalidate(results, TURN_FAILURE_USAGE_BOUND, + "nested Provider usage exceeded the Turn usage bound"); + return; + } + memset(&results->pending_usage, 0, sizeof(results->pending_usage)); + turn_result_account(results->turn); +} + +static void +turn_generation_dispose(struct turn_generation_step *step) +{ + if (step->result.assistant != NULL) + msg_free(step->result.assistant); + generation_free(step->generation); + memset(step, 0, sizeof(*step)); +} + +static int +turn_generate(struct turn_state *turn, enum turn_tools_mode tools, + struct turn_generation_step *step) +{ + struct turn_generation_request request; + struct turn_generation_io io; + char error[TURN_ERROR_MAX]; + enum turn_io status; + int data_failed, usage_failed; + + memset(step, 0, sizeof(*step)); + memset(&request, 0, sizeof(request)); + turn->adapter->projection(turn->adapter->arg, &request.projection); + request.tools = tools; + memset(&io, 0, sizeof(io)); + io.turn = turn; + io.projection = request.projection; + io.generation = generation_new(turn->options->data_limit - + turn->data_used); + step->generation = io.generation; + error[0] = '\0'; + status = turn->adapter->generate(turn->adapter->arg, &request, &io, + error, sizeof(error)); + error[sizeof(error) - 1] = '\0'; + if (status == TURN_IO_INTERRUPTED) { + turn_interrupt(turn); + return (-1); + } + if (status != TURN_IO_OK || io.failed || !io.sent || !io.terminal) { + if (turn_checkpoint(turn) != 0) + return (-1); + turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE, + io.error[0] != '\0' ? io.error : error[0] != '\0' ? error : + status == TURN_IO_ERROR ? "Generation Adapter failed" : + "Generation Adapter returned before terminal"); + return (-1); + } + if (generation_take(step->generation, &step->result) == -1) { + if (turn_checkpoint(turn) != 0) + return (-1); + turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE, + "could not take terminal Generation"); + return (-1); + } + data_failed = step->result.data_used > turn->options->data_limit - + turn->data_used; + if (!data_failed) + turn->data_used += step->result.data_used; + usage_failed = turn_usage_add(&turn->usage, &step->result.usage) == -1; + if (turn_checkpoint(turn) != 0) + return (-1); + if (data_failed) { + turn_fail(turn, TURN_FAILURE_DATA_BOUND, + GENERATION_INVALID_DATA_BOUND, + "Generation exceeded the Turn data bound"); + return (-1); + } + if (usage_failed) { + turn_fail(turn, TURN_FAILURE_USAGE_BOUND, + GENERATION_INVALID_USAGE_BOUND, + "Generation exceeded the Turn usage bound"); + return (-1); + } + if (step->result.outcome == GENERATION_PROVIDER_ERROR) { + turn_fail(turn, TURN_FAILURE_PROVIDER, GENERATION_INVALID_NONE, + step->result.reason); + return (-1); + } + if (step->result.outcome == GENERATION_INVALID) { + turn_fail(turn, TURN_FAILURE_GENERATION, + step->result.invalid_cause, step->result.reason); + return (-1); + } + if (step->result.outcome != GENERATION_NORMAL && + step->result.outcome != GENERATION_LENGTH) { + turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE, + "Generation returned an unknown outcome"); + return (-1); + } + return (0); +} + +static int +turn_accept_assistant(struct turn_state *turn, + struct turn_generation_step *step) +{ + struct turn_assistant_event event; + + if (turn_checkpoint(turn) != 0) + return (-1); + memset(&event, 0, sizeof(event)); + event.message = step->result.assistant; + event.outcome = step->result.outcome; + event.stop_reason = step->result.reason; + event.synthesized_text = step->result.synthesized_text; + event.tool_calls = step->result.tool_calls; + turn->adapter->accept(turn->adapter->arg, step->result.assistant); + turn->result->assistant = step->result.assistant; + turn_strerror(turn->result->stop_reason, + sizeof(turn->result->stop_reason), step->result.reason); + step->result.assistant = NULL; + if (turn->adapter->assistant_accepted != NULL) + turn->adapter->assistant_accepted(turn->adapter->arg, &event); + if (turn_checkpoint(turn) != 0) + return (-1); + return (0); +} + +static int +turn_batch_build(const struct msg *assistant, size_t expected, + struct turn_tool_batch *batch, struct turn_tool_call *calls) +{ + struct block *block; + size_t count = 0; + + TAILQ_FOREACH(block, &assistant->blocks, entry) { + if (block->type != BLOCK_TOOL_USE) + continue; + if (count >= GENERATION_CALL_MAX) + return (-1); + calls[count].id = block->tool_id; + calls[count].name = block->tool_name; + calls[count].input = block->tool_input; + calls[count].input_len = block->tool_input_len; + count++; + } + if (count != expected || count == 0) + return (-1); + memset(batch, 0, sizeof(*batch)); + batch->assistant = assistant; + batch->calls = calls; + batch->count = count; + return (0); +} + +static void +turn_tool_results_free(struct turn_tool_results *results) +{ + size_t i; + + for (i = 0; i < results->batch->count; i++) + free(results->slots[i].data); + buf_free(&results->text); +} + +static int +turn_execute(struct turn_state *turn, const struct msg *assistant, + size_t expected, int conclude) +{ + struct turn_tool_call calls[GENERATION_CALL_MAX]; + struct turn_tool_batch batch; + struct turn_tool_results results; + struct msg *message; + char error[TURN_ERROR_MAX]; + enum turn_io status; + size_t i; + + memset(calls, 0, sizeof(calls)); + if (turn_batch_build(assistant, expected, &batch, calls) == -1) { + turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE, + "accepted assistant has inconsistent tool calls"); + return (-1); + } + if (turn->adapter->execute == NULL) { + turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE, + "Turn has tool calls but no Tool Adapter"); + return (-1); + } + memset(&results, 0, sizeof(results)); + results.turn = turn; + results.batch = &batch; + results.failure = TURN_FAILURE_TOOL_RESULTS; + buf_init(&results.text); + error[0] = '\0'; + status = turn->adapter->execute(turn->adapter->arg, &batch, &results, + error, sizeof(error)); + error[sizeof(error) - 1] = '\0'; + turn_tool_results_account_usage(&results); + if (status == TURN_IO_INTERRUPTED) { + turn_interrupt(turn); + turn_tool_results_free(&results); + return (-1); + } + if (turn_checkpoint(turn) != 0) { + turn_tool_results_free(&results); + return (-1); + } + if (results.invalid) { + turn_fail(turn, results.failure, GENERATION_INVALID_NONE, + results.error); + turn_tool_results_free(&results); + return (-1); + } + if (status != TURN_IO_OK) { + turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE, + error[0] != '\0' ? error : "Tool Adapter failed"); + turn_tool_results_free(&results); + return (-1); + } + for (i = 0; i < batch.count; i++) { + if (!results.slots[i].set) { + turn_fail(turn, TURN_FAILURE_TOOL_RESULTS, + GENERATION_INVALID_NONE, + "Tool Adapter omitted a result"); + turn_tool_results_free(&results); + return (-1); + } + } + if (turn_checkpoint(turn) != 0) { + turn_tool_results_free(&results); + return (-1); + } + message = msg_new(ROLE_USER); + for (i = 0; i < batch.count; i++) + msg_add_tool_result(message, batch.calls[i].id, + results.slots[i].data, results.slots[i].len, + results.slots[i].is_error); + if (results.text.len > 0) + msg_add_text(message, (const char *)results.text.data, + results.text.len); + /* Fixed Module guidance is outside the hostile-data bound. */ + if (conclude) + msg_add_text(message, turn_subagent_conclude, + sizeof(turn_subagent_conclude) - 1); + turn->adapter->accept(turn->adapter->arg, message); + turn->data_used += results.pending_data; + turn->tool_batches++; + turn_result_account(turn); + turn_tool_results_free(&results); + if (turn_checkpoint(turn) != 0) + return (-1); + return (0); +} + +static int +turn_accept_dangling(struct turn_state *turn, const struct msg *assistant, + size_t expected) +{ + struct turn_tool_call calls[GENERATION_CALL_MAX]; + struct turn_tool_batch batch; + struct msg *message; + const char *text[GENERATION_CALL_MAX]; + size_t len[GENERATION_CALL_MAX]; + size_t i, data_used = 0, reason_len, bound_len, room; + + memset(calls, 0, sizeof(calls)); + memset(&batch, 0, sizeof(batch)); + if (expected > 0 && + turn_batch_build(assistant, expected, &batch, calls) == -1) { + turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE, + "accepted assistant has inconsistent dangling tool calls"); + return (-1); + } + reason_len = sizeof(turn_subagent_dangling) - 1; + bound_len = sizeof(turn_subagent_bound) - 1; + room = turn->options->data_limit - turn->data_used; + for (i = 0; i < expected; i++) { + if (reason_len <= room) { + text[i] = turn_subagent_dangling; + len[i] = reason_len; + } else { + text[i] = turn_subagent_bound; + len[i] = bound_len < room ? bound_len : room; + } + room -= len[i]; + data_used += len[i]; + } + if (turn_checkpoint(turn) != 0) + return (-1); + message = msg_new(ROLE_USER); + for (i = 0; i < expected; i++) + msg_add_tool_result(message, batch.calls[i].id, + text[i], len[i], 1); + /* Fixed Module guidance is outside the hostile-data bound. */ + msg_add_text(message, turn_subagent_conclude, + sizeof(turn_subagent_conclude) - 1); + turn->adapter->accept(turn->adapter->arg, message); + turn->data_used += data_used; + turn_result_account(turn); + if (turn_checkpoint(turn) != 0) + return (-1); + return (0); +} + +static int +turn_conclude(struct turn_state *turn) +{ + struct turn_generation_step step; + const struct msg *fallback; + char stop_reason[GENERATION_STOP_MAX]; + + fallback = turn->result->assistant; + turn_strerror(stop_reason, sizeof(stop_reason), + turn->result->stop_reason); + + if (turn_generate(turn, TURN_TOOLS_DISABLED, &step) == -1) { + if (turn->result->status != TURN_STATUS_INTERRUPTED) + goto failed; + turn_generation_dispose(&step); + return (-1); + } + if (step.result.tool_calls != 0) { + turn_fail(turn, TURN_FAILURE_GENERATION, + GENERATION_INVALID_NONE, + "tools-disabled conclusion returned a tool call"); + goto failed; + } + if (turn_accept_assistant(turn, &step) == -1) { + turn_generation_dispose(&step); + return (-1); + } + turn_generation_dispose(&step); + turn->result->status = TURN_STATUS_CONCLUDED; + turn->result->failure = TURN_FAILURE_NONE; + turn_result_account(turn); + return (0); + +failed: + turn->result->conclusion_failure = turn->result->failure; + turn->result->conclusion_invalid_cause = turn->result->invalid_cause; + turn_strerror(turn->result->conclusion_error, + sizeof(turn->result->conclusion_error), turn->result->error); + turn->result->status = TURN_STATUS_CONCLUDED; + turn->result->failure = TURN_FAILURE_NONE; + turn->result->invalid_cause = GENERATION_INVALID_NONE; + turn->result->assistant = fallback; + turn_strerror(turn->result->stop_reason, + sizeof(turn->result->stop_reason), stop_reason); + turn->result->error[0] = '\0'; + turn_result_account(turn); + turn_generation_dispose(&step); + return (0); +} + +int +turn_run(const struct turn_adapter *adapter, const struct turn_options *options, + struct turn_result *result) +{ + struct turn_state turn; + struct turn_generation_step step; + const struct msg *assistant; + size_t tool_calls; + enum generation_outcome generation_outcome; + int conclude; + + if (adapter == NULL || options == NULL || result == NULL || + adapter->projection == NULL || adapter->accept == NULL || + adapter->generate == NULL || options->data_used > options->data_limit || + options->tool_batch_limit == 0 || + (options->policy != TURN_POLICY_LEAD && + options->policy != TURN_POLICY_SUBAGENT)) + return (-1); + memset(result, 0, sizeof(*result)); + memset(&turn, 0, sizeof(turn)); + turn.adapter = adapter; + turn.options = options; + turn.result = result; + turn.data_used = options->data_used; + if (turn_checkpoint(&turn) != 0) + return (0); + + for (;;) { + if (turn_generate(&turn, TURN_TOOLS_ENABLED, &step) == -1) { + turn_generation_dispose(&step); + return (0); + } + tool_calls = step.result.tool_calls; + generation_outcome = step.result.outcome; + if (turn_accept_assistant(&turn, &step) == -1) { + turn_generation_dispose(&step); + return (0); + } + assistant = result->assistant; + turn_generation_dispose(&step); + if (options->policy == TURN_POLICY_SUBAGENT && + generation_outcome == GENERATION_LENGTH) { + if (turn_accept_dangling(&turn, assistant, tool_calls) == -1) + return (0); + (void)turn_conclude(&turn); + return (0); + } + if (tool_calls == 0) { + result->status = TURN_STATUS_DONE; + result->failure = TURN_FAILURE_NONE; + turn_result_account(&turn); + return (0); + } + conclude = options->policy == TURN_POLICY_SUBAGENT && + turn.tool_batches + 1 >= options->tool_batch_limit; + if (turn_execute(&turn, assistant, tool_calls, conclude) == -1) + return (0); + if (turn.tool_batches >= options->tool_batch_limit) { + if (options->policy == TURN_POLICY_SUBAGENT) { + (void)turn_conclude(&turn); + return (0); + } + result->status = TURN_STATUS_CAP; + result->failure = TURN_FAILURE_NONE; + turn_result_account(&turn); + return (0); + } + } +} blob - /dev/null blob + 78385af229953d731d1d02cccf6ca08726f3513f (mode 644) --- /dev/null +++ src/common/turn_mechanics.h @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#ifndef TURN_MECHANICS_H +#define TURN_MECHANICS_H + +#include + +#include + +#include "generation.h" + +struct msg; +struct msglist; +struct turn_generation_io; +struct turn_tool_results; + +#define TURN_ERROR_MAX GENERATION_ERROR_MAX + +enum turn_policy_kind { + TURN_POLICY_LEAD, + TURN_POLICY_SUBAGENT +}; + +enum turn_tools_mode { + TURN_TOOLS_ENABLED, + TURN_TOOLS_DISABLED +}; + +enum turn_io { + TURN_IO_OK, + TURN_IO_ERROR, + TURN_IO_INTERRUPTED +}; + +enum turn_checkpoint { + TURN_CHECKPOINT_CONTINUE, + TURN_CHECKPOINT_INTERRUPT +}; + +enum turn_status { + TURN_STATUS_DONE, + TURN_STATUS_CAP, + TURN_STATUS_CONCLUDED, + TURN_STATUS_FAILED, + TURN_STATUS_INTERRUPTED +}; + +enum turn_failure { + TURN_FAILURE_NONE, + TURN_FAILURE_ADAPTER, + TURN_FAILURE_PROVIDER, + TURN_FAILURE_GENERATION, + TURN_FAILURE_TOOL_RESULTS, + TURN_FAILURE_DATA_BOUND, + TURN_FAILURE_USAGE_BOUND +}; + +/* + * A borrowed Projection remains valid for one generate callback. receipt + * identifies that exact view; it advances only when the Adapter marks the + * complete request sent through turn_generation_sent(). + */ +struct turn_projection { + const struct msglist *messages; + size_t message_count; + int cache_tail_msg; + uint64_t receipt; +}; + +/* + * data_limit bounds hostile Turn bytes: Adapter-supplied initial data, + * Provider assistant text/tool input, and emitted tool result/error or added + * text, including synthesized dangling errors. Fixed conclusion guidance is + * excluded, as are system prompts and tool definitions. options.data_used + * supplies hostile bytes accepted before turn_run(). + */ +struct turn_options { + enum turn_policy_kind policy; + size_t data_limit; + size_t data_used; + size_t tool_batch_limit; /* paired batches, nonzero */ +}; + +struct turn_generation_request { + struct turn_projection projection; + enum turn_tools_mode tools; +}; + +/* Every value is borrowed from the accepted assistant for execute(). */ +struct turn_tool_call { + const char *id; + const char *name; + const void *input; + size_t input_len; +}; + +struct turn_tool_batch { + const struct msg *assistant; + const struct turn_tool_call *calls; + size_t count; +}; + +/* + * Borrowed for assistant_accepted(), after ownership transfer and before the + * post-accept checkpoint. synthesized_text aliases message storage. + */ +struct turn_assistant_event { + const struct msg *message; + enum generation_outcome outcome; + const char *stop_reason; + struct generation_span synthesized_text; + size_t tool_calls; +}; + +struct turn_result { + enum turn_status status; + enum turn_failure failure; + enum generation_invalid_cause invalid_cause; + enum turn_failure conclusion_failure; + enum generation_invalid_cause conclusion_invalid_cause; + /* Completed Generations plus nested usage reported by execute(). */ + struct generation_usage usage; + size_t data_used; + size_t tool_batches; + const struct msg *assistant; + char stop_reason[GENERATION_STOP_MAX]; + char error[TURN_ERROR_MAX]; + char conclusion_error[TURN_ERROR_MAX]; +}; + +/* + * generate() and execute() are blocking Adapter calls. Their io/result + * handles are borrowed only for the duration of the callback. accept() + * takes ownership of its message. checkpoint() may be NULL. generate() + * marks a completely queued Projection exactly once, then feeds through a + * terminal event before returning OK. execute() sets exactly one matching + * result for every borrowed call ordinal; it may fill ordinals in any order. + * The first checkpoint precedes the first Projection; further checkpoints + * surround blocking callbacks and accepted messages. + */ +struct turn_adapter { + void *arg; + void (*projection)(void *, struct turn_projection *); + void (*projection_sent)(void *, const struct turn_projection *); + void (*accept)(void *, struct msg *); + void (*assistant_accepted)(void *, const struct turn_assistant_event *); + enum turn_checkpoint (*checkpoint)(void *); + enum turn_io (*generate)(void *, + const struct turn_generation_request *, struct turn_generation_io *, + char *, size_t); + enum turn_io (*execute)(void *, const struct turn_tool_batch *, + struct turn_tool_results *, char *, size_t); +}; + +/* + * Returns -1 only for an invalid Interface/configuration. Otherwise the + * tagged result is authoritative. assistant is borrowed from Adapter-owned + * Projection storage and is NULL on FAILED/INTERRUPTED. data_used includes + * options.data_used plus hostile bytes accepted by completed collectors, even + * when cancellation prevents Projection acceptance. A forced Subagent + * conclusion remains CONCLUDED when its single final Generation fails; + * conclusion_* describes that fallback. + */ +int turn_run(const struct turn_adapter *, const struct turn_options *, + struct turn_result *); + +int turn_generation_sent(struct turn_generation_io *); +enum generation_feed_status turn_generation_feed(struct turn_generation_io *, + enum generation_kind, const void *, size_t, struct generation_event *); + +/* Result bytes and added text are copied; the matching id is NUL-terminated. */ +int turn_tool_result_set(struct turn_tool_results *, size_t, const char *, + const void *, size_t, int); +int turn_tool_results_add_text(struct turn_tool_results *, const void *, + size_t); + +/* + * Adds nested Provider usage observed during execute(). Accepted usage is + * included in the final Turn aggregate even if execute later fails or is + * interrupted. + */ +int turn_tool_results_add_usage(struct turn_tool_results *, + const struct generation_usage *); + +#endif /* TURN_MECHANICS_H */