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>
This commit is contained in:
yushen 2026-02-06 19:38:18 +08:00
parent a50239a9f5
commit a3acd732e0
4 changed files with 93 additions and 2 deletions

View file

@ -7,6 +7,7 @@ const internalRunState = { value: false };
const runMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
const runInternalMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined }));
const flushSessionMock = vi.fn(async () => {});
const persistAssistantSummaryMock = vi.fn();
const subscribeAllMock = vi.fn((fn: (event: any) => void) => {
subscribeCallbacks.push(fn);
return () => {};
@ -19,6 +20,7 @@ vi.mock("./runner.js", () => ({
run = runMock;
runInternal = runInternalMock;
flushSession = flushSessionMock;
persistAssistantSummary = persistAssistantSummaryMock;
get isInternalRun() {
return internalRunState.value;
}
@ -84,6 +86,7 @@ describe("AsyncAgent internal flow", () => {
runMock.mockReset();
runInternalMock.mockReset();
flushSessionMock.mockReset();
persistAssistantSummaryMock.mockReset();
subscribeAllMock.mockClear();
runMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
runInternalMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined });
@ -207,4 +210,55 @@ describe("AsyncAgent internal flow", () => {
internalRunState.value = false;
agent.close();
});
it("persists assistant summary when persistResponse is true and result has text", async () => {
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).toHaveBeenCalledOnce();
expect(persistAssistantSummaryMock).toHaveBeenCalledWith("Summary of findings");
// flushSession called twice: once after runInternal, once after persistAssistantSummary
expect(flushSessionMock).toHaveBeenCalledTimes(2);
agent.close();
});
it("does not persist assistant summary when result text is NO_REPLY", async () => {
runInternalMock.mockResolvedValueOnce({ text: "NO_REPLY", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
agent.close();
});
it("does not persist assistant summary when result text is empty", async () => {
runInternalMock.mockResolvedValueOnce({ text: " ", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true, persistResponse: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
agent.close();
});
it("does not persist assistant summary when persistResponse is not set", async () => {
runInternalMock.mockResolvedValueOnce({ text: "Summary of findings", thinking: undefined, error: undefined });
const agent = new AsyncAgent();
agent.writeInternal("announce findings", { forwardAssistant: true });
await agent.waitForIdle();
expect(persistAssistantSummaryMock).not.toHaveBeenCalled();
agent.close();
});
});

View file

@ -13,6 +13,8 @@ export type ChannelItem = Message | AgentEvent | MulticaEvent;
export interface WriteInternalOptions {
/** Forward assistant message_end events to realtime stream during internal runs */
forwardAssistant?: boolean | undefined;
/** After internal run completes, persist the LLM's summary as a non-internal assistant message */
persistResponse?: boolean | undefined;
}
export class AsyncAgent {
@ -74,6 +76,7 @@ export class AsyncAgent {
writeInternal(content: string, options?: WriteInternalOptions): void {
if (this._closed) throw new Error("Agent is closed");
const forwardAssistant = options?.forwardAssistant === true;
const persistResponse = options?.persistResponse === true;
this.queue = this.queue
.then(async () => {
@ -87,6 +90,11 @@ export class AsyncAgent {
// Internal run errors are for diagnostics only; do not leak to user stream.
console.error(`[AsyncAgent] Internal run error: ${result.error}`);
}
// Persist the LLM summary so it remains in parent context for future turns
if (persistResponse && result.text?.trim() && result.text.trim() !== "NO_REPLY") {
this.agent.persistAssistantSummary(result.text.trim());
await this.agent.flushSession();
}
} finally {
this.forwardInternalAssistant = prevForward;
}

View file

@ -563,6 +563,35 @@ export class Agent {
return this._internalRun;
}
/**
* Persist a synthetic assistant message into both in-memory state and session JSONL.
* Used after an internal run to keep the LLM summary visible in future turns
* while the internal prompt stays hidden.
*/
persistAssistantSummary(text: string): void {
const model = this.agent.state.model;
const message = {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: model?.api ?? "openai-completions",
provider: model?.provider ?? "internal",
model: model?.id ?? "unknown",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: Date.now(),
};
this.agent.appendMessage(message);
this.session.saveMessage(message);
}
/** Ensure session messages are loaded from disk (idempotent) */
async ensureInitialized(): Promise<void> {
if (this.initialized) return;

View file

@ -293,7 +293,7 @@ export function runCoalescedAnnounceFlow(
return false;
}
parentAgent.writeInternal(message, { forwardAssistant: true });
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
return true;
} catch (err) {
console.error(`[SubagentAnnounce] Failed to coalesced-announce to parent:`, err);
@ -341,7 +341,7 @@ export function runSubagentAnnounceFlow(params: SubagentAnnounceParams): boolean
return false;
}
parentAgent.writeInternal(message, { forwardAssistant: true });
parentAgent.writeInternal(message, { forwardAssistant: true, persistResponse: true });
return true;
} catch (err) {
console.error(`[SubagentAnnounce] Failed to announce to parent:`, err);