Skip to content

Commit c4ccff5

Browse files
authored
Merge pull request #137 from browser-use/retry-output-limit
fix(opencode): retry output-limit responses
2 parents 36f3c0d + 290d419 commit c4ccff5

4 files changed

Lines changed: 187 additions & 4 deletions

File tree

packages/opencode/src/session/processor.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ interface ProcessorContext extends Input {
9696
needsCompaction: boolean
9797
currentText: SessionV1.TextPart | undefined
9898
reasoningMap: Record<string, SessionV1.ReasoningPart>
99+
outputLimitUsage: Pick<SessionV1.StepFinishPart, "cost" | "tokens"> | undefined
99100
}
100101

101102
type StreamEvent = LLMEvent
@@ -135,6 +136,7 @@ const layer = Layer.effect(
135136
needsCompaction: false,
136137
currentText: undefined,
137138
reasoningMap: {},
139+
outputLimitUsage: undefined,
138140
}
139141
let aborted = false
140142

@@ -165,6 +167,26 @@ const layer = Layer.effect(
165167
return { call, part }
166168
})
167169

170+
const resetOutputLimit = Effect.fn("SessionProcessor.resetOutputLimit")(function* () {
171+
const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe(
172+
Effect.provideService(Database.Service, database),
173+
)
174+
// Replace the streamed attempt before resampling the unchanged request.
175+
// Its usage is carried into the next step-finish part.
176+
yield* Effect.forEach(
177+
parts,
178+
(part) =>
179+
session.removePart({
180+
sessionID: part.sessionID,
181+
messageID: part.messageID,
182+
partID: part.id,
183+
}),
184+
{ concurrency: "unbounded" },
185+
)
186+
ctx.assistantMessage.finish = undefined
187+
yield* session.updateMessage(ctx.assistantMessage)
188+
})
189+
168190
const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
169191
toolCallID: string,
170192
update: (part: SessionV1.ToolPart) => SessionV1.ToolPart,
@@ -466,20 +488,40 @@ const layer = Layer.effect(
466488
usage: value.usage ?? new Usage({}),
467489
metadata: value.providerMetadata,
468490
})
491+
const previous = ctx.outputLimitUsage
492+
const total =
493+
previous?.tokens.total === undefined && usage.tokens.total === undefined
494+
? undefined
495+
: (previous?.tokens.total ?? 0) + (usage.tokens.total ?? 0)
496+
const accounted = {
497+
cost: (previous?.cost ?? 0) + usage.cost,
498+
tokens: {
499+
...(total === undefined ? {} : { total }),
500+
input: (previous?.tokens.input ?? 0) + usage.tokens.input,
501+
output: (previous?.tokens.output ?? 0) + usage.tokens.output,
502+
reasoning: (previous?.tokens.reasoning ?? 0) + usage.tokens.reasoning,
503+
cache: {
504+
read: (previous?.tokens.cache.read ?? 0) + usage.tokens.cache.read,
505+
write: (previous?.tokens.cache.write ?? 0) + usage.tokens.cache.write,
506+
},
507+
},
508+
}
509+
ctx.outputLimitUsage = value.reason === "length" ? accounted : undefined
469510
ctx.assistantMessage.finish = value.reason
470511
ctx.assistantMessage.cost += usage.cost
471-
ctx.assistantMessage.tokens = usage.tokens
512+
ctx.assistantMessage.tokens = accounted.tokens
472513
yield* session.updatePart({
473514
id: PartID.ascending(),
474515
reason: value.reason,
475516
snapshot: completedSnapshot,
476517
messageID: ctx.assistantMessage.id,
477518
sessionID: ctx.assistantMessage.sessionID,
478519
type: "step-finish",
479-
tokens: usage.tokens,
480-
cost: usage.cost,
520+
tokens: accounted.tokens,
521+
cost: accounted.cost,
481522
})
482523
yield* session.updateMessage(ctx.assistantMessage)
524+
if (value.reason === "length") throw new SessionV1.OutputLengthError({})
483525
if (ctx.snapshot) {
484526
const patch = yield* snapshot.patch(ctx.snapshot)
485527
if (patch.files.length) {
@@ -692,6 +734,10 @@ const layer = Layer.effect(
692734
SessionRetry.policy({
693735
provider: input.model.providerID,
694736
parse,
737+
// Only replace attempts that will be retried. Cloud intentionally
738+
// returns the terminal partial next to the truncation error.
739+
onRetry: (error) =>
740+
SessionV1.OutputLengthError.isInstance(error) ? resetOutputLimit() : Effect.void,
695741
set: (info) => {
696742
return status.set(ctx.sessionID, {
697743
type: "retry",

packages/opencode/src/session/retry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {
2323

2424
export type Retryable = {
2525
message: string
26+
maxAttempts?: number
2627
action?: {
2728
reason: RetryReason
2829
provider: string
@@ -76,6 +77,9 @@ export function delay(attempt: number, error?: SessionV1.APIError) {
7677
}
7778

7879
export function retryable(error: Err, provider: string) {
80+
if (SessionV1.OutputLengthError.isInstance(error)) {
81+
return { message: "Model hit its output limit", maxAttempts: 3 }
82+
}
7983
// context overflow errors should not be retried
8084
if (SessionV1.ContextOverflowError.isInstance(error)) return undefined
8185
if (SessionV1.APIError.isInstance(error)) {
@@ -186,14 +190,17 @@ function parseJSON(value: unknown) {
186190
export function policy(opts: {
187191
provider: string
188192
parse: (error: unknown) => Err
193+
onRetry?: (error: Err) => Effect.Effect<void>
189194
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
190195
}) {
191196
return Schedule.fromStepWithMetadata(
192197
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
193198
const error = opts.parse(meta.input)
194199
const retry = retryable(error, opts.provider)
195200
if (!retry) return Cause.done(meta.attempt)
201+
if (retry.maxAttempts !== undefined && meta.attempt >= retry.maxAttempts) return Cause.done(meta.attempt)
196202
return Effect.gen(function* () {
203+
if (opts.onRetry) yield* opts.onRetry(error)
197204
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
198205
const now = yield* Clock.currentTimeMillis
199206
yield* opts.set({

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,28 @@ const ref = {
4141
modelID: ModelV2.ID.make("test-model"),
4242
}
4343

44+
const outputRetryModel: Provider.Model = {
45+
id: ref.modelID,
46+
providerID: ref.providerID,
47+
api: { id: "test-model", url: "https://example.com", npm: "@ai-sdk/openai" },
48+
name: "Test Model",
49+
capabilities: {
50+
temperature: true,
51+
reasoning: false,
52+
attachment: false,
53+
toolcall: true,
54+
input: { text: true, audio: false, image: false, video: false, pdf: false },
55+
output: { text: true, audio: false, image: false, video: false, pdf: false },
56+
interleaved: false,
57+
},
58+
cost: { input: 1, output: 1, cache: { read: 0, write: 0 } },
59+
limit: { context: 100_000, input: 100_000, output: 10_000 },
60+
status: "active",
61+
options: {},
62+
headers: {},
63+
release_date: "2026-01-01",
64+
}
65+
4466
const cfg = {
4567
provider: {
4668
test: {
@@ -226,6 +248,38 @@ const fragmentFailureLLM = Layer.succeed(
226248
const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])
227249
const itFragmentFailure = testEffect(fragmentFailureEnv)
228250

251+
const outputRetryInputs: LLM.StreamInput[] = []
252+
const outputRetryUsage = {
253+
truncated: { input: 3, output: 5 },
254+
complete: { input: 7, output: 11 },
255+
} as const
256+
const outputRetryLLM = Layer.succeed(
257+
LLM.Service,
258+
LLM.Service.of({
259+
stream: (input) => {
260+
outputRetryInputs.push(input)
261+
const first = outputRetryInputs.length === 1
262+
return Stream.make(
263+
LLMEvent.stepStart({ index: 0 }),
264+
LLMEvent.textStart({ id: "text-1" }),
265+
LLMEvent.textDelta({ id: "text-1", text: first ? "truncated" : "complete" }),
266+
LLMEvent.textEnd({ id: "text-1" }),
267+
LLMEvent.stepFinish({
268+
index: 0,
269+
reason: first ? "length" : "stop",
270+
usage: {
271+
inputTokens: first ? outputRetryUsage.truncated.input : outputRetryUsage.complete.input,
272+
outputTokens: first ? outputRetryUsage.truncated.output : outputRetryUsage.complete.output,
273+
},
274+
}),
275+
LLMEvent.finish({ reason: first ? "length" : "stop" }),
276+
)
277+
},
278+
}),
279+
)
280+
const outputRetryEnv = LayerNode.compile(root, [...replacements, [LLM.node, outputRetryLLM]])
281+
const itOutputRetry = testEffect(outputRetryEnv)
282+
229283
const boot = Effect.fn("test.boot")(function* () {
230284
const processors = yield* SessionProcessor.Service
231285
const session = yield* Session.Service
@@ -514,6 +568,58 @@ it.live("session.processor effect tests reset reasoning state across retries", (
514568
),
515569
)
516570

571+
itOutputRetry.live("session.processor effect tests resample the exact request after an output limit", () =>
572+
provideTmpdirInstance((dir) =>
573+
Effect.gen(function* () {
574+
const { processors, session } = yield* boot()
575+
outputRetryInputs.length = 0
576+
577+
const chat = yield* session.create({})
578+
const parent = yield* user(chat.id, "resample")
579+
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
580+
const handle = yield* processors.create({
581+
assistantMessage: msg,
582+
sessionID: chat.id,
583+
model: outputRetryModel,
584+
})
585+
586+
const value = yield* handle.process({
587+
user: {
588+
id: parent.id,
589+
sessionID: chat.id,
590+
role: "user",
591+
time: parent.time,
592+
agent: parent.agent,
593+
model: { providerID: ref.providerID, modelID: ref.modelID },
594+
} satisfies SessionV1.User,
595+
sessionID: chat.id,
596+
model: outputRetryModel,
597+
agent: agent(),
598+
system: [],
599+
messages: [{ role: "user", content: "resample" }],
600+
tools: {},
601+
})
602+
603+
const parts = yield* MessageV2.parts(msg.id)
604+
605+
expect(value).toBe("continue")
606+
expect(outputRetryInputs).toHaveLength(2)
607+
expect(outputRetryInputs[1]).toBe(outputRetryInputs[0])
608+
expect(parts.filter((part) => part.type === "text").map((part) => part.text)).toStrictEqual(["complete"])
609+
const finishes = parts.filter((part) => part.type === "step-finish")
610+
const input = outputRetryUsage.truncated.input + outputRetryUsage.complete.input
611+
const output = outputRetryUsage.truncated.output + outputRetryUsage.complete.output
612+
expect(finishes).toHaveLength(1)
613+
expect(finishes[0]).toMatchObject({
614+
reason: "stop",
615+
tokens: { input, output },
616+
})
617+
expect(finishes[0]?.cost).toBeCloseTo((input + output) / 1_000_000)
618+
expect(handle.message.finish).toBe("stop")
619+
}),
620+
),
621+
)
622+
517623
it.live("session.processor effect tests do not retry unknown json errors", () =>
518624
provideTmpdirServer(
519625
({ dir, llm }) =>

packages/opencode/test/session/retry.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
44
import type { NamedError } from "@opencode-ai/core/util/error"
55
import { APICallError } from "ai"
66
import { setTimeout as sleep } from "node:timers/promises"
7-
import { Effect, Schedule, Schema } from "effect"
7+
import { Effect, Exit, Schedule, Schema } from "effect"
88
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
99
import { SessionRetry } from "../../src/session/retry"
1010
import { MessageV2 } from "../../src/session/message-v2"
@@ -115,6 +115,30 @@ describe("session.retry.delay", () => {
115115
})
116116
}),
117117
)
118+
119+
it.effect("policy caps output-length errors at three total calls", () =>
120+
Effect.gen(function* () {
121+
const error = new SessionV1.OutputLengthError({}).toObject()
122+
const attempts: number[] = []
123+
let retries = 0
124+
const step = yield* Schedule.toStep(
125+
SessionRetry.policy({
126+
provider: "test",
127+
parse: () => error,
128+
onRetry: () => Effect.sync(() => retries++),
129+
set: (info) => Effect.sync(() => attempts.push(info.attempt)),
130+
}),
131+
)
132+
133+
yield* step(0, error)
134+
yield* step(0, error)
135+
const third = yield* step(0, error).pipe(Effect.exit)
136+
137+
expect(attempts).toStrictEqual([1, 2])
138+
expect(retries).toBe(2)
139+
expect(Exit.isFailure(third)).toBe(true)
140+
}),
141+
)
118142
})
119143

120144
describe("session.retry.retryable", () => {

0 commit comments

Comments
 (0)