mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
fix(core): recover partial provider failures
This commit is contained in:
@@ -61,10 +61,13 @@ const isDecline = (
|
||||
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
|
||||
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
|
||||
|
||||
const isInterruptedStream = (failure: AIError) => {
|
||||
if (failure.reason._tag === "InvalidProviderOutput")
|
||||
return failure.reason.classification === "incomplete-stream"
|
||||
const shouldContinueAfterFailure = (failure: AIError, record: StepRecord) => {
|
||||
if (failure.reason._tag === "InvalidProviderOutput") return failure.reason.classification === "incomplete-stream"
|
||||
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
|
||||
// A fresh generation can continue partial output, but not cross a tool boundary
|
||||
// whose provider-side effects may be ambiguous.
|
||||
if (failure.reason._tag === "ProviderInternal")
|
||||
return SessionRunnerRetry.isRetryable(failure) && record.calls.length === 0
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -120,7 +123,7 @@ const classifyToolExits = (
|
||||
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
|
||||
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
|
||||
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
|
||||
const CONTINUE_AFTER_INCOMPLETE_STREAM =
|
||||
const CONTINUE_AFTER_PARTIAL_FAILURE =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
const layer = Layer.effect(
|
||||
@@ -302,7 +305,7 @@ const layer = Layer.effect(
|
||||
).pipe(Pull.catchDone(() => Effect.fail(outcome.cause)))
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
|
||||
text: CONTINUE_AFTER_PARTIAL_FAILURE,
|
||||
})
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}
|
||||
@@ -597,7 +600,7 @@ const layer = Layer.effect(
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
isInterruptedStream(llmFailure) &&
|
||||
shouldContinueAfterFailure(llmFailure, record) &&
|
||||
record.outputStarted &&
|
||||
tools.declines.length === 0 &&
|
||||
!tools.interrupted
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
ProviderInternalReason,
|
||||
RateLimitReason,
|
||||
} from "@opencode-ai/ai"
|
||||
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
|
||||
@@ -572,6 +573,13 @@ const streamDisconnected = () =>
|
||||
}),
|
||||
})
|
||||
|
||||
const providerInternal = () =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new ProviderInternalReason({ message: "The provider failed while generating a response" }),
|
||||
})
|
||||
|
||||
const continuationRejected = (recovery: "retry-full" | "rotate-and-retry-full") =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
@@ -596,7 +604,7 @@ const incompleteStream = () =>
|
||||
}),
|
||||
})
|
||||
|
||||
const INCOMPLETE_STREAM_CONTINUATION =
|
||||
const PARTIAL_FAILURE_CONTINUATION =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
const invalidRequest = () =>
|
||||
@@ -4591,7 +4599,7 @@ describe("SessionRunnerLLM", () => {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
text: PARTIAL_FAILURE_CONTINUATION,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -4606,7 +4614,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
{
|
||||
type: "synthetic",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
text: PARTIAL_FAILURE_CONTINUATION,
|
||||
},
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
|
||||
])
|
||||
@@ -4641,12 +4649,12 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages.at(-1)).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
|
||||
content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }],
|
||||
})
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "error", content: [{ type: "text", text: "Partial" }] },
|
||||
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
|
||||
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
|
||||
])
|
||||
}),
|
||||
@@ -4680,7 +4688,7 @@ describe("SessionRunnerLLM", () => {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
text: PARTIAL_FAILURE_CONTINUATION,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -4720,7 +4728,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
|
||||
expect(requests[1]?.messages.slice(-2)).toMatchObject([
|
||||
{ role: "user", content: [{ type: "text", text: "Recover disconnected reasoning" }] },
|
||||
{ role: "user", content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }] },
|
||||
{ role: "user", content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }] },
|
||||
])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
@@ -4735,12 +4743,77 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
|
||||
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a provider failure after reasoning without tool activity", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Recover failed reasoning")
|
||||
const failure = providerInternal()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.reasoningStart({ id: "failed-reasoning" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "failed-reasoning",
|
||||
providerMetadata: {
|
||||
openai: { itemId: "rs_failed", reasoningEncryptedContent: "failed-encrypted-state" },
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "provider-failure-recovery"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages).toHaveLength(2)
|
||||
expect(requests[1]?.messages.slice(-2)).toMatchObject([
|
||||
{ role: "user", content: [{ type: "text", text: "Recover failed reasoning" }] },
|
||||
{ role: "user", content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }] },
|
||||
])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "provider.internal" },
|
||||
content: [{ type: "reasoning", text: "" }],
|
||||
},
|
||||
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue a provider failure after tool activity", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const failure = providerInternal()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-failure", name: "echo", input: { text: "settled" } }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* runPrompt(session, "Do not repeat tool activity").pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(executions).toEqual(["settled"])
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues an incomplete stream after settling a local tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -4772,7 +4845,7 @@ describe("SessionRunnerLLM", () => {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
text: PARTIAL_FAILURE_CONTINUATION,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -4812,7 +4885,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
|
||||
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user