anomalyco / anomalyco/opencode

Agent step loop never terminates on "unknown" finish reason with no tool calls — unbounded request storm

Open
#49,414 3 comments 0 reactions 1 assignee View on GitHub

@kitlangton is already working on this.

Since Sep 16, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Summary

When a completion has no tool calls and the provider's finish reason doesn't map to one of
opencode's own recognized FinishReason values (stop, length, tool-calls, content-filter,
error) — and instead falls back to unknown — the agent step loop in SessionPrompt.run never
exits. It keeps re-sending essentially the same request indefinitely, with no upper bound, no
backoff, and no visible error
. We measured 86–138 requests within 10 seconds in a repro
against a local mock server (and the same signature against a real corporate OpenAI-compatible
backend). The TUI shows no error; it just oscillates between "Thinking" / "Compaction" / the
current agent name, which looks exactly like a hang.

This is model-independent: it's a bug in the loop's exit condition, not in any specific model or
backend.

Root cause (found in source, 1.18.31 / current main)

packages/opencode/src/session/prompt.ts, SessionPrompt.run (runLoop), around line 1111:

const hasToolCalls =
  lastAssistantMsg?.parts.some(
    (part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
  ) ?? false

if (
  lastAssistant?.finish &&
  !["tool-calls", "unknown"].includes(lastAssistant.finish) &&
  !hasToolCalls &&
  lastAssistant.parentID === lastUser.id
) {
  // ... break
}

The loop only exits when lastAssistant.finish is a value other than "tool-calls" and
"unknown". "unknown" comes from packages/opencode/src/session/llm/ai-sdk.ts:23:

function finishReason(value: string | undefined): FinishReason {
  return Schema.is(FinishReason)(value) ? value : "unknown"
}

FinishReason is defined in packages/llm/src/schema/ids.ts:39 as
Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) — it does
not include the AI SDK's own "other" catch-all. So: if the underlying @ai-sdk/openai-compatible
provider (from the ai npm package) reports a finish reason that it itself can't map to one of its
recognized values and falls through to "other", opencode's finishReason() here normalizes that
to "unknown" — and the loop-exit check explicitly treats "unknown" the same as "tool-calls"
(i.e. "there might still be more work to do, keep looping"), even though there are demonstrably no
tool calls (hasToolCalls === false) and nothing about the conversation state will change on the
next request. The result: the same request gets sent again, forever, at whatever rate the event
loop allows.

This matches our field observation exactly: every step-finish event we captured while reproducing
this reports "reason":"unknown".

We believe this is specifically why any backend whose finish_reason string doesn't land cleanly in
the small set the AI SDK provider recognizes (common with some self-hosted/vLLM-served
OpenAI-compatible endpoints) hits this every time, deterministically, on the very first turn — not
an occasional/rare edge case.

What we tried that does NOT fix it

  • agent.<name>.steps (max agentic iterations before forcing a text-only response): tested 1,
    5, and unset. All three produced 86–138 requests in 10 seconds — no difference. Looking at
    the code, maxSteps/isLastStep (line ~1178) is computed but doesn't appear to be consulted by
    the exit condition above at all — the loop only ever breaks via the finish/hasToolCalls check,
    so a step ceiling doesn't help once that check is permanently false.
  • Upgrading 1.18.301.18.31: identical signature, identical request rate. The code above is
    unchanged between the two.
  • compaction.prune / reserved / preserve_recent_tokens: not reachable in isolation — our
    adversarial repro hits this loop before the compaction threshold is ever reached.

Suggested minimal fix

Stop treating "unknown" as equivalent to "tool-calls" for loop-continuation purposes. Something
like:

if (
  lastAssistant?.finish &&
  lastAssistant.finish !== "tool-calls" &&
  !hasToolCalls &&
  lastAssistant.parentID === lastUser.id
) {
  if (lastAssistant.finish === "unknown") {
    yield* Effect.logWarning("loop exit on unrecognized finish reason", {
      "session.id": sessionID,
      messageID: lastAssistant.id,
    })
  }
  // ... break (existing orphan-handling code unchanged)
}

i.e. treat any finish reason that isn't "tool-calls" as terminal when there are no actual tool
calls present, and just log a warning when it was specifically "unknown" so users/maintainers get
a visible signal that a provider's finish reason wasn't recognized (which is useful information on
its own, separately from this loop bug). Happy to open a PR with this change plus a regression test
if that's welcome — we already have a from-scratch mock-server harness that reproduces the bug in a
few seconds with no external dependencies.

Environment

  • opencode 1.18.30 (standalone binary) and 1.18.31 (npm) — both affected, confirmed against
    current main (88c6c7a, 2026-09-16) that the code path is unchanged.
  • Provider: @ai-sdk/openai-compatible, tested against both a corporate-internal vLLM-served
    OpenAI-compatible endpoint and a minimal local mock HTTP server (for isolation).
  • OS: Linux. Reproduced with zero network access (unshare --net, mock server on loopback) to rule
    out timing/network as a factor.

Why this matters beyond us

This will affect any deployment using a smaller/local/self-hosted model behind an OpenAI-compatible
endpoint whose finish_reason values don't land exactly in the AI SDK's recognized set — which is
fairly common outside the handful of major hosted providers. For anyone running this against an
offline/air-gapped backend with no visibility into request volume, this silently hammers the
backend and looks exactly like "the tool doesn't work," with zero diagnostic signal.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.