Skip to content
Merged
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
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.
25 changes: 22 additions & 3 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,18 @@ 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 hint = resumeHint(input.sessionID)
const body =
input.state === "error" && !input.text.includes(hint)
? `${input.text}\n${hint}`
: 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 @@ -101,6 +108,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 @@ -263,9 +279,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
70 changes: 69 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, Deferred, Effect, Exit, Fiber, Layer } from "effect" // kilocode_change - Cause/Deferred for resume-hint coverage
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,73 @@ 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 parentInjected = yield* Deferred.make<void>()

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.as(Deferred.succeed(parentInjected, undefined), reply(input, "ack"))
}
return Effect.die(new Error("child prompt failed and can be resumed later"))
},
} 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.
// The parent-session injection is forked asynchronously; wait for it before asserting.
yield* Deferred.await(parentInjected).pipe(Effect.timeout("1 second"))

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