Skip to content

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

Closed as not planned

Description

@alparslanozturk

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.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) 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.

Activity

  1. github-actions commented on Sep 16, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. alparslanozturk commented on Sep 16, 2026

    @alparslanozturk
    Author

    Update: opened a draft fix — #49418.

    One correction to the suggested fix in the issue body above: I initially proposed just removing "unknown" from the loop's exclusion list entirely, but running the existing test suite showed that's too blunt — there's a deliberate test ("loop continues when finish is unknown") covering a provider that drops its finish signal on an otherwise-healthy stream, where retrying once is the correct, intended behavior. The actual bug is that this retry has no upper bound. #49418 caps consecutive unresolved (unknown, no tool calls) retries at a small constant instead of changing when unknown is allowed to retry at all — keeps the existing recovery behavior intact and fixes the unbounded case.

  3. ciszkin commented on Sep 17, 2026

    @ciszkin

    Confirming this with a real-world trigger that isn't in the thread yet: a provider that answers with HTTP 200 and a JSON error body (not a 4xx, not a malformed SSE frame).

    Trigger

    A gateway whose Anthropic-compatible base URL was configured without the /v1 suffix. The @ai-sdk/anthropic client appends /messages, so requests went to a non-existent path and the gateway replied:

    HTTP/1.1 200 OK
    {"code":500,"msg":"404 NOT_FOUND","success":false}
    

    Because it's a 200, the stream "completes cleanly" with no content and no usage, so opencode records an empty turn with finish: "unknown" — and the loop predicate ("unknown" treated as "not finished") never terminates.

    Evidence

    opencode 1.18.31, subagent run. 1905 iterations over ~14 min, then the parent aborted:

    • every assistant message: finish: "unknown", tokens.input=0, output=0
    • parts: 1904 × step-finish (reason: "unknown"), 1 × user text
    • logs: message=loop step=0 … step=1904, one message=stream per step, no exiting loop, no surfaced error
    • the TUI just showed the agent as busy

    Why the 200-error-body case matters

    The other repros use a non-standard finish_reason in a well-formed SSE stream. Here the body isn't an SSE stream at all, so nothing in the transport layer can distinguish it from an empty completion — the only signal is the shape of the body ({"code":500,...}) and the fact that the step produced zero output.

    A step with no text, no tool calls, and zero usage has no possible next action that changes the conversation, so it should fail the step (and surface the provider error) rather than re-request. If the loop predicate is kept as-is, at minimum a bounded retry with backoff would turn this from a silent hang into a visible failure.

    Fix confirmed for my case by correcting the base URL, but the loop behavior is the underlying issue reported here.

  4. wesleysimplicio commented on Oct 6, 2026

    @wesleysimplicio

    Still happening on 1.18.32 (Homebrew anomalyco/tap), macOS.

    Session ses_ef10e90f1ffeixZOGrsMT17kXI, 2026-10-05, OpenAI-compatible provider. After one normal stop and one compaction, the build agent ran 994 steps in 11 minutes. Each step-finish was reason: "unknown", 0 input / 0 output, no text and no tool part, about 0.5s each. It stopped only on MessageAbortedError. The previous session did 15941 of the same steps over about 4 hours.

    The binary still leaves the loop only when finish is set and is not tool-calls or unknown. SessionProcessor.process returns "continue" unless the step is compaction, blocked, or an error. The OpenAI-compatible mapper turns every other finish_reason, including a missing one, into unknown. The build agent has no step cap.

    An unknown finish with no tool call and zero usage should end the turn and surface an error.

  5. thdxr commented on Oct 11, 2026

    @thdxr
    Member

    Thanks for the report! OpenCode 2.0 is now out and development is focused on V2, so we're closing issues filed against OpenCode 1. Please upgrade (opencode upgrade --major, details: https://anoma.ly/notes/opencode-2-0), and if this is still relevant on 2.0, reopen this issue or open a new one with your 2.0 version.

    — from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions