Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline e83e4994e5 fix(core): allow resuming subagents 2026-07-02 15:41:34 -05:00
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 = 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." "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({ export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }), 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" }), 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" }), 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({ background: Schema.Boolean.pipe(Schema.optional).annotate({
description: description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.", "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 = [ export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.", "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.", "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.", "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.", "Use background only for independent work that can run while you continue elsewhere.",
@@ -69,7 +87,7 @@ export const Plugin = {
) { ) {
yield* runtime.session.synthetic({ yield* runtime.session.synthetic({
sessionID: parentID, 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, description,
input: Input, input: Input,
output: Output, 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) => execute: (input, context) =>
Effect.gen(function* () { Effect.gen(function* () {
const parent = yield* runtime.session 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. // Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model const model = agent.model ?? parent.model
const child = yield* runtime.session const agentID = AgentV2.ID.make(input.agent)
.create({ const child = input.subagent_id
parentID: context.sessionID, ? yield* runtime.session
title: input.description, .get(input.subagent_id)
agent: AgentV2.ID.make(input.agent), .pipe(
model, Effect.mapError(() => new ToolFailure({ message: `Subagent not found: ${input.subagent_id}` })),
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent )
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions. : 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 const background = input.background === true
+56 -1
View File
@@ -189,7 +189,12 @@ describe("SubagentTool", () => {
}) })
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) 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({ expect(child).toMatchObject({
parentID: parent.id, parentID: parent.id,
location: parent.location, 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", () => it.live("returns child runner failures as tool errors", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),