Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ globalThis.AI_SDK_LOG_WARNINGS = false
const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info)
const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part)
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
// How many times in a row we'll silently retry a turn that ends with an
// unrecognized ("unknown") finish reason and no tool calls before giving up.
// A provider occasionally dropping its finish signal is expected to resolve
// within a request or two; a provider that never resolves it (e.g. a
// finish_reason value this build's FinishReason schema doesn't recognize)
// must not retry forever. See anomalyco/opencode#49414.
const MAX_UNKNOWN_FINISH_RETRIES = 3
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
"application/pdf",
"image/gif",
Expand Down Expand Up @@ -1083,6 +1090,12 @@ const layer = Layer.effect(
const ctx = yield* InstanceState.context
let structured: unknown
let step = 0
// "unknown" finish reasons are treated as possibly-transient (see below) and the
// loop retries by design (e.g. a provider that ends its stream without a proper
// finish signal). If a provider deterministically never resolves to a recognized
// finish reason, that retry has no bound today and re-sends the same request
// forever - see anomalyco/opencode#49414. Cap it.
let consecutiveUnknownFinishes = 0
const session = yield* sessions.get(sessionID).pipe(Effect.orDie)

while (true) {
Expand All @@ -1108,6 +1121,10 @@ const layer = Layer.effect(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false

const isUnresolvedFinish =
lastAssistant?.finish === "unknown" && !hasToolCalls && lastAssistant.parentID === lastUser.id
consecutiveUnknownFinishes = isUnresolvedFinish ? consecutiveUnknownFinishes + 1 : 0

if (
lastAssistant?.finish &&
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
Expand All @@ -1129,6 +1146,20 @@ const layer = Layer.effect(
break
}

// An "unknown" finish reason is treated above as possibly-transient and
// retried like "tool-calls", but with no tool calls to resolve there's
// nothing that will change about the next request. If a provider never
// resolves to a recognized finish reason, don't retry forever - see
// anomalyco/opencode#49414.
if (isUnresolvedFinish && consecutiveUnknownFinishes > MAX_UNKNOWN_FINISH_RETRIES) {
yield* Effect.logWarning("loop exit after repeated unrecognized finish reason", {
"session.id": sessionID,
messageID: lastAssistant?.id,
attempts: consecutiveUnknownFinishes,
})
break
}

step++
if (step === 1)
yield* title({
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,47 @@ noLLMServer.instance(
{ config: cfg },
)

it.instance(
// Regression test for #49414: "loop continues when finish is unknown"
// (below) intentionally retries once when a provider ends a stream
// without a proper finish signal - that's a reasonable way to recover
// from a one-off glitch. But if a provider *never* resolves to a
// recognized finish reason (e.g. its finish_reason string never maps to
// one of our known values), the same retry had no upper bound and kept
// re-sending the same request forever. This asserts it's now capped.
"loop gives up after repeated unrecognized finish reason instead of retrying forever",
() =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({
title: "Pinned",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
})
yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
// Every reply ends its stream without a finish line, so every attempt
// maps to "unknown" - never a real "stop". Push more than the retry
// cap; the loop must give up before exhausting the queue.
for (let i = 0; i < 10; i++) {
yield* llm.push(reply().text(`attempt ${i}`))
}

const result = yield* prompt.loop({ sessionID: session.id })
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") expect(result.info.finish).toBe("unknown")
// 1 initial attempt + MAX_UNKNOWN_FINISH_RETRIES retries, then it must
// stop instead of consuming the rest of the queued replies.
expect(yield* llm.calls).toBe(4)
}),
{ config: cfg },
)

noLLMServer.instance(
"loop exits for a completed parent turn with nonmonotonic message IDs",
() =>
Expand Down
Loading