Skip to content

Commit 5140cef

Browse files
authored
Merge pull request #175 from browser-use/transport-recovery
fix(session): recover interrupted V4 model streams safely
2 parents ea72b83 + 625c05b commit 5140cef

9 files changed

Lines changed: 304 additions & 13 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,5 @@ runs this filtered command.
233233
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
234234
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
235235
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
236+
237+
- Retry streamed transport failures with a finite budget only while no tool call has been observed and no tool execution has begun; preserve earlier parts and discard only the incomplete attempt.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# V4 LLM transport recovery
2+
3+
The Cloud proxy emits `upstream_stream_failed` after an interrupted upstream stream. BrowserCode previously treated this envelope as an unknown terminal error. The fix recognizes transport exception classes, permits at most three total attempts with existing backoff, removes only incomplete attempt parts, and refuses replay once prepared tool execution starts or a tool call is observed.
4+
5+
The focused test uses monotonic Responses sequence numbers, distinct output indexes, the actual OpenAI SDK and an HTTP 200 SSE fixture. It covers ReadError, RemoteProtocolError, APIConnectionError, exhaustion, an executed action, a non-transport exception, and interruption during backoff. Recovery requires two HTTP calls, one tool execution, recovered output only, and correct token/cost accounting. Exhaustion requires exactly three HTTP calls.
6+
7+
With the final unchanged regression (SHA256 `bf17f328cead02406ab2bbd81ba2a93bb009d893efe3ce33c30b5e9d2af38673`), baseline main had two passing safety cases and five failures; the fix passed all seven. Package typecheck passed. Focused lint reported zero errors and 42 warnings. VM evidence is in `/home/exedev/v4-llm-transport-proof`.
8+
9+
Run from `packages/opencode` with Bun 1.3.14:
10+
```sh
11+
bun test test/session/processor-effect.test.ts -t 'gateway transport recovery' --timeout 25000
12+
bun typecheck
13+
```
14+
15+
This fixes a recovery gap, not the unconfirmed provider/network trigger. Production still uses BrowserCode 0.1.21-telemetry.1; an updated binary release and a reviewed Cloud V4 worker pin are required. No release, Cloud pin, AWS change, or deployment was performed.

‎packages/opencode/src/provider/error.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export type ParsedStreamError =
9797
message: string
9898
isRetryable: boolean
9999
responseBody: string
100+
metadata?: Record<string, string>
100101
}
101102

102103
export function parseStreamError(input: unknown): ParsedStreamError | undefined {
@@ -142,6 +143,21 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
142143
isRetryable: false,
143144
responseBody,
144145
}
146+
case "upstream_stream_failed":
147+
if (
148+
typeof error.message !== "string" ||
149+
!/^(ReadError|ReadTimeout|RemoteProtocolError|APIConnectionError|ConnectError|ConnectTimeout)(?::|$)/.test(
150+
error.message,
151+
)
152+
)
153+
return
154+
return {
155+
type: "api_error",
156+
message: error.message,
157+
isRetryable: true,
158+
responseBody,
159+
metadata: { code: "upstream_stream_failed" },
160+
}
145161
case "server_is_overloaded":
146162
case "server_error":
147163
return {

‎packages/opencode/src/session/llm.ts‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export type StreamInput = {
4444
messages: ModelMessage[]
4545
small?: boolean
4646
tools: Record<string, Tool>
47+
onToolExecution?: () => void
4748
retries?: number
4849
toolChoice?: "auto" | "required" | "none"
4950
}
@@ -115,6 +116,22 @@ const live: Layer.Layer<
115116
isWorkflow,
116117
})
117118

