fix(core): allow resuming subagents
This commit is contained in:
parent
33705e632a
commit
e83e4994e5
2 changed files with 114 additions and 14 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue