anomalyco / anomalyco/opencode
Agent step loop never terminates on "unknown" finish reason with no tool calls — unbounded request storm
@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): tested1,
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 thefinish/hasToolCallscheck,
so a step ceiling doesn't help once that check is permanently false.- Upgrading
1.18.30→1.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) and1.18.31(npm) — both affected, confirmed against
currentmain(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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.