feat(subagent): add announce:"silent" mode for deferred coalesced announcements

Add an `announce` parameter to sessions_spawn that controls when findings
are delivered to the parent agent:

- "immediate" (default): announce per-completion (existing behavior)
- "silent": defer until ALL silent runs from the same requester complete,
  then deliver ONE coalesced announcement with all findings

This enables workflows like "spawn 10 parallel subagents to collect data,
then summarize everything at once" without intermediate results.

Changes:
- types.ts: add `announce` field to SubagentRunRecord & RegisterSubagentRunParams
- sessions-spawn.ts: add `announce` parameter to tool schema
- registry.ts: split checkAndAnnounce into immediate/silent groups,
  extract announceGroup helper, use count-match guard for silent readiness
- sections.ts: add announce mode guidance to system prompt
- registry.test.ts: add silent mode tests (field storage, group isolation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-11 17:40:00 +08:00
parent de928cfe2b
commit d7a02182ab
5 changed files with 149 additions and 15 deletions

View file

@ -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

View file

@ -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();

View file

@ -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 */

View file

@ -273,6 +273,11 @@ export function buildConditionalToolSections(
"- Complex tasks (code generation, PDF creation, multi-file operations): 12001800 (2030 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.",
"",
);
}

View file

@ -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),
});