feat(subagent): add groupId/next params to spawn and group display to list

sessions_spawn now accepts groupId and next parameters. First spawn with
next auto-creates a group; subsequent spawns join via groupId. sessions_list
groups runs by groupId with completion progress display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-12 17:11:31 +08:00
parent f46c00e902
commit 48185c3e77
2 changed files with 115 additions and 21 deletions

View file

@ -7,7 +7,7 @@
import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js";
import { listSubagentRuns, getSubagentRun, getSubagentGroup } from "../subagent/registry.js";
import type { SubagentRunRecord } from "../subagent/types.js";
const SessionsListSchema = Type.Object({
@ -79,6 +79,11 @@ function formatRunDetail(record: SubagentRunRecord, now: number): string {
];
if (record.label) lines.push(`Label: ${record.label}`);
if (record.groupId) {
const group = getSubagentGroup(record.groupId);
lines.push(`Group: ${record.groupId}${group?.label ? ` (${group.label})` : ""}`);
if (group?.next) lines.push(`Continuation: ${group.next.slice(0, 120)}${group.next.length > 120 ? "…" : ""}`);
}
lines.push(`Task: ${record.task}`);
lines.push(`Status: ${status}${record.outcome?.error ? `${record.outcome.error}` : ""}`);
lines.push(`Child Session: ${record.childSessionId}`);
@ -128,8 +133,7 @@ export function createSessionsListTool(
description:
"List all subagent runs spawned by this session and their current status. " +
"Optionally pass a runId to get detailed information about a specific run. " +
"NOTE: Do NOT call this immediately after spawning subagents — results arrive automatically in your context when subagents complete. " +
"Only use this if a long time has passed or the user explicitly asks about subagent status.",
"Use this to check subagent progress or when the user asks about status.",
parameters: SessionsListSchema,
execute: async (_toolCallId, args) => {
const { runId } = args as SessionsListArgs;
@ -177,21 +181,59 @@ export function createSessionsListTool(
const someRunning = runs.some((r) => !r.endedAt);
// Build status lines for each run
// Build status lines, grouping runs by groupId
const statusLines: string[] = [];
for (let i = 0; i < runs.length; i++) {
const r = runs[i]!;
const groupedRuns = new Map<string, SubagentRunRecord[]>();
const ungroupedRuns: SubagentRunRecord[] = [];
for (const r of runs) {
if (r.groupId) {
const list = groupedRuns.get(r.groupId) ?? [];
list.push(r);
groupedRuns.set(r.groupId, list);
} else {
ungroupedRuns.push(r);
}
}
let idx = 0;
// Grouped runs
for (const [gId, gRuns] of groupedRuns) {
const group = getSubagentGroup(gId);
const groupLabel = group?.label || `Group ${gId.slice(0, 8)}`;
const done = gRuns.filter(r => r.endedAt).length;
const nextSnippet = group?.next ? ` → next: "${group.next.slice(0, 60)}${group.next.length > 60 ? "…" : ""}"` : "";
statusLines.push(`\n 📦 ${groupLabel} (${done}/${gRuns.length} done${nextSnippet})`);
for (const r of gRuns) {
idx++;
const displayName = r.label || r.task.slice(0, 60);
const status = resolveStatus(r);
if (status === "running") {
const elapsed = r.startedAt ? formatElapsed(now - r.startedAt) : "just spawned";
statusLines.push(` ${idx}. [RUNNING] "${displayName}" (${elapsed})`);
} else {
const elapsed = r.startedAt && r.endedAt ? formatElapsed(r.endedAt - r.startedAt) : "";
statusLines.push(` ${idx}. [${status.toUpperCase()}] "${displayName}" (${elapsed})`);
}
}
}
// Ungrouped runs
for (const r of ungroupedRuns) {
idx++;
const displayName = r.label || r.task.slice(0, 60);
const status = resolveStatus(r);
if (status === "running") {
const elapsed = r.startedAt ? formatElapsed(now - r.startedAt) : "just spawned";
statusLines.push(` ${i + 1}. [RUNNING] "${displayName}" (${elapsed})`);
statusLines.push(` ${idx}. [RUNNING] "${displayName}" (${elapsed})`);
} else {
const elapsed = r.startedAt && r.endedAt ? formatElapsed(r.endedAt - r.startedAt) : "";
const findings = r.findingsCaptured
? (r.findings ? r.findings.slice(0, 200) + (r.findings.length > 200 ? "…" : "") : "(no output)")
: "(findings not yet captured)";
statusLines.push(` ${i + 1}. [${status.toUpperCase()}] "${displayName}" (${elapsed})\n Findings: ${findings}`);
statusLines.push(` ${idx}. [${status.toUpperCase()}] "${displayName}" (${elapsed})\n Findings: ${findings}`);
}
}

View file

@ -10,7 +10,7 @@ import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { getHub } from "../../hub/hub-singleton.js";
import { buildSubagentSystemPrompt } from "../subagent/announce.js";
import { registerSubagentRun } from "../subagent/registry.js";
import { registerSubagentRun, createSubagentGroup, getSubagentGroup } from "../subagent/registry.js";
import { resolveTools } from "../tools.js";
const SessionsSpawnSchema = Type.Object({
@ -41,7 +41,26 @@ const SessionsSpawnSchema = Type.Object({
"Announcement mode. 'immediate' (default): findings delivered as each subagent completes. " +
"'silent': defer all announcements until every silent subagent from this session finishes, " +
"then deliver one combined report. Use 'silent' when spawning multiple subagents to collect " +
"data in parallel and you want to summarize everything at once.",
"data in parallel and you want to summarize everything at once. " +
"Ignored when groupId is provided (groups always collect all results before announcing).",
}),
),
groupId: Type.Optional(
Type.String({
description:
"Join an existing group. Pass the groupId returned by a previous sessions_spawn call " +
"to add this subagent to the same group. All runs in a group are announced together " +
"when the last one completes. If omitted AND 'next' is provided, a new group is created automatically.",
}),
),
next: Type.Optional(
Type.String({
description:
"Continuation task to execute after ALL subagents in the group complete. " +
"Only used when creating a new group (first spawn without groupId). " +
"When set, the combined findings from all subagents plus this 'next' prompt " +
"are delivered to you so you can perform follow-up work (e.g. summarize, generate reports, write files). " +
"Setting 'next' automatically creates a group and implies silent collection.",
}),
),
});
@ -53,12 +72,15 @@ type SessionsSpawnArgs = {
cleanup?: "delete" | "keep";
timeoutSeconds?: number;
announce?: "immediate" | "silent";
groupId?: string;
next?: string;
};
export type SessionsSpawnResult = {
status: "accepted" | "error";
childSessionId?: string;
runId?: string;
groupId?: string;
error?: string;
};
@ -79,13 +101,15 @@ export function createSessionsSpawnTool(
label: "Spawn Subagent",
description:
"Spawn a background subagent to handle a specific task. The subagent runs in an isolated session with its own tool set. " +
"When it completes, its findings are delivered directly into your context automatically — you do NOT need to poll or check. " +
"IMPORTANT: After spawning subagents, continue with any other immediate tasks you have, or simply finish your turn and wait. " +
"Do NOT call sessions_list to check on subagents you just spawned — results take time and will arrive on their own. " +
"When it completes, its findings are delivered directly into your context automatically. " +
"After spawning, do NOT proceed with work that depends on the results — but you can still chat or do unrelated tasks. " +
"When spawning multiple subagents for a collect-then-act workflow, ALWAYS use the `next` parameter " +
"on the first spawn to define follow-up work, then pass the returned groupId to subsequent spawns. " +
"Use this for parallelizable work, long-running analysis, or tasks that benefit from isolation.",
parameters: SessionsSpawnSchema,
execute: async (_toolCallId, args) => {
const { task, label, model, cleanup = "delete", timeoutSeconds, announce } = args as SessionsSpawnArgs;
const { task, label, model, cleanup = "delete", timeoutSeconds, announce, next } = args as SessionsSpawnArgs;
let { groupId } = args as SessionsSpawnArgs;
// Guard: subagents cannot spawn subagents
if (options.isSubagent) {
@ -102,6 +126,28 @@ export function createSessionsSpawnTool(
const runId = uuidv7();
const childSessionId = uuidv7();
// Validate groupId if provided
if (groupId) {
const existingGroup = getSubagentGroup(groupId);
if (!existingGroup) {
return {
content: [{ type: "text", text: `Error: group not found: ${groupId}. Use the groupId returned by a previous sessions_spawn call.` }],
details: { status: "error", error: `group not found: ${groupId}` },
};
}
}
// Auto-create group when `next` is provided without an existing groupId
if (!groupId && next) {
groupId = uuidv7();
createSubagentGroup({
groupId,
requesterSessionId,
label: label ? `Group: ${label}` : undefined,
next,
});
}
// Resolve tools for the subagent (with isSubagent=true for policy filtering)
const subagentTools = resolveTools({ isSubagent: true });
const toolNames = subagentTools.map((t) => t.name);
@ -135,21 +181,27 @@ export function createSessionsSpawnTool(
label,
cleanup,
timeoutSeconds,
announce,
announce: groupId ? "silent" : announce,
groupId,
start: () => childAgent.write(task),
});
// Build response text
const groupInfo = groupId ? `\nGroup: ${groupId}` : "";
const nextInfo = next ? `\nContinuation: "${next.slice(0, 100)}${next.length > 100 ? "…" : ""}"` : "";
const responseText =
`Subagent spawned: ${label || task.slice(0, 80)}\n` +
`Run: ${runId}${groupInfo}${nextInfo}\n\n` +
`⏳ WAITING FOR RESULTS — do NOT proceed with work that depends on these results.\n` +
`Do NOT fabricate data or completion status. Results will arrive in your context automatically.`;
return {
content: [
{
type: "text",
text: `Subagent spawned successfully.\n\nRun ID: ${runId}\nSession: ${childSessionId}\nTask: ${label || task.slice(0, 80)}\n\nThe subagent is now working in the background. Its findings will be delivered directly into your context when it completes — do NOT poll or call sessions_list for it. Continue with other tasks or finish your turn.`,
},
],
content: [{ type: "text", text: responseText }],
details: {
status: "accepted",
childSessionId,
runId,
groupId,
},
};
} catch (err) {