commit c46c1d5a37ed2a2f3626a2b10708eab0864e4795 from: Isaac Meerleo date: Fri Jul 17 21:51:57 2026 UTC Preserve typed context overflow errors commit - 4a93e0de0e87b979672e0c5ca321485d1ee4a16f commit + c46c1d5a37ed2a2f3626a2b10708eab0864e4795 blob - ce34704401519986ee51a3cb2370b366b5ff2d8e blob + 0752a8746db3bf46705bf8d2ad79828b4509338e --- regress/anthropic/anthropic_test.c +++ regress/anthropic/anthropic_test.c @@ -432,6 +432,7 @@ struct capture { char tool_name[64]; char stop[64]; char err_type[64]; + enum provider_error_category error_category; int n_text, n_toolbegin, n_usage, n_done, n_error; int64_t in_tokens, out_tokens, cache_read, cache_write; }; @@ -471,6 +472,7 @@ on_event(const struct astream_event *ev, void *arg) break; case ASTREAM_ERROR: strlcpy(c->err_type, ev->err_type, sizeof(c->err_type)); + c->error_category = ev->error_category; c->n_error++; break; } @@ -596,6 +598,7 @@ test_stream_error(void) CHECK(anthropic_stream_feed(s, doc, sizeof(doc) - 1) == -1); CHECK(c.n_error == 1); CHECK(strcmp(c.err_type, "overloaded_error") == 0); + CHECK(c.error_category == PROVIDER_ERROR_OTHER); CHECK(anthropic_stream_error(s)[0] != '\0'); /* the stream is terminal after an error */ CHECK(anthropic_stream_feed(s, doc, sizeof(doc) - 1) == -1); blob - 509f8d4740d072eea5ffd48d487799572573190a blob + 4dc039173c274085210510e34846c7c51a195b8d --- regress/generation/generation_test.c +++ regress/generation/generation_test.c @@ -395,12 +395,62 @@ test_usage_retry_and_provider_error(void) CHECK(generation_take(g, &r) == 0); CHECK(r.outcome == GENERATION_PROVIDER_ERROR && r.assistant == NULL && r.invalid_cause == GENERATION_INVALID_NONE && + r.provider_error_category == PROVIDER_ERROR_OTHER && + r.provider_content_accepted == 1 && r.synthesized_text.data == NULL && r.synthesized_text.len == 0 && r.data_used == 3 && strcmp(r.reason, "provider broke") == 0); generation_free(g); } static void +test_provider_error_category_and_content(void) +{ + struct generation *g; + struct generation_result r = {0}; + struct generation_usage usage = { 1, 2, 3, 4 }; + + /* Usage, retry notices, and empty deltas are not Provider content. */ + g = generation_new(1); + CHECK(generation_feed(g, GENERATION_USAGE, &usage, sizeof(usage), NULL) == + GENERATION_MORE); + CHECK(generation_feed(g, GENERATION_RETRY, "retry", 5, NULL) == + GENERATION_MORE); + CHECK(generation_feed(g, GENERATION_TEXT, NULL, 0, NULL) == + GENERATION_MORE); + CHECK(generation_feed(g, GENERATION_CONTEXT_OVERFLOW, "too large", 9, + NULL) == GENERATION_TERMINAL); + CHECK(generation_take(g, &r) == 0); + CHECK(r.outcome == GENERATION_PROVIDER_ERROR && + r.provider_error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW && + r.provider_content_accepted == 0 && r.data_used == 0 && + strcmp(r.reason, "too large") == 0); + generation_free(g); + + /* Any nonempty text accepted before the terminal is observable. */ + memset(&r, 0, sizeof(r)); + g = generation_new(1); + CHECK(generation_feed(g, GENERATION_TEXT, "x", 1, NULL) == + GENERATION_MORE); + CHECK(generation_feed(g, GENERATION_CONTEXT_OVERFLOW, "too large", 9, + NULL) == GENERATION_TERMINAL); + CHECK(generation_take(g, &r) == 0); + CHECK(r.provider_error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW && + r.provider_content_accepted == 1 && r.data_used == 1); + generation_free(g); + + /* Opening a Tool call itself counts, before any arguments arrive. */ + memset(&r, 0, sizeof(r)); + g = generation_new(0); + CHECK(feed_begin(g, 0, "call", "read") == GENERATION_MORE); + CHECK(generation_feed(g, GENERATION_ERROR, "provider broke", 14, NULL) == + GENERATION_TERMINAL); + CHECK(generation_take(g, &r) == 0); + CHECK(r.provider_error_category == PROVIDER_ERROR_OTHER && + r.provider_content_accepted == 1 && r.tool_calls == 1); + generation_free(g); +} + +static void test_usage_overflow_drains_to_terminal(void) { struct generation *g; @@ -773,6 +823,7 @@ main(void) test_malformed_arguments_are_never_accepted(); test_length_outcome_and_empty_placeholder(); test_usage_retry_and_provider_error(); + test_provider_error_category_and_content(); test_usage_overflow_drains_to_terminal(); test_usage_values_are_bounded(); test_data_budget_drains_to_terminal(); blob - 8804c1922526d689f55fa0461c77863d72cae22e blob + 6bb5f30365904a29172c625e2f2822b92cfc77cd --- regress/openai/openai_test.c +++ regress/openai/openai_test.c @@ -264,6 +264,7 @@ struct capture { char stop[64]; char err_type[64]; char err_message[128]; + enum provider_error_category error_category; int ndone; int nerror; int nusage; @@ -330,6 +331,7 @@ on_event(const struct astream_event *ev, void *arg) strlcpy(c->err_type, ev->err_type, sizeof(c->err_type)); strlcpy(c->err_message, ev->err_message, sizeof(c->err_message)); + c->error_category = ev->error_category; c->nerror++; break; } @@ -436,12 +438,56 @@ test_stream_error(void) CHECK(c.nerror == 1); CHECK(strcmp(c.err_type, "rate_limit_error") == 0); CHECK(strcmp(c.err_message, "slow down") == 0); + CHECK(c.error_category == PROVIDER_ERROR_OTHER); CHECK(openai_stream_error(s)[0] != '\0'); openai_stream_free(s); capture_free(&c); } static void +test_stream_context_overflow_category(void) +{ + static const char exact[] = + "data: {\"error\":{\"type\":\"invalid_request_error\"," + "\"code\":\"context_length_exceeded\"," + "\"message\":\"too many tokens\"}}\n\n"; + static const char message_only[] = + "data: {\"error\":{\"type\":\"invalid_request_error\"," + "\"code\":\"other\",\"message\":" + "\"context_length_exceeded\"}}\n\n"; + static const char near[] = + "data: {\"error\":{\"type\":\"invalid_request_error\"," + "\"code\":\"context_length_exceeded_later\"," + "\"message\":\"too many tokens\"}}\n\n"; + static const char numeric[] = + "data: {\"error\":{\"type\":\"invalid_request_error\"," + "\"code\":400,\"message\":\"still readable\"}}\n\n"; + const char *docs[] = { exact, message_only, near, numeric }; + size_t lens[] = { sizeof(exact) - 1, + sizeof(message_only) - 1, + sizeof(near) - 1, + sizeof(numeric) - 1 }; + enum provider_error_category want[] = { + PROVIDER_ERROR_CONTEXT_OVERFLOW, + PROVIDER_ERROR_OTHER, + PROVIDER_ERROR_OTHER, + PROVIDER_ERROR_OTHER + }; + struct openai_stream *s; + struct capture c; + size_t i; + + for (i = 0; i < sizeof(docs) / sizeof(docs[0]); i++) { + capture_init(&c); + s = openai_stream_new(on_event, &c); + CHECK(openai_stream_feed(s, docs[i], lens[i]) == -1); + CHECK(c.nerror == 1 && c.error_category == want[i]); + openai_stream_free(s); + capture_free(&c); + } +} + +static void test_stream_hostile(void) { static const char wrong_type[] = @@ -561,16 +607,43 @@ test_error_body(void) static const char good[] = "{\"error\":{\"message\":\"bad model\"," "\"type\":\"invalid_request_error\"}}"; + static const char overflow[] = + "{\"error\":{\"message\":\"too many tokens\"," + "\"type\":\"invalid_request_error\"," + "\"code\":\"context_length_exceeded\"}}"; + static const char near[] = + "{\"error\":{\"message\":\"context_length_exceeded\"," + "\"type\":\"invalid_request_error\"," + "\"code\":\"context_length_exceeded \"}}"; + static const char numeric[] = + "{\"error\":{\"message\":\"still readable\"," + "\"type\":\"invalid_request_error\",\"code\":400}}"; static const char nul[] = "{\"error\":{\"message\":\"bad\\u0000model\"," "\"type\":\"invalid_request_error\"}}"; + enum provider_error_category category; char out[128]; - CHECK(openai_error_body(good, sizeof(good) - 1, out, - sizeof(out)) == 0); + CHECK(openai_error_body(good, sizeof(good) - 1, 400, out, + sizeof(out), &category) == 0); CHECK(strcmp(out, "invalid_request_error: bad model") == 0); - CHECK(openai_error_body(nul, sizeof(nul) - 1, out, - sizeof(out)) == -1); + CHECK(category == PROVIDER_ERROR_OTHER); + CHECK(openai_error_body(overflow, sizeof(overflow) - 1, 400, out, + sizeof(out), &category) == 0); + CHECK(category == PROVIDER_ERROR_CONTEXT_OVERFLOW); + CHECK(openai_error_body(overflow, sizeof(overflow) - 1, 413, out, + sizeof(out), &category) == 0); + CHECK(category == PROVIDER_ERROR_OTHER); + CHECK(openai_error_body(near, sizeof(near) - 1, 400, out, + sizeof(out), &category) == 0); + CHECK(category == PROVIDER_ERROR_OTHER); + CHECK(openai_error_body(numeric, sizeof(numeric) - 1, 400, out, + sizeof(out), &category) == 0); + CHECK(category == PROVIDER_ERROR_OTHER && + strcmp(out, "invalid_request_error: still readable") == 0); + CHECK(openai_error_body(nul, sizeof(nul) - 1, 400, out, + sizeof(out), &category) == -1); + CHECK(category == PROVIDER_ERROR_OTHER); } static void @@ -604,6 +677,7 @@ main(void) test_stream_whole(); test_stream_bytewise(); test_stream_error(); + test_stream_context_overflow_category(); test_stream_hostile(); test_stream_bad_call_arguments(); test_error_body(); blob - 3aafeed64c4a0d64a3cfe44ea0bac160be1d9bed blob + e492b090e91a1319c6bc4b95fcd6f020d9306a1a --- regress/turn_mechanics/turn_mechanics_test.c +++ regress/turn_mechanics/turn_mechanics_test.c @@ -233,6 +233,12 @@ script_provider_error(struct scripted_generation *scri } static void +script_context_overflow(struct scripted_generation *script, const char *error) +{ + script_bytes(script, GENERATION_CONTEXT_OVERFLOW, error, strlen(error)); +} + +static void script_usage(struct scripted_generation *script, const struct generation_usage *usage) { @@ -1497,6 +1503,8 @@ test_subagent_conclusion_failure_preserves_fallback(vo result.invalid_cause == GENERATION_INVALID_NONE && result.conclusion_failure == TURN_FAILURE_PROVIDER && result.conclusion_invalid_cause == GENERATION_INVALID_NONE && + result.conclusion_provider_error_category == PROVIDER_ERROR_OTHER && + result.conclusion_provider_content_accepted == 0 && strcmp(result.conclusion_error, "conclusion down") == 0 && result.error[0] == '\0'); CHECK(result.assistant == fallback && result.data_used == 8 && @@ -1507,6 +1515,27 @@ test_subagent_conclusion_failure_preserves_fallback(vo f.request_tools[1] == TURN_TOOLS_DISABLED && message_count(&f.accepted) == 3); msglist_free(&f.accepted); + + /* A typed overflow is terminal here too; Subagents never recover it. */ + 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_context_overflow(generation, "conclusion too large"); + 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 && + result.provider_error_category == PROVIDER_ERROR_NONE && + result.conclusion_failure == TURN_FAILURE_PROVIDER && + result.conclusion_provider_error_category == + PROVIDER_ERROR_CONTEXT_OVERFLOW && + result.conclusion_provider_content_accepted == 0 && + strcmp(result.conclusion_error, "conclusion too large") == 0 && + f.generation_next == 2); + msglist_free(&f.accepted); } static void @@ -1716,6 +1745,8 @@ test_provider_and_adapter_errors_remain_distinct(void) 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 && + result.provider_error_category == PROVIDER_ERROR_OTHER && + result.provider_content_accepted == 1 && strcmp(result.error, "provider down") == 0 && result.assistant == NULL && f.sent_callbacks == 1 && f.accepts == 0 && f.nassistants == 0); @@ -1730,6 +1761,8 @@ test_provider_and_adapter_errors_remain_distinct(void) 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.provider_error_category == PROVIDER_ERROR_NONE && + result.provider_content_accepted == 0 && strcmp(result.error, "request serialization failed") == 0 && result.assistant == NULL && result.stop_reason[0] == '\0'); CHECK(f.generation_next == 1 && f.sent_callbacks == 0 && @@ -1739,6 +1772,42 @@ test_provider_and_adapter_errors_remain_distinct(void) } static void +test_context_overflow_projects_to_turn_result(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_context_overflow(generation, "request exceeds context window"); + 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.provider_error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW && + result.provider_content_accepted == 0 && result.data_used == 1 && + strcmp(result.error, "request exceeds context window") == 0); + msglist_free(&f.accepted); + + fixture_init(&f, &adapter, "q"); + generation = fixture_generation(&f, TURN_TOOLS_ENABLED); + script_text(generation, "partial"); + script_context_overflow(generation, "late overflow"); + 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.provider_error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW && + result.provider_content_accepted == 1 && result.data_used == 8 && + strcmp(result.error, "late overflow") == 0); + msglist_free(&f.accepted); +} + +static void test_out_of_order_results_are_accepted_in_call_order(void) { struct fixture f; @@ -1975,6 +2044,7 @@ main(void) 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_context_overflow_projects_to_turn_result(); 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 - 02a55dc2107eed0db4319e9bf5a3392133b70222 blob + a9a8f1675970a3f21c588b1745af99de9cf0f35d --- src/common/anthropic_stream.c +++ src/common/anthropic_stream.c @@ -194,6 +194,7 @@ handle_event(struct anthropic_stream *s, const char *t memset(&ev, 0, sizeof(ev)); ev.kind = ASTREAM_ERROR; + ev.error_category = PROVIDER_ERROR_OTHER; if ((eo = json_obj_get(&j, root, "error")) != -1) { (void)obj_ident(&j, eo, "type", &etype); (void)obj_ident(&j, eo, "message", &emsg); blob - beec2eba89ea957330a8f57292790d96aad1f3ff blob + 657b425de6e4b62054e4eba65a9a4652f22c0c16 --- src/common/generation.c +++ src/common/generation.c @@ -42,6 +42,7 @@ struct generation { struct msg *assistant; enum generation_outcome outcome; enum generation_invalid_cause invalid_cause; + enum provider_error_category provider_error_category; struct generation_span synthesized_text; char reason[GENERATION_ERROR_MAX]; char error[GENERATION_ERROR_MAX]; @@ -50,6 +51,7 @@ struct generation { int protocol; int taken; int synthesized; + int provider_content_accepted; }; static void @@ -201,7 +203,8 @@ generation_feed(struct generation *g, enum generation_ return (GENERATION_PROTOCOL); if (len > 0 && data == NULL) return (generation_protocol(g, "missing Generation payload")); - if (g->drain && kind != GENERATION_DONE && kind != GENERATION_ERROR) + if (g->drain && kind != GENERATION_DONE && kind != GENERATION_ERROR && + kind != GENERATION_CONTEXT_OVERFLOW) return (generation_drain_event(ev)); switch (kind) { @@ -216,6 +219,8 @@ generation_feed(struct generation *g, enum generation_ return (generation_drain_event(ev)); if (len > 0) buf_add(&g->text, data, len); + if (len > 0) + g->provider_content_accepted = 1; return (GENERATION_MORE); case GENERATION_TOOL_BEGIN: if (len < sizeof(index) + 3) @@ -257,6 +262,7 @@ generation_feed(struct generation *g, enum generation_ memcpy(call->name, p + 1, namelen); call->name[namelen] = '\0'; buf_init(&call->input); + g->provider_content_accepted = 1; return (GENERATION_MORE); case GENERATION_TOOL_INPUT: if (len < sizeof(index)) @@ -278,6 +284,8 @@ generation_feed(struct generation *g, enum generation_ if (fragmentlen > 0) buf_add(&call->input, (const u_char *)data + sizeof(index), fragmentlen); + if (fragmentlen > 0) + g->provider_content_accepted = 1; return (GENERATION_MORE); case GENERATION_USAGE: { struct generation_usage u; @@ -352,6 +360,7 @@ generation_feed(struct generation *g, enum generation_ generation_build_assistant(g); return (GENERATION_TERMINAL); case GENERATION_ERROR: + case GENERATION_CONTEXT_OVERFLOW: if (len == 0 || len >= GENERATION_ERROR_MAX || memchr(data, '\0', len) != NULL) return (generation_protocol(g, @@ -366,6 +375,8 @@ generation_feed(struct generation *g, enum generation_ memcpy(g->reason, data, len); g->reason[len] = '\0'; g->outcome = GENERATION_PROVIDER_ERROR; + g->provider_error_category = kind == GENERATION_CONTEXT_OVERFLOW ? + PROVIDER_ERROR_CONTEXT_OVERFLOW : PROVIDER_ERROR_OTHER; generation_set_error(g, g->reason); return (GENERATION_TERMINAL); default: @@ -386,6 +397,8 @@ generation_take(struct generation *g, struct generatio memset(r, 0, sizeof(*r)); r->outcome = g->outcome; r->invalid_cause = g->invalid_cause; + r->provider_error_category = g->provider_error_category; + r->provider_content_accepted = g->provider_content_accepted; r->assistant = g->assistant; r->usage = g->usage; r->data_used = g->data_used; blob - 769cff33015930a31fafa58fb25c6f572f327144 blob + 1286e1092a85361b6236930dcfbbad7c81b4547d --- src/common/generation.h +++ src/common/generation.h @@ -21,6 +21,8 @@ #include +#include "provider_error.h" + struct msg; struct generation; @@ -36,9 +38,9 @@ struct generation; /* * Canonical payload kinds after a Provider codec has decoded the wire. - * TEXT, RETRY, DONE, and ERROR carry their bytes directly. TOOL_BEGIN is - * `int index`, then `id\0name` (name is length-carried). TOOL_INPUT is - * `int index`, then one argument fragment. USAGE is exactly one + * TEXT, RETRY, DONE, ERROR, and CONTEXT_OVERFLOW carry their bytes directly. + * TOOL_BEGIN is `int index`, then `id\0name` (name is length-carried). + * TOOL_INPUT is `int index`, then one argument fragment. USAGE is exactly one * struct generation_usage. The native in-process representation is * deliberate: an Adapter, not this Module, owns any transport encoding. * Tool indices and ids are each unique within one Generation. @@ -50,14 +52,16 @@ enum generation_kind { GENERATION_USAGE, GENERATION_RETRY, GENERATION_DONE, - GENERATION_ERROR + GENERATION_ERROR, + GENERATION_CONTEXT_OVERFLOW }; /* * One feed either extends the Generation, ends it, or enters drain mode. * PROTOCOL makes the Generation unusable. DRAIN latches a local bound; - * callers continue feeding until the first valid DONE or ERROR, whose - * status is TERMINAL. No event is accepted after that terminal. + * callers continue feeding until the first valid DONE, ERROR, or + * CONTEXT_OVERFLOW, whose status is TERMINAL. No event is accepted after + * that terminal. */ enum generation_feed_status { GENERATION_PROTOCOL = -1, @@ -115,7 +119,9 @@ struct generation_event { * generation_take() transfers assistant to the caller. reason remains * borrowed from the Generation and is valid until generation_free(). * invalid_cause is non-NONE exactly when outcome is INVALID; an invalid - * result has no assistant. synthesized_text is non-empty only for the + * result has no assistant. provider_error_category is non-NONE only for a + * Provider error; provider_content_accepted reports text/tool content accepted + * before any outcome. synthesized_text is non-empty only for the * placeholder created by an otherwise-empty LENGTH outcome. It aliases * assistant storage and remains valid until that assistant is freed; its * bytes are already included in data_used. @@ -123,6 +129,8 @@ struct generation_event { struct generation_result { enum generation_outcome outcome; enum generation_invalid_cause invalid_cause; + enum provider_error_category provider_error_category; + int provider_content_accepted; struct msg *assistant; struct generation_usage usage; size_t data_used; blob - 668ad1f4bed9525859a8b9e5f17f69ceae7eb77c blob + e8480670c7decd387141a71a14cc8db6f4b56910 --- src/common/openai.h +++ src/common/openai.h @@ -48,7 +48,8 @@ int openai_stream_feed(struct openai_stream *, const void *, size_t); const char *openai_stream_error(const struct openai_stream *); -/* provider type/message out of a non-2xx HTTP error body */ -int openai_error_body(const void *, size_t, char *, size_t); +/* provider type/message and bounded category from a non-2xx HTTP error */ +int openai_error_body(const void *, size_t, int, char *, size_t, + enum provider_error_category *); #endif /* OPENAI_H */ blob - 8caeb21e0de0bab5898e68ef1ee22c71a3b47d6e blob + b302cf0dda281f2c4069498406b0bdd470b64406 --- src/common/openai_stream.c +++ src/common/openai_stream.c @@ -67,6 +67,18 @@ struct openai_stream { static int json_is_null_value(const struct json *, int); +static const char openai_context_code[] = "context_length_exceeded"; + +static enum provider_error_category +openai_error_category(int status, const char *code) +{ + /* status zero denotes an in-band error on a successful SSE response. */ + if (code != NULL && strcmp(code, openai_context_code) == 0 && + (status == 0 || status == 400)) + return (PROVIDER_ERROR_CONTEXT_OVERFLOW); + return (PROVIDER_ERROR_OTHER); +} + static void stream_fail(struct openai_stream *s, const char *msg) { @@ -365,9 +377,9 @@ static void handle_error(struct openai_stream *s, const struct json *j, int eo) { struct astream_event ev; - char *type = NULL, *message = NULL; - size_t typelen = 0, messagelen = 0; - int rt, rm; + char *type = NULL, *message = NULL, *code = NULL; + size_t typelen = 0, messagelen = 0, codelen = 0; + int rt, rm, rc; if (!json_is_object(j, eo)) { stream_fail(s, "bad provider error object"); @@ -375,9 +387,15 @@ handle_error(struct openai_stream *s, const struct jso } rt = obj_ident(j, eo, "type", &type, &typelen); rm = obj_ident(j, eo, "message", &message, &messagelen); + rc = obj_ident(j, eo, "code", &code, &codelen); + if (rc < 0 || codelen >= 128) { + free(code); + code = NULL; + } if (rt < 0 || rm < 0 || typelen >= 128 || messagelen >= 1024) { free(type); free(message); + free(code); stream_fail(s, "NUL in provider error"); return; } @@ -385,9 +403,11 @@ handle_error(struct openai_stream *s, const struct jso ev.kind = ASTREAM_ERROR; ev.err_type = type != NULL ? type : "error"; ev.err_message = message != NULL ? message : ""; + ev.error_category = openai_error_category(0, code); s->cb(&ev, s->arg); free(type); free(message); + free(code); stream_fail(s, "provider error event"); } @@ -535,12 +555,18 @@ openai_stream_feed(struct openai_stream *s, const void } int -openai_error_body(const void *data, size_t len, char *out, size_t outsz) +openai_error_body(const void *data, size_t len, int status, char *out, + size_t outsz, enum provider_error_category *category) { struct json j; - char *type = NULL, *message = NULL; - int root, eo, rt, rm; + char *type = NULL, *message = NULL, *code = NULL; + size_t codelen = 0; + int root, eo, rt, rm, rc; + if (category == NULL) + return (-1); + *category = PROVIDER_ERROR_OTHER; + if (json_parse(&j, data, len, 0) != 0) return (-1); root = json_root(&j); @@ -552,17 +578,25 @@ openai_error_body(const void *data, size_t len, char * } rt = obj_ident(&j, eo, "type", &type, NULL); rm = obj_ident(&j, eo, "message", &message, NULL); + rc = obj_ident(&j, eo, "code", &code, &codelen); json_done(&j); + if (rc < 0 || codelen >= 128) { + free(code); + code = NULL; + } if (rt < 0 || rm < 0 || (type == NULL && message == NULL)) { free(type); free(message); + free(code); return (-1); } + *category = openai_error_category(status, code); (void)snprintf(out, outsz, "%s%s%s", type != NULL ? type : "error", message != NULL ? ": " : "", message != NULL ? message : ""); free(type); free(message); + free(code); return (0); } blob - 4b18cf7bfde90367ee9d33c05d9b6007a95e9262 blob + fc88c006923fe1a11ce82ffcb119408d10f00761 --- src/common/provider_stream.h +++ src/common/provider_stream.h @@ -21,6 +21,8 @@ #include +#include "provider_error.h" + /* * Provider-neutral events emitted by each wire decoder. They are the * credential custodian's length-carried translation seam: the Coordinator @@ -57,6 +59,7 @@ struct astream_event { const char *err_type; /* ERROR */ const char *err_message; + enum provider_error_category error_category; }; typedef void (*astream_cb)(const struct astream_event *, void *); blob - /dev/null blob + a0fcc634ea26b7a54dd71c3259ea621b6971e07b (mode 644) --- /dev/null +++ src/common/provider_error.h @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Isaac + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +#ifndef PROVIDER_ERROR_H +#define PROVIDER_ERROR_H + +/* + * A bounded, provider-neutral failure classification. OTHER retains the + * existing terminal Provider-error behavior; CONTEXT_OVERFLOW is reserved + * for an exact structured signal recognized by a Provider Adapter. + */ +enum provider_error_category { + PROVIDER_ERROR_NONE = 0, + PROVIDER_ERROR_OTHER, + PROVIDER_ERROR_CONTEXT_OVERFLOW, + PROVIDER_ERROR_MAX +}; + +#endif /* PROVIDER_ERROR_H */ blob - 18f0d987fe56d92ab02525c5e9c8bf451f0f7272 blob + eb43c47a6b721daed18e537eccde0b3a9d378c29 --- src/common/turn_mechanics.c +++ src/common/turn_mechanics.c @@ -111,6 +111,8 @@ turn_fail(struct turn_state *turn, enum turn_failure f turn->result->status = TURN_STATUS_FAILED; turn->result->failure = failure; turn->result->invalid_cause = invalid_cause; + turn->result->provider_error_category = PROVIDER_ERROR_NONE; + turn->result->provider_content_accepted = 0; turn->result->assistant = NULL; turn->result->stop_reason[0] = '\0'; turn_result_account(turn); @@ -118,16 +120,31 @@ turn_fail(struct turn_state *turn, enum turn_failure f } static void +turn_fail_provider(struct turn_state *turn, + const struct generation_result *result) +{ + turn_fail(turn, TURN_FAILURE_PROVIDER, GENERATION_INVALID_NONE, + result->reason); + turn->result->provider_error_category = result->provider_error_category; + turn->result->provider_content_accepted = + result->provider_content_accepted; +} + +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->provider_error_category = PROVIDER_ERROR_NONE; + turn->result->provider_content_accepted = 0; 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_provider_error_category = PROVIDER_ERROR_NONE; + turn->result->conclusion_provider_content_accepted = 0; turn->result->conclusion_error[0] = '\0'; turn_result_account(turn); } @@ -412,8 +429,7 @@ turn_generate(struct turn_state *turn, enum turn_tools return (-1); } if (step->result.outcome == GENERATION_PROVIDER_ERROR) { - turn_fail(turn, TURN_FAILURE_PROVIDER, GENERATION_INVALID_NONE, - step->result.reason); + turn_fail_provider(turn, &step->result); return (-1); } if (step->result.outcome == GENERATION_INVALID) { @@ -668,11 +684,17 @@ turn_conclude(struct turn_state *turn) failed: turn->result->conclusion_failure = turn->result->failure; turn->result->conclusion_invalid_cause = turn->result->invalid_cause; + turn->result->conclusion_provider_error_category = + turn->result->provider_error_category; + turn->result->conclusion_provider_content_accepted = + turn->result->provider_content_accepted; 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->provider_error_category = PROVIDER_ERROR_NONE; + turn->result->provider_content_accepted = 0; turn->result->assistant = fallback; turn_strerror(turn->result->stop_reason, sizeof(turn->result->stop_reason), stop_reason); blob - 78385af229953d731d1d02cccf6ca08726f3513f blob + 355785fb1a00449bdf44276859d9a7f026b35e22 --- src/common/turn_mechanics.h +++ src/common/turn_mechanics.h @@ -130,8 +130,12 @@ struct turn_result { enum turn_status status; enum turn_failure failure; enum generation_invalid_cause invalid_cause; + enum provider_error_category provider_error_category; + int provider_content_accepted; enum turn_failure conclusion_failure; 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(). */ struct generation_usage usage; size_t data_used; @@ -173,7 +177,8 @@ struct turn_adapter { * 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. + * conclusion_* describes that fallback. Provider category/content fields + * project the terminal Generation that caused the matching Provider failure. */ int turn_run(const struct turn_adapter *, const struct turn_options *, struct turn_result *); blob - 1bb802445df759a57bc8d38fc2c0dfa2fa850466 blob + e9856197ed488e1ee142c8e93a296cdf7922f96f --- src/fugu/coord.c +++ src/fugu/coord.c @@ -1290,6 +1290,9 @@ stream_event(struct coord *c, struct imsg *imsg) case FUGU_IMSG_A_ERROR: kind = GENERATION_ERROR; break; + case FUGU_IMSG_A_CONTEXT_OVERFLOW: + kind = GENERATION_CONTEXT_OVERFLOW; + break; default: fatalx("unexpected lead provider imsg %u", type); } @@ -1314,7 +1317,8 @@ stream_event(struct coord *c, struct imsg *imsg) * its standalone fail-closed Generation behavior. */ if (c->turn_generation_io != NULL && c->generation_protocol) { - if (kind == GENERATION_DONE || kind == GENERATION_ERROR) { + if (kind == GENERATION_DONE || kind == GENERATION_ERROR || + kind == GENERATION_CONTEXT_OVERFLOW) { c->generation_terminal = 1; c->wait = WAIT_NONE; event_loopexit(NULL); @@ -1332,7 +1336,8 @@ stream_event(struct coord *c, struct imsg *imsg) if (c->turn_generation_io != NULL) { c->generation_protocol = 1; - if (kind == GENERATION_DONE || kind == GENERATION_ERROR) { + if (kind == GENERATION_DONE || kind == GENERATION_ERROR || + kind == GENERATION_CONTEXT_OVERFLOW) { c->generation_terminal = 1; c->wait = WAIT_NONE; event_loopexit(NULL); @@ -1906,6 +1911,7 @@ allowed_from(enum fugu_role role, uint32_t type) case FUGU_IMSG_A_RETRY: case FUGU_IMSG_A_DONE: case FUGU_IMSG_A_ERROR: + case FUGU_IMSG_A_CONTEXT_OVERFLOW: case FUGU_IMSG_MODEL: case FUGU_IMSG_MODELS_END: return (role == ROLE_API); @@ -2012,7 +2018,8 @@ agent_provider_event(struct subagent *a, struct imsg * len = payload.len; if (!a->done) imsgev_send(&a->iev, type, 0, p, len); - if (type == FUGU_IMSG_A_DONE || type == FUGU_IMSG_A_ERROR) + if (type == FUGU_IMSG_A_DONE || type == FUGU_IMSG_A_ERROR || + type == FUGU_IMSG_A_CONTEXT_OVERFLOW) a->api_inflight = 0; if (a->coord->wait == WAIT_AGENTS && agents_settled(a->coord)) event_loopexit(NULL); @@ -2160,6 +2167,7 @@ coord_dispatch(int fd, short event, void *arg) case FUGU_IMSG_A_RETRY: case FUGU_IMSG_A_DONE: case FUGU_IMSG_A_ERROR: + case FUGU_IMSG_A_CONTEXT_OVERFLOW: if (imsg_get_id(&imsg) == coord->lead_api_id) stream_event(coord, &imsg); else { @@ -2167,7 +2175,8 @@ coord_dispatch(int fd, short event, void *arg) int terminal; terminal = type == FUGU_IMSG_A_DONE || - type == FUGU_IMSG_A_ERROR; + type == FUGU_IMSG_A_ERROR || + type == FUGU_IMSG_A_CONTEXT_OVERFLOW; if ((a = agent_by_api_id(coord, imsg_get_id(&imsg))) != NULL) agent_provider_event(a, &imsg); blob - 7b8578e1ec86394caa443f5a5995fcec73cd9bb9 blob + 17023b82816ee90f02871ec7e42960398f530609 --- src/fugu/proto.h +++ src/fugu/proto.h @@ -165,6 +165,7 @@ enum fugu_imsg { FUGU_IMSG_A_RETRY, /* transient failure, retrying: notice string */ FUGU_IMSG_A_DONE, /* stop_reason string; turn complete */ FUGU_IMSG_A_ERROR, /* provider/transport error string */ + FUGU_IMSG_A_CONTEXT_OVERFLOW, /* exact typed Provider overflow */ /* a tool call: coordinator -> fugu-tool, then results stream back. * The input JSON and the result are each chunked (16 KiB imsg blob - bf01efcf15f2cd29b2d1068e0e201b2362451e6a blob + bd840851d4e73fb8dd0e7e17a989c435b707833d --- src/fugu-api/main.c +++ src/fugu-api/main.c @@ -756,6 +756,8 @@ usage_add(struct a_usage *dst, const struct a_usage *s return (0); } +static void relay_error_category(struct api_request *, const char *, + enum provider_error_category); static void relay_error(struct api_request *, const char *); static void @@ -916,17 +918,22 @@ on_event(const struct astream_event *ev, void *arg) { char reason[FUGU_ERROR_MAX]; const char *parts[3]; + enum provider_error_category category; + /* Anthropic's shared invalid-request shape is not specific. */ + category = a->provs[r->slot].type == PROVIDER_OPENAI && + ev->error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW ? + PROVIDER_ERROR_CONTEXT_OVERFLOW : PROVIDER_ERROR_OTHER; parts[0] = ev->err_type != NULL ? ev->err_type : "error"; parts[1] = ev->err_message != NULL && ev->err_message[0] != '\0' ? ": " : ""; parts[2] = ev->err_message != NULL ? ev->err_message : ""; if (contains_secret_parts(a, parts, 3)) - relay_error(r, "\n"); + relay_error_category(r, "\n", category); else { (void)snprintf(reason, sizeof(reason), "%s%s%s", parts[0], parts[1], parts[2]); - relay_error(r, reason); + relay_error_category(r, reason, category); } } break; @@ -934,7 +941,8 @@ on_event(const struct astream_event *ev, void *arg) } static void -relay_error(struct api_request *r, const char *msg) +relay_error_category(struct api_request *r, const char *msg, + enum provider_error_category category) { struct api *a = r->api; /* CR/LF are forbidden in configured credentials. A single newline is @@ -950,11 +958,19 @@ relay_error(struct api_request *r, const char *msg) } else if (len >= FUGU_ERROR_MAX) len = FUGU_ERROR_MAX - 1; secret_streams_clear(r); - relay(r, FUGU_IMSG_A_ERROR, msg, len); + /* No unrecognized enum value is allowed onto the typed wire path. */ + relay(r, category == PROVIDER_ERROR_CONTEXT_OVERFLOW ? + FUGU_IMSG_A_CONTEXT_OVERFLOW : FUGU_IMSG_A_ERROR, msg, len); r->terminal = 1; } +static void +relay_error(struct api_request *r, const char *msg) +{ + relay_error_category(r, msg, PROVIDER_ERROR_OTHER); +} + /* * Provider requests are independent libevent state machines. The API role * remains the sole credential custodian, but a slow subagent connection no @@ -1240,6 +1256,15 @@ request_fail(struct api_request *r, const char *reason request_free(r); } +static void +request_fail_category(struct api_request *r, const char *reason, + enum provider_error_category category) +{ + if (!r->terminal) + relay_error_category(r, reason, category); + request_free(r); +} + /* Retryable failures are only connect/TLS, HTTP 429, and 5xx before a * content delta. Each request owns its timer, so retries remain concurrent. */ static void @@ -1286,13 +1311,14 @@ request_error_done(struct api_request *r) struct prov *v = &r->api->provs[r->slot]; char *raw, *reason; size_t rawsz = API_ERRBODY_MAX + 32; + enum provider_error_category category = PROVIDER_ERROR_OTHER; int retryable = r->retryable; int retry_after = r->retry_after; raw = xcalloc(rawsz, 1); if ((v->type == PROVIDER_OPENAI ? - openai_error_body(r->errbody.data, r->errbody.len, raw, - rawsz) : + openai_error_body(r->errbody.data, r->errbody.len, r->http.status, + raw, rawsz, &category) : anthropic_error_body(r->errbody.data, r->errbody.len, raw, rawsz)) == -1) (void)snprintf(raw, rawsz, "HTTP %d from provider", @@ -1305,7 +1331,7 @@ request_error_done(struct api_request *r) if (retryable) request_retry(r, reason, retry_after); else - request_fail(r, reason); + request_fail_category(r, reason, category); freezero(reason, strlen(reason)); freezero(raw, rawsz); } blob - 1c1397340c9773ece4b600e2a764f3320239b078 blob + 6fe07fe93ca07271f5c5411e2c7f1917583330b1 --- src/fugu-tool/agent.c +++ src/fugu-tool/agent.c @@ -363,6 +363,8 @@ agent_generation_kind(uint32_t type) return (GENERATION_DONE); case FUGU_IMSG_A_ERROR: return (GENERATION_ERROR); + case FUGU_IMSG_A_CONTEXT_OVERFLOW: + return (GENERATION_CONTEXT_OVERFLOW); default: fatalx("subagent unexpected imsg %u", type); } @@ -535,7 +537,8 @@ agent_generate(void *arg, const struct turn_generation kind = agent_generation_kind(type); if (protocol) { imsg_free(&imsg); - if (kind == GENERATION_DONE || kind == GENERATION_ERROR) + if (kind == GENERATION_DONE || kind == GENERATION_ERROR || + kind == GENERATION_CONTEXT_OVERFLOW) return (TURN_IO_ERROR); continue; } @@ -556,7 +559,8 @@ agent_generate(void *arg, const struct turn_generation if (status == GENERATION_PROTOCOL) { protocol = 1; imsg_free(&imsg); - if (kind == GENERATION_DONE || kind == GENERATION_ERROR) + if (kind == GENERATION_DONE || kind == GENERATION_ERROR || + kind == GENERATION_CONTEXT_OVERFLOW) return (TURN_IO_ERROR); continue; }