multica/src/agent/subagent/announce.ts
yushen a3acd732e0 fix(subagent): persist LLM summary after internal announce to parent context
After child subagents complete, the coalesced announcement runs as an
internal turn which rolls back all messages from the parent's in-memory
context. This causes the parent LLM to lose findings in subsequent turns.

Add persistResponse option to writeInternal that re-injects the LLM's
summary as a non-internal assistant message after the internal run
completes. The internal prompt stays hidden while the summary persists
in both memory and session JSONL for future turns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 19:38:18 +08:00

350 lines
11 KiB
TypeScript

/**
* Subagent announcement flow.
*
* Handles result propagation from child → parent agent:
* - Builds system prompts for child agents
* - Reads child session output
* - Formats and delivers announcement messages
*/
import { readEntries } from "../session/storage.js";
import { getHub } from "../../hub/hub-singleton.js";
import { buildSystemPrompt } from "../system-prompt/index.js";
import type {
SubagentAnnounceParams,
SubagentRunOutcome,
SubagentRunRecord,
SubagentSystemPromptParams,
} from "./types.js";
/**
* Build the system prompt injected into a subagent session.
* Uses the structured prompt builder with "minimal" mode.
*/
export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): string {
return buildSystemPrompt({
mode: "minimal",
subagent: {
requesterSessionId: params.requesterSessionId,
childSessionId: params.childSessionId,
label: params.label,
task: params.task,
},
tools: params.tools,
});
}
/**
* Read the latest assistant reply from a session's JSONL file.
*/
export function readLatestAssistantReply(sessionId: string): string | undefined {
const entries = readEntries(sessionId);
let latestToolResultText: string | undefined;
// Walk backwards to find the last non-empty assistant reply.
// If no assistant text exists (e.g. run ended after tool execution),
// fall back to the latest non-empty toolResult content.
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]!;
if (entry.type !== "message") continue;
const message = entry.message;
if (message.role === "assistant") {
const text = extractAssistantText(message);
if (text) return text;
continue;
}
if (message.role === "toolResult" && !latestToolResultText) {
const text = extractToolResultText(message);
if (text) latestToolResultText = text;
}
}
return latestToolResultText;
}
/**
* Extract text content from an assistant message.
* AgentMessage.content for assistant is (TextContent | ThinkingContent | ToolCall)[].
*/
function extractAssistantText(message: { role: string; content: unknown }): string {
return extractTextLikeContent(message.content);
}
/**
* Extract text content from a toolResult message.
*/
function extractToolResultText(message: { role: string; content: unknown }): string {
return extractTextLikeContent(message.content);
}
function extractTextLikeContent(content: unknown): string {
if (typeof content === "string") {
return sanitizeText(content);
}
if (!Array.isArray(content)) return "";
const textParts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
if ("text" in block) {
textParts.push(String((block as { text: unknown }).text));
}
}
return sanitizeText(textParts.join("\n"));
}
/**
* Strip thinking tags and tool markers from text.
*/
function sanitizeText(text: string): string {
return text
.replace(/<thinking>[\s\S]*?<\/thinking>/g, "")
.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, "")
.trim();
}
/**
* Format the duration between two timestamps as a human-readable string.
*/
function formatDuration(startMs: number, endMs: number): string {
const totalSeconds = Math.round((endMs - startMs) / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
}
/**
* Format a status label from an outcome.
*/
function formatStatusLabel(outcome: SubagentRunOutcome | undefined): string {
if (!outcome) return "completed with unknown status";
switch (outcome.status) {
case "ok":
return "completed successfully";
case "error":
return outcome.error ? `failed: ${outcome.error}` : "failed";
case "timeout":
return "timed out";
default:
return "completed with unknown status";
}
}
/** Parameters for formatAnnouncementMessage */
export interface FormatAnnouncementParams {
runId: string;
childSessionId: string;
requesterSessionId: string;
task: string;
label?: string | undefined;
cleanup: "delete" | "keep";
outcome?: SubagentRunOutcome | undefined;
startedAt?: number | undefined;
endedAt?: number | undefined;
findings?: string | undefined;
}
/**
* Format the announcement message sent to the parent agent.
*/
export function formatAnnouncementMessage(params: FormatAnnouncementParams): string {
const { task, label, outcome, findings, startedAt, endedAt, childSessionId } = params;
const displayName = label || task.slice(0, 60);
const statusLabel = formatStatusLabel(outcome);
const parts: string[] = [
`A background task "${displayName}" just ${statusLabel}.`,
"",
"Findings:",
findings || "(no output)",
];
// Stats line
const stats: string[] = [];
if (startedAt && endedAt) {
stats.push(`runtime ${formatDuration(startedAt, endedAt)}`);
}
stats.push(`session ${childSessionId}`);
parts.push("", `Stats: ${stats.join(" • ")}`);
parts.push(
"",
"Summarize this naturally for the user. Keep it brief (1-2 sentences).",
"Flow it into the conversation naturally.",
"Do not mention technical details like session IDs or that this was a background task.",
"You can respond with NO_REPLY if no announcement is needed (e.g., internal task with no user-facing result).",
);
return parts.join("\n");
}
/**
* Format a coalesced announcement message from multiple completed subagent runs.
* When only one record is provided, delegates to formatAnnouncementMessage.
*/
export function formatCoalescedAnnouncementMessage(
records: SubagentRunRecord[],
): string {
// Single record: delegate to existing format for backward-compatible behavior
if (records.length === 1) {
const r = records[0]!;
return formatAnnouncementMessage({
runId: r.runId,
childSessionId: r.childSessionId,
requesterSessionId: r.requesterSessionId,
task: r.task,
label: r.label,
cleanup: r.cleanup,
outcome: r.outcome,
startedAt: r.startedAt,
endedAt: r.endedAt,
findings: r.findings,
});
}
// Multiple records: build combined message.
// Include a strict raw-findings section so parent can reliably cover every task result.
const parts: string[] = [
`All ${records.length} background tasks have completed. Here are the combined results:`,
"",
];
for (let i = 0; i < records.length; i++) {
const r = records[i]!;
const displayName = r.label || r.task.slice(0, 60);
const statusLabel = formatStatusLabel(r.outcome);
const durationStr = (r.startedAt && r.endedAt)
? ` (${formatDuration(r.startedAt, r.endedAt)})`
: "";
parts.push(
`### Task ${i + 1}: "${displayName}"`,
`Status: ${statusLabel}${durationStr}`,
"",
"Findings:",
r.findings || "(no output)",
"",
);
}
// Overall stats
const allStartTimes = records.map(r => r.startedAt).filter(Boolean) as number[];
const allEndTimes = records.map(r => r.endedAt).filter(Boolean) as number[];
if (allStartTimes.length > 0 && allEndTimes.length > 0) {
const wallTime = formatDuration(Math.min(...allStartTimes), Math.max(...allEndTimes));
parts.push(`Total wall time: ${wallTime}`);
}
const okCount = records.filter(r => r.outcome?.status === "ok").length;
const failCount = records.length - okCount;
parts.push(`Results: ${okCount} succeeded, ${failCount} failed/timed out`);
parts.push("", "Raw findings from each task (MUST cover all items):", "");
for (let i = 0; i < records.length; i++) {
const r = records[i]!;
const displayName = r.label || r.task.slice(0, 60);
parts.push(
`[${i + 1}] ${displayName}:`,
r.findings || "(no output)",
"",
);
}
parts.push(
"",
"Summarize these results naturally for the user.",
"You MUST include findings from every task item above, without omission.",
"Keep it concise, but preserve concrete findings from each task.",
"Do not mention technical details like session IDs or that these were background tasks.",
"You can respond with NO_REPLY if no announcement is needed.",
);
return parts.join("\n");
}
/**
* Run the coalesced announcement flow for all completed runs of a requester.
* Formats a single combined message and delivers it to the parent agent.
*/
export function runCoalescedAnnounceFlow(
requesterSessionId: string,
records: SubagentRunRecord[],
): boolean {
const message = formatCoalescedAnnouncementMessage(records);
try {
const hub = getHub();
const parentAgent = hub.getAgent(requesterSessionId);
if (!parentAgent || parentAgent.closed) {
console.warn(
`[SubagentAnnounce] Parent agent not found or closed: ${requesterSessionId}`,
);
return false;
}
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
return true;
} catch (err) {
console.error(`[SubagentAnnounce] Failed to coalesced-announce to parent:`, err);
return false;
}
}
/**
* Run the full subagent announcement flow:
* 1. Read child's last assistant reply
* 2. Format announcement message
* 3. Send to parent agent via Hub
*
* @deprecated Use runCoalescedAnnounceFlow instead, which supports
* batching multiple completed runs into a single announcement.
*/
export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean {
const { requesterSessionId, childSessionId } = params;
// Read child's final output
const findings = readLatestAssistantReply(childSessionId);
// Format the announcement
const message = formatAnnouncementMessage({
runId: params.runId,
childSessionId: params.childSessionId,
requesterSessionId: params.requesterSessionId,
task: params.task,
label: params.label,
cleanup: params.cleanup,
outcome: params.outcome,
startedAt: params.startedAt,
endedAt: params.endedAt,
findings,
});
// Deliver to parent agent via Hub
try {
const hub = getHub();
const parentAgent = hub.getAgent(requesterSessionId);
if (!parentAgent || parentAgent.closed) {
console.warn(
`[SubagentAnnounce] Parent agent not found or closed: ${requesterSessionId}`,
);
return false;
}
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
return true;
} catch (err) {
console.error(`[SubagentAnnounce] Failed to announce to parent:`, err);
return false;
}
}