diff --git a/packages/core/src/agent/tools/sessions-list.ts b/packages/core/src/agent/tools/sessions-list.ts index 988bfa1c..93ef896c 100644 --- a/packages/core/src/agent/tools/sessions-list.ts +++ b/packages/core/src/agent/tools/sessions-list.ts @@ -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(); + 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}`); } } diff --git a/packages/core/src/agent/tools/sessions-spawn.ts b/packages/core/src/agent/tools/sessions-spawn.ts index 1df31e41..87ba89dc 100644 --- a/packages/core/src/agent/tools/sessions-spawn.ts +++ b/packages/core/src/agent/tools/sessions-spawn.ts @@ -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) {