Skip to content

fix(agent): recover zero-token empty responses - #4174

Closed
chulmin-dev wants to merge 2 commits into
Yeachan-Heo:devfrom
chulmin-dev:fix/opencode-go-empty-stop
Closed

fix(agent): recover zero-token empty responses#4174
chulmin-dev wants to merge 2 commits into
Yeachan-Heo:devfrom
chulmin-dev:fix/opencode-go-empty-stop

Conversation

@chulmin-dev

@chulmin-dev chulmin-dev commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What

Treat one provider-specific impossible-success shape as a typed empty_response failure:

  • provider is opencode-go on the Responses API;
  • a completed response id exists;
  • output items and assistant content are empty;
  • every usage counter is zero.

The OpenCode Go adapter stamps the typed fact at the completion boundary. Existing policy then:

  • discards managed provisional output and advances the configured fallback chain;
  • retries a clean single-model scope within retry.maxRetries;
  • respects visible-progress, dirty/missing-scope, opt-out, and exhaustion gates.

Generic untyped zero-token empty stops retain existing context-overflow precedence and therefore continue through promotion/compaction rather than retry policy.

Why

OpenCode Go Grok 4.5 returned completed Responses objects with an id but no output items or usage during a critic run on GJC 0.12.21. GJC accepted those turns as success, allowing a load-bearing reviewer lane to stop without a verdict.

This is separate from the reserved web_search collision fixed by #4106.

The first revision classified every zero-token empty stop as empty_response. Maintainer review correctly identified that proxy/LiteLLM context overflows share that shape. The repaired revision moves evidence to the OpenCode Go adapter, gives generic overflow classification precedence, and adds a session regression proving untyped proxy empties still promote instead of retrying.

Testing

  • 145 focused tests across Responses provider facts, fallback classification, agent-loop conversion, bounded retry, managed fallback, and context promotion — pass
  • bun --cwd=packages/ai run check:types — pass
  • bun --cwd=packages/agent run check:types — pass
  • bun --cwd=packages/coding-agent run check:types — pass
  • Focused Biome check — pass

GJC verdict

gajae.pr-review-verdict.v1 needs-human sha256:38b5191e35b69fb3fb4fa6f5b9f519b2c7eccd2008750d5e9dcc7b07de9cea82 reviewer:human evidence:local-focused-tests-145-pass-types-3pkgs-biome-12files

The prior human review was against the superseded head. This repaired exact head remains needs-human pending maintainer re-review.


  • Target branch is dev
  • bun check passes
  • Tested locally
  • CHANGELOG updated (if user-facing)
  • Verdict above matches the exact PR head, not an earlier commit

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Review verdict: MERGE_READY

gajae.pr-review-verdict.v1 needs-human sha256:dce8356a214c711cebc18cc7eb5379fe61d411980768d78e52a6a81fd7c495ec reviewer:human verdict:MERGE_READY head:80ff2c324423637ba6a51be45439a976e2dea478 evidence:focused-123-pass,types-3pkgs,biome-8files,ci-23green-6skipped

Reviewed exactly head 80ff2c324423637ba6a51be45439a976e2dea478 against base d1d4807a2f (dev). No merge performed; review-only.

Protocol / classifier — safe

isZeroUsageEmptyStop (agent-loop.ts) promotes only stopReason:"stop" + content.length===0 + every usage counter ===0. The all-zero-usage discriminator is the load-bearing guard: any real provider reports input > 0 for an actually-completed turn, so legitimate empty assistant turns (model has nothing to add after a tool cycle) are never promoted — verified by the pre-existing "realistic usage" test, still passing. Near-zero fabricated usage (LiteLLM input:1/output:1) is not all-zero, so it still routes through the overflow detector. A content block, even [""], blocks promotion. AssistantMessage.usage is required and zero-normalized by every provider path, so the check cannot throw on a missing field. Promotion sits after the invalid_prompt/reasoning repair breakers and before overflow/managed-retryable/accept, and agentLoopContinue shares runLoopBody, so continuation turns get the same treatment.

Retry / fallback lifecycle — bounded and replay-safe

