diff --git a/packages/core/src/agent/subagent/registry.test.ts b/packages/core/src/agent/subagent/registry.test.ts index 14414e95..e1886dac 100644 --- a/packages/core/src/agent/subagent/registry.test.ts +++ b/packages/core/src/agent/subagent/registry.test.ts @@ -266,6 +266,103 @@ describe("subagent registry — coalescing", () => { }); }); +describe("subagent registry — silent announce mode", () => { + // Note: In tests (no Hub), watchChildAgent completes synchronously within + // registerSubagentRun(), so each run's lifecycle finishes before the next + // registration call. Multi-run coalescing requires async child agents and + // is validated in integration tests. + + it("stores announce field on the record", () => { + const record = registerSubagentRun({ + runId: "run-ann", + childSessionId: "child-ann", + requesterSessionId: "parent-1", + task: "Task", + announce: "silent", + }); + expect(record.announce).toBe("silent"); + }); + + it("defaults announce to undefined (immediate behavior)", () => { + const record = registerSubagentRun({ + runId: "run-def", + childSessionId: "child-def", + requesterSessionId: "parent-1", + task: "Task", + }); + expect(record.announce).toBeUndefined(); + }); + + it("silent runs are announced via runCoalescedAnnounceFlow", async () => { + const announceModule = await import("./announce.js"); + const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true); + + registerSubagentRun({ + runId: "run-s1", + childSessionId: "child-s1", + requesterSessionId: "parent-1", + task: "Silent A", + announce: "silent", + }); + + await flushQueue(); + + // Silent run announced (via runCoalescedAnnounceFlow mock) + const silentCalls = spy.mock.calls.filter( + ([reqId, records]) => + reqId === "parent-1" && + records.some((r: { announce?: string }) => r.announce === "silent"), + ); + expect(silentCalls.length).toBeGreaterThanOrEqual(1); + + const runS1 = getSubagentRun("run-s1"); + expect(runS1?.announced).toBe(true); + expect(runS1?.announce).toBe("silent"); + + spy.mockRestore(); + }); + + it("immediate and silent runs are never mixed in the same announce call", async () => { + const announceModule = await import("./announce.js"); + const spy = vi.spyOn(announceModule, "runCoalescedAnnounceFlow").mockReturnValue(true); + + // Register immediate run, then silent run + registerSubagentRun({ + runId: "run-imm", + childSessionId: "child-imm", + requesterSessionId: "parent-1", + task: "Immediate task", + }); + registerSubagentRun({ + runId: "run-s1", + childSessionId: "child-s1", + requesterSessionId: "parent-1", + task: "Silent task", + announce: "silent", + }); + + await flushQueue(); + + const calls = spy.mock.calls.filter( + ([reqId]) => reqId === "parent-1", + ); + + // Immediate and silent should never be in the same announce call + const mixedCalls = calls.filter(([, records]) => { + const hasImm = records.some((r: { announce?: string }) => r.announce !== "silent"); + const hasSilent = records.some((r: { announce?: string }) => r.announce === "silent"); + return hasImm && hasSilent; + }); + expect(mixedCalls).toHaveLength(0); + + // Both should be announced (in separate calls) + expect(getSubagentRun("run-imm")?.announced).toBe(true); + expect(getSubagentRun("run-s1")?.announced).toBe(true); + + spy.mockRestore(); + }); +}); + describe("subagent registry — post-announce cleanup", () => { it("keeps runs in registry after successful announcement with archiveAtMs", async () => { // Mock runCoalescedAnnounceFlow to succeed diff --git a/packages/core/src/agent/subagent/registry.ts b/packages/core/src/agent/subagent/registry.ts index 665eb3fc..c19a5529 100644 --- a/packages/core/src/agent/subagent/registry.ts +++ b/packages/core/src/agent/subagent/registry.ts @@ -101,6 +101,7 @@ export function registerSubagentRun(params: RegisterSubagentRunParams): Subagent label, cleanup = "delete", timeoutSeconds, + announce, start, } = params; @@ -111,6 +112,7 @@ export function registerSubagentRun(params: RegisterSubagentRunParams): Subagent task, label, cleanup, + announce, createdAt: Date.now(), }; @@ -296,28 +298,42 @@ function captureFindings(record: SubagentRunRecord): void { } /** - * Phase 2: Announce completed-but-unannounced runs immediately. + * Phase 2: Announce completed-but-unannounced runs. * - * Does NOT wait for all runs to finish — each completed run is announced - * as soon as its findings are captured. The three-tier delivery in - * announce.ts (steer → queue → direct) handles batching via the - * announce-queue debounce/collect mechanism when multiple runs complete - * close together. + * Runs with announce="silent" are held back until ALL silent runs from the + * same requester have completed. All other runs (immediate / undefined) are + * announced per-completion as before. */ function checkAndAnnounce(requesterSessionId: string): void { const allRuns = listSubagentRuns(requesterSessionId); - // Only consider unannounced runs that are done with findings captured - const ready = allRuns.filter( - r => !r.announced && r.endedAt !== undefined && r.findingsCaptured, + // ── Immediate runs: announce per-completion (default behavior) ── + const immediateReady = allRuns.filter( + r => !r.announced && r.endedAt !== undefined && r.findingsCaptured && r.announce !== "silent", ); - if (ready.length === 0) return; + if (immediateReady.length > 0) { + announceGroup(requesterSessionId, immediateReady); + } - // Announce all ready runs - const announced = runCoalescedAnnounceFlow(requesterSessionId, ready); + // ── Silent runs: announce only when ALL silent runs are done ── + const silentRuns = allRuns.filter(r => r.announce === "silent"); + const unannouncedSilent = silentRuns.filter(r => !r.announced); + const silentReady = unannouncedSilent.filter( + r => r.endedAt !== undefined && r.findingsCaptured, + ); + + // All unannounced silent runs must be ready (ended + findings captured) + if (silentReady.length > 0 && silentReady.length === unannouncedSilent.length) { + announceGroup(requesterSessionId, silentReady); + } +} + +/** Announce a group of runs and mark them as announced. */ +function announceGroup(requesterSessionId: string, runs: SubagentRunRecord[]): void { + const announced = runCoalescedAnnounceFlow(requesterSessionId, runs); if (announced) { - for (const r of ready) { + for (const r of runs) { r.announced = true; r.cleanupHandled = true; // Keep records for querying via sessions_list; let sweeper archive later @@ -326,7 +342,7 @@ function checkAndAnnounce(requesterSessionId: string): void { persist(); } else { // Allow retry — mark cleanupHandled false so initSubagentRegistry() retries - for (const r of ready) { + for (const r of runs) { r.cleanupHandled = false; } persist(); diff --git a/packages/core/src/agent/subagent/types.ts b/packages/core/src/agent/subagent/types.ts index 1d3ca0ac..96277181 100644 --- a/packages/core/src/agent/subagent/types.ts +++ b/packages/core/src/agent/subagent/types.ts @@ -45,6 +45,9 @@ export type SubagentRunRecord = { findingsCaptured?: boolean | undefined; /** Whether the coalesced announcement has been sent to parent */ announced?: boolean | undefined; + /** Announcement mode: "immediate" (default) announces per-completion, + * "silent" defers until all silent runs from the same requester complete. */ + announce?: "immediate" | "silent" | undefined; }; /** Parameters for registering a new subagent run */ @@ -58,6 +61,8 @@ export type RegisterSubagentRunParams = { timeoutSeconds?: number | undefined; /** Callback invoked when the queue slot is acquired (used to defer childAgent.write). */ start?: (() => void) | undefined; + /** Announcement mode: "immediate" (default) or "silent" (defer until all silent runs complete). */ + announce?: "immediate" | "silent" | undefined; }; /** Parameters for the announce flow */ diff --git a/packages/core/src/agent/system-prompt/sections.ts b/packages/core/src/agent/system-prompt/sections.ts index 4d2bf1cd..da502605 100644 --- a/packages/core/src/agent/system-prompt/sections.ts +++ b/packages/core/src/agent/system-prompt/sections.ts @@ -273,6 +273,11 @@ export function buildConditionalToolSections( "- Complex tasks (code generation, PDF creation, multi-file operations): 1200–1800 (20–30 min)", "When in doubt, use a longer timeout. It is always better to wait longer than to lose completed work.", "", + "### Announce Modes", + "- `announce: \"immediate\"` (default): Each sub-agent's findings are delivered to you as soon as it completes.", + "- `announce: \"silent\"`: All findings are held back until every silent sub-agent finishes, then delivered as ONE combined report.", + "Use \"silent\" when you want to collect data from multiple sub-agents first, then summarize everything at once.", + "", ); } diff --git a/packages/core/src/agent/tools/sessions-spawn.ts b/packages/core/src/agent/tools/sessions-spawn.ts index 7c26de07..1df31e41 100644 --- a/packages/core/src/agent/tools/sessions-spawn.ts +++ b/packages/core/src/agent/tools/sessions-spawn.ts @@ -35,6 +35,15 @@ const SessionsSpawnSchema = Type.Object({ minimum: 0, }), ), + announce: Type.Optional( + Type.Union([Type.Literal("immediate"), Type.Literal("silent")], { + description: + "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.", + }), + ), }); type SessionsSpawnArgs = { @@ -43,6 +52,7 @@ type SessionsSpawnArgs = { model?: string; cleanup?: "delete" | "keep"; timeoutSeconds?: number; + announce?: "immediate" | "silent"; }; export type SessionsSpawnResult = { @@ -75,7 +85,7 @@ export function createSessionsSpawnTool( "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 } = args as SessionsSpawnArgs; + const { task, label, model, cleanup = "delete", timeoutSeconds, announce } = args as SessionsSpawnArgs; // Guard: subagents cannot spawn subagents if (options.isSubagent) { @@ -125,6 +135,7 @@ export function createSessionsSpawnTool( label, cleanup, timeoutSeconds, + announce, start: () => childAgent.write(task), });