commit d8dc62cf90f48602dfdd56da2caf057b9fa09352 from: Isaac Meerleo date: Fri Jul 17 22:12:52 2026 UTC Add Lead Generation compaction hooks commit - 6bc0ac427c4a9ee19ec553c4faedfa250054ed10 commit + d8dc62cf90f48602dfdd56da2caf057b9fa09352 blob - e492b090e91a1319c6bc4b95fcd6f020d9306a1a blob + 96dcda366b275e8e38ca8230bdc88b52c69dc117 --- regress/turn_mechanics/turn_mechanics_test.c +++ regress/turn_mechanics/turn_mechanics_test.c @@ -82,6 +82,20 @@ struct scripted_tool { int fill_error; }; +struct scripted_preflight { + enum turn_tools_mode tools; + struct generation_usage usage; + enum turn_io io; + const char *error; + int fill_error; +}; + +struct observed_preflight { + struct generation_usage usage; + size_t data_used; + size_t tool_batches; +}; + struct observed_assistant { const struct msg *message; enum generation_outcome outcome; @@ -107,11 +121,21 @@ struct fixture { struct scripted_tool tools[SCRIPT_TOOLS_MAX]; size_t ntools; size_t tool_next; + struct scripted_preflight prepares[SCRIPT_GENERATIONS_MAX]; + size_t nprepares; + size_t prepare_next; + struct scripted_preflight recoveries[SCRIPT_GENERATIONS_MAX]; + size_t nrecoveries; + size_t recovery_next; + struct observed_preflight prepare_observed[SCRIPT_GENERATIONS_MAX]; + struct observed_preflight recovery_observed[SCRIPT_GENERATIONS_MAX]; + struct turn_result *running_result; 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]; + uint64_t projection_receipt[SCRIPT_GENERATIONS_MAX]; enum turn_tools_mode request_tools[SCRIPT_GENERATIONS_MAX]; int set_result_rc[SCRIPT_RESULTS_MAX]; size_t nset_result_rc; @@ -119,6 +143,7 @@ struct fixture { int add_usage_rc; int accepts; int projections; + uint64_t projection_revision; int sent_callbacks; int marking_sent; int checkpoints; @@ -127,6 +152,18 @@ struct fixture { }; static void +observe_preflight(struct observed_preflight *observed, + const struct turn_result *result) +{ + memset(observed, 0, sizeof(*observed)); + if (result == NULL) + return; + observed->usage = result->usage; + observed->data_used = result->data_used; + observed->tool_batches = result->tool_batches; +} + +static void copy_string(char *dst, size_t dstsz, const char *src) { size_t len; @@ -285,6 +322,34 @@ fixture_tool(struct fixture *f) return (tool); } +static struct scripted_preflight * +fixture_prepare(struct fixture *f, enum turn_tools_mode tools) +{ + struct scripted_preflight *script; + + if (f->nprepares >= SCRIPT_GENERATIONS_MAX) + abort(); + script = &f->prepares[f->nprepares++]; + memset(script, 0, sizeof(*script)); + script->tools = tools; + script->io = TURN_IO_OK; + return (script); +} + +static struct scripted_preflight * +fixture_recovery(struct fixture *f, enum turn_tools_mode tools) +{ + struct scripted_preflight *script; + + if (f->nrecoveries >= SCRIPT_GENERATIONS_MAX) + abort(); + script = &f->recoveries[f->nrecoveries++]; + memset(script, 0, sizeof(*script)); + script->tools = tools; + script->io = TURN_IO_OK; + return (script); +} + static void script_result(struct scripted_tool *tool, size_t ordinal, const char *id, const void *data, size_t len, int is_error) @@ -312,7 +377,8 @@ fixture_projection(void *arg, struct turn_projection * projection->messages = &f->accepted; projection->message_count = message_count(&f->accepted); projection->cache_tail_msg = -1; - projection->receipt = projection->message_count + 100; + projection->receipt = f->projection_revision * 100 + + projection->message_count; } static void @@ -321,11 +387,61 @@ fixture_projection_sent(void *arg, const struct turn_p struct fixture *f = arg; if (!f->marking_sent || projection->receipt != - projection->message_count + 100) + f->projection_revision * 100 + projection->message_count) f->bad = 1; f->sent_callbacks++; } +static enum turn_io +fixture_prepare_generation(void *arg, enum turn_tools_mode tools, + struct generation_usage *usage, char *error, size_t errorsz) +{ + struct fixture *f = arg; + struct scripted_preflight *script; + + if (f->prepare_next >= f->nprepares) { + f->bad = 1; + return (TURN_IO_ERROR); + } + observe_preflight(&f->prepare_observed[f->prepare_next], + f->running_result); + script = &f->prepares[f->prepare_next++]; + if (tools != script->tools) + f->bad = 1; + *usage = script->usage; + f->projection_revision++; + if (script->error != NULL && errorsz > 0) + (void)snprintf(error, errorsz, "%s", script->error); + if (script->fill_error && errorsz > 0) + memset(error, 'P', errorsz); + return (script->io); +} + +static enum turn_io +fixture_recover_context_overflow(void *arg, enum turn_tools_mode tools, + struct generation_usage *usage, char *error, size_t errorsz) +{ + struct fixture *f = arg; + struct scripted_preflight *script; + + if (f->recovery_next >= f->nrecoveries) { + f->bad = 1; + return (TURN_IO_ERROR); + } + observe_preflight(&f->recovery_observed[f->recovery_next], + f->running_result); + script = &f->recoveries[f->recovery_next++]; + if (tools != script->tools) + f->bad = 1; + *usage = script->usage; + f->projection_revision++; + if (script->error != NULL && errorsz > 0) + (void)snprintf(error, errorsz, "%s", script->error); + if (script->fill_error && errorsz > 0) + memset(error, 'R', errorsz); + return (script->io); +} + static void fixture_accept(void *arg, struct msg *message) { @@ -411,6 +527,7 @@ fixture_generate(void *arg, const struct turn_generati slot = f->generation_next; script = &f->generations[f->generation_next++]; f->projection_count[slot] = request->projection.message_count; + f->projection_receipt[slot] = request->projection.receipt; f->request_tools[slot] = request->tools; if (request->projection.messages != &f->accepted || request->tools != script->tools) @@ -576,6 +693,7 @@ test_normal_text_completes(void) result.usage.cache_write == 0); CHECK(f.generation_next == 1 && f.accepts == 1 && f.sent_callbacks == 1 && f.nassistants == 1 && + f.projections == 1 && f.checkpoints == 4 && f.request_tools[0] == TURN_TOOLS_ENABLED && f.projection_count[0] == 1 && message_count(&f.accepted) == 2); CHECK(f.assistants[0].outcome == GENERATION_NORMAL && @@ -621,6 +739,46 @@ test_subagent_normal_text_completes_without_conclusion } static void +test_prepare_precedes_tools_disabled_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"); + f.running_result = &result; + adapter.prepare_generation = fixture_prepare_generation; + (void)fixture_prepare(&f, TURN_TOOLS_ENABLED); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "partial"); + script_done(generation, "max_tokens"); + (void)fixture_prepare(&f, TURN_TOOLS_DISABLED); + 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 && f.bad == 0 && + result.status == TURN_STATUS_CONCLUDED && + result.failure == TURN_FAILURE_NONE && + strcmp(result.stop_reason, "stop") == 0); + CHECK(f.prepare_next == 2 && f.generation_next == 2 && + f.projections == 2 && f.request_tools[0] == TURN_TOOLS_ENABLED && + f.request_tools[1] == TURN_TOOLS_DISABLED && + f.projection_receipt[0] == 101 && + f.projection_receipt[1] == 203); + CHECK(f.prepare_observed[0].data_used == 1 && + f.prepare_observed[1].data_used == 8 && + f.prepare_observed[1].tool_batches == 0 && + f.accepts == 3 && f.nassistants == 2 && f.tool_next == 0); + msglist_free(&f.accepted); +} + +static void test_tool_results_are_paired_before_next_generation(void) { static const char input[] = "{\"path\":\"x\"}"; @@ -686,6 +844,58 @@ test_tool_results_are_paired_before_next_generation(vo } static void +test_prepare_precedes_each_fresh_projection(void) +{ + static const struct generation_usage first = { 1, 2, 3, 4 }; + static const struct generation_usage second = { 10, 20, 30, 40 }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_preflight *prepare; + struct scripted_tool *tool; + int rc; + + fixture_init(&f, &adapter, "q"); + f.running_result = &result; + adapter.prepare_generation = fixture_prepare_generation; + prepare = fixture_prepare(&f, TURN_TOOLS_ENABLED); + prepare->usage = first; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "prepared", "read"); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + script_result(tool, 0, NULL, "ok", 2, 0); + prepare = fixture_prepare(&f, TURN_TOOLS_ENABLED); + prepare->usage = second; + 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.failure == TURN_FAILURE_NONE && result.tool_batches == 1); + CHECK(f.prepare_next == 2 && f.projections == 2 && + f.generation_next == 2 && f.tool_next == 1 && + f.projection_count[0] == 1 && f.projection_count[1] == 3 && + f.projection_receipt[0] == 101 && + f.projection_receipt[1] == 203); + CHECK(result.usage.input_tokens == 11 && + result.usage.output_tokens == 22 && + result.usage.cache_read == 33 && + result.usage.cache_write == 44); + CHECK(f.prepare_observed[0].data_used == 1 && + f.prepare_observed[0].tool_batches == 0 && + f.prepare_observed[0].usage.input_tokens == 0 && + f.prepare_observed[1].data_used == 5 && + f.prepare_observed[1].tool_batches == 1 && + f.prepare_observed[1].usage.input_tokens == 1); + msglist_free(&f.accepted); +} + +static void test_invalid_generations_are_never_accepted(void) { static const struct { @@ -1123,6 +1333,122 @@ test_initial_checkpoint_precedes_first_projection(void } static void +test_prepare_callback_terminal_results(void) +{ + enum prepare_case { + PREPARE_ERROR, + PREPARE_INTERRUPTED, + PREPARE_CANCELLED, + PREPARE_FILLED_ERROR + }; + 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_preflight *prepare; + int which, rc; + + for (which = PREPARE_ERROR; which <= PREPARE_FILLED_ERROR; which++) { + fixture_init(&f, &adapter, "q"); + adapter.prepare_generation = fixture_prepare_generation; + prepare = fixture_prepare(&f, TURN_TOOLS_ENABLED); + prepare->usage = nested; + prepare->io = TURN_IO_ERROR; + prepare->error = "preparation summary failed"; + if (which == PREPARE_INTERRUPTED) + prepare->io = TURN_IO_INTERRUPTED; + else if (which == PREPARE_CANCELLED) + f.interrupt_checkpoint = 2; + else if (which == PREPARE_FILLED_ERROR) { + prepare->error = NULL; + prepare->fill_error = 1; + } + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "must not run"); + 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.data_used == 1 && result.assistant == NULL && + f.prepare_next == 1 && f.projections == 0 && + f.generation_next == 0 && f.sent_callbacks == 0 && + f.accepts == 0 && f.checkpoints == + (which == PREPARE_INTERRUPTED ? 1 : 2)); + CHECK(result.usage.input_tokens == 5 && + result.usage.output_tokens == 6 && + result.usage.cache_read == 7 && + result.usage.cache_write == 8); + if (which == PREPARE_INTERRUPTED || + which == PREPARE_CANCELLED) { + CHECK(result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && + result.error[0] == '\0'); + } else if (which == PREPARE_FILLED_ERROR) { + CHECK(result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_ADAPTER && + result.error[0] == 'P' && + result.error[TURN_ERROR_MAX - 2] == 'P' && + result.error[TURN_ERROR_MAX - 1] == '\0'); + } else { + CHECK(result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_ADAPTER && + strcmp(result.error, + "preparation summary failed") == 0); + } + msglist_free(&f.accepted); + } +} + +static void +test_prepare_usage_obeys_whole_turn_bound(void) +{ + static const struct generation_usage full = { + GENERATION_USAGE_MAX, 0, 0, 0 + }; + static const 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_preflight *prepare; + struct scripted_tool *tool; + int rc; + + fixture_init(&f, &adapter, "q"); + adapter.prepare_generation = fixture_prepare_generation; + prepare = fixture_prepare(&f, TURN_TOOLS_ENABLED); + prepare->usage = full; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "prepare-bound", "read"); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + script_result(tool, 0, NULL, "ok", 2, 0); + prepare = fixture_prepare(&f, TURN_TOOLS_ENABLED); + prepare->usage = one; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "must not run"); + 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_USAGE_BOUND && + result.invalid_cause == GENERATION_INVALID_USAGE_BOUND && + result.usage.input_tokens == GENERATION_USAGE_MAX && + result.usage.output_tokens == 0 && result.data_used == 5 && + result.tool_batches == 1 && result.error[0] != '\0'); + CHECK(f.prepare_next == 2 && f.projections == 1 && + f.generation_next == 1 && f.tool_next == 1 && f.nbatches == 1 && + f.accepts == 2 && f.nassistants == 1 && + f.projection_receipt[0] == 101); + msglist_free(&f.accepted); +} + +static void test_generation_helper_misuse_is_rejected(void) { enum helper_misuse { @@ -1808,6 +2134,334 @@ test_context_overflow_projects_to_turn_result(void) } static void +test_lead_recovers_one_precontent_context_overflow(void) +{ + static const struct generation_usage rejected = { 1, 2, 3, 4 }; + static const struct generation_usage recovery = { 10, 20, 30, 40 }; + static const struct generation_usage retried = { 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_preflight *preflight; + int rc; + + fixture_init(&f, &adapter, "q"); + f.running_result = &result; + adapter.recover_context_overflow = fixture_recover_context_overflow; + preflight = fixture_recovery(&f, TURN_TOOLS_ENABLED); + preflight->usage = recovery; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_usage(generation, &rejected); + script_context_overflow(generation, "too large"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "answer"); + script_usage(generation, &retried); + 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.failure == TURN_FAILURE_NONE && result.data_used == 7 && + result.assistant != NULL && strcmp(result.stop_reason, "stop") == 0); + CHECK(f.recovery_next == 1 && f.generation_next == 2 && + f.projections == 2 && f.projection_receipt[0] == 1 && + f.projection_receipt[1] == 101 && f.sent_callbacks == 2 && + f.accepts == 1 && f.nassistants == 1 && f.tool_next == 0); + CHECK(result.usage.input_tokens == 111 && + result.usage.output_tokens == 222 && + result.usage.cache_read == 333 && + result.usage.cache_write == 444); + CHECK(f.recovery_observed[0].data_used == 1 && + f.recovery_observed[0].tool_batches == 0 && + f.recovery_observed[0].usage.input_tokens == 1 && + f.recovery_observed[0].usage.output_tokens == 2 && + f.recovery_observed[0].usage.cache_read == 3 && + f.recovery_observed[0].usage.cache_write == 4); + msglist_free(&f.accepted); +} + +static void +test_post_tool_overflow_retries_without_reexecuting_tools(void) +{ + static const struct generation_usage prepare_first = { 1, 0, 0, 0 }; + static const struct generation_usage prepare_next = { 2, 0, 0, 0 }; + static const struct generation_usage rejected = { 4, 0, 0, 0 }; + static const struct generation_usage recovery = { 8, 0, 0, 0 }; + static const struct generation_usage retried = { 16, 0, 0, 0 }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + struct scripted_preflight *preflight; + struct scripted_tool *tool; + int rc; + + fixture_init(&f, &adapter, "q"); + f.running_result = &result; + adapter.prepare_generation = fixture_prepare_generation; + adapter.recover_context_overflow = fixture_recover_context_overflow; + preflight = fixture_prepare(&f, TURN_TOOLS_ENABLED); + preflight->usage = prepare_first; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "once", "read"); + script_done(generation, "tool_use"); + tool = fixture_tool(&f); + script_result(tool, 0, NULL, "ok", 2, 0); + preflight = fixture_prepare(&f, TURN_TOOLS_ENABLED); + preflight->usage = prepare_next; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_usage(generation, &rejected); + script_context_overflow(generation, "continuation too large"); + preflight = fixture_recovery(&f, TURN_TOOLS_ENABLED); + preflight->usage = recovery; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "final"); + script_usage(generation, &retried); + script_done(generation, "stop"); + options_init(&options, TURN_POLICY_LEAD, 1, 3); + 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 == 10 && + result.tool_batches == 1 && result.usage.input_tokens == 31 && + strcmp(result.stop_reason, "stop") == 0); + CHECK(f.prepare_next == 2 && f.recovery_next == 1 && + f.generation_next == 3 && f.projections == 3 && + f.projection_count[0] == 1 && f.projection_count[1] == 3 && + f.projection_count[2] == 3 && f.projection_receipt[0] == 101 && + f.projection_receipt[1] == 203 && + f.projection_receipt[2] == 303); + CHECK(f.tool_next == 1 && f.nbatches == 1 && f.accepts == 3 && + f.nassistants == 2 && f.sent_callbacks == 3 && + f.prepare_observed[1].data_used == 5 && + f.prepare_observed[1].tool_batches == 1 && + f.prepare_observed[1].usage.input_tokens == 1 && + f.recovery_observed[0].data_used == 5 && + f.recovery_observed[0].tool_batches == 1 && + f.recovery_observed[0].usage.input_tokens == 7); + msglist_free(&f.accepted); +} + +static void +test_ineligible_provider_failures_do_not_recover(void) +{ + enum ineligible_case { + INELIGIBLE_GENERIC, + INELIGIBLE_POST_CONTENT, + INELIGIBLE_SUBAGENT + }; + struct fixture f; + struct turn_adapter adapter; + struct turn_options options; + struct turn_result result; + struct scripted_generation *generation; + enum provider_error_category want_category; + enum turn_policy_kind policy; + int which, rc; + + for (which = INELIGIBLE_GENERIC; which <= INELIGIBLE_SUBAGENT; + which++) { + fixture_init(&f, &adapter, "q"); + adapter.recover_context_overflow = + fixture_recover_context_overflow; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + want_category = PROVIDER_ERROR_CONTEXT_OVERFLOW; + policy = TURN_POLICY_LEAD; + if (which == INELIGIBLE_GENERIC) { + script_provider_error(generation, "generic failure"); + want_category = PROVIDER_ERROR_OTHER; + } else { + if (which == INELIGIBLE_POST_CONTENT) + script_text(generation, "x"); + else + policy = TURN_POLICY_SUBAGENT; + script_context_overflow(generation, "typed overflow"); + } + options_init(&options, policy, 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.provider_error_category == want_category && + result.provider_content_accepted == + (which == INELIGIBLE_POST_CONTENT) && + result.data_used == + (which == INELIGIBLE_POST_CONTENT ? 2 : 1)); + CHECK(f.recovery_next == 0 && f.generation_next == 1 && + f.projections == 1 && f.sent_callbacks == 1 && + f.accepts == 0 && f.nassistants == 0 && f.tool_next == 0); + msglist_free(&f.accepted); + } +} + +static void +test_context_overflow_recovery_is_turn_wide_once(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"); + adapter.prepare_generation = fixture_prepare_generation; + adapter.recover_context_overflow = fixture_recover_context_overflow; + (void)fixture_prepare(&f, TURN_TOOLS_ENABLED); + (void)fixture_prepare(&f, TURN_TOOLS_ENABLED); + (void)fixture_recovery(&f, TURN_TOOLS_ENABLED); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_context_overflow(generation, "first overflow"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_tool_begin(generation, 0, "after-recovery", "read"); + 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_context_overflow(generation, "second overflow"); + options_init(&options, TURN_POLICY_LEAD, 1, 3); + rc = turn_run(&adapter, &options, &result); + + CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_PROVIDER && + result.provider_error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW && + result.provider_content_accepted == 0 && result.data_used == 5 && + result.tool_batches == 1 && + strcmp(result.error, "second overflow") == 0); + CHECK(f.prepare_next == 2 && f.recovery_next == 1 && + f.generation_next == 3 && f.projections == 3 && + f.projection_receipt[0] == 101 && + f.projection_receipt[1] == 201 && + f.projection_receipt[2] == 303 && f.tool_next == 1 && + f.nbatches == 1 && f.accepts == 2 && f.nassistants == 1 && + f.sent_callbacks == 3); + msglist_free(&f.accepted); +} + +static void +test_recovery_callback_terminal_results(void) +{ + enum recovery_case { + RECOVERY_ERROR, + RECOVERY_INTERRUPTED, + RECOVERY_CANCELLED, + RECOVERY_FILLED_ERROR + }; + static const struct generation_usage rejected = { 1, 2, 3, 4 }; + 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_preflight *recovery; + int which, rc; + + for (which = RECOVERY_ERROR; which <= RECOVERY_FILLED_ERROR; which++) { + fixture_init(&f, &adapter, "q"); + adapter.recover_context_overflow = + fixture_recover_context_overflow; + recovery = fixture_recovery(&f, TURN_TOOLS_ENABLED); + recovery->usage = nested; + recovery->io = TURN_IO_ERROR; + recovery->error = "recovery summary failed"; + if (which == RECOVERY_INTERRUPTED) + recovery->io = TURN_IO_INTERRUPTED; + else if (which == RECOVERY_CANCELLED) { + recovery->io = TURN_IO_OK; + recovery->error = NULL; + f.interrupt_checkpoint = 3; + } else if (which == RECOVERY_FILLED_ERROR) { + recovery->error = NULL; + recovery->fill_error = 1; + } + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_usage(generation, &rejected); + script_context_overflow(generation, "too large"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "must not retry"); + 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.data_used == 1 && + result.assistant == NULL && f.recovery_next == 1 && + f.projections == 1 && f.generation_next == 1 && + f.sent_callbacks == 1 && f.accepts == 0 && f.checkpoints == + (which == RECOVERY_INTERRUPTED ? 2 : 3)); + CHECK(result.usage.input_tokens == 6 && + result.usage.output_tokens == 8 && + result.usage.cache_read == 10 && + result.usage.cache_write == 12); + if (which == RECOVERY_INTERRUPTED || + which == RECOVERY_CANCELLED) { + CHECK(result.status == TURN_STATUS_INTERRUPTED && + result.failure == TURN_FAILURE_NONE && + result.provider_error_category == PROVIDER_ERROR_NONE && + result.error[0] == '\0'); + } else if (which == RECOVERY_FILLED_ERROR) { + CHECK(result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_ADAPTER && + result.error[0] == 'R' && + result.error[TURN_ERROR_MAX - 2] == 'R' && + result.error[TURN_ERROR_MAX - 1] == '\0'); + } else { + CHECK(result.status == TURN_STATUS_FAILED && + result.failure == TURN_FAILURE_ADAPTER && + strcmp(result.error, "recovery summary failed") == 0); + } + msglist_free(&f.accepted); + } +} + +static void +test_recovery_usage_obeys_whole_turn_bound(void) +{ + static const struct generation_usage full = { + GENERATION_USAGE_MAX, 0, 0, 0 + }; + static const 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_preflight *recovery; + int rc; + + fixture_init(&f, &adapter, "q"); + f.running_result = &result; + adapter.recover_context_overflow = fixture_recover_context_overflow; + recovery = fixture_recovery(&f, TURN_TOOLS_ENABLED); + recovery->usage = one; + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_usage(generation, &full); + script_context_overflow(generation, "too large"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "must not retry"); + 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_USAGE_BOUND && + result.invalid_cause == GENERATION_INVALID_USAGE_BOUND && + result.usage.input_tokens == GENERATION_USAGE_MAX && + result.usage.output_tokens == 0 && result.data_used == 1 && + result.tool_batches == 0 && result.error[0] != '\0'); + CHECK(f.recovery_next == 1 && f.projections == 1 && + f.generation_next == 1 && f.sent_callbacks == 1 && + f.accepts == 0 && f.nassistants == 0 && + f.recovery_observed[0].usage.input_tokens == GENERATION_USAGE_MAX); + msglist_free(&f.accepted); +} + +static void test_out_of_order_results_are_accepted_in_call_order(void) { struct fixture f; @@ -2022,7 +2676,9 @@ main(void) { test_normal_text_completes(); test_subagent_normal_text_completes_without_conclusion(); + test_prepare_precedes_tools_disabled_conclusion(); test_tool_results_are_paired_before_next_generation(); + test_prepare_precedes_each_fresh_projection(); test_invalid_generations_are_never_accepted(); test_no_argument_call_uses_canonical_empty_object(); test_lead_length_text_completes_and_calls_continue(); @@ -2032,6 +2688,8 @@ main(void) test_lead_steers_do_not_reset_tool_batch_limit(); test_subagent_cap_gets_one_tools_disabled_conclusion(); test_initial_checkpoint_precedes_first_projection(); + test_prepare_callback_terminal_results(); + test_prepare_usage_obeys_whole_turn_bound(); test_generation_helper_misuse_is_rejected(); test_adapter_interrupted_results_are_authoritative(); test_terminal_generation_is_checkpointed_before_accept(); @@ -2045,6 +2703,12 @@ main(void) test_fixed_conclusion_guidance_is_outside_hostile_data_bound(); test_provider_and_adapter_errors_remain_distinct(); test_context_overflow_projects_to_turn_result(); + test_lead_recovers_one_precontent_context_overflow(); + test_post_tool_overflow_retries_without_reexecuting_tools(); + test_ineligible_provider_failures_do_not_recover(); + test_context_overflow_recovery_is_turn_wide_once(); + test_recovery_callback_terminal_results(); + test_recovery_usage_obeys_whole_turn_bound(); 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(); blob - eb43c47a6b721daed18e537eccde0b3a9d378c29 blob + fac8235f9b47046f635279d4fff54902989fe1e8 --- src/common/turn_mechanics.c +++ src/common/turn_mechanics.c @@ -34,6 +34,7 @@ struct turn_state { size_t data_used; size_t tool_batches; struct generation_usage usage; + int context_overflow_recovered; }; struct turn_generation_io { @@ -70,6 +71,9 @@ struct turn_tool_results { char error[TURN_ERROR_MAX]; }; +typedef enum turn_io (*turn_preflight_fn)(void *, enum turn_tools_mode, + struct generation_usage *, char *, size_t); + 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"; @@ -366,6 +370,64 @@ turn_generation_dispose(struct turn_generation_step *s } static int +turn_preflight(struct turn_state *turn, enum turn_tools_mode tools, + turn_preflight_fn callback, const char *fallback_error, + const char *usage_error) +{ + struct generation_usage usage; + char error[TURN_ERROR_MAX]; + enum turn_io status; + int usage_failed; + + memset(&usage, 0, sizeof(usage)); + error[0] = '\0'; + turn_result_account(turn); + status = callback(turn->adapter->arg, tools, &usage, error, + sizeof(error)); + error[sizeof(error) - 1] = '\0'; + usage_failed = turn_usage_add(&turn->usage, &usage) == -1; + turn_result_account(turn); + if (status == TURN_IO_INTERRUPTED) { + turn_interrupt(turn); + return (-1); + } + if (turn_checkpoint(turn) != 0) + return (-1); + if (usage_failed) { + turn_fail(turn, TURN_FAILURE_USAGE_BOUND, + GENERATION_INVALID_USAGE_BOUND, usage_error); + return (-1); + } + if (status != TURN_IO_OK) { + turn_fail(turn, TURN_FAILURE_ADAPTER, GENERATION_INVALID_NONE, + error[0] != '\0' ? error : fallback_error); + return (-1); + } + return (0); +} + +static int +turn_prepare_generation(struct turn_state *turn, enum turn_tools_mode tools) +{ + if (turn->adapter->prepare_generation == NULL) + return (0); + return (turn_preflight(turn, tools, + turn->adapter->prepare_generation, "Generation preparation failed", + "Generation preparation usage exceeded the Turn usage bound")); +} + +static int +turn_recover_context_overflow(struct turn_state *turn, + enum turn_tools_mode tools) +{ + turn->context_overflow_recovered = 1; + return (turn_preflight(turn, tools, + turn->adapter->recover_context_overflow, + "context-overflow recovery failed", + "context-overflow recovery usage exceeded the Turn usage bound")); +} + +static int turn_generate(struct turn_state *turn, enum turn_tools_mode tools, struct turn_generation_step *step) { @@ -376,6 +438,9 @@ turn_generate(struct turn_state *turn, enum turn_tools int data_failed, usage_failed; memset(step, 0, sizeof(*step)); + if (turn_prepare_generation(turn, tools) == -1) + return (-1); +retry: memset(&request, 0, sizeof(request)); turn->adapter->projection(turn->adapter->arg, &request.projection); request.tools = tools; @@ -429,6 +494,17 @@ turn_generate(struct turn_state *turn, enum turn_tools return (-1); } if (step->result.outcome == GENERATION_PROVIDER_ERROR) { + if (step->result.provider_error_category == + PROVIDER_ERROR_CONTEXT_OVERFLOW && + !step->result.provider_content_accepted && + turn->options->policy == TURN_POLICY_LEAD && + turn->adapter->recover_context_overflow != NULL && + !turn->context_overflow_recovered) { + turn_generation_dispose(step); + if (turn_recover_context_overflow(turn, tools) == -1) + return (-1); + goto retry; + } turn_fail_provider(turn, &step->result); return (-1); } blob - 355785fb1a00449bdf44276859d9a7f026b35e22 blob + c5ebdac298d608821223488cc2d5277a259f61ed --- src/common/turn_mechanics.h +++ src/common/turn_mechanics.h @@ -136,7 +136,7 @@ struct turn_result { enum generation_invalid_cause conclusion_invalid_cause; enum provider_error_category conclusion_provider_error_category; int conclusion_provider_content_accepted; - /* Completed Generations plus nested usage reported by execute(). */ + /* Completed Generations plus nested usage reported by Adapter callbacks. */ struct generation_usage usage; size_t data_used; size_t tool_batches; @@ -147,14 +147,19 @@ struct turn_result { }; /* - * 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. + * prepare_generation(), recover_context_overflow(), generate(), and execute() + * are blocking Adapter calls. The two preflight callbacks are optional, + * precede borrowing the Projection they may replace, and receive zeroed usage + * output for nested Provider work only. prepare_generation() runs before + * each prospective Generation except the immediate retry prepared by a + * successful recovery. 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; @@ -163,6 +168,10 @@ struct turn_adapter { void (*accept)(void *, struct msg *); void (*assistant_accepted)(void *, const struct turn_assistant_event *); enum turn_checkpoint (*checkpoint)(void *); + enum turn_io (*prepare_generation)(void *, enum turn_tools_mode, + struct generation_usage *, char *, size_t); + enum turn_io (*recover_context_overflow)(void *, enum turn_tools_mode, + struct generation_usage *, char *, size_t); enum turn_io (*generate)(void *, const struct turn_generation_request *, struct turn_generation_io *, char *, size_t);