diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 517d1ce65f..b9795e5cec 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -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 +}) => + [ + ``, + input.text, + "", + ].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: `\n${text}\n`, + 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 diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 6274e329aa..75a78af8f4 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -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: `\n${childText}\n`, + }) + 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()),