commit - ffccea59b4d4877ec18fbcbac7f41c955416b635
commit + f82b590e4d17bcf395c594962292546a67ee7c8a
blob - 09b03732599422322cc16ad000349f3775b5efef
blob + fc82d725238f321e7772e907d668f823f1a322ea
--- regress/Makefile
+++ regress/Makefile
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 <bsd.subdir.mk>
blob - /dev/null
blob + 12f905a99af298d8f5ca9a9f06b840832bde9dee (mode 644)
--- /dev/null
+++ regress/turn_mechanics/Makefile
+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 <bsd.regress.mk>
blob - /dev/null
blob + 4f26ab1c8e976dcba4f063e969217872a3924c8e (mode 644)
--- /dev/null
+++ regress/turn_mechanics/turn_mechanics_test.c
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * 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 <sys/queue.h>
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#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
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * 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 <sys/queue.h>
+#include <sys/types.h>
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#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
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * 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 <sys/types.h>
+
+#include <stdint.h>
+
+#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 */