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:
parent
a50239a9f5
commit
a3acd732e0
4 changed files with 93 additions and 2 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue