Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,26 @@ const layer = Layer.effect(
return { call, part }
})

const resetOutputLimit = Effect.fn("SessionProcessor.resetOutputLimit")(function* () {
const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe(
Effect.provideService(Database.Service, database),
)
// Replace the streamed partial before resampling the unchanged request.
// Keep step-finish so usage from the billed attempt remains accounted for.
yield* Effect.forEach(
parts.filter((part) => part.type !== "step-finish"),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
(part) =>
session.removePart({
sessionID: part.sessionID,
messageID: part.messageID,
partID: part.id,
}),
{ concurrency: "unbounded" },
)
ctx.assistantMessage.finish = undefined
yield* session.updateMessage(ctx.assistantMessage)
})

const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
toolCallID: string,
update: (part: SessionV1.ToolPart) => SessionV1.ToolPart,
Expand Down Expand Up @@ -480,6 +500,7 @@ const layer = Layer.effect(
cost: usage.cost,
})
yield* session.updateMessage(ctx.assistantMessage)
if (value.reason === "length") throw new SessionV1.OutputLengthError({})
if (ctx.snapshot) {
const patch = yield* snapshot.patch(ctx.snapshot)
if (patch.files.length) {
Expand Down Expand Up @@ -688,6 +709,9 @@ const layer = Layer.effect(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.fail(Cause.squash(cause)),
),
Effect.tapError((error) =>
SessionV1.OutputLengthError.isInstance(error) ? resetOutputLimit() : Effect.void,
),
Effect.retry(
SessionRetry.policy({
provider: input.model.providerID,
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
export const OUTPUT_LENGTH_MAX_RETRIES = 3

function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
Expand Down Expand Up @@ -76,6 +77,7 @@ export function delay(attempt: number, error?: SessionV1.APIError) {
}

export function retryable(error: Err, provider: string) {
if (SessionV1.OutputLengthError.isInstance(error)) return { message: "Model hit its output limit" }
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
// context overflow errors should not be retried
if (SessionV1.ContextOverflowError.isInstance(error)) return undefined
if (SessionV1.APIError.isInstance(error)) {
Expand Down Expand Up @@ -188,11 +190,15 @@ export function policy(opts: {
parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
}) {
let outputLengthRetries = 0
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
if (SessionV1.OutputLengthError.isInstance(error) && ++outputLengthRetries > OUTPUT_LENGTH_MAX_RETRIES) {
return Cause.done(meta.attempt)
}
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
Expand Down
86 changes: 86 additions & 0 deletions packages/opencode/test/session/processor-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ const ref = {
modelID: ModelV2.ID.make("test-model"),
}

const outputRetryModel: Provider.Model = {
id: ref.modelID,
providerID: ref.providerID,
api: { id: "test-model", url: "https://example.com", npm: "@ai-sdk/openai" },
name: "Test Model",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 100_000, input: 100_000, output: 10_000 },
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
}

const cfg = {
provider: {
test: {
Expand Down Expand Up @@ -226,6 +248,27 @@ const fragmentFailureLLM = Layer.succeed(
const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])
const itFragmentFailure = testEffect(fragmentFailureEnv)

const outputRetryInputs: LLM.StreamInput[] = []
const outputRetryLLM = Layer.succeed(
LLM.Service,
LLM.Service.of({
stream: (input) => {
outputRetryInputs.push(input)
const first = outputRetryInputs.length === 1
return Stream.make(
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-1" }),
LLMEvent.textDelta({ id: "text-1", text: first ? "truncated" : "complete" }),
LLMEvent.textEnd({ id: "text-1" }),
LLMEvent.stepFinish({ index: 0, reason: first ? "length" : "stop" }),
LLMEvent.finish({ reason: first ? "length" : "stop" }),
)
},
}),
)
const outputRetryEnv = LayerNode.compile(root, [...replacements, [LLM.node, outputRetryLLM]])
const itOutputRetry = testEffect(outputRetryEnv)

const boot = Effect.fn("test.boot")(function* () {
const processors = yield* SessionProcessor.Service
const session = yield* Session.Service
Expand Down Expand Up @@ -514,6 +557,49 @@ it.live("session.processor effect tests reset reasoning state across retries", (
),
)

itOutputRetry.live("session.processor effect tests resample the exact request after an output limit", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const { processors, session } = yield* boot()
outputRetryInputs.length = 0

const chat = yield* session.create({})
const parent = yield* user(chat.id, "resample")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: outputRetryModel,
})

const value = yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies SessionV1.User,
sessionID: chat.id,
model: outputRetryModel,
agent: agent(),
system: [],
messages: [{ role: "user", content: "resample" }],
tools: {},
})

const parts = yield* MessageV2.parts(msg.id)

expect(value).toBe("continue")
expect(outputRetryInputs).toHaveLength(2)
expect(outputRetryInputs[1]).toBe(outputRetryInputs[0])
expect(parts.filter((part) => part.type === "text").map((part) => part.text)).toStrictEqual(["complete"])
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
expect(handle.message.finish).toBe("stop")
}),
),
)

it.live("session.processor effect tests do not retry unknown json errors", () =>
provideTmpdirServer(
({ dir, llm }) =>
Expand Down
24 changes: 23 additions & 1 deletion packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { NamedError } from "@opencode-ai/core/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Schedule, Schema } from "effect"
import { Effect, Exit, Schedule, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
Expand Down Expand Up @@ -115,6 +115,28 @@ describe("session.retry.delay", () => {
})
}),
)

it.instance("policy caps output-length errors at three retries", () =>
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Effect.gen(function* () {
const error = new SessionV1.OutputLengthError({}).toObject()
const attempts: number[] = []
const step = yield* Schedule.toStep(
SessionRetry.policy({
provider: "test",
parse: () => error,
set: (info) => Effect.sync(() => attempts.push(info.attempt)),
}),
)

yield* step(0, error)
yield* step(0, error)
yield* step(0, error)
const fourth = yield* step(0, error).pipe(Effect.exit)

expect(attempts).toStrictEqual([1, 2, 3])
expect(Exit.isFailure(fourth)).toBe(true)
}),
)
})

describe("session.retry.retryable", () => {
Expand Down
Loading