Skip to content

Commit d89019d

Browse files
author
Ralf Waldukat
committed
fix(opencode): retry empty unknown responses (upstream anomalyco#41466)
1 parent 0fe6b02 commit d89019d

3 files changed

Lines changed: 86 additions & 3 deletions

File tree

packages/opencode/src/session/processor.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,14 +636,30 @@ const layer = Layer.effect(
636636
yield* Effect.gen(function* () {
637637
ctx.currentText = undefined
638638
ctx.reasoningMap = {}
639+
let generated = false
639640
yield* status.set(ctx.sessionID, { type: "busy" })
640641
const stream = llm.stream(streamInput)
641642

642643
yield* stream.pipe(
643-
Stream.tap((event) => handleEvent(event)),
644+
Stream.tap((event) => {
645+
if (
646+
(event.type === "text-delta" && event.text.length > 0) ||
647+
(event.type === "reasoning-delta" && event.text.length > 0) ||
648+
event.type === "tool-input-start" ||
649+
event.type === "tool-call"
650+
) {
651+
generated = true
652+
}
653+
return handleEvent(event)
654+
}),
644655
Stream.takeUntil(() => ctx.needsCompaction),
645656
Stream.runDrain,
646657
)
658+
if (ctx.assistantMessage.finish === "unknown" && !generated) {
659+
yield* new SessionRetry.EmptyResponseError({
660+
message: "The model returned an empty response with an unknown finish reason",
661+
})
662+
}
647663
}).pipe(
648664
Effect.onInterrupt(() =>
649665
Effect.gen(function* () {

packages/opencode/src/session/retry.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import type { NamedError } from "@opencode-ai/core/util/error"
22
import { SessionV1 } from "@opencode-ai/core/v1/session"
3-
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
3+
import { Cause, Clock, Duration, Effect, Schedule, Schema } from "effect"
44
import { MessageV2 } from "./message-v2"
55
import { iife } from "@/util/iife"
66
import { isRecord } from "@/util/record"
77

88
export type Err = ReturnType<NamedError["toObject"]>
99

10+
export class EmptyResponseError extends Schema.TaggedErrorClass<EmptyResponseError>()("SessionEmptyResponseError", {
11+
message: Schema.String,
12+
}) {}
13+
1014
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
1115
export const GO_UPSELL_URL = "https://opencode.ai/go"
1216
export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {})
@@ -242,7 +246,8 @@ export function policy(opts: {
242246
return Schedule.fromStepWithMetadata(
243247
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
244248
const error = opts.parse(meta.input)
245-
const retry = retryable(error, opts.provider)
249+
const retry =
250+
meta.input instanceof EmptyResponseError ? { message: meta.input.message } : retryable(error, opts.provider)
246251
if (!retry) return Cause.done(meta.attempt)
247252
return Effect.gen(function* () {
248253
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)

packages/opencode/test/session/processor-effect.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,68 @@ it.live("session.processor effect tests retry recognized structured json errors"
604604
),
605605
)
606606

607+
it.live("session.processor effect tests retry empty responses with unknown finish reasons", () =>
608+
provideTmpdirServer(
609+
({ dir, llm }) =>
610+
Effect.gen(function* () {
611+
const { processors, session, provider } = yield* boot()
612+
613+
yield* llm.push(
614+
raw({
615+
chunks: [
616+
{
617+
id: "chatcmpl-test",
618+
object: "chat.completion.chunk",
619+
choices: [{ delta: { role: "assistant" }, finish_reason: null }],
620+
},
621+
{
622+
id: "chatcmpl-test",
623+
object: "chat.completion.chunk",
624+
choices: [{ delta: {}, finish_reason: "unknown_reason" }],
625+
},
626+
],
627+
}),
628+
reply().text("after").stop(),
629+
)
630+
631+
const chat = yield* session.create({})
632+
const parent = yield* user(chat.id, "retry empty")
633+
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
634+
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
635+
const handle = yield* processors.create({
636+
assistantMessage: msg,
637+
sessionID: chat.id,
638+
model: mdl,
639+
})
640+
641+
const value = yield* handle.process({
642+
user: {
643+
id: parent.id,
644+
sessionID: chat.id,
645+
role: "user",
646+
time: parent.time,
647+
agent: parent.agent,
648+
model: { providerID: ref.providerID, modelID: ref.modelID },
649+
} satisfies SessionV1.User,
650+
sessionID: chat.id,
651+
model: mdl,
652+
agent: agent(),
653+
system: [],
654+
messages: [{ role: "user", content: "retry empty" }],
655+
tools: {},
656+
})
657+
658+
const parts = yield* MessageV2.parts(msg.id)
659+
660+
expect(value).toBe("continue")
661+
expect(yield* llm.calls).toBe(2)
662+
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
663+
expect(handle.message.error).toBeUndefined()
664+
}),
665+
{ config: (url) => providerCfg(url) },
666+
),
667+
)
668+
607669
it.live("session.processor effect tests retry OpenAI-compatible midstream server errors", () =>
608670
provideTmpdirServer(
609671
({ dir, llm }) =>

0 commit comments

Comments
 (0)