Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,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
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
Loading