fix(core): allow resuming subagents

This commit is contained in:
Aiden Cline
2026-07-02 15:41:34 -05:00
parent 33705e632a
commit e83e4994e5
2 changed files with 114 additions and 14 deletions
+58 -13
View File
@@ -14,10 +14,26 @@ const NO_TEXT = "Subagent completed without a text response."
const BACKGROUND_STARTED =
"The subagent is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
const renderOutput = (input: {
sessionID: SessionSchema.ID | string
state: "completed" | "running" | "error" | "cancelled"
description?: string
text: string
}) =>
[
`<subagent id="${input.sessionID}" state="${input.state}"${input.description ? ` description="${input.description}"` : ""}>`,
input.text,
"</subagent>",
].join("\n")
export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
subagent_id: SessionSchema.ID.pipe(Schema.optional).annotate({
description:
"Set this only to continue a previous subagent session. Pass the id from an earlier subagent result and the prompt will continue that same subagent session instead of creating a new one.",
}),
background: Schema.Boolean.pipe(Schema.optional).annotate({
description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
@@ -32,6 +48,8 @@ export const Output = Schema.Struct({
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
"Each new subagent invocation starts with fresh context unless subagent_id is provided to continue the same subagent session.",
"The result includes a subagent id. Reuse it as subagent_id only when you need to send more prompts to that same subagent.",
"Foreground (default) runs the subagent to completion and returns its final response.",
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
"Use background only for independent work that can run while you continue elsewhere.",
@@ -69,7 +87,7 @@ export const Plugin = {
) {
yield* runtime.session.synthetic({
sessionID: parentID,
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
text: renderOutput({ sessionID: childID, state, description, text }),
})
})
@@ -98,7 +116,17 @@ export const Plugin = {
description,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
toModelOutput: ({ input, output }) => [
{
type: "text",
text: renderOutput({
sessionID: output.sessionID,
state: output.status,
description: input.description,
text: output.output,
}),
},
],
execute: (input, context) =>
Effect.gen(function* () {
const parent = yield* runtime.session
@@ -113,18 +141,35 @@ export const Plugin = {
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const child = yield* runtime.session
.create({
parentID: context.sessionID,
title: input.description,
agent: AgentV2.ID.make(input.agent),
model,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
const agentID = AgentV2.ID.make(input.agent)
const child = input.subagent_id
? yield* runtime.session
.get(input.subagent_id)
.pipe(
Effect.mapError(() => new ToolFailure({ message: `Subagent not found: ${input.subagent_id}` })),
)
: yield* runtime.session
.create({
parentID: context.sessionID,
title: input.description,
agent: agentID,
model,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
})
.pipe(
Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }),
),
)
if (input.subagent_id && child.parentID !== context.sessionID)
return yield* new ToolFailure({
message: `Subagent ${input.subagent_id} does not belong to this session`,
})
if (input.subagent_id && child.agent !== agentID)
return yield* new ToolFailure({
message: `Subagent ${input.subagent_id} is not using agent ${input.agent}`,
})
.pipe(
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
)
const background = input.background === true
+56 -1
View File
@@ -189,7 +189,12 @@ describe("SubagentTool", () => {
})
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.content[0]).toEqual({
type: "text",
text: `<subagent id="${childID}" state="completed" description="review">\n${childText}\n</subagent>`,
})
const child = yield* sessions.get(childID)
expect(child).toMatchObject({
parentID: parent.id,
location: parent.location,
@@ -214,6 +219,56 @@ describe("SubagentTool", () => {
),
)
it.live("continues an existing child session when subagent_id is provided", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* SessionV2.Service
const parent = yield* sessions.create({ location, model: parentModel })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const first = yield* settleTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-subagent-first",
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review this" },
},
})
const childID = outputSessionID(first.output?.structured)
const second = yield* settleTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-subagent-second",
name: SubagentTool.name,
input: {
agent: "reviewer",
description: "follow up",
prompt: "continue this",
subagent_id: childID,
},
},
})
expect(outputSessionID(second.output?.structured)).toBe(childID)
expect(yield* sessions.list({ directory: location.directory })).toHaveLength(2)
expect((yield* sessions.get(childID)).title).toBe("review")
}),
),
),
)
it.live("returns child runner failures as tool errors", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),