ENG-1081: give the completion verifier room to answer (fixes the silent-stop root cause) - #277
Conversation
The verdict call is a forced tool call with max_tokens=256. Models that narrate before acting — the open-weight aliases MindsHub serves through Fireworks (mindshub_air/kimi, deepseek) — spend the whole budget on plain prose and never reach the tool call, so `generate_object_code` raises and the fail-safe converts it into a silent "task complete": the turn ends with no message and the agent looks like it died mid-task. Measured in prod Langfuse over 2026-07-14..07-27: 171 of 687 verdict calls returned no tool call (24.9%; 37.1% for external users), 145/147 of them on mindshub_air, every single one with completion_tokens == max_tokens exactly. Not new with ENG-716 — the old free-text verifier also passed max_tokens=256 and 21% of its verdicts were unparseable for the same reason; ENG-716 only changed the consequence from "keep working" to "stop silently". - Verifier budget 256 -> 2048, with one retry at 4096 when truncated. 2048 is measured: mindshub_air spent 1,654 output tokens even with the no-preamble instruction, so 1024 was not enough (1/3 at 1024, 3/3 at 2048). - Verifier prompt now asks for the tool call first, no preamble. Needed alongside the budget, not instead of it (0/3 at 256 even with it). - StructuredOutputError carries `truncated` so a blown budget is retryable while a genuine failure isn't. Detected by output-token count, because the gateway reports finish_reason "stop" at the cap for most aliases (ENG-1082); "length"/"max_tokens" are honoured when reported. - Truncation is logged distinctly from other verifier failures, which all logged identically as "verifier unavailable" before — the reason a 25% failure rate went unseen for 10 days. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review follow-up. If the truncation retry is itself truncated, the model won't fit a verdict at any budget we're willing to pay for — so a chronically-narrating model would otherwise cost 2048+4096 wasted output tokens on every turn instead of the old 256. Latch it after the first proof and stop buying the retry for the rest of the session; the first attempt still runs, so a transient truncation still recovers. Also drops a pointless f-string in the verifier prompt concatenation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adversarial self-reviewFive findings. Two fixed in 1. Anthropic-dialect truncation was missed — fixed (in
|
Second, harder adversarial pass on this PRThe first self-review found process-level issues. This one found a real defect and a design mistake in my own change, plus new measurements that weaken a claim in the PR body. Eight findings, ranked. #1 and #2 should be fixed before this merges. 1. Parity break — the sync twin still raises blind (defect + convention violation)
The sync path is the more exposed one: it's handed to model-generated scratchpad code via Per convention 7 ("if a new pattern is genuinely better, the same PR must include a migration plan for existing occurrences") this diff owes the migration, not a follow-up. Fix: move the classification into 2. The retry latch is the wrong shape — output length is stochastic, not per-modelI added Same payload, same config ( Output length varies 6.7x run to run for an identical request. So a single truncation past 4096 is a rare tail event, not a property of the model — and my latch responds to one tail sample by permanently disabling the retry for the whole session, where the cost of being wrong is a silent stop on every subsequent turn. Given the retry barely fires at 2048 anyway (0 of 12 truncations in the latest sample), I now think the right call is to drop the latch entirely — it optimizes a path that rarely runs, using evidence that doesn't support the inference. If it stays, it needs two consecutive proofs or a decay, not a one-shot permanent flip. 3. The PR body overstates the headroom at 2048The body says 4. The session tests don't actually test the detectionAll three session-level tests raise This is the ENG-747 lesson (never hand-build error fixtures; verify the whole chain) and I walked straight into it. Fix: one test with a fake provider returning prose-at-the-cap, through the real 5. Test fragility: the fake is coupled to the tool loop's call count
6. Latency: measured after the fact, should have been in the bodyRaising a token ceiling 8x invites "how much slower is the turn?" and I shipped without answering it. Measured now,
≈ +2–4s on narrating models, unchanged elsewhere (first-party models emit ~80 tokens regardless of the ceiling). Fine — but it belongs in the PR body, not in a reviewer's head. 7. Suppressing chain-of-thought may cost verdict accuracy — unmeasuredThe no-preamble clause tells CoT-trained models to skip the reasoning they were trained to emit. 12/12 runs returned the identical verdict ( 8. After the latch fires we still buy a doomed callIf the latch survives #2: it drops to one attempt at 2048, which we have proof can't fit — so it spends 2048 tokens per turn to fail. If we know it can't work, skip the verdict call and go straight to the fail-safe (or #276's honest message) rather than paying for a guaranteed failure. Verdict on my own PR: the core fix (budget + prompt, and the retry that makes the budget robust) is sound and evidenced. #1 is a genuine defect, #2 is a design mistake of mine that adds a new silent-failure risk, #4 means part of my test coverage is theatre. I'd fix 1, 2, 4, 5 and re-state 3 and 6 in the body before asking anyone to review this seriously. |
…NG-1081)
Adversarial-review follow-up. Four fixes:
1. The detection lived in `client.py`, so the sync twin in the scratchpad
subprocess (`_ScratchpadLLM.generate_object`) still raised a blind
`ValueError("LLM did not return structured output.")` — the same silent,
causeless failure this branch set out to remove, on the path that is
handed to model-written scratchpad code with a caller-chosen max_tokens.
`structured.py` exists to keep those two paths in lockstep, so the
classifier moves there as `raise_missing_tool_call` and both call it.
2. Drop the per-session retry latch. It assumed a double truncation proves
the model can never fit a verdict. Measured: 16 identical verdict calls to
mindshub_air spent 245..1654 output tokens — a 6.7x spread for the same
request — so one truncation is a tail sample, not proof about the next
turn, and latching the retry off on that evidence would reintroduce silent
stops. The retry is cheap because it only fires when the first attempt
truncates (0 of 12 at 2048).
3. The session tests hand-built StructuredOutputError, so they would have
passed even with the detection broken. Added an end-to-end test wiring a
fake provider through the real LLMClient into the session; verified by
mutation (breaking the classifier fails it, and 4 others).
4. The plan_stream fake alternated on `call_count % 2`, coupling it to the
tool loop's internal call count; a third call in a turn would desync it
and the test would assert the wrong thing. Now keyed off turn state.
Also records the measured distribution next to the budget constant, so the
2048 number carries its evidence (median ~290, max 1654, ~1.24x headroom)
instead of a single sample.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings addressed —
|
| # | Finding | Outcome |
|---|---|---|
| 1 | Sync twin still raised blind | fixed |
| 2 | Retry latch built on stochastic evidence | fixed (latch removed) |
| 3 | PR body overstated the 2048 headroom | fixed (body rewritten) |
| 4 | Session tests didn't test the detection | fixed (+ mutation-verified) |
| 5 | Test fake coupled to internal call count | fixed |
| 6 | Latency unmeasured | fixed (measured, in body) |
| 7 | Verdict accuracy under CoT suppression | not fixed — follow-up, needs a labelled set |
| 8 | Doomed call after the latch | moot — latch is gone |
1. Shared classifier (the actual defect)
Detection moved out of client.py into structured.py as raise_missing_tool_call, called by both the async LLMClient._generate_object_with and the sync _ScratchpadLLM.generate_object (scratchpad_boot.py). The blind ValueError("LLM did not return structured output.") is gone. That path is the one handed to model-written scratchpad code with a caller-chosen max_tokens, so it was the more exposed of the two. A test now asserts the sync path uses the shared classifier and that the old raise is absent, so the parity structured.py exists to guarantee is enforced rather than assumed.
2. Latch removed
It assumed a double truncation proves the model can never fit a verdict. The measurement says otherwise — 16 identical calls spent 245…1654 output tokens, a 6.7x spread for the same request. One truncation is a tail sample, so latching the retry off would have reintroduced silent stops on later turns for a model that usually succeeds. Deleted rather than tuned: the retry only fires when the first attempt truncates (0 of 12 at 2048), so there was little to optimise. The reasoning now sits in a comment next to the budget constant so nobody re-adds it.
4. The end-to-end test, mutation-verified
New test_truncation_retry_through_the_real_client wires a fake provider through the real LLMClient into the session, so provider → client → session is the real code path. Proof it has teeth — breaking the classifier (truncated = False) fails it:
FAILED test_no_tool_call_at_the_cap_is_reported_as_truncated
FAILED test_both_provider_dialects_for_truncation[length]
FAILED test_both_provider_dialects_for_truncation[max_tokens]
FAILED test_stop_reason_length_is_honoured_below_the_cap
FAILED test_truncation_retry_through_the_real_client <-- the new one
5 failed, 5 passed
Also added a defensive test that a response with no usage doesn't blow up the classifier (it reports truncated=False — the safe direction, no retry bought without evidence).
Testing after these changes
12 tests in the file; full unit suite 1326 passed, 17 skipped, with the same 2 pre-existing test_chat_scratchpad failures that are present on clean staging. tests/e2e/scenarios/test_loop_safety.py 6 passed.
Still open
#7 — the no-preamble clause suppresses chain-of-thought on models trained to emit it. 12/12 identical verdicts shows stability on one scenario, not accuracy across scenarios. Measuring that properly needs a labelled set of turns run with and without the clause, per model (same shape as the ENG-381 ground-truth fixtures) — too big for this PR, and it doesn't gate the fix: today those models return no verdict at all, so any correct-ish verdict is an improvement. Worth its own ticket if you want it tracked.
… (ENG-1081) Third review pass found the same bug behind a second door. The budget can run out *inside* the forced tool call's JSON, not only in the prose before it. That arrives as a non-empty but incomplete tool call — safe_parse_tool_input repairs what it can and sets parse_error — so the missing-call check never sees it, unwrap_structured_response raises a bare ValidationError, the verifier's `except Exception` treats it as a hard failure, skips the retry, and lands on exactly the silent-stop fail-safe this branch exists to remove. Given output length varies ~6.7x per call, a model that narrates ~2000 tokens and then starts emitting the call is precisely the case that lands here. - `looks_truncated` extracted so the same evidence rule covers both shapes. - `raise_missing_tool_call` -> `raise_unusable_tool_call`, message adapts to "did not return" vs "returned an unusable", and now typed NoReturn. - Both the async client and the scratchpad's sync twin classify a failed unwrap as truncation *only* when the response shows it; a malformed call with budget to spare still propagates as a validation error, so genuine schema bugs stay visible instead of buying a hopeless retry. - Parity test upgraded from a substring grep (which a comment could satisfy) to an AST check that the call really is inside the sync `generate_object`. Tests: 15 in the file, mutation-verified — breaking `looks_truncated` fails 7 of them, including both end-to-end shapes. Full unit suite 1329 passed with the same 2 pre-existing scratchpad-exec failures; all 36 e2e scenarios pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third pass — found the same bug behind a second door (
|
…error cause Tool-assisted review pass. - The e2e stub could not express a truncated response (usage.completion_tokens was hardcoded to 10), so the retry had no coverage on the real subprocess path. `_Response.output_tokens` + `queue_verification_truncated()` emulate what the gateway actually returns — prose, no tool call, completion_tokens at the cap, and `finish_reason: "stop"` anyway (ENG-1082). New scenario asserts the session makes a second verdict call with a larger budget and honours the retried verdict instead of stopping silently. - The verifier's generic `except Exception` logged a bare `verdict=ERROR` with no cause — the same undiagnosable swallow this branch exists to fix, and the same thing I flagged on #276. Now logs the exception type and message. Full unit suite 1329 passed; all 37 e2e scenarios pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@torrmal review request — this is the other half of ENG-1079, so a quick orientation on how it relates to your #276. Short version: your diagnosis was right, but the trigger sits one layer below where the ticket points. The verdict call asks for a forced tool call with Two things that may change how you read the ticket:
Merge order — this one first, then #276. Not stacked; both branch from One thing to carry into #276: the truncation check can't use Where I'd focus your review:
I've also left a review on #276. One confirmed item there: the stale-reply history bug you fixed for the new path still exists on the Follow-ups already filed: ENG-1082 (gateway |
pnewsam
left a comment
There was a problem hiding this comment.
Code Review
Verdict: REQUEST_CHANGES
The truncation recovery itself is well designed and thoroughly tested, but the new generic verifier error log can write model-generated conversation data or provider response details into ordinary application logs. That privacy risk should be removed before merge.
Summary of findings
| Severity | File | Issue |
|---|---|---|
| Major | anton/core/session.py:2746 |
Logging an arbitrary exception message can expose conversation content or PII. |
What's working well
The retry is bounded, truncation detection covers both provider stop-reason dialects, the sync and async structured-output paths share the classifier, and the provider-to-session behavior has meaningful unit and subprocess-level coverage.
Reviewed by Codex · alejandrocantu/eng-1081-completion-verifiers-256-token-ceiling-truncates-narrating → staging · 2026-07-27
| # that mistake for whatever comes next. | ||
| _verifier_log.info( | ||
| "completion-verifier verdict=ERROR budget=%d error=%s: %s", | ||
| budget, type(exc).__name__, exc, |
There was a problem hiding this comment.
Major: Avoid logging str(exc) for arbitrary verifier failures.
Pydantic validation errors embed the rejected input_value, which here is model-generated verdict data derived from the user's conversation. Provider exceptions may similarly include response bodies. This can copy conversation content or PII into ordinary application logs and contradicts the PR's security claim that the new logging contains only token metadata.
Please log safe structured fields only—such as exception type and validation locations/error codes—without including rejected inputs or arbitrary exception messages.
There was a problem hiding this comment.
Agreed, and thank you — this was a real leak and I introduced it while arguing the opposite case on #276 (that a bare verdict=ERROR hides the cause). Fixed in 9122e65.
_safe_error_detail(exc) now emits the exception type plus, for validation errors, only the failing field locations and error codes — errors(include_input=False, include_url=False, include_context=False), and it reads only loc/type even on the pydantic-v1 fallback, so msg/input/ctx can never be quoted. Provider errors reduce to type + status_code. So ValidationError(status:literal_error) instead of a message containing the rejected verdict text.
Your finding also applies to a line I was not touching, and more broadly. The pre-existing summary log at session.py:2794 wrote reason=%s — the model's free-text justification of the verdict, derived from the user's conversation — on every verdict, not just failures. Same exposure class, larger volume. I dropped it in the same commit: status and counters stay (that's what makes the verdict distribution measurable), and the reason is still in the Langfuse trace, where conversation content already lives under the appropriate controls. Shout if you would rather that were a separate change — it is a pre-existing line and I did widen the diff to include it.
Regression test plants a recognisable value in the rejected input and asserts it never reaches the log detail.
|
|
||
| Raises: | ||
| StructuredOutputError: Always. ``.truncated`` carries the verdict. | ||
| """ |
There was a problem hiding this comment.
@alecantu7 Some of these comments are getting very long -- maybe worth a quick prompt to shorten wherever possible?
There was a problem hiding this comment.
Fair, and the comments were long. Trimmed across the diff in 9122e65 — structured.py, the budget/prompt constants in session.py, client.py and scratchpad_boot.py — same facts in roughly half the lines. The 17-number token distribution is now one line rather than a block, and the "why no latch" rationale is one sentence.
I kept the numbers that justify the constants (2048 vs 1024, the 6.7x spread) because they are the load-bearing part — without them the next reader has no way to tell 2048 from a guess, and it was originally 1024 until measurement said otherwise. Happy to cut further if any specific block still reads as too much.
@pnewsam, Major on #277: logging `str(exc)` can copy conversation content into ordinary application logs. Correct — a pydantic ValidationError embeds the rejected `input_value`, which for the verifier is model-generated text derived from the user's conversation, and provider exceptions can carry response bodies. It also contradicted this PR's own security claim. - `_safe_error_detail(exc)` emits the exception type plus, for validation errors, the failing field locations and error codes only — via `errors(include_input=False, include_url=False, include_context=False)`, and reading only `loc`/`type` even on the v1 fallback, so `msg`/`input`/`ctx` can never be quoted. Provider errors reduce to type + status_code. - Same leak class found in a pre-existing line: the summary log wrote `reason=%s`, the model's free-text justification, on EVERY verdict — a broader exposure than the one reported. Dropped; status and counters stay, and the reason remains in the Langfuse trace where conversation content belongs. - 3 regression tests, including one asserting a planted value never reaches the log detail. Also shortened the long comment blocks flagged in review (structured.py, session.py budget/prompt constants, client.py, scratchpad_boot.py) — same facts, roughly half the lines. 1332 unit tests pass; all 37 e2e scenarios pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Confidence Score: 5/5 - Safe to MergeSafe to merge — safe to merge. Key Findings:
|
Review addressed —
|
| model | today at 256 | with the fix |
|---|---|---|
| MindsHub Air | no tool call (capped) | WAITING in 313t |
| Qwen3.7 Plus | no tool call (capped) | WAITING in 1054t |
| DeepSeek V4 Pro | no tool call (capped) | WAITING in 272t |
| GPT 5.3 Codex | tool call but unparseable args | WAITING in 181t |
Two things worth noting for the review:
- GPT 5.3 Codex is the mid-arguments truncation case in the wild — a tool call whose JSON is cut off at exactly 256 tokens. That's the specific hole the third commit on this branch closed; without it Codex stays broken. (New since this morning: the catalog now applies a default reasoning effort to Codex, and reasoning tokens eat the budget.)
- Qwen needed 1054 tokens, independently confirming that
1024would have been too small — I'd argued that from Kimi's 1,654 alone. Max across the whole catalog is 1054, so 2048 has ~1.9x headroom. - GLM 5.2 passes today at exactly 256 tokens — a latent narrator that happens to fit, and would start failing on any longer transcript.
Three models this PR does not fix, all outside its mechanism (capability, not budget):
- Kimi K3 →
400 "tool_choice 'specified' is incompatible with thinking enabled"— forced tool choice rejected outright. - Muse Spark 1.1 →
400 "named tool_choice is not supported". - Claude Fable 5 → returns
arguments: "{}"in 8 tokens, identical at 256 and 2048; works with a simplified schema, so something about the real_VerifierVerdictschema (likely its long enum descriptions) defeats it.
For those three the verifier lands on the fail-safe, which is a concrete argument for landing #276 after this one rather than dropping it. Filing them separately — they need their own fix and no open PR covers them.
pnewsam
left a comment
There was a problem hiding this comment.
Code Review
Verdict: APPROVE
The previous privacy finding is resolved. Verifier failures now use safe structured error details without logging arbitrary exception messages or model-generated justification text, and the regression tests explicitly cover rejected values, provider response bodies, and the generic fallback. I found no remaining blocking issues.
Summary of findings
No issues found. Code looks good to merge.
What's working well
The truncation retry remains bounded and evidence-based, sync and async structured-output paths share the classifier, provider-to-session behavior is covered end to end, and all required CI and CodeQL checks are green.
Reviewed by Codex · alejandrocantu/eng-1081-completion-verifiers-256-token-ceiling-truncates-narrating → staging · 2026-07-27
|
Retargeted at `main` as a hotfix in #278 (team convention for hotfixes: PR to main, then merge main back into staging, then close the staging PR). Same 6 commits cherry-picked onto `origin/main` — zero conflicts, and the resulting files are hash-identical to this branch, so #278 is byte-for-byte this change rather than a re-implementation. All files touched here are identical between `main` and `staging` (the two branches differ in exactly one unrelated file), and on the main base it is 1334 unit tests / 0 failures plus 37/37 e2e scenarios. Leaving this open until #278 lands, then closing it as superseded once the main→staging sync brings the content into staging. Note the automatic sync-back (`staging-unfreeze.yml`) is gated on `head.ref == 'staging'`, so it will not fire for the hotfix — that merge needs doing by hand. @pnewsam your privacy fix is included (`9122e65`, cherry-picked as `058f705`). This PR remains the review of record — the evidence, the token distribution behind 2048, and the 19-model matrix all live in this thread. |
It runs inside the verifier's `except` handler, so an exception escaping it would convert a gracefully-handled verifier failure into a dead turn — the exact failure mode this branch exists to remove. A custom exception can expose `status_code`/`errors` as a property that raises, so the attribute reads are wrapped too. Degrades to the exception type name, never a crash and never the message. Found while assessing hotfix risk for #278. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Superseded by #278, which is merged and released as Provenance: #278 is the 7 commits from this branch cherry-picked onto This thread stays the review of record for the change: the prod measurement (171/687 verdicts, 98.6% on
@torrmal for #276: rebase onto staging after #279 lands, so the ceiling fix is underneath the honest-diagnosis fail-safe. My review notes on #276 (including the reproduced stale-reply duplication on the STUCK and budget-exhausted paths) are still open. |
What & why
The completion verifier asks for its verdict as a forced tool call with
max_tokens=256. Models that narrate before acting spend the entire budget on plain prose and never reach the call —generate_object_coderaises, and the fail-safe converts that into a silentCOMPLETE. The turn ends with no message at all, which is what users report as "Anton dies on the first hurdle, even after restarting".Measured in prod Langfuse (2026-07-14 → 07-27; all 20,976
Chat Completions Handler v1generations scanned, 1,241 verifier calls identified by system prompt and split by protocol):mindshub_air(98.6%), and every single failure hascompletion_tokens == max_tokensexactly, with ~1,300 characters of reasoning prose cut off mid-sentence.This is not a regression from ENG-716. The old free-text verifier passed
max_tokens=256too, and 116/554 (21%) of its verdicts were unparseable for the same reason (sampled 8/8 sitting exactly at the cap). ENG-716 changed the consequence of truncation — fromINCOMPLETE/"keep working" toexcept/silent stop — not its frequency. It also isn't a model-capability problem: all nine non-narrating aliases, cheap ones included, answer correctly at 256.Evidence
Identical verifier payload (real system prompt + real
_VerifierVerdictschema, synthetic transcript) against prodapi.mindshub.ai, all 14 aliases:max_tokens=256The nine that work are first-party APIs where
tool_choiceis enforced structurally — the model cannot emit anything before the call. The three that fail are open-weight chain-of-thought models on Fireworks, wheretool_choiceis advisory and the narration lands in ordinarycontent, eating the same budget.mindshub_airnarrates even on"say ok".The fix numbers come from a measured distribution, not one sample.
mindshub_air, identical verdict payload:256(current)256+ no-preamble prompt1024alone2048+ no-preambleAcross 16 runs at 2048 + no-preamble the output length was:
Two things follow.
1024was genuinely too small (a 1,654-token run would blow it). And 2048 is only ~1.24x the observed worst case, so the headroom is thinner than a single number suggests — which is why the4096retry exists rather than one larger budget, and why there is deliberately no "this model can't do it" latch: with a 6.7x per-call spread, one truncation is a tail sample, not proof about the next turn. The retry stays cheap because it only fires when the first attempt truncates — 0 of 12 at 2048. All three narrating models (kimi, mindshub_air, deepseek) pass at 2048 + no-preamble.Latency (the fair question when raising a ceiling 8x),
mindshub_airon the verifier payload: 4.8–8.2s at 2048 + no-preamble vs 3.4–5.4s at 256 — so ~+2–4s on the narrating models, and unchanged everywhere else (first-party models emit 43–115 tokens regardless of the ceiling). Today's 256 calls spend that time and return nothing.What changed
_VerifierVerdict.__name__so it can't go stale.StructuredOutputError(aValueErrorsubclass, so existing catchers are unaffected) carriestruncated,output_tokens,max_tokens,stop_reason— so a blown budget is retried while a genuine failure isn't charged a pointless second call.finish_reason: the gateway returns"stop"while handing back exactlymax_tokensfor 9 of 11 aliases (ENG-1082)."length"and Anthropic's"max_tokens"are honoured when reported, so BYOK-direct users are covered too.reason="verifier unavailable"— the reason a 25% failure rate went unseen for 10 days. The rate is now measurable from logs alone.Testing
tests/test_verifier_truncation.py— 12 new tests: prose-at-the-cap is reported truncated even when the gateway says"stop"; both provider dialects ("length"/"max_tokens") count as truncation; a short empty response is not truncated (so it isn't retried); a truncated verdict is retried exactly once with the larger budget and the retried verdict is the one that counts; a non-truncated failure costs exactly one call; the no-preamble instruction reaches the model.tests/test_chat_scratchpad.pyscratchpad-exec) are identical on cleanstaging— verified by stashing this diff and re-running. Pre-existing env issue, not this change.tests/e2e/scenarios/test_loop_safety.py— 6 passed, including ENG-716's WAITING test.Relationship to #276 (ENG-1079)
Complementary, not competing — and this one should land first or together. #276 replaces the silent stop with an honest "the check failed, how would you like to proceed?" message. That's the right last-resort behavior and still needed for the failures a budget can't fix (a disabled model 403, a provider 500, malformed args — see ENG-1083). But at ~99% failure on
mindshub_air, #276 alone would show that message to those users on nearly every turn. With the ceiling fixed, it stays the rare fallback it's designed to be.Both touch the same
try/exceptaround the verdict call, so expect a small conflict. Deliberately not stacked: this branches fromstaging. Whichever merges second resolves — this PR's version keeps the fail-safe in anelse:branch, so #276's honest-diagnosis body drops into it directly.Security
Reviewed the diff: no new inputs, endpoints, auth paths or deserialization. The new exception and log line carry only token counts, a budget, and a
stop_reason— no conversation content, no credentials. The verifier's LLM payload is unchanged in content (only its token budget and one instruction line). Raising the budget cannot leak anything the same call didn't already send.Closes ENG-1081.