feat(core): finalize session event lifecycle (#35272)

This commit is contained in:
Kit Langton 2026-07-06 18:14:06 -04:00 committed by GitHub
commit bf01264661
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 5200 additions and 3711 deletions

View file

@ -3,13 +3,19 @@ export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema"
import type { MessageDecodeError } from "../error"
import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
import { SessionRunnerModel } from "./model"
import type { Instructions } from "../../instructions/index"
import type { ToolOutputStore } from "../../tool-output-store"
export type RunError =
LLMError | SessionRunnerModel.Error | MessageDecodeError | Instructions.InitializationBlocked | ToolOutputStore.Error
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| StepFailedError
| UserInterruptedError
| Instructions.InitializationBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {

View file

@ -10,7 +10,8 @@ import {
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
@ -32,6 +33,7 @@ import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
@ -44,6 +46,9 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError, UserInterruptedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
/**
* Runs one durable coding-agent Session until it settles.
@ -54,10 +59,10 @@ import { llmClient } from "../../effect/app-node-platform"
* - Session ownership and controls
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
* - [x] Publish durable historical execution lifecycle and bounded retry observations.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
* - [x] Honor optional agent step limits.
* - [ ] Bound provider retries and repeated identical tool calls.
* - [ ] Bound repeated identical tool calls (provider retries are bounded).
*
* - Runtime context assembly
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
@ -66,7 +71,7 @@ import { llmClient } from "../../effect/app-node-platform"
* - [x] Translate every projected V2 Session message variant into canonical
* `@opencode-ai/llm` messages.
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
* - [x] Stream exactly one `llm.stream(request)` physical attempt.
* - [x] Stream exactly one `llm.stream(request)` call per attempt.
* - [x] Persist assistant text and usage events incrementally as they arrive.
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
@ -87,7 +92,7 @@ import { llmClient } from "../../effect/app-node-platform"
* - [ ] Coalesce streamed deltas and add covering projected-history indexes.
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
*
* Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here.
* Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here.
* Durable continuation recovery remains a separate future slice with an explicit retry policy.
*
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
@ -137,19 +142,13 @@ const layer = Layer.effect(
sessionID,
assistantMessageID: message.id,
callID: tool.id,
error: { type: "unknown", message: "Tool execution interrupted" },
provider: {
executed: tool.provider?.executed === true,
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
},
error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
}
}
})
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
cause.reasons.some(
@ -176,6 +175,7 @@ const layer = Layer.effect(
promotion: SessionInput.Delivery | undefined,
step: number,
recoverOverflow?: typeof compaction.compactAfterOverflow,
assistantMessageID?: SessionMessage.ID,
) {
const session = yield* getSession(sessionID)
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
@ -189,7 +189,8 @@ const layer = Layer.effect(
loadInstructions(agent, session.id),
session.id,
)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
let needsContinuation = false
let currentStep = step
if (promotion) {
@ -236,21 +237,23 @@ const layer = Layer.effect(
// The selected catalog identity, not model.id: route-level ids are provider API
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref,
provider: model.provider,
snapshot: startSnapshot,
assistantMessageID,
})
const publication = Semaphore.makeUnsafe(1)
// Durable publishes are serialized so tool fibers and step settlement never interleave
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
serialized(publisher.publish(event, outputPaths))
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
serialized(publisher.publish(event, outputPaths, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
overflowFailure = event
return
}
@ -258,33 +261,49 @@ const layer = Layer.effect(
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
yield* serialized(
publisher.failUnsettledTools({
type: "tool.execution",
message: "Tools are disabled after the maximum agent steps",
}),
)
return
}
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
call: event,
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.outputPaths ?? [],
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
call: event,
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.outputPaths ?? [],
settlement.error,
).pipe(
Effect.andThen(
settlement.error?.type === "permission.rejected"
? serialized(publisher.failAssistant(settlement.error)).pipe(
Effect.andThen(Effect.fail(new UserInterruptedError())),
)
: Effect.void,
),
),
),
),
),
).pipe(FiberSet.run(toolFibers))
).pipe(FiberSet.run(toolFibers)),
)
}),
),
Effect.ensuring(serialized(publisher.flush())),
@ -327,64 +346,118 @@ const layer = Layer.effect(
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
!publisher.hasAssistantStarted() &&
!publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
)
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
// An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure fails hosted tool calls and the assistant unless a provider
// error was already recorded from the stream.
// thrown LLM failure records the assistant failure unless a provider error was
// already recorded from the stream. Terminal publication waits for owned tools.
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* serialized(publisher.failAssistant(llmFailure.reason.message))
const error = toSessionError(llmFailure)
if (
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.hasRetryEvidence() &&
(agent.info?.steps === undefined || currentStep < agent.info.steps)
) {
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
assistantMessageID: yield* publisher.startAssistant(),
error,
step: currentStep,
})
}
yield* serialized(publisher.failAssistant(error))
}
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError()
// Settle tool fibers: an interrupted stream abandons unstarted tool work first.
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
// the individual fibers and await all exits before publishing the terminal step event.
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
const userDeclined = settled._tag === "Failure" && isUserDeclined(settled.cause)
const settled = yield* restore(
Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
).pipe(Effect.exit)
const settledCauses =
settled._tag === "Failure"
? [settled.cause]
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
const userDeclined = settledCauses.some(isUserDeclined)
const permissionRejected = settledCauses.some(
(cause) => Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof UserInterruptedError,
)
if (userDeclined || streamInterrupted || toolsInterrupted) {
if (userDeclined || permissionRejected || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
yield* serialized(publisher.failAssistant("Step interrupted"))
if (userDeclined) return yield* Effect.interrupt
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
}
// A settled tool fiber failure is one of two things. A defect from a tool
// implementation becomes a failed tool call the model can read, and the step still
// settles so the model may recover. A typed infrastructure failure (tool output
// could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
const settledFailure = settledCauses.find(
(cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause) && !permissionRejected,
)
const infraError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure)
const message = failure instanceof Error ? failure.message : String(failure)
yield* serialized(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
if (infraError !== undefined)
yield* serialized(publisher.failAssistant(`Tool execution failed: ${message}`))
const error = toSessionError(failure)
yield* serialized(publisher.failUnsettledTools(error))
if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
}
// Fail unresolved calls before the terminal step event. Local calls have joined, so
// these sweeps only close calls that could not produce a truthful settlement.
if (providerFailed)
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
if (llmFailure && !providerFailed)
yield* serialized(
publisher.failUnsettledTools(
{
type: "tool.result-missing",
message: "Provider did not return a tool result",
},
true,
),
)
const hostedResultMissing =
stream._tag === "Success" && !providerFailed
? yield* serialized(
publisher.failUnsettledTools(
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
true,
),
)
: false
if (hostedResultMissing && !publisher.stepSettlement())
yield* serialized(
publisher.failAssistant({
type: "tool.result-missing",
message: "Provider did not return a tool result",
}),
)
const stepFailure = publisher.stepFailure()
const stepSettlement = publisher.stepSettlement()
const stepEndedCleanly =
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
// A provider error orphans recorded local calls; a clean stream can still leave
// hosted calls without results.
if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !providerFailed)
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
if (stepFailure) yield* serialized(publisher.publishStepFailure())
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
return yield* Effect.failCause(settled.cause)
if (userDeclined) return yield* Effect.interrupt
if (permissionRejected) return yield* new UserInterruptedError()
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
return yield* Effect.failCause(settledFailure)
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
return {
_tag: "Completed",
needsContinuation: !providerFailed && needsContinuation,
@ -405,8 +478,31 @@ const layer = Layer.effect(
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
let currentPromotion = promotion
let currentStep = step
let assistantMessageID: SessionMessage.ID | undefined
while (true) {
const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow)
const attempt = yield* Effect.suspend(() =>
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
).pipe(
Effect.tapError((error) =>
error instanceof SessionRunnerRetry.RetryableFailure
? Effect.sync(() => {
currentStep = error.step + 1
assistantMessageID = error.assistantMessageID
currentPromotion = undefined
})
: Effect.void,
),
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
return events
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID: error.assistantMessageID,
error: error.error,
})
.pipe(Effect.andThen(Effect.fail(error.cause)))
}),
)
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
yield* Effect.yieldNow
@ -415,8 +511,7 @@ const layer = Layer.effect(
}
})
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per
// drain here.
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean

View file

@ -1,16 +1,19 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import { DateTime, Effect } from "effect"
import { Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionError } from "@opencode-ai/schema/session-error"
type Input = {
readonly sessionID: SessionSchema.ID
readonly agent: string
readonly model: ModelV2.Ref
readonly provider: string
readonly snapshot?: string
readonly assistantMessageID?: SessionMessage.ID
}
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
@ -41,10 +44,10 @@ const message = (value: unknown) => {
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: { readonly type: "unknown"; readonly message: string } }
| { readonly error: SessionError.Error }
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
@ -61,20 +64,25 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
called: boolean
settled: boolean
providerExecuted: boolean
providerMetadata?: ProviderMetadata
}
>()
const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined
let assistantActive = false
let assistantFailed = false
let assistantMessageID = input.assistantMessageID
let stepStarted = false
let stepFailed = false
let providerFailed = false
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
let retryEvidence = false
let stepFailure: SessionError.Error | undefined
let stepSettlement:
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly tokens: ReturnType<typeof tokens>
}
| undefined
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = SessionMessage.ID.create()
assistantActive = true
if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
assistantMessageID ??= SessionMessage.ID.create()
stepStarted = true
yield* events.publish(SessionEvent.Step.Started, {
...input,
assistantMessageID,
@ -86,29 +94,34 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID === undefined
? Effect.die(new Error("Tool event before assistant step start"))
: Effect.succeed(assistantMessageID)
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
const fragments = (
name: string,
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
single = false,
) => {
const chunks = new Map<string, string[]>()
const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>()
let nextOrdinal = 0
const start = (id: string) =>
Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
chunks.set(id, [])
return Effect.void
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
const ordinal = nextOrdinal++
chunks.set(id, { ordinal, values: [] })
return Effect.succeed(ordinal)
})
const append = (id: string, value: string) =>
Effect.suspend(() => {
const current = chunks.get(id)
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
current.push(value)
return Effect.void
current.values.push(value)
return Effect.succeed(current.ordinal)
})
const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) {
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* ended(id, current.join(""), providerMetadata)
yield* ended(id, current.values.join(""), current.ordinal, state)
chunks.delete(id)
})
const flush = Effect.fnUntraced(function* () {
@ -117,26 +130,32 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return { start, append, end, flush }
}
const text = fragments("text", (textID, value) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
textID,
text: value,
})
}),
const text = fragments(
"text",
(_textID, value, ordinal) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
text: value,
})
}),
true,
)
const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
reasoningID,
text: value,
providerMetadata,
})
}),
const reasoning = fragments(
"reasoning",
(_reasoningID, value, ordinal, state) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
text: value,
state,
})
}),
true,
)
const toolInput = fragments("tool input", (callID, value) =>
Effect.gen(function* () {
@ -191,37 +210,41 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flushFragments()
})
const failAssistant = Effect.fnUntraced(function* (message: string) {
if (assistantFailed) return
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
yield* flush()
yield* startAssistant()
if (replace || stepFailure === undefined) stepFailure = error
})
const publishStepFailure = Effect.fnUntraced(function* () {
if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant()
assistantActive = false
assistantFailed = true
stepFailed = true
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
assistantMessageID,
error: { type: "unknown", message },
error: stepFailure,
})
})
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
message: string,
error: SessionError.Error,
hostedOnly = false,
) {
let failed = false
for (const [callID, tool] of tools) {
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
tool.settled = true
failed = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error: { type: "unknown", message },
provider: {
executed: tool.providerExecuted,
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
},
error,
executed: tool.providerExecuted,
})
}
return failed
})
const assistantMessageIDForTool = (callID: string) => {
@ -232,24 +255,27 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent,
outputPaths: ReadonlyArray<string> = [],
error?: SessionError.Error,
) {
switch (event.type) {
case "step-start":
yield* startAssistant()
return
case "text-start":
yield* text.start(event.id)
retryEvidence = true
const startedTextOrdinal = yield* text.start(event.id)
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
textID: event.id,
ordinal: startedTextOrdinal,
})
return
case "text-delta":
yield* text.append(event.id, event.text)
const deltaTextOrdinal = yield* text.append(event.id, event.text)
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
textID: event.id,
ordinal: deltaTextOrdinal,
delta: event.text,
})
return
@ -257,27 +283,29 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* text.end(event.id)
return
case "reasoning-start":
yield* reasoning.start(event.id)
retryEvidence = true
const startedReasoningOrdinal = yield* reasoning.start(event.id)
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
reasoningID: event.id,
providerMetadata: event.providerMetadata,
ordinal: startedReasoningOrdinal,
state: providerState(event.providerMetadata),
})
return
case "reasoning-delta":
yield* reasoning.append(event.id, event.text)
const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text)
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
reasoningID: event.id,
ordinal: deltaReasoningOrdinal,
delta: event.text,
})
return
case "reasoning-end":
yield* reasoning.end(event.id, event.providerMetadata)
yield* reasoning.end(event.id, providerState(event.providerMetadata))
return
case "tool-input-start":
retryEvidence = true
yield* startToolInput(event)
return
case "tool-input-delta": {
@ -299,6 +327,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* endToolInput(event)
return
case "tool-call": {
retryEvidence = true
if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)!
if (!tool.inputEnded) yield* endToolInput(event)
@ -307,21 +336,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
tool.called = true
tool.providerExecuted = event.providerExecuted === true
tool.providerMetadata = event.providerMetadata
const state = providerState(event.providerMetadata)
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
tool: event.name,
input: record(event.input),
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
executed: tool.providerExecuted,
state,
})
return
}
case "tool-result": {
retryEvidence = true
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
if (tool.name !== event.name)
@ -331,11 +358,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
}
tool.settled = true
const result = settledOutput(event.output, event.result)
const provider = {
executed: event.providerExecuted === true || tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
}
const result = error ? { error } : settledOutput(event.output, event.result)
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
if ("error" in result) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
@ -343,7 +368,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id,
error: result.error,
result: event.result,
provider,
executed,
resultState,
})
return
}
@ -353,12 +379,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id,
...result,
outputPaths,
...(provider.executed ? { result: event.result } : {}),
provider,
...(executed ? { result: event.result } : {}),
executed,
resultState,
})
return
}
case "tool-error": {
retryEvidence = true
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
if (tool.name !== event.name)
@ -369,25 +397,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: { type: "unknown", message: event.message },
provider: {
executed: tool.providerExecuted,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
error:
event.message === `Unknown tool: ${event.name}`
? { type: "tool.unknown", message: event.message }
: { type: "tool.execution", message: event.message },
executed: tool.providerExecuted,
resultState: providerState(event.providerMetadata),
})
return
}
case "step-finish":
yield* flush()
assistantActive = false
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
if (event.reason === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
return
}
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
return
case "finish":
return
case "provider-error":
providerFailed = true
yield* failAssistant(event.message)
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
return
}
})
@ -396,10 +429,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
publish,
flush,
failAssistant,
publishStepFailure,
failUnsettledTools,
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
hasRetryEvidence: () => retryEvidence,
stepFailure: () => stepFailure,
stepSettlement: () => stepSettlement,
startAssistant,
assistantMessageID: assistantMessageIDForTool,

View file

@ -0,0 +1,67 @@
export * as SessionRunnerRetry from "./retry"
import { LLMError } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { EventV2 } from "../../event"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import type { SessionRunner } from "./index"
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
readonly cause: LLMError
readonly assistantMessageID: SessionMessage.ID
readonly error: SessionError.Error
readonly step: number
}> {}
export function isRetryable(error: LLMError) {
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
case "Transport":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
return false
default: {
const exhaustive: never = error.reason
return exhaustive
}
}
}
const retryAfter = (failure: RetryableFailure) => {
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
return failure.cause.reason.retryAfterMs
return undefined
}
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
Schedule.exponential("2 seconds").pipe(
Schedule.take(4),
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
Schedule.passthrough,
Schedule.while(({ input }) => input instanceof RetryableFailure),
Schedule.modifyDelay((failure, delay) => {
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
}),
Schedule.tap((metadata) =>
metadata.input instanceof RetryableFailure
? events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: metadata.input.assistantMessageID,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
})
: Effect.void,
),
)

View file

@ -43,6 +43,11 @@ const textAttachment = (file: FileAttachment) =>
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const providerMetadata = (
provider: string,
state: Record<string, unknown> | undefined,
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
const toolInput = (tool: SessionMessage.AssistantTool) =>
tool.state.status === "pending"
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
@ -53,7 +58,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
id: tool.id,
name: tool.name,
input: toolInput(tool),
providerExecuted: tool.provider?.executed,
providerExecuted: tool.executed,
providerMetadata,
})
@ -62,14 +67,14 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
// TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result =
tool.provider?.executed === true && tool.state.result !== undefined
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result,
providerExecuted: tool.provider?.executed,
providerExecuted: tool.executed,
providerMetadata,
})
}
@ -78,11 +83,11 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
id: tool.id,
name: tool.name,
result:
tool.provider?.executed === true && tool.state.result !== undefined
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
resultType: "error",
providerExecuted: tool.provider?.executed,
providerExecuted: tool.executed,
providerMetadata,
})
}
@ -100,17 +105,22 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
{
type: "reasoning",
text: item.text,
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
providerMetadata: reuseProviderMetadata ? providerMetadata(model.providerID, item.state) : undefined,
},
]
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
if (item.provider?.executed !== true) return [call]
const call = toolCall(
item,
reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
const result = toolResult(
item,
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
reuseProviderMetadata
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
: undefined,
)
return result ? [call, result] : [call]
})
@ -120,9 +130,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true)
.map((item) =>
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
toolResult(
item,
reuseProviderMetadata
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
: undefined,
),
)
.filter((message) => message !== undefined)
.map(Message.tool)
@ -141,9 +156,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
case "user":
const files = message.files ?? []
return [
...files
.filter((file) => file.mime === "text/plain")
.map(textAttachment),
...files.filter((file) => file.mime === "text/plain").map(textAttachment),
Message.make({
id: message.id,
role: "user",