Skip to content

Commit dccd374

Browse files
committed
fix(opencode): retry empty reasoning turns
1 parent 7708a57 commit dccd374

2 files changed

Lines changed: 205 additions & 4 deletions

File tree

packages/opencode/src/session/processor.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ interface ProcessorContext extends Input {
9797
currentText: SessionV1.TextPart | undefined
9898
reasoningMap: Record<string, SessionV1.ReasoningPart>
9999
outputLimitUsage: Pick<SessionV1.StepFinishPart, "cost" | "tokens"> | undefined
100+
emittedText: boolean
101+
emittedTool: boolean
100102
}
101103

102104
type StreamEvent = LLMEvent
@@ -137,14 +139,18 @@ const layer = Layer.effect(
137139
currentText: undefined,
138140
reasoningMap: {},
139141
outputLimitUsage: undefined,
142+
emittedText: false,
143+
emittedTool: false,
140144
}
141145
let aborted = false
142146

143147
const parse = (e: unknown) =>
144-
MessageV2.fromError(e, {
145-
providerID: input.model.providerID,
146-
aborted,
147-
})
148+
SessionV1.APIError.isInstance(e)
149+
? e.toObject()
150+
: MessageV2.fromError(e, {
151+
providerID: input.model.providerID,
152+
aborted,
153+
})
148154

