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
5 changes: 5 additions & 0 deletions .changeset/subagent-resumable-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Surface the resumable `task_id` when a subagent stops on an error. Both foreground and background subagent failures now tell the parent agent that the session can be resumed via the task tool with `task_id="<id>"`, so a stopped subagent can be continued instead of being lost.
21 changes: 18 additions & 3 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,14 @@ function backgroundMessage(input: {
input.state === "completed"
? `Background task completed: ${input.description}`
: `Background task failed: ${input.description}`
// kilocode_change start - surface the resumable task_id when a background subagent fails (#11620)
const body = input.state === "error" ? `${input.text}\n${resumeHint(input.sessionID)}` : input.text
// kilocode_change end
return [
`<task id="${input.sessionID}" state="${input.state}">`,
`<summary>${title}</summary>`,
`<${tag}>`,
input.text,
body, // kilocode_change - was input.text
`</${tag}>`,
"</task>",
].join("\n")
Expand All @@ -99,6 +102,15 @@ function errorText(error: unknown) {
return String(error)
}

// kilocode_change start - tell the parent agent how to resume a stopped/failed subagent (#11620)
function resumeHint(sessionID: SessionID) {
return [
`This subagent session can be resumed: call the task tool again with task_id="${sessionID}"`,
`and a prompt describing how to continue or recover. Its prior context is preserved.`,
].join(" ")
}
// kilocode_change end

export const TaskTool = Tool.define(
id,
Effect.gen(function* () {
Expand Down Expand Up @@ -255,9 +267,12 @@ export const TaskTool = Tool.define(
},
parts,
})
// kilocode_change start - expose terminal child assistant errors through the task tool boundary
// kilocode_change start - expose terminal child assistant errors through the task tool boundary,
// including the resumable task_id so the parent agent can continue the subagent (#11620)
if (result.info.role === "assistant" && result.info.error) {
return yield* Effect.fail(new Error(errorMessage(result.info.error)))
return yield* Effect.fail(
new Error(`${errorMessage(result.info.error)}\n${resumeHint(nextSession.id)}`),
Comment thread
marius-kilocode marked this conversation as resolved.
)
}
// kilocode_change end
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
Expand Down
67 changes: 66 additions & 1 deletion packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer } from "effect"
import { Cause, Effect, Exit, Fiber, Layer } from "effect" // kilocode_change - Cause for squashing the failure exit
import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Bus } from "@/bus"
Expand Down Expand Up @@ -510,6 +510,7 @@ describe("tool.task", () => {
// kilocode_change start - terminal child assistant errors fail the task tool boundary
it.instance("execute fails when child prompt returns assistant error", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
Expand Down Expand Up @@ -551,6 +552,70 @@ describe("tool.task", () => {
.pipe(Effect.exit)

expect(Exit.isFailure(exit)).toBe(true)

// the failure surfaces the resumable task_id so the parent can continue the subagent (#11620)
const kids = yield* sessions.children(chat.id)
const childId = kids[0]?.id
expect(childId).toBeDefined()
const squashed = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined
const message = squashed instanceof Error ? squashed.message : String(squashed)
expect(message).toContain("child prompt failed")
expect(message).toContain(`task_id="${childId}"`)
expect(message).toContain("can be resumed")
}),
)
// kilocode_change end

// kilocode_change start - background subagent failures also surface the resumable task_id (#11620)
background.instance("background task failure injects a resumable task_id into the parent", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const injected: 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(),
prompt: (input) => {
// The parent-session prompt is the injected background result; capture it.
if (input.sessionID === chat.id) {
injected.push(input)
return Effect.succeed(reply(input, "ack"))
}
return Effect.die(new Error("child prompt failed"))
},
} satisfies TaskPromptOps,
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)

const childId = result.metadata.sessionId
yield* jobs.wait({ id: childId, timeout: 1_000 })
Comment thread
marius-kilocode marked this conversation as resolved.

const text = injected
.flatMap((input) => input.parts ?? [])
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n")
expect(text).toContain(`state="error"`)
expect(text).toContain(`task_id="${childId}"`)
expect(text).toContain("can be resumed")
}),
)
// kilocode_change end
Expand Down
Loading