Bare-default retry (single model, no legacy retry.* keys) is admitted via canReplayEmptyResponse, but only when the attempt is content-free AND the scope exists and is currently clean (#attemptRecordStore state "clean" = no extension handler was delivered; scope still current). Visible/tool content, missing/dirty scope, and retry.enabled=false all veto the retry (each covered by a test). Budget is retry.maxRetries (default 3): attemptsUsed = #retryAttempt + 1 ≤ maxRetries, and auto_retry_start reports maxAttempts = maxRetries, unbounded: false. Managed chains advance through the existing controller.

Transaction / usage accounting — no duplicates

Managed: the loop discards the transaction and splices the provisional message from context; the session receives retryable_discarded, drops staged lifecycle events, and the chain advances. The new test asserts exactly 2 model calls, exactly 1 message_start, and exactly one accepted assistant message in history. The discarded attempt carries zero usage, so token/cost accounting cannot double-bill. Bare retry strips the trailing errored tail via replaceMessages(historyRewrite:"retry") before continue — no second assistant tail is replayed.

Typed surfaces / telemetry

EMPTY_RESPONSE_PROVIDER_CODE is exported from @gajae-code/ai and consumed by both agent-loop and agent-session. transportFailureFacts allow-lists it, classifyFallbackTrigger maps it to server (retryable, no credential mutation), #classifyErrorForRetry returns the new empty_response class, and retry events carry the typed facts.

Verification (all run locally on the exact head)

  • bun test packages/ai/test/model-fallback-transport-facts.test.ts packages/agent/test/agent-loop.test.ts packages/coding-agent/test/agent-session-resilient-retry.test.ts packages/coding-agent/test/agent-session-fallback-attempt-transaction.test.ts123 pass / 0 fail (739 expect), matching the PR claim.
  • check:types for packages/ai, packages/agent, packages/coding-agent → all exit 0.
  • Biome check on all 8 changed files → OK.
  • Pre-existing overflow boundary tests (near-zero and realistic usage) still green — the reclassification is confined to the all-zero shape.
  • CI terminal on this head: 23 completed success / 6 skipped / 0 failed / 0 pending (Virtual integration validation completed success since the 22-green snapshot; skips are platform-conditional Windows/darwin, live-release state, and the unexpanded Python matrix). bun check is not claimed (author excluded it; same scope honesty here).

Non-blocking observations

  • No dedicated test exercises the dirty-scope branch of the empty-response gate (the branch is shared with the first-event-timeout machinery, which has coverage; visible-content and opt-out boundaries are tested directly).
  • Behavioral note for maintainers: the all-zero empty stop previously fell into the ≤5-token overflow detector; genuine near-zero overflow signatures are unaffected. CHANGELOG entry is accurate.

@probepark probepark left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and thanks especially for being straight in the PR body that bun check was unchecked and that the architect/critic passes did not complete — that made it obvious where to aim. BLOCK, on one issue: the trigger is too broad and reclassifies a case that dev handles correctly today.

The predicate cannot tell a transport fault from a normal empty response

packages/agent/src/agent-loop.ts adds:

function isZeroUsageEmptyStop(message: AssistantMessage): boolean {
  ... message.stopReason === "stop" && message.usage.totalTokens === 0
}
// then
message.stopReason = "error";
message.errorMessage = "Provider returned an empty response with zero token usage";
providerCode: EMPTY_RESPONSE_PROVIDER_CODE,

stopReason === "stop" plus totalTokens === 0 is the only evidence. That set contains more than transport faults:

  • Proxy/LiteLLM-style context overflow currently classifies as overflow and reaches promotion, then compaction (agent-session.ts:~13358-13370). Under this PR it becomes empty_response and enters retry/fallback policy instead.
  • Unrecognized silent refusals land in the same bucket.

The consequence is a deterministic input being replayed: a bare session makes up to four total requests by default, and managed chains allow three attempts per model before advancing. At exhaustion the bare session surfaces an error and the managed chain terminalizes — and in the overflow case the user has now lost the promotion/compaction path that would actually have fixed their turn.

So the regression is not just wasted requests; it is a working recovery being replaced by a failing one.

To be clear on what is safe: recognized Anthropic/OpenAI/Google safety stops are not affected, because those adapters emit provider_safety_stop. The gap is specifically the normal-stop empty responses that are not transport faults.

Required change: classify overflow before synthesizing empty_response, and only synthesize the transport failure from provider-specific evidence that rules out overflow and refusal. Please add a session-level regression proving a zero-token proxy overflow still invokes promotion/compaction rather than retry policy — that is the case that silently regresses.

Verification (rebased onto origin/dev 7080dace, commit a6fb60c8)

run result
PR-focused suites 123 pass / 0 fail
neighbouring retry/safety suites 19 pass / 0 fail
mutation production present 123/0

Rebases cleanly. The tests you wrote do pass — the problem is the case they do not cover.

Interaction with #4169 — no conflict, but a note

I asked specifically about this because you and I are both editing #handleRetryableError. git merge-tree shows no textual conflict; the hunks are disjoint, and there is no semantic revert#4169's auto_retry_start rejection guard still wraps this new path once both land.

One sequencing caveat: until #4169 lands, this PR adds another way to reach the existing dead-turn bug (a rejected auto_retry_start delivery leaves #retryPromise unresolved and the prompt gate held). Not your defect, and not a reason to change this PR — just worth landing #4169 first.

Fallback ordering

The fallback-transport.ts change leaves ordering and eligibility intact for other failure codes. Only the zero-token case changes classification, from context maintenance to "server" fallback, retrying the current model up to its per-entry budget before advancing. That is contained.

CHANGELOG

Currently inaccurate: it presents all zero-token empty responses as transport failures, and omits that overflow promotion/compaction no longer runs for them. Please reword once the classification is narrowed.

Also

bun check is still unchecked in the PR body. Worth running before the next push — I did not treat its absence as a finding, but it should be green before merge.

Narrow the trigger and add the overflow regression, and I think this is a good fix — the underlying symptom is real and worth handling.

Some provider streams can end with stop and no content or token usage. Treat that impossible-success shape as a typed transient failure so clean bare sessions retry within budget and managed chains discard it before fallback.

Constraint: only content-empty stop responses with every usage counter at zero are reclassified

Rejected: provider-specific Grok handling | the failure shape is provider-agnostic

Confidence: high

Scope-risk: narrow

Reversibility: easy

Tested: 123 focused tests; AI, agent, and coding-agent typechecks
Generic zero-token empty stops also represent proxy context overflow, where promotion or compaction is the correct recovery. Stamp empty_response only at the OpenCode Go Responses completion boundary when a completed response id has no output items or usage, and let untyped empties retain overflow precedence.

Constraint: preserve generic proxy overflow promotion and compaction

Rejected: generic zero-usage classifier | conflates transport anomalies with overflow and refusal

Confidence: high

Scope-risk: narrow

Reversibility: easy

Tested: 145 focused tests; AI, agent, and coding-agent typechecks
@chulmin-dev
chulmin-dev force-pushed the fix/opencode-go-empty-stop branch from 80ff2c3 to bb6b7a7 Compare August 10, 2026 15:59
@chulmin-dev

Copy link
Copy Markdown
Contributor Author

Requested-change repair is pushed at bb6b7a7c0a4eee0f1cd56b0d4e97e28191abfc75 (rebased onto current dev).

  • Generic untyped zero-token empty stops now keep overflow classification precedence.
  • empty_response is stamped only by the OpenCode Go Responses adapter when a completed response id has no output items/content/usage.
  • Added a session regression proving an untyped proxy empty stop promotes to the larger-context model and emits no retry start.
  • Reworded the changelog to describe the narrowed contract.

Verification: 145 focused tests pass; AI/agent/coding-agent typechecks pass; focused Biome passes. Exact diff SHA-256: 38b5191e35b69fb3fb4fa6f5b9f519b2c7eccd2008750d5e9dcc7b07de9cea82.

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review verdict: MERGE_READY

gajae.pr-review-verdict.v1 needs-human sha256:925572b7a242efb4d9ea0486804ba3161e21beed798cb17b107f88cc3fdf4d9e reviewer:human verdict:MERGE_READY head:bb6b7a7c0a4eee0f1cd56b0d4e97e28191abfc75 evidence:focused-tests-145-pass,types-3pkgs-ai-agent-codingagent,ci-24green,prior-blocker-closed

Adversarial re-review of the exact repaired head bb6b7a7c0a4eee0f1cd56b0d4e97e28191abfc75 (base 7858b0ff63db6a3fc0cce93639a8d329ca474b2c). Review-only: no source mutation, push, merge, or release performed.

Prior blocker (probepark REQUEST_CHANGES) — verified closed at exact head

The finding was that the broad isZeroUsageEmptyStop classifier (stopReason:"stop" + totalTokens===0) reclassified proxy/LiteLLM context overflows and unrecognized silent refusals that dev handles correctly via overflow -> promotion/compaction. The repair addresses all three required changes:

  1. Overflow classified before empty-response synthesis. agent-loop.ts now computes managedContextOverflow first and converts to stopReason:"error" only under !overflow — overflow keeps precedence.
  2. Transport failure synthesized only from provider-specific evidence. The empty_response tag is stamped solely by the OpenCode Go Responses adapter (isOpenCodeGoEmptyCompletedResponse): provider opencode-go, non-empty responseId, stopReason:"stop", empty content, nativeOutputItemCount===0, and every usage counter ===0. agent-loop's isTypedEmptyResponseStop additionally requires the empty_response provider code, so no other adapter or untyped stop is reclassified.
  3. Session regression proves untyped proxy overflow still promotes. agent-session-context-promotion.test.ts -> keeps an untyped zero-token proxy empty stop on the promotion path asserts the model promotes to the larger-context model with zero auto_retry_start events.
  4. CHANGELOG reworded to describe the narrowed contract (typed OpenCode Go retryable failure; untyped proxy empties retain overflow promotion/compaction).

Boundary attacks on the repaired trigger

  • Provider/proxy/LiteLLM / context-overflow: only opencode-go stamps empty_response; untyped empty stops (proxy/LiteLLM) retain classifyContextOverflow's silent-overflow path -> promotion/compaction. empty_response sits in NON_OVERFLOW_PROVIDER_CODES, so tagged messages are not misclassified as overflow. The zero-usage evidence rules out genuine context overflow (a zero-token request cannot overflow).
  • Legitimate empty-stop semantics (other adapters): isTypedEmptyResponseStop requires the empty_response provider code, which only the opencode-go adapter emits in production. Anthropic/OpenAI/Google and all other adapters are untouched — the applicable adapter header/affinity/overflow logic is byte-identical base->head.
  • Bounded retry loops: empty_response is not in the legacyUnbounded (transient-only) class; attemptsUsed = #retryAttempt + 1 <= retry.maxRetries bounds it (agent-session-resilient-retry.test.ts: capped at maxRetries, unbounded:false; no retry after visible progress or when retry.enabled=false).
  • Fallback transaction: managed fallback discards the provisional empty attempt and advances the chain; agent-session-fallback-attempt-transaction.test.ts asserts exactly 2 model calls, 1 message_start, one accepted assistant message.
  • Content/scope guard: non-managed empty-response retry requires content-free message + clean current scope; visible/tool content or missing/dirty scope vetoes the retry.
  • Reserved tool interactions: orthogonal — history neutralization runs before the empty-response conversion; the added test is a unit test of isOpenCodeGoEmptyCompletedResponse alongside the existing reserved-tool suite. No interference.
  • No behavior change for other adapters: confirmed — the changed classification paths only fire for messages carrying the empty_response provider code.

Verification (exact head)

  • Focused suites: agent-loop (38), model-fallback-transport-facts + openai-responses-reserved-tool-names (27), agent-session-context-promotion (10), agent-session-fallback-attempt-transaction (12), agent-session-resilient-retry (58) = 145 pass / 0 fail.
  • Full packages/ai and packages/agent suites green except pre-existing environment artifacts (ambient ANTHROPIC_BASE_URL/OPENAI_BASE_URL overrides and network-dependent cache-affinity tests) whose exercised code is byte-identical to base — not caused by this PR.
  • check:types + Biome pass for packages/ai, packages/agent, packages/coding-agent.
  • Exact-head CI: 24 completed success / 0 failed (all affected-path checks pass; Virtual integration validation completed success).

Non-blocking observations

  • OpenCode Go zero-token empty stops now shift from the generic overflow->promotion heuristic to the typed retry path. This is contained (bounded, content-free, clean-scope gated) and defensible given the zero-usage evidence; flagged for maintainers in case OpenCode Go ever swallows a genuine overflow into a zero-token empty.
  • No dedicated test exercises the dirty-scope branch of the empty-response gate in isolation (shared with the first-event-timeout machinery, which has coverage).

The underlying symptom is real, the repair is narrow and correct, and the prior blocker is closed at the exact head. Approving.

@probepark probepark left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the stale-review delta through head bb6b7a7c0a4eee0f1cd56b0d4e97e28191abfc75. The provider-specific narrowing fixes the original broad-classifier objection, but the retry path still has three replay/event-boundary defects.

Major — the typed failure is created after message_end has already escaped

packages/agent/src/agent-loop.ts:1772-1776 changes the completed message from stop to error, but streamAssistantResponse() already emitted that same response as message_end at packages/agent/src/agent-loop.ts:2436-2450. AgentSession synchronously persists the assistant message before its first await (packages/coding-agent/src/session/agent-session.ts:4177-4210) and then emits it to TUI/extensions. Thus the observable and durable boundary reports a successful empty turn before the later mutation/retry. Extensions can act on the false success, and a crash/reload in that window retains the wrong terminal state.

Move the classification to the provider/final-response boundary before the done event is emitted, or otherwise ensure message_end is born with stopReason: "error" and the canonical error text. The new resilient-retry tests do not cover this boundary: their helper constructs an already-error terminal event (packages/coding-agent/test/agent-session-resilient-retry.test.ts:150-175). Add an assertion against the actual emitted/persisted message_end from a typed zero-token stop stream.

Major — managed fallback ignores dirty or missing attempt scopes

At packages/coding-agent/src/session/agent-session.ts:15972-15978, the empty-response clean-scope gate is restricted to !managedFallback. Managed outcomes pass their scope and cleanliness into this method (:15728-15734), but an empty response proceeds even when a context/request extension already made the attempt scope dirty. The fallback then replays on another model and can duplicate extension-observable side effects. The analogous first-event-timeout path correctly requires a clean scope for managed fallback at :15979-15985.

Apply the content/scope cleanliness gate to managed empty-response outcomes too, returning the managed terminal decision when replay is unsafe. Add a managed-chain regression with a participating context handler.

Major — later empty-response retries bypass accumulated replay-safety state

packages/coding-agent/src/session/agent-session.ts:16028-16029 makes every typed empty response bypass the bare-default replay-safety block. After the first retry, an auto_retry_start extension handler marks #hasCleanRetryReplaySafety false (:6116-6118), but the second empty response still enters another retry because canReplayEmptyResponse remains true. That reruns lifecycle side effects through the full retry budget. The existing watchdog regression at packages/coding-agent/test/agent-session-resilient-retry.test.ts:1681-1724 demonstrates the intended contract—one retry, then stop once an auto_retry_start handler participates—but the new empty-response tests do not exercise it.

Gate subsequent empty-response retries on the accumulated replay-safety state, matching the typed first-event-timeout behavior, and add the corresponding extension-handler regression.

Verification: the two isolated AI suites passed locally (27 tests, 105 assertions). The agent/coding-agent suites could not load because this review worktree resolves stale/missing workspace links (@gajae-code/ai/core, @gajae-code/utils/shell-config, and a stale external @gajae-code/ai export); I did not mutate the checkout to repair dependencies.

gajae.pr-review-verdict.v1: needs-human

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Superseded by #4203 from the owner repair branch because this lane cannot safely push to contributor-owned chulmin-dev/fix/opencode-go-empty-stop.

#4203 preserves chulmin-dev's exact reviewed head bb6b7a7c0a4eee0f1cd56b0d4e97e28191abfc75 and its valuable implementation, then adds a narrowly scoped repair for review-identified lifecycle publication and replay-safety defects. Contributor attribution and the original commits are retained in the successor PR.

Yeachan-Heo added a commit that referenced this pull request Aug 11, 2026
Supersedes #4174 while retaining chulmin-dev contributor commits and repairs lifecycle/replay-safety boundaries.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants