Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
29 changes: 25 additions & 4 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
LLMClient,
LLMError,
LLMEvent,
InvalidProviderOutputReason,
Message,
SystemPart,
isContextOverflowFailure,
Expand Down Expand Up @@ -296,7 +297,24 @@ const layer = Layer.effect(
)
return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = failure instanceof LLMError ? failure : undefined
const recordedFinish = publisher.stepSettlement()?.finish
// A successful stream with an explicit provider error is still a failed assistant turn.
// `length` is intentionally excluded: it is a valid truncation outcome and may contain
// usable partial output, so it is outside this subagent failure fix.
const invalidFinish =
stream._tag === "Success" && (recordedFinish === "unknown" || recordedFinish === "error")
? new LLMError({
module: "SessionRunner",
method: "run",
reason: new InvalidProviderOutputReason({
message:
recordedFinish === "error"
? "The provider response ended with an error finish reason."
: "The provider response ended with an unknown finish reason.",
}),
})
: undefined
const llmFailure = failure instanceof LLMError ? failure : invalidFinish
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
Expand All @@ -323,7 +341,7 @@ const layer = Layer.effect(
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
}
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !publisher.hasProviderError()) {
if (stepSettlement && !publisher.hasProviderError() && !publisher.hasAssistantFailure()) {
const endSnapshot = yield* snapshots.capture()
const files =
startSnapshot && endSnapshot
Expand All @@ -346,12 +364,15 @@ const layer = Layer.effect(
}
if (publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())
if (stream._tag === "Success" && !publisher.hasProviderError() && !publisher.hasAssistantFailure())
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
return yield* Effect.failCause(settled.cause)
return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
return {
needsContinuation: !publisher.hasProviderError() && !publisher.hasAssistantFailure() && needsContinuation,
step: currentStep,
}
}),
)
}, Effect.scoped)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/runner/publish-llm-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
failUnsettledTools,
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasAssistantFailure: () => assistantFailed,
hasProviderError: () => providerFailed,
stepSettlement: () => stepSettlement,
startAssistant,
Expand Down
29 changes: 29 additions & 0 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3183,6 +3183,35 @@ describe("SessionRunnerLLM", () => {
}),
)

it.effect("projects an error finish as a terminal assistant step failure", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish with an error" }), resume: false })

response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "error-finish-text" }),
LLMEvent.textDelta({ id: "error-finish-text", text: "Partial output" }),
LLMEvent.textEnd({ id: "error-finish-text" }),
LLMEvent.stepFinish({ index: 0, reason: "error" }),
LLMEvent.finish({ reason: "error" }),
]

yield* session.resume(sessionID)

expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Finish with an error" },
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: "The provider response ended with an error finish reason." },
content: [{ type: "text", text: "Partial output" }],
},
])
}),
)

it.effect("projects provider errors emitted before assistant step start", () =>
Effect.gen(function* () {
yield* setup
Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,12 @@ export const TaskTool = Tool.define(
agent: next.name,
parts,
})
if (result.info.role === "assistant" && result.info.error) {
const message =
"message" in result.info.error.data && typeof result.info.error.data.message === "string"
if (result.info.role === "assistant" && (result.info.error || result.info.finish === "error")) {
const message = result.info.error
? "message" in result.info.error.data && typeof result.info.error.data.message === "string"
? result.info.error.data.message
: result.info.error.name
: `finished with error${result.info.finish ? ` (finish: ${result.info.finish})` : ""}`
return yield* Effect.fail(new Error(`Subagent failed (task_id: ${nextSession.id}): ${message}`))
}
const failed = result.parts.findLast((item) => item.type === "tool" && item.state.status === "error")
Expand Down
95 changes: 93 additions & 2 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,15 @@ function stubOps(opts?: {
text?: string
error?: NonNullable<SessionV1.Assistant["error"]>
toolError?: string
finish?: string
}): TaskPromptOps {
return {
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.sync(() => {
opts?.onPrompt?.(input)
return reply(input, opts?.text ?? "done", opts?.error, opts?.toolError)
return reply(input, opts?.text ?? "done", opts?.error, opts?.toolError, opts?.finish)
}),
}
}
Expand All @@ -118,6 +119,7 @@ function reply(
text: string,
error?: NonNullable<SessionV1.Assistant["error"]>,
toolError?: string,
finish?: string,
): SessionV1.WithParts {
const id = MessageID.ascending()
return {
Expand All @@ -134,7 +136,7 @@ function reply(
modelID: input.model?.modelID ?? ref.modelID,
providerID: input.model?.providerID ?? ref.providerID,
time: { created: Date.now() },
finish: "stop",
finish: finish ?? "stop",
error,
},
parts: [
Expand Down Expand Up @@ -327,6 +329,95 @@ describe("tool.task", () => {
}),
)

it.instance("execute fails when subagent finishes with error and no error object", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()

const exit = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {
promptOps: stubOps({
text: "",
finish: "error",
}),
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.exit)

expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) throw new Error("expected task failure")
const child = (yield* sessions.children(chat.id))[0]
expect(child).toBeDefined()
const failure = Cause.squash(exit.cause)
expect(failure).toBeInstanceOf(Error)
if (!(failure instanceof Error)) throw new Error("expected Error defect")
expect(failure.message).toBe(`Subagent failed (task_id: ${child?.id}): finished with error (finish: error)`)
}),
)

background.instance("background delivery reports a subagent finish error", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const notified = defer<SessionPrompt.PromptInput>()

const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {
promptOps: stubOps({
finish: "error",
onPrompt: (input) => {
if (input.sessionID === chat.id) notified.resolve(input)
},
}),
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)

const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
expect(waited.info?.status).toBe("error")
expect(waited.info?.error).toBe(
`Subagent failed (task_id: ${result.metadata.sessionId}): finished with error (finish: error)`,
)
const notification = yield* Effect.promise(() => notified.promise)
const part = notification.parts[0]
expect(part?.type).toBe("text")
if (part?.type === "text") expect(part.text).toContain('state="error"')
}),
)

it.instance("execute surfaces terminal child tool errors with a resumable task_id", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
Expand Down
Loading