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:
@@ -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> {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user