From cdb11631450c7d00c3b6b81270ad38c84a081de5 Mon Sep 17 00:00:00 2001 From: "Hanjun(Tony)" <54878785+wilgon456@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:08:41 +0900 Subject: [PATCH] Notify callers when watched children close and cap responses (#3192) Wake caller agents when watched children close and cap embedded child responses at 4,000 characters. Co-authored-by: Hanjun(Tony) <54878785+wilgon456@users.noreply.github.com> Co-authored-by: Thong Van --- docs/agent-lifecycle.md | 1 + .../src/server/agent/agent-prompt.test.ts | 48 +++++++++++++++++++ .../server/src/server/agent/agent-prompt.ts | 21 ++++---- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index ac3ceb053..c8cd0e11a 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -51,6 +51,7 @@ Users can also detach an existing subagent from the subagents track. Detach is d `notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. +A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown. ## Provider-managed child agents diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 742e19bfb..cc84c88d6 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -52,6 +52,7 @@ interface FinishNotificationScenario { resolveChildPermissionWhileIdle(requestId?: string): void; finishChild(): void; finishChildAndReadParentPrompt(): Promise; + closeChildAndReadParentPrompt(): Promise; parentPrompts(): string[]; wasParentPrompted(): boolean; } @@ -211,6 +212,25 @@ function createFinishNotificationScenario( return parentPrompt; }, + async closeChildAndReadParentPrompt() { + const parentPrompt = new Promise((resolve) => { + resolveParentPrompt = resolve; + }); + + childAgent.lifecycle = "running"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + childAgent.lifecycle = "closed"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + return parentPrompt; + }, parentPrompts() { return parentPrompts; }, @@ -279,6 +299,34 @@ test("finish notifications tell the parent the child's last assistant message", ); }); +test("finish notifications truncate oversized child responses", async () => { + const included = "x".repeat(4000); + const omitted = "TAIL-MARKER".repeat(50); + const scenario = createFinishNotificationScenario({ + childLastAssistantMessage: included + omitted, + }); + + scenario.startWatchingChild(); + const parentPrompt = await scenario.finishChildAndReadParentPrompt(); + + expect(parentPrompt).toContain(included); + expect(parentPrompt).toContain( + `[truncated ${omitted.length} chars; use get_agent_activity for the full response]`, + ); + expect(parentPrompt).not.toContain("TAIL-MARKER"); +}); + +test("closing a watched child notifies the caller", async () => { + const scenario = createFinishNotificationScenario(); + + scenario.startWatchingChild(); + const parentPrompt = await scenario.closeChildAndReadParentPrompt(); + + expect(parentPrompt).toEqual( + formatSystemNotificationPrompt("Agent child-agent (Child Agent) was closed."), + ); +}); + test("finish notifications survive permission responses", async () => { const scenario = createFinishNotificationScenario(); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 375f643b0..d92b225b3 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -253,10 +253,14 @@ export interface SetupFinishNotificationParams { logger: Logger; } +type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed"; + +const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; + interface FinishNotificationBodyInput { childAgentId: string; title: string; - reason: "finished" | "errored" | "needs permission"; + reason: FinishNotificationReason; lastAssistantMessage: string | null; permissionRequest?: AgentPermissionRequest; } @@ -278,8 +282,12 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri )}\n`, ); } - const lastAssistantMessage = params.lastAssistantMessage?.trim(); + let lastAssistantMessage = params.lastAssistantMessage?.trim(); if (lastAssistantMessage) { + if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) { + const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT; + lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`; + } sections.push(`\n${lastAssistantMessage}\n`); } return sections.join("\n\n"); @@ -312,7 +320,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): } async function notify( - reason: "finished" | "errored" | "needs permission", + reason: FinishNotificationReason, permissionRequest?: AgentPermissionRequest, ): Promise { const callerRecord = await agentStorage.get(callerAgentId); @@ -344,10 +352,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): }); } - function notifySafely( - reason: "finished" | "errored" | "needs permission", - options: NotifySafelyOptions = {}, - ): void { + function notifySafely(reason: FinishNotificationReason, options: NotifySafelyOptions = {}): void { if (stopped) return; if (options.terminal ?? true) stop(); notificationQueue = notificationQueue @@ -387,7 +392,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } if (event.agent.lifecycle === "closed") { - stop(); + notifySafely("was closed"); return; } return;