Skip to content

Commit 882b8e1

Browse files
committed
core: track retry attempts with detailed error context on assistant entries
users can now see when transient failures occur during assistant responses, such as rate limits or provider overloads, giving visibility into what issues were encountered and automatically resolved before the final response
1 parent 95edbc0 commit 882b8e1

4 files changed

Lines changed: 130 additions & 4 deletions

File tree

packages/opencode/src/v2/session-entry-stepper.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { castDraft, produce, type WritableDraft } from "immer"
1+
import { produce, type WritableDraft } from "immer"
22
import { SessionEvent } from "./session-event"
33
import { SessionEntry } from "./session-entry"
44

@@ -235,7 +235,15 @@ export function stepWith<Result>(adapter: Adapter<Result>, event: SessionEvent.E
235235
)
236236
}
237237
},
238-
retried: () => {},
238+
retried: (event) => {
239+
if (currentAssistant) {
240+
adapter.updateAssistant(
241+
produce(currentAssistant, (draft) => {
242+
draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)]
243+
}),
244+
)
245+
}
246+
},
239247
compacted: (event) => {
240248
adapter.appendEntry(SessionEntry.Compaction.fromEvent(event))
241249
},

packages/opencode/src/v2/session-entry.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,24 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
104104
text: Schema.String,
105105
}) {}
106106

107+
export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
108+
attempt: Schema.Number,
109+
error: SessionEvent.RetryError,
110+
time: Schema.Struct({
111+
created: Schema.DateTimeUtc,
112+
}),
113+
}) {
114+
static fromEvent(event: SessionEvent.Retried) {
115+
return new AssistantRetry({
116+
attempt: event.attempt,
117+
error: event.error,
118+
time: {
119+
created: event.timestamp,
120+
},
121+
})
122+
}
123+
}
124+
107125
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
108126
Schema.toTaggedUnion("type"),
109127
)
@@ -113,6 +131,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
113131
...Base,
114132
type: Schema.Literal("assistant"),
115133
content: AssistantContent.pipe(Schema.Array),
134+
retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
116135
cost: Schema.Number.pipe(Schema.optional),
117136
tokens: Schema.Struct({
118137
input: Schema.Number,
@@ -137,6 +156,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
137156
created: event.timestamp,
138157
},
139158
content: [],
159+
retries: [],
140160
})
141161
}
142162
}

packages/opencode/src/v2/session-event.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ export namespace SessionEvent {
5353
source: Source.pipe(Schema.optional),
5454
}) {}
5555

56+
export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
57+
message: Schema.String,
58+
statusCode: Schema.Number.pipe(Schema.optional),
59+
isRetryable: Schema.Boolean,
60+
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
61+
responseBody: Schema.String.pipe(Schema.optional),
62+
metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
63+
}) {}
64+
5665
export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
5766
...Base,
5867
type: Schema.Literal("prompt"),
@@ -386,14 +395,16 @@ export namespace SessionEvent {
386395
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
387396
...Base,
388397
type: Schema.Literal("retried"),
389-
error: Schema.String,
398+
attempt: Schema.Number,
399+
error: RetryError,
390400
}) {
391-
static create(input: BaseInput & { error: string }) {
401+
static create(input: BaseInput & { attempt: number; error: RetryError }) {
392402
return new Retried({
393403
id: input.id ?? ID.create(),
394404
type: "retried",
395405
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
396406
metadata: input.metadata,
407+
attempt: input.attempt,
397408
error: input.error,
398409
})
399410
}

packages/opencode/test/session/session-entry-stepper.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,24 @@ function assistant() {
2727
type: "assistant",
2828
time: { created: time(0) },
2929
content: [],
30+
retries: [],
31+
})
32+
}
33+
34+
function retryError(message: string) {
35+
return new SessionEvent.RetryError({
36+
message,
37+
isRetryable: true,
38+
})
39+
}
40+
41+
function retry(attempt: number, message: string, created: number) {
42+
return new SessionEntry.AssistantRetry({
43+
attempt,
44+
error: retryError(message),
45+
time: {
46+
created: time(created),
47+
},
3048
})
3149
}
3250

@@ -78,6 +96,12 @@ function tool(state: SessionEntryStepper.MemoryState, callID: string) {
7896
return tools(state).find((x) => x.callID === callID)
7997
}
8098

99+
function retriesOf(state: SessionEntryStepper.MemoryState) {
100+
const entry = last(state)
101+
if (!entry) return []
102+
return entry.retries ?? []
103+
}
104+
81105
function adapterStore() {
82106
return {
83107
committed: [] as SessionEntry.Entry[],
@@ -168,6 +192,33 @@ describe("session-entry-stepper", () => {
168192
])
169193
expect(store.committed[0].time.completed).toEqual(time(7))
170194
})
195+
196+
test("aggregates retry events onto the current assistant", () => {
197+
const store = adapterStore()
198+
store.committed.push(assistant())
199+
200+
SessionEntryStepper.stepWith(
201+
adapterFor(store),
202+
SessionEvent.Retried.create({
203+
attempt: 1,
204+
error: retryError("rate limited"),
205+
timestamp: time(1),
206+
}),
207+
)
208+
SessionEntryStepper.stepWith(
209+
adapterFor(store),
210+
SessionEvent.Retried.create({
211+
attempt: 2,
212+
error: retryError("provider overloaded"),
213+
timestamp: time(2),
214+
}),
215+
)
216+
217+
expect(store.committed[0]?.type).toBe("assistant")
218+
if (store.committed[0]?.type !== "assistant") return
219+
220+
expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)])
221+
})
171222
})
172223

173224
describe("memory", () => {
@@ -231,6 +282,21 @@ describe("session-entry-stepper", () => {
231282

232283
expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }])
233284
})
285+
286+
test("stepWith through memory records retries", () => {
287+
const state = active()
288+
289+
SessionEntryStepper.stepWith(
290+
SessionEntryStepper.memory(state),
291+
SessionEvent.Retried.create({
292+
attempt: 1,
293+
error: retryError("rate limited"),
294+
timestamp: time(1),
295+
}),
296+
)
297+
298+
expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)])
299+
})
234300
})
235301

236302
describe("step", () => {
@@ -481,6 +547,27 @@ describe("session-entry-stepper", () => {
481547
})
482548
})
483549

550+
test("records retries on the pending assistant", () => {
551+
const next = run(
552+
[
553+
SessionEvent.Retried.create({
554+
attempt: 1,
555+
error: retryError("rate limited"),
556+
timestamp: time(1),
557+
}),
558+
SessionEvent.Retried.create({
559+
attempt: 2,
560+
error: retryError("provider overloaded"),
561+
timestamp: time(2),
562+
}),
563+
],
564+
active(),
565+
)
566+
567+
expect(retriesOf(next)).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)])
568+
})
569+
})
570+
484571
describe("known reducer gaps", () => {
485572
test("prompt appends immutably when no assistant is pending", () => {
486573
FastCheck.assert(

0 commit comments

Comments
 (0)