149155
const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
150156
const done = ctx.toolcalls[toolCallID]?.done
@@ -378,6 +384,7 @@ const layer = Layer.effect(
378384
if (ctx.assistantMessage.summary) {
379385
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
380386
}
387+
ctx.emittedTool = true
381388
yield* ensureToolCall(value)
382389
const input = isRecord(value.input) ? value.input : { value: value.input }
383390
yield* updateToolCall(value.id, (match) => ({
@@ -567,6 +574,7 @@ const layer = Layer.effect(
567574
case "text-delta":
568575
if (!ctx.currentText) return
569576
ctx.currentText.text += value.text
577+
ctx.emittedText ||= value.text.trim().length > 0
570578
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
571579
yield* session.updatePartDelta({
572580
sessionID: ctx.currentText.sessionID,
@@ -596,6 +604,7 @@ const layer = Layer.effect(
596604
}
597605
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
598606
yield* session.updatePart(ctx.currentText)
607+
ctx.emittedText ||= ctx.currentText.text.trim().length > 0
599608
ctx.currentText = undefined
600609
return
601610

@@ -704,11 +713,14 @@ const layer = Layer.effect(
704713
})
705714
ctx.needsCompaction = false
706715
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
716+
let emptyResponses = 0
707717

708718
return yield* Effect.gen(function* () {
709719
yield* Effect.gen(function* () {
710720
ctx.currentText = undefined
711721
ctx.reasoningMap = {}
722+
ctx.emittedText = false
723+
ctx.emittedTool = false
712724
yield* status.set(ctx.sessionID, { type: "busy" })
713725
const stream = llm.stream(streamInput)
714726

@@ -717,6 +729,19 @@ const layer = Layer.effect(
717729
Stream.takeUntil(() => ctx.needsCompaction),
718730
Stream.runDrain,
719731
)
732+
733+
const empty =
734+
!ctx.needsCompaction &&
735+
streamInput.toolChoice !== "required" &&
736+
(ctx.assistantMessage.finish === "stop" || ctx.assistantMessage.finish === "unknown") &&
737+
!ctx.emittedText &&
738+
!ctx.emittedTool
739+
if (!empty) return
740+
emptyResponses++
741+
throw new SessionV1.APIError({
742+
message: "Model returned reasoning without an answer or tool call",
743+
isRetryable: emptyResponses === 1,
744+
})
720745
}).pipe(
721746
Effect.onInterrupt(() =>
722747
Effect.gen(function* () {

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

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,31 @@ const outputRetryLLM = Layer.succeed(
280280
const outputRetryEnv = LayerNode.compile(root, [...replacements, [LLM.node, outputRetryLLM]])
281281
const itOutputRetry = testEffect(outputRetryEnv)
282282

283+
function reasoningOnly(text: string) {
284+
return Stream.make(
285+
LLMEvent.stepStart({ index: 0 }),
286+
LLMEvent.reasoningStart({ id: "reasoning-1" }),
287+
LLMEvent.reasoningDelta({ id: "reasoning-1", text }),
288+
LLMEvent.reasoningEnd({ id: "reasoning-1" }),
289+
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
290+
LLMEvent.finish({ reason: "stop" }),
291+
)
292+
}
293+
294+
const semanticRetryInputs: LLM.StreamInput[] = []
295+
const semanticRetryStreams: Stream.Stream<LLMEvent>[] = []
296+
const semanticRetryLLM = Layer.succeed(
297+
LLM.Service,
298+
LLM.Service.of({
299+
stream: (input) => {
300+
semanticRetryInputs.push(input)
301+
return semanticRetryStreams.shift() ?? Stream.fail(new Error("missing semantic retry stream"))
302+
},
303+
}),
304+
)
305+
const semanticRetryEnv = LayerNode.compile(root, [...replacements, [LLM.node, semanticRetryLLM]])
306+
const itSemanticRetry = testEffect(semanticRetryEnv)
307+
283308
const boot = Effect.fn("test.boot")(function* () {
284309
const processors = yield* SessionProcessor.Service
285310
const session = yield* Session.Service
@@ -521,6 +546,157 @@ it.live("session.processor effect tests capture reasoning from http mock", () =>
521546
),
522547
)
523548

549+
itSemanticRetry.live("session.processor effect tests retry a reasoning-only response once", () =>
550+
provideTmpdirInstance((dir) =>
551+
Effect.gen(function* () {
552+
const { processors, session } = yield* boot()
553+
semanticRetryInputs.length = 0
554+
semanticRetryStreams.length = 0
555+
semanticRetryStreams.push(
556+
reasoningOnly("unfinished"),
557+
Stream.make(
558+
LLMEvent.stepStart({ index: 0 }),
559+
LLMEvent.textStart({ id: "text-1" }),
560+
LLMEvent.textDelta({ id: "text-1", text: "done" }),
561+
LLMEvent.textEnd({ id: "text-1" }),
562+
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
563+
LLMEvent.finish({ reason: "stop" }),
564+
),
565+
)
566+
567+
const chat = yield* session.create({})
568+
const parent = yield* user(chat.id, "reason")
569+
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
570+
const handle = yield* processors.create({
571+
assistantMessage: msg,
572+
sessionID: chat.id,
573+
model: outputRetryModel,
574+
})
575+
576+
const input = {
577+
user: {
578+
id: parent.id,
579+
sessionID: chat.id,
580+
role: "user",
581+
time: parent.time,
582+
agent: parent.agent,
583+
model: { providerID: ref.providerID, modelID: ref.modelID },
584+
} satisfies SessionV1.User,
585+
sessionID: chat.id,
586+
model: outputRetryModel,
587+
agent: agent(),
588+
system: [],
589+
messages: [{ role: "user", content: "reason" }],
590+
tools: {},
591+
} satisfies LLM.StreamInput
592+
593+
const value = yield* handle.process(input)
594+
const parts = yield* MessageV2.parts(msg.id)
595+
596+
expect(value).toBe("continue")
597+
expect(semanticRetryInputs).toHaveLength(2)
598+
expect(semanticRetryInputs[1]).toBe(semanticRetryInputs[0])
599+
expect(parts.some((part) => part.type === "reasoning" && part.text === "unfinished")).toBe(true)
600+
expect(parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
601+
expect(handle.message.error).toBeUndefined()
602+
}),
603+
),
604+
)
605+
606+
itSemanticRetry.live("session.processor effect tests fail after two reasoning-only responses", () =>
607+
provideTmpdirInstance((dir) =>
608+
Effect.gen(function* () {
609+
const { processors, session } = yield* boot()
610+
semanticRetryInputs.length = 0
611+
semanticRetryStreams.length = 0
612+
semanticRetryStreams.push(reasoningOnly("one"), reasoningOnly("two"))
613+
614+
const chat = yield* session.create({})
615+
const parent = yield* user(chat.id, "reason")
616+
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
617+
const handle = yield* processors.create({
618+
assistantMessage: msg,
619+
sessionID: chat.id,
620+
model: outputRetryModel,
621+
})
622+
623+
const value = yield* handle.process({
624+
user: {
625+
id: parent.id,
626+
sessionID: chat.id,
627+
role: "user",
628+
time: parent.time,
629+
agent: parent.agent,
630+
model: { providerID: ref.providerID, modelID: ref.modelID },
631+
} satisfies SessionV1.User,
632+
sessionID: chat.id,
633+
model: outputRetryModel,
634+
agent: agent(),
635+
system: [],
636+
messages: [{ role: "user", content: "reason" }],
637+
tools: {},
638+
})
639+
640+
expect(value).toBe("stop")
641+
expect(semanticRetryInputs).toHaveLength(2)
642+
expect(handle.message.error).toMatchObject({
643+
name: "APIError",
644+
data: { message: "Model returned reasoning without an answer or tool call" },
645+
})
646+
}),
647+
),
648+
)
649+
650+
itSemanticRetry.live("session.processor effect tests do not retry unterminated nonblank text", () =>
651+
provideTmpdirInstance((dir) =>
652+
Effect.gen(function* () {
653+
const { processors, session } = yield* boot()
654+
semanticRetryInputs.length = 0
655+
semanticRetryStreams.length = 0
656+
semanticRetryStreams.push(
657+
Stream.make(
658+
LLMEvent.stepStart({ index: 0 }),
659+
LLMEvent.textStart({ id: "text-1" }),
660+
LLMEvent.textDelta({ id: "text-1", text: "visible" }),
661+
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
662+
LLMEvent.finish({ reason: "stop" }),
663+
),
664+
)
665+
666+
const chat = yield* session.create({})
667+
const parent = yield* user(chat.id, "reason")
668+
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
669+
const handle = yield* processors.create({
670+
assistantMessage: msg,
671+
sessionID: chat.id,
672+
model: outputRetryModel,
673+
})
674+
675+
const value = yield* handle.process({
676+
user: {
677+
id: parent.id,
678+
sessionID: chat.id,
679+
role: "user",
680+
time: parent.time,
681+
agent: parent.agent,
682+
model: { providerID: ref.providerID, modelID: ref.modelID },
683+
} satisfies SessionV1.User,
684+
sessionID: chat.id,
685+
model: outputRetryModel,
686+
agent: agent(),
687+
system: [],
688+
messages: [{ role: "user", content: "reason" }],
689+
tools: {},
690+
})
691+
const parts = yield* MessageV2.parts(msg.id)
692+
693+
expect(value).toBe("continue")
694+
expect(semanticRetryInputs).toHaveLength(1)
695+
expect(parts.some((part) => part.type === "text" && part.text === "visible")).toBe(true)
696+
}),
697+
),
698+
)
699+
524700
it.live("session.processor effect tests reset reasoning state across retries", () =>
525701
provideTmpdirServer(
526702
({ dir, llm }) =>

0 commit comments

Comments
 (0)