119+
// SDK tool execution can begin before the processor receives its tool-call event.
120+
const tools = Object.fromEntries(
121+
Object.entries(prepared.tools).map(([name, value]) => [
122+
name,
123+
value.execute
124+
? {
125+
...value,
126+
execute: (...args: Parameters<NonNullable<typeof value.execute>>) => {
127+
input.onToolExecution?.()
128+
return value.execute!(...args)
129+
},
130+
}
131+
: value,
132+
]),
133+
)
134+
118135
// Wire up toolExecutor for DWS workflow models so that tool calls
119136
// from the workflow service are executed via opencode's tool system
120137
// and results sent back over the WebSocket.
@@ -128,7 +145,7 @@ const live: Layer.Layer<
128145
workflowModel.sessionID = input.sessionID
129146
workflowModel.systemPrompt = prepared.system.join("\n")
130147
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
131-
const t = prepared.tools[toolName]
148+
const t = tools[toolName]
132149
if (!t || !t.execute) {
133150
return { result: "", error: `Unknown tool: ${toolName}` }
134151
}
@@ -151,7 +168,7 @@ const live: Layer.Layer<
151168
}
152169

153170
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
154-
workflowModel.sessionPreapprovedTools = Object.keys(prepared.tools).filter((name) => {
171+
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
155172
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
156173
return !match || match.action !== "ask"
157174
})
@@ -234,7 +251,7 @@ const live: Layer.Layer<
234251
auth: info,
235252
llmClient,
236253
messages: prepared.messages,
237-
tools: prepared.tools,
254+
tools,
238255
toolChoice: input.toolChoice,
239256
temperature: prepared.params.temperature,
240257
topP: prepared.params.topP,
@@ -299,7 +316,7 @@ const live: Layer.Layer<
299316
includeRawChunks: input.model.providerID.includes("github-copilot"),
300317
async experimental_repairToolCall(failed) {
301318
const lower = failed.toolCall.toolName.toLowerCase()
302-
if (lower !== failed.toolCall.toolName && prepared.tools[lower]) {
319+
if (lower !== failed.toolCall.toolName && tools[lower]) {
303320
return {
304321
...failed.toolCall,
305322
toolName: lower,
@@ -319,8 +336,8 @@ const live: Layer.Layer<
319336
topP: prepared.params.topP,
320337
topK: prepared.params.topK,
321338
providerOptions: ProviderTransform.providerOptions(input.model, prepared.params.options),
322-
activeTools: Object.keys(prepared.tools).filter((x) => x !== "invalid"),
323-
tools: prepared.tools,
339+
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
340+
tools,
324341
toolChoice: input.toolChoice,
325342
maxOutputTokens: prepared.params.maxOutputTokens,
326343
abortSignal: input.abort,

‎packages/opencode/src/session/message-v2.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,7 @@ export function fromError(
747747
message: parsed.message,
748748
isRetryable: parsed.isRetryable,
749749
responseBody: parsed.responseBody,
750+
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
750751
},
751752
{
752753
cause: e,

‎packages/opencode/src/session/processor.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,14 @@ const layer = Layer.effect(
167167
return { call, part }
168168
})
169169

170-
const resetOutputLimit = Effect.fn("SessionProcessor.resetOutputLimit")(function* () {
170+
const resetAttempt = Effect.fn("SessionProcessor.resetAttempt")(function* (keep?: ReadonlySet<PartID>) {
171171
const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe(
172172
Effect.provideService(Database.Service, database),
173173
)
174174
// Replace the streamed attempt before resampling the unchanged request.
175175
// Its usage is carried into the next step-finish part.
176176
yield* Effect.forEach(
177-
parts,
177+
parts.filter((part) => !keep?.has(part.id)),
178178
(part) =>
179179
session.removePart({
180180
sessionID: part.sessionID,
@@ -702,6 +702,19 @@ const layer = Layer.effect(
702702
"session.id": input.sessionID,
703703
messageID: input.assistantMessage.id,
704704
})
705+
const retained = new Set(
706+
(yield* MessageV2.parts(ctx.assistantMessage.id).pipe(Effect.provideService(Database.Service, database))).map(
707+
(part) => part.id,
708+
),
709+
)
710+
let toolExecuted = false
711+
const request = {
712+
...streamInput,
713+
onToolExecution: () => {
714+
toolExecuted = true
715+
streamInput.onToolExecution?.()
716+
},
717+
}
705718
ctx.needsCompaction = false
706719
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
707720

@@ -710,10 +723,13 @@ const layer = Layer.effect(
710723
ctx.currentText = undefined
711724
ctx.reasoningMap = {}
712725
yield* status.set(ctx.sessionID, { type: "busy" })
713-
const stream = llm.stream(streamInput)
726+
const stream = llm.stream(request)
714727

715728
yield* stream.pipe(
716-
Stream.tap((event) => handleEvent(event)),
729+
Stream.tap((event) => {
730+
if (event.type === "tool-call" || event.type === "tool-result") toolExecuted = true
731+
return handleEvent(event)
732+
}),
717733
Stream.takeUntil(() => ctx.needsCompaction),
718734
Stream.runDrain,
719735
)
@@ -734,10 +750,15 @@ const layer = Layer.effect(
734750
SessionRetry.policy({
735751
provider: input.model.providerID,
736752
parse,
753+
canRetry: (error) => !SessionRetry.isTransportError(error) || !toolExecuted,
737754
// Only replace attempts that will be retried. Cloud intentionally
738755
// returns the terminal partial next to the truncation error.
739756
onRetry: (error) =>
740-
SessionV1.OutputLengthError.isInstance(error) ? resetOutputLimit() : Effect.void,
757+
SessionV1.OutputLengthError.isInstance(error)
758+
? resetAttempt()
759+
: SessionRetry.isTransportError(error)
760+
? resetAttempt(retained)
761+
: Effect.void,
741762
set: (info) => {
742763
return status.set(ctx.sessionID, {
743764
type: "retry",

‎packages/opencode/src/session/retry.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,18 @@ export function delay(attempt: number, error?: SessionV1.APIError) {
8080
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
8181
}
8282

83+
export function isTransportError(error: Err) {
84+
return SessionV1.APIError.isInstance(error) && error.data.metadata?.code === "upstream_stream_failed"
85+
}
86+
8387
export function retryable(error: Err, provider: string) {
8488
if (SessionV1.OutputLengthError.isInstance(error)) {
8589
return { message: "Model hit its output limit", maxTotalAttempts: 3 }
8690
}
8791
// context overflow errors should not be retried
8892
if (SessionV1.ContextOverflowError.isInstance(error)) return undefined
8993
if (SessionV1.APIError.isInstance(error)) {
94+
if (isTransportError(error)) return { message: error.data.message, maxTotalAttempts: 3 }
9095
const status = error.data.statusCode
9196
// 5xx errors are transient server failures and should always be retried,
9297
// even when the provider SDK doesn't explicitly mark them as retryable.
@@ -194,14 +199,15 @@ function parseJSON(value: unknown) {
194199
export function policy(opts: {
195200
provider: string
196201
parse: (error: unknown) => Err
202+
canRetry?: (error: Err) => boolean
197203
onRetry?: (error: Err) => Effect.Effect<void>
198204
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
199205
}) {
200206
return Schedule.fromStepWithMetadata(
201207
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
202208
const error = opts.parse(meta.input)
203209
const retry = retryable(error, opts.provider)
204-
if (!retry) return Cause.done(meta.attempt)
210+
if (!retry || opts.canRetry?.(error) === false) return Cause.done(meta.attempt)
205211
// `meta.attempt` is 1-based and shared by every reason (Schedule keeps one
206212
// counter per request), which is why the cap reads as a total.
207213
if (retry.maxTotalAttempts !== undefined && meta.attempt >= retry.maxTotalAttempts)

‎packages/opencode/test/lib/llm-server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ function flow(item: Sse) {
330330
}
331331

332332
function responses(item: Sse, model: string) {
333+
if ([...item.head, ...item.tail].some((part) => part && typeof part === "object" && "type" in part)) return item
333334
let seq = 1
334335
let msg: string | undefined
335336
let reason: string | undefined

0 commit comments

Comments
 (0)