feat(coding-agent): added first-turn hindsight recall injection to startup prompts

- Added a new optional `beforeAgentStartPrompt` hook to `MemoryBackend` and implemented it in the Hindsight backend to recall long-term context for the first turn.
- Updated `AgentSession` startup flow to inject the recalled context into the turn-specific system prompt before the first response is generated.
- Preserved `<hindsight_memories>` tags in Hindsight developer instructions and added tests for first-turn injection and state caching.
This commit is contained in:
can1357
2026-05-03 06:40:25 +02:00
parent fea97766dd
commit 7784717935
13 changed files with 158 additions and 31 deletions
+33 -5
View File
@@ -24,7 +24,6 @@ import {
type HindsightMessage,
prepareRetentionTranscript,
sliceLastTurnsByUserBoundary,
stripMemoryTags,
truncateRecallQuery,
} from "./content";
import { extractMessages } from "./transcript";
@@ -111,7 +110,11 @@ async function recallForContext(
}
}
async function retainSession(state: HindsightSessionState, sessionId: string, messages: HindsightMessage[]): Promise<void> {
async function retainSession(
state: HindsightSessionState,
sessionId: string,
messages: HindsightMessage[],
): Promise<void> {
const { client, bankId, config, missionsSet } = state;
const retainFullWindow = config.retainMode === "full-session";
@@ -174,11 +177,13 @@ async function maybeRecallOnAgentStart(state: HindsightSessionState): Promise<vo
const messages = extractMessages(state.session.sessionManager);
const lastUser = [...messages].reverse().find(m => m.role === "user");
if (!lastUser) return;
state.hasRecalledForFirstTurn = true;
const query = composeRecallQuery(lastUser.content, messages, state.config.recallContextTurns);
const truncated = truncateRecallQuery(query, lastUser.content, state.config.recallMaxQueryChars);
const { context } = await recallForContext(state, truncated);
const { context, ok } = await recallForContext(state, truncated);
if (!ok) return;
state.hasRecalledForFirstTurn = true;
if (!context) return;
state.lastRecallSnippet = context;
@@ -254,11 +259,34 @@ export const hindsightBackend: MemoryBackend = {
const parts = [STATIC_INSTRUCTIONS];
if (recallSnippet) {
parts.push(stripMemoryTags(recallSnippet) || recallSnippet);
parts.push(recallSnippet);
}
return parts.join("\n\n");
},
async beforeAgentStartPrompt(session: AgentSession, promptText: string): Promise<string | undefined> {
const sessionId = session.sessionId;
if (!sessionId) return undefined;
const state = STATE_BY_SESSION_ID.get(sessionId);
if (!state?.config.autoRecall || state.hasRecalledForFirstTurn) return undefined;
const latestPrompt = promptText.trim();
if (!latestPrompt) return undefined;
const history = extractMessages(session.sessionManager);
const queryMessages = [...history, { role: "user", content: latestPrompt }];
const query = composeRecallQuery(latestPrompt, queryMessages, state.config.recallContextTurns);
const truncated = truncateRecallQuery(query, latestPrompt, state.config.recallMaxQueryChars);
const { context, ok } = await recallForContext(state, truncated);
if (!ok) return undefined;
state.hasRecalledForFirstTurn = true;
if (!context) return undefined;
state.lastRecallSnippet = context;
return context;
},
async clear(_agentDir, _cwd): Promise<void> {
// Hindsight memory is server-side. The local cache (per-session WeakMap-
// equivalent) is what we can wipe — operators who want to delete the
@@ -148,6 +148,8 @@ export function loadHindsightConfig(settings: Settings, env: NodeJS.ProcessEnv =
}
/** Whether the caller has enough config to talk to a Hindsight server. */
export function isHindsightConfigured(config: HindsightConfig): config is HindsightConfig & { hindsightApiUrl: string } {
export function isHindsightConfigured(
config: HindsightConfig,
): config is HindsightConfig & { hindsightApiUrl: string } {
return typeof config.hindsightApiUrl === "string" && config.hindsightApiUrl.length > 0;
}
@@ -167,7 +167,10 @@ export interface RetentionTranscript {
* Messages are tag-stripped before framing to break the recall→retain loop.
* Returns `{ transcript: null }` when nothing meaningful survives.
*/
export function prepareRetentionTranscript(messages: HindsightMessage[], retainFullWindow = false): RetentionTranscript {
export function prepareRetentionTranscript(
messages: HindsightMessage[],
retainFullWindow = false,
): RetentionTranscript {
if (messages.length === 0) return { transcript: null, messageCount: 0 };
let targetMessages: HindsightMessage[];
@@ -45,6 +45,17 @@ export interface MemoryBackend {
/** Force consolidation/retain to happen now (slash `/memory enqueue`). */
enqueue(agentDir: string, cwd: string): Promise<void>;
/**
* Optional hook to inject a backend-specific block into the current turn's
* system prompt before the agent starts generating.
*
* This is the only place a backend can affect the very first answer of a
* fresh session. The returned text is appended to the already-built base
* system prompt for this turn only; callers may separately cache it and
* surface it through `buildDeveloperInstructions()` on later rebuilds.
*/
beforeAgentStartPrompt?(session: AgentSession, promptText: string): Promise<string | undefined>;
/**
* Optional hook to splice extra context into a compaction summarization.
*
@@ -2290,6 +2290,23 @@ export class AgentSession {
this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
}
async #buildSystemPromptForAgentStart(promptText: string): Promise<string> {
const backend = resolveMemoryBackend(this.settings);
if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt;
try {
const injected = await backend.beforeAgentStartPrompt(this, promptText);
if (!injected) return this.#baseSystemPrompt;
return `${this.#baseSystemPrompt}\n\n${injected}`;
} catch (err) {
logger.debug("Memory backend beforeAgentStartPrompt failed", {
backend: backend.id,
error: String(err),
});
return this.#baseSystemPrompt;
}
}
/**
* Compose a stable signature for the inputs that `rebuildSystemPrompt` reads.
* Two calls producing identical signatures are guaranteed to produce identical
@@ -2909,12 +2926,14 @@ export class AgentSession {
messages.push(...fileMentionMessages);
}
const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
// Emit before_agent_start extension event
if (this.#extensionRunner) {
const result = await this.#extensionRunner.emitBeforeAgentStart(
expandedText,
options?.images,
this.#baseSystemPrompt,
beforeAgentStartSystemPrompt,
);
if (result?.messages) {
const promptAttribution: "user" | "agent" | undefined =
@@ -2935,8 +2954,10 @@ export class AgentSession {
if (result?.systemPrompt !== undefined) {
this.agent.setSystemPrompt(result.systemPrompt);
} else {
this.agent.setSystemPrompt(this.#baseSystemPrompt);
this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
}
} else {
this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
}
// Bail out if a newer abort/prompt cycle has started since we began setup
@@ -7,17 +7,15 @@ import type { ToolSession } from ".";
const hindsightReflectSchema = Type.Object({
query: Type.String({ description: "The question to answer using long-term memory." }),
context: Type.Optional(
Type.String({ description: "Optional additional context to guide the reflection." }),
),
context: Type.Optional(Type.String({ description: "Optional additional context to guide the reflection." })),
});
export type HindsightReflectParams = Static<typeof hindsightReflectSchema>;
const DESCRIPTION = [
"Generate a synthesised answer using long-term memory. Unlike recall (which returns raw memories),",
"reflect blends memories into a coherent answer. Use for questions like \"What do you know about",
"this user?\" or \"Summarize project decisions.\"",
'reflect blends memories into a coherent answer. Use for questions like "What do you know about',
'this user?" or "Summarize project decisions."',
].join(" ");
export class HindsightReflectTool implements AgentTool<typeof hindsightReflectSchema> {
+3 -3
View File
@@ -30,10 +30,10 @@ import { EvalTool } from "./eval";
import { ExitPlanModeTool } from "./exit-plan-mode";
import { FindTool } from "./find";
import { GithubTool } from "./gh";
import { InspectImageTool } from "./inspect-image";
import { HindsightRecallTool } from "./hindsight-recall";
import { HindsightReflectTool } from "./hindsight-reflect";
import { HindsightRetainTool } from "./hindsight-retain";
import { InspectImageTool } from "./inspect-image";
import { IrcTool } from "./irc";
import { JobTool } from "./job";
import { NotebookTool } from "./notebook";
@@ -72,11 +72,11 @@ export * from "./eval";
export * from "./exit-plan-mode";
export * from "./find";
export * from "./gh";
export * from "./image-gen";
export * from "./inspect-image";
export * from "./hindsight-recall";
export * from "./hindsight-reflect";
export * from "./hindsight-retain";
export * from "./image-gen";
export * from "./inspect-image";
export * from "./irc";
export * from "./job";
export * from "./notebook";
@@ -9,7 +9,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { Settings, _resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import {
clearHindsightSessionStateForTest,
getHindsightSessionState,
@@ -182,9 +182,7 @@ describe("hindsightBackend.preCompactionContext", () => {
it("returns undefined when no apiUrl is configured", async () => {
const settings = Settings.isolated({ "memory.backend": "hindsight", "hindsight.apiUrl": "" });
const messages: AgentMessage[] = [
{ role: "user", content: "hi", timestamp: 0 } as never,
];
const messages: AgentMessage[] = [{ role: "user", content: "hi", timestamp: 0 } as never];
const ctx = await hindsightBackend.preCompactionContext?.(messages, settings);
expect(ctx).toBeUndefined();
});
@@ -207,9 +205,7 @@ describe("hindsightBackend.preCompactionContext", () => {
results: [{ id: "1", text: "remembered fact" }],
} as never);
const messages: AgentMessage[] = [
{ role: "user", content: "What did we decide?", timestamp: 0 } as never,
];
const messages: AgentMessage[] = [{ role: "user", content: "What did we decide?", timestamp: 0 } as never];
const ctx = await hindsightBackend.preCompactionContext?.(messages, settings);
expect(ctx).toBeDefined();
expect(ctx).toContain("<hindsight_memories>");
@@ -237,6 +233,73 @@ describe("hindsightBackend.preCompactionContext", () => {
});
});
describe("hindsightBackend first-turn injection", () => {
beforeEach(() => {
_resetSettingsForTest();
clearHindsightSessionStateForTest();
});
afterEach(() => {
vi.restoreAllMocks();
clearHindsightSessionStateForTest();
});
it("returns a tagged block for the current first turn before agent_start", async () => {
const settings = Settings.isolated({
"memory.backend": "hindsight",
"hindsight.apiUrl": "http://localhost:8888",
});
const session = makeFakeSession({
sessionId: "s8",
entries: [{ role: "assistant", text: "previous assistant context" }],
});
await hindsightBackend.start({
session: session as never,
settings,
modelRegistry: {} as never,
agentDir: "/tmp",
taskDepth: 0,
});
vi.spyOn(HindsightClient.prototype, "recall").mockResolvedValue({
results: [{ id: "1", text: "Can prefers concise communication" }],
} as never);
const block = await hindsightBackend.beforeAgentStartPrompt?.(
session as never,
"What do I know about this user?",
);
expect(block).toContain("<hindsight_memories>");
expect(block).toContain("Can prefers concise communication");
expect(getHindsightSessionState("s8")?.hasRecalledForFirstTurn).toBe(true);
expect(getHindsightSessionState("s8")?.lastRecallSnippet).toBe(block);
});
it("keeps the <hindsight_memories> wrapper in buildDeveloperInstructions", async () => {
const settings = Settings.isolated({
"memory.backend": "hindsight",
"hindsight.apiUrl": "http://localhost:8888",
});
const session = makeFakeSession({ sessionId: "s9" });
await hindsightBackend.start({
session: session as never,
settings,
modelRegistry: {} as never,
agentDir: "/tmp",
taskDepth: 0,
});
const state = getHindsightSessionState("s9");
expect(state).toBeDefined();
state!.lastRecallSnippet = "<hindsight_memories>\nremembered fact\n</hindsight_memories>";
const prompt = await hindsightBackend.buildDeveloperInstructions("/tmp", settings);
expect(prompt).toContain("<hindsight_memories>");
expect(prompt).toContain("</hindsight_memories>");
expect(prompt).toContain("remembered fact");
});
});
describe("hindsightBackend.clear", () => {
beforeEach(() => {
_resetSettingsForTest();
@@ -114,9 +114,7 @@ describe("ensureBankMission", () => {
});
it("swallows API failures and does not mark the bank as initialised", async () => {
createSpy = vi
.spyOn(HindsightClient.prototype, "createBank")
.mockRejectedValue(new Error("HTTP 500"));
createSpy = vi.spyOn(HindsightClient.prototype, "createBank").mockRejectedValue(new Error("HTTP 500"));
const seen = new Set<string>();
const config = baseConfig({ bankMission: "do the thing" });
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { Settings, _resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { isHindsightConfigured, loadHindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config";
describe("loadHindsightConfig", () => {
@@ -162,7 +162,10 @@ describe("prepareRetentionTranscript", () => {
});
it("returns null when nothing meaningful remains", () => {
const empty = prepareRetentionTranscript([{ role: "user", content: "<hindsight_memories>x</hindsight_memories>" }], true);
const empty = prepareRetentionTranscript(
[{ role: "user", content: "<hindsight_memories>x</hindsight_memories>" }],
true,
);
expect(empty.transcript).toBeNull();
});
});
@@ -9,7 +9,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { Settings, _resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import {
clearHindsightSessionStateForTest,
setHindsightSessionStateForTest,
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { Settings, _resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { resolveMemoryBackend } from "@oh-my-pi/pi-coding-agent/memory-backend";
describe("resolveMemoryBackend", () => {