diff --git a/packages/coding-agent/src/hindsight/backend.ts b/packages/coding-agent/src/hindsight/backend.ts index e37281986..82017b1bf 100644 --- a/packages/coding-agent/src/hindsight/backend.ts +++ b/packages/coding-agent/src/hindsight/backend.ts @@ -52,6 +52,15 @@ export interface HindsightSessionState { hasRecalledForFirstTurn: boolean; lastRecallSnippet?: string; unsubscribe?: () => void; + /** + * When set, this entry is a subagent alias that reuses the parent's bank, + * scope, config, client, and missionsSet. Aliases skip auto-recall and + * auto-retain — those run on the parent only — but the recall/retain/reflect + * tools resolve via the alias so they persist to the same bank as the + * parent. Iteration sites (`enqueue`, `buildDeveloperInstructions`) skip + * aliases to avoid double-counting the shared state. + */ + aliasOf?: HindsightSessionState; } const STATE_BY_SESSION_ID = new Map(); @@ -84,6 +93,22 @@ export function clearHindsightSessionStateForTest(): void { STATE_BY_SESSION_ID.clear(); } +/** + * Pick a top-level (non-alias) state. Subagent aliases reuse the parent's + * state, so when wiring a new subagent we need the originating primary entry + * to copy bank/scope/config/missionsSet from. Returns the most recently + * registered primary; with one top-level session per process this is the + * correct one. Returns undefined when no primary state has been registered. + */ +function pickPrimaryState(): HindsightSessionState | undefined { + let result: HindsightSessionState | undefined; + for (const state of STATE_BY_SESSION_ID.values()) { + if (state.aliasOf) continue; + result = state; + } + return result; +} + interface RecallOutcome { context: string | null; ok: boolean; @@ -219,10 +244,32 @@ export const hindsightBackend: MemoryBackend = { async start(options: MemoryBackendStartOptions): Promise { const { session, settings } = options; const sessionId = session.sessionId; - // Subagents and ephemeral runs share the same harness path but Hindsight - // only makes sense for top-level persistent sessions. if (!sessionId) return; - if (options.taskDepth > 0) return; + + // Subagents alias the parent's state so recall/retain/reflect tool calls + // persist to the same Hindsight bank. Auto-recall and auto-retain stay + // with the parent — running them per subagent would double-recall and + // pollute the bank with internal exploration transcripts. + if (options.taskDepth > 0) { + const parent = pickPrimaryState(); + if (!parent) return; + const previous = STATE_BY_SESSION_ID.get(sessionId); + previous?.unsubscribe?.(); + STATE_BY_SESSION_ID.set(sessionId, { + client: parent.client, + bankId: parent.bankId, + retainTags: parent.retainTags, + recallTags: parent.recallTags, + recallTagsMatch: parent.recallTagsMatch, + config: parent.config, + session, + missionsSet: parent.missionsSet, + lastRetainedTurn: 0, + hasRecalledForFirstTurn: true, + aliasOf: parent, + }); + return; + } const config = loadHindsightConfig(settings); if (!isHindsightConfigured(config)) { @@ -265,6 +312,7 @@ export const hindsightBackend: MemoryBackend = { // freshest snippet across all states is the correct one. let recallSnippet: string | undefined; for (const state of STATE_BY_SESSION_ID.values()) { + if (state.aliasOf) continue; if (state.lastRecallSnippet) recallSnippet = state.lastRecallSnippet; } @@ -315,6 +363,7 @@ export const hindsightBackend: MemoryBackend = { async enqueue(_agentDir, _cwd): Promise { // Force an immediate retain across every active session. for (const state of STATE_BY_SESSION_ID.values()) { + if (state.aliasOf) continue; const sessionId = state.session.sessionId; if (!sessionId) continue; const messages = extractMessages(state.session.sessionManager); diff --git a/packages/coding-agent/test/hindsight-backend.test.ts b/packages/coding-agent/test/hindsight-backend.test.ts index b60d149ae..d1e6857c8 100644 --- a/packages/coding-agent/test/hindsight-backend.test.ts +++ b/packages/coding-agent/test/hindsight-backend.test.ts @@ -150,12 +150,51 @@ describe("hindsightBackend.start", () => { expect(retainSpy).toHaveBeenCalledTimes(1); }); - it("does nothing on subagent runs (taskDepth > 0)", async () => { + it("aliases parent state on subagent runs (taskDepth > 0) so tools share the parent bank", async () => { const settings = Settings.isolated({ "memory.backend": "hindsight", "hindsight.apiUrl": "http://localhost:8888", }); - const session = makeFakeSession({ sessionId: "s4" }); + + // Register a primary (top-level) state first. + const parentSession = makeFakeSession({ sessionId: "parent" }); + await hindsightBackend.start({ + session: parentSession as never, + settings, + modelRegistry: {} as never, + agentDir: "/tmp", + taskDepth: 0, + }); + const parentState = getHindsightSessionState("parent"); + expect(parentState).toBeDefined(); + + // Subagent runs with taskDepth > 0 should alias the parent. + const subSession = makeFakeSession({ sessionId: "sub" }); + await hindsightBackend.start({ + session: subSession as never, + settings, + modelRegistry: {} as never, + agentDir: "/tmp", + taskDepth: 1, + }); + const subState = getHindsightSessionState("sub"); + expect(subState).toBeDefined(); + expect(subState?.aliasOf).toBe(parentState); + expect(subState?.bankId).toBe(parentState?.bankId); + expect(subState?.client).toBe(parentState?.client); + expect(subState?.missionsSet).toBe(parentState?.missionsSet); + // Aliases must not subscribe to session events — the parent owns auto-recall/auto-retain. + expect(subState?.unsubscribe).toBeUndefined(); + // hasRecalledForFirstTurn=true suppresses beforeAgentStartPrompt auto-recall on the sub. + expect(subState?.hasRecalledForFirstTurn).toBe(true); + }); + + it("returns silently for subagent runs when no primary state has been registered", async () => { + const settings = Settings.isolated({ + "memory.backend": "hindsight", + "hindsight.apiUrl": "http://localhost:8888", + }); + const session = makeFakeSession({ sessionId: "orphan-sub" }); await hindsightBackend.start({ session: session as never, @@ -165,7 +204,7 @@ describe("hindsightBackend.start", () => { taskDepth: 1, }); - expect(getHindsightSessionState("s4")).toBeUndefined(); + expect(getHindsightSessionState("orphan-sub")).toBeUndefined(); }); });