fix(memory): synchronized live backend lifecycle
- Serialized backend transitions across runtime state, tools, and prompts. - Rehydrated Mnemopi listeners after clear and enqueue maintenance. - Made memory.backend the sole post-migration local runtime gate. Fixes #5638
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed mid-session `memory.backend` changes leaving runtime state, tools, listeners, and prompt context on different backends; Mnemopi clear/enqueue now rehydrate listeners, and legacy `memories.enabled` no longer activates the local pipeline after migration ([#5638](https://github.com/can1357/oh-my-pi/issues/5638)).
|
||||
- Fixed `error.notify` raising a "Stopped with error" toast for provider failures while an auto-retry or async-delivery continuation was pending; the toast now waits for the true terminal settle.
|
||||
- Fixed terminal `yield` results racing post-turn maintenance, which could trigger an unnecessary automatic handoff or compaction.
|
||||
- Fixed credential-shaped tokens (GitHub/GitLab/OpenAI/Anthropic key patterns) being redacted from outbound provider requests even with `secrets.enabled` off; the pattern redaction now follows the `secrets.enabled` ("Hide Secrets") setting like the secret obfuscator.
|
||||
|
||||
@@ -32,4 +32,4 @@ The agent supports three mutually-exclusive memory backends, selected via the `m
|
||||
- `HINDSIGHT_RECALL_BUDGET`, `HINDSIGHT_RECALL_MAX_TOKENS` — recall sizing
|
||||
- `HINDSIGHT_BANK_MISSION`, `HINDSIGHT_DEBUG`
|
||||
|
||||
Switching backends mid-session is honoured on the next system-prompt rebuild and the next `/memory` slash command. Existing users with `memories.enabled = true|false` are migrated to `memory.backend = "local"|"off"` exactly once on first launch.
|
||||
Switching backends mid-session immediately replaces the live backend, memory tools, listeners, and system-prompt context. Existing users with `memories.enabled = true|false` are migrated to `memory.backend = "local"|"off"` exactly once on first launch; afterward, `memory.backend` is the sole runtime selector.
|
||||
|
||||
@@ -2493,9 +2493,8 @@ export const SETTINGS_SCHEMA = {
|
||||
"memories.summaryInjectionTokenLimit": { type: "number", default: 5000 },
|
||||
|
||||
// Memory backend selector — picks between local memories pipeline,
|
||||
// Mnemopi local SQLite, Hindsight remote memory, or off. Legacy
|
||||
// `memories.enabled` keeps gating the local backend; see config/settings.ts
|
||||
// migration for details.
|
||||
// Mnemopi local SQLite, Hindsight remote memory, or off. The legacy
|
||||
// `memories.enabled` flag is migration input only; see config/settings.ts.
|
||||
"memory.backend": {
|
||||
type: "enum",
|
||||
values: ["off", "local", "hindsight", "mnemopi"] as const,
|
||||
|
||||
@@ -142,9 +142,12 @@ export function startMemoryStartupTask(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
void runMemoryStartup({ session, settings, modelRegistry, agentDir, config: cfg }).catch(error => {
|
||||
logger.warn("Memory startup failed", { error: String(error) });
|
||||
});
|
||||
const signal = session.beginLocalMemoryStartup?.() ?? new AbortController().signal;
|
||||
void runMemoryStartup({ session, settings, modelRegistry, agentDir, config: cfg, signal })
|
||||
.catch(error => {
|
||||
if (!signal.aborted) logger.warn("Memory startup failed", { error: String(error) });
|
||||
})
|
||||
.finally(() => session.endLocalMemoryStartup?.(signal));
|
||||
}
|
||||
|
||||
interface MemoryInstructionSession {
|
||||
@@ -315,26 +318,32 @@ export function enqueueMemoryConsolidation(agentDir: string, cwd: string, source
|
||||
}
|
||||
}
|
||||
|
||||
async function runMemoryStartup(options: {
|
||||
interface MemoryStartupOptions {
|
||||
session: AgentSession;
|
||||
settings: Settings;
|
||||
modelRegistry: ModelRegistry;
|
||||
agentDir: string;
|
||||
config: MemoryRuntimeConfig;
|
||||
}): Promise<void> {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
function isMemoryStartupActive(options: MemoryStartupOptions): boolean {
|
||||
return !options.signal.aborted && !options.session.isDisposed && options.settings.get("memory.backend") === "local";
|
||||
}
|
||||
|
||||
async function runMemoryStartup(options: MemoryStartupOptions): Promise<void> {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await runPhase1(options);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await runPhase2(options);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await refreshMemoryToolDeveloperInstructionsCacheAfterStartup(options.session, options.agentDir, options.settings);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await options.session.refreshBaseSystemPrompt?.();
|
||||
}
|
||||
|
||||
async function runPhase1(options: {
|
||||
session: AgentSession;
|
||||
settings: Settings;
|
||||
modelRegistry: ModelRegistry;
|
||||
agentDir: string;
|
||||
config: MemoryRuntimeConfig;
|
||||
}): Promise<void> {
|
||||
async function runPhase1(options: MemoryStartupOptions): Promise<void> {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const { session, modelRegistry, agentDir, config } = options;
|
||||
const db = openMemoryDb(getAgentDbPath(agentDir));
|
||||
const nowSec = unixNow();
|
||||
@@ -344,6 +353,7 @@ async function runPhase1(options: {
|
||||
|
||||
try {
|
||||
const threads = await collectThreads(session, currentThreadId);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
upsertThreads(db, threads);
|
||||
|
||||
const phase1Model = await resolveMemoryModel({
|
||||
@@ -364,6 +374,7 @@ async function runPhase1(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const claims = claimStage1Jobs(db, {
|
||||
nowSec,
|
||||
threadScanLimit: config.threadScanLimit,
|
||||
@@ -387,6 +398,7 @@ async function runPhase1(options: {
|
||||
};
|
||||
|
||||
await runWithConcurrency(claims, config.stage1Concurrency, async claim => {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const result = await runStage1Job({
|
||||
claim,
|
||||
model: phase1Model,
|
||||
@@ -395,6 +407,7 @@ async function runPhase1(options: {
|
||||
config,
|
||||
metadata: session.agent?.metadataForProvider(phase1Model.provider),
|
||||
});
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
|
||||
if (result.kind === "failed") {
|
||||
logger.error("Memory phase1 stage1 job failed", {
|
||||
@@ -460,13 +473,8 @@ async function runPhase1(options: {
|
||||
}
|
||||
}
|
||||
|
||||
async function runPhase2(options: {
|
||||
session: AgentSession;
|
||||
settings: Settings;
|
||||
modelRegistry: ModelRegistry;
|
||||
agentDir: string;
|
||||
config: MemoryRuntimeConfig;
|
||||
}): Promise<void> {
|
||||
async function runPhase2(options: MemoryStartupOptions): Promise<void> {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const { session, modelRegistry, agentDir, config } = options;
|
||||
const cwd = session.sessionManager.getCwd();
|
||||
const db = openMemoryDb(getAgentDbPath(agentDir));
|
||||
@@ -488,8 +496,10 @@ async function runPhase2(options: {
|
||||
const newWatermark = computeCompletionWatermark(claim.inputWatermark, outputs);
|
||||
|
||||
await syncPhase2Artifacts(memoryRoot, outputs);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
if (outputs.length === 0) {
|
||||
await cleanupConsolidatedArtifacts(memoryRoot);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const marked = markGlobalPhase2Succeeded(db, {
|
||||
ownershipToken: claim.ownershipToken,
|
||||
newWatermark,
|
||||
@@ -502,6 +512,7 @@ async function runPhase2(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const phase2Model = await resolveMemoryModel({
|
||||
modelRegistry,
|
||||
session,
|
||||
@@ -529,8 +540,13 @@ async function runPhase2(options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
let heartbeatLostOwnership = false;
|
||||
const heartbeat = setInterval(() => {
|
||||
if (!isMemoryStartupActive(options)) {
|
||||
clearInterval(heartbeat);
|
||||
return;
|
||||
}
|
||||
const ok = heartbeatGlobalJob(db, {
|
||||
ownershipToken: claim.ownershipToken,
|
||||
leaseSeconds: config.phase2LeaseSeconds,
|
||||
@@ -544,13 +560,16 @@ async function runPhase2(options: {
|
||||
}, config.phase2HeartbeatSeconds * 1000);
|
||||
|
||||
try {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
const consolidated = await runConsolidationModel({
|
||||
memoryRoot,
|
||||
model: phase2Model,
|
||||
apiKey: modelRegistry.resolver(phase2Model, session.sessionId),
|
||||
metadata: session.agent?.metadataForProvider(phase2Model.provider),
|
||||
});
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
await applyConsolidation(memoryRoot, consolidated);
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
if (heartbeatLostOwnership) {
|
||||
throw new Error("Phase2 lease ownership lost before completion");
|
||||
}
|
||||
@@ -564,6 +583,7 @@ async function runPhase2(options: {
|
||||
throw new Error("Phase2 could not mark success: ownership lost");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMemoryStartupActive(options)) return;
|
||||
markPhase2FailureWithFallback(db, {
|
||||
claim,
|
||||
retryDelaySeconds: config.phase2RetryDelaySeconds,
|
||||
@@ -1227,7 +1247,7 @@ async function resolveMemoryModel(options: {
|
||||
|
||||
function loadMemoryConfig(settings: Settings): MemoryRuntimeConfig {
|
||||
return {
|
||||
enabled: settings.get("memory.backend") === "local" || settings.get("memories.enabled") === true,
|
||||
enabled: settings.get("memory.backend") === "local",
|
||||
maxRolloutsPerStartup: settings.get("memories.maxRolloutsPerStartup") ?? DEFAULTS.maxRolloutsPerStartup,
|
||||
maxRolloutAgeDays: settings.get("memories.maxRolloutAgeDays") ?? DEFAULTS.maxRolloutAgeDays,
|
||||
minRolloutIdleHours: settings.get("memories.minRolloutIdleHours") ?? DEFAULTS.minRolloutIdleHours,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Built-in tools whose availability depends on the selected memory backend. */
|
||||
export const MEMORY_BACKEND_TOOL_NAMES = ["retain", "recall", "reflect", "memory_edit", "learn"] as const;
|
||||
@@ -62,6 +62,20 @@ const STATIC_INSTRUCTIONS = [
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
async function installMnemopiState(session: AgentSession, config: MnemopiBackendConfig): Promise<MnemopiSessionState> {
|
||||
const state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
|
||||
const previous = setMnemopiSessionState(session, state);
|
||||
await previous?.dispose();
|
||||
try {
|
||||
state.attachSessionListeners();
|
||||
return state;
|
||||
} catch (error) {
|
||||
setMnemopiSessionState(session, undefined);
|
||||
await state.dispose({ consolidate: false });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const mnemopiBackend: MemoryBackend = {
|
||||
id: "mnemopi",
|
||||
|
||||
@@ -90,10 +104,7 @@ export const mnemopiBackend: MemoryBackend = {
|
||||
try {
|
||||
const config = await loadMnemopiConfigWithProviders(settings, agentDir, modelRegistry, sessionId);
|
||||
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
||||
const state = new MnemopiSessionState({ sessionId, config, session });
|
||||
const previous = setMnemopiSessionState(session, state);
|
||||
await previous?.dispose();
|
||||
state.attachSessionListeners();
|
||||
await installMnemopiState(session, config);
|
||||
} catch (error) {
|
||||
logger.warn("Mnemopi: backend startup failed; memory backend inert.", { error: String(error) });
|
||||
}
|
||||
@@ -129,12 +140,19 @@ export const mnemopiBackend: MemoryBackend = {
|
||||
requireMnemopiCore().resetMemoryForTests();
|
||||
await Bun.sleep(0);
|
||||
await removeDbFiles(getMnemopiScopedDbPaths(config));
|
||||
if (!session?.sessionId || previous?.aliasOf || session.settings.get("memory.backend") !== "mnemopi") return;
|
||||
try {
|
||||
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
||||
await installMnemopiState(session, config);
|
||||
} catch (error) {
|
||||
logger.warn("Mnemopi: clear rehydrate failed; memory backend inert.", { error: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
async enqueue(agentDir, _cwd, session): Promise<void> {
|
||||
try {
|
||||
let state = getMnemopiSessionState(session);
|
||||
if (!state && session) {
|
||||
if (!state && session?.sessionId) {
|
||||
const config = await loadMnemopiConfigWithProviders(
|
||||
session.settings,
|
||||
agentDir,
|
||||
@@ -142,8 +160,7 @@ export const mnemopiBackend: MemoryBackend = {
|
||||
session.sessionId,
|
||||
);
|
||||
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
||||
state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
|
||||
setMnemopiSessionState(session, state);
|
||||
state = await installMnemopiState(session, config);
|
||||
}
|
||||
await state?.consolidate({ full: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -437,6 +437,11 @@ export class SelectorController {
|
||||
this.ctx.showError(`Failed to apply personality: ${err}`);
|
||||
});
|
||||
break;
|
||||
case "memory.backend":
|
||||
void this.ctx.session.applyMemoryBackend().catch(err => {
|
||||
this.ctx.showError(`Failed to apply memory backend: ${err}`);
|
||||
});
|
||||
break;
|
||||
|
||||
case "autocompleteMaxVisible":
|
||||
this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value));
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
} from "./mcp";
|
||||
import { MCP_CONNECTION_STATUS_EVENT_CHANNEL, type McpConnectionStatusEvent } from "./mcp/startup-events";
|
||||
import { createSessionMemoryRuntimeContext, resolveMemoryBackend } from "./memory-backend";
|
||||
import { MEMORY_BACKEND_TOOL_NAMES } from "./memory-backend/tool-names";
|
||||
import type { MnemopiSessionState } from "./mnemopi/state";
|
||||
import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with { type: "text" };
|
||||
import { AgentLifecycleManager } from "./registry/agent-lifecycle";
|
||||
@@ -3029,6 +3030,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
skillsSettings: settings.getGroup("skills"),
|
||||
modelRegistry,
|
||||
toolRegistry,
|
||||
memoryAgentDir: agentDir,
|
||||
memoryTaskDepth: taskDepth,
|
||||
createMemoryTools: restrictToolNames
|
||||
? undefined
|
||||
: async () => {
|
||||
const tools = await Promise.all(
|
||||
MEMORY_BACKEND_TOOL_NAMES.map(name => BUILTIN_TOOLS[name](toolSession)),
|
||||
);
|
||||
return tools.filter((tool): tool is AgentTool => tool !== null);
|
||||
},
|
||||
createVibeTools:
|
||||
(options.taskDepth ?? 0) === 0 && !options.parentTaskPrefix
|
||||
? () => createVibeTools(toolSession)
|
||||
|
||||
@@ -256,7 +256,8 @@ import type { Goal, GoalModeState } from "../goals/state";
|
||||
import type { HindsightSessionState } from "../hindsight/state";
|
||||
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
||||
import { IrcBus, type IrcMessage } from "../irc/bus";
|
||||
import { resolveMemoryBackend } from "../memory-backend";
|
||||
import { resolveMemoryBackend } from "../memory-backend/resolve";
|
||||
import { MEMORY_BACKEND_TOOL_NAMES } from "../memory-backend/tool-names";
|
||||
import { shutdownMnemopiEmbedClient } from "../mnemopi/embed-client";
|
||||
import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
|
||||
import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
|
||||
@@ -936,6 +937,12 @@ export interface AgentSessionConfig {
|
||||
/** Custom commands (TypeScript slash commands) */
|
||||
customCommands?: LoadedCustomCommand[];
|
||||
skillsSettings?: SkillsSettings;
|
||||
/** Agent directory used when applying memory backend changes during a live session. */
|
||||
memoryAgentDir?: string;
|
||||
/** Recursion depth used to suppress live backend replacement in subagents. */
|
||||
memoryTaskDepth?: number;
|
||||
/** Creates the built-in memory tools allowed by the current backend selection. */
|
||||
createMemoryTools?: () => Promise<AgentTool[]>;
|
||||
/** Model registry for API key resolution and model discovery */
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Tool registry for LSP and settings */
|
||||
@@ -2258,6 +2265,11 @@ export class AgentSession {
|
||||
#synchronouslyTerminatedYieldToolCallIds = new Set<string>();
|
||||
#providerSessionState = new Map<string, ProviderSessionState>();
|
||||
#hindsightSessionState: HindsightSessionState | undefined = undefined;
|
||||
#memoryAgentDir: string | undefined;
|
||||
#memoryTaskDepth = 0;
|
||||
#createMemoryTools: (() => Promise<AgentTool[]>) | undefined;
|
||||
#memoryBackendTransition: Promise<void> = Promise.resolve();
|
||||
#localMemoryStartupAbort: AbortController | undefined;
|
||||
readonly rawSseDebugBuffer: RawSseDebugBuffer;
|
||||
|
||||
#resetPromptMaintenanceState(): void {
|
||||
@@ -2790,6 +2802,9 @@ export class AgentSession {
|
||||
this.#skillsReloadable = config.skillsReloadable ?? true;
|
||||
this.#skillsSettings = config.skillsSettings;
|
||||
this.#modelRegistry = config.modelRegistry;
|
||||
this.#memoryAgentDir = config.memoryAgentDir;
|
||||
this.#memoryTaskDepth = config.memoryTaskDepth ?? 0;
|
||||
this.#createMemoryTools = config.createMemoryTools;
|
||||
// Resolve the wire service-tier per request so the Fireworks Priority
|
||||
// toggle scopes priority to Fireworks alone, without mutating the shared
|
||||
// session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority.
|
||||
@@ -6976,6 +6991,7 @@ export class AgentSession {
|
||||
*/
|
||||
beginDispose(): void {
|
||||
this.#isDisposed = true;
|
||||
this.cancelLocalMemoryStartup();
|
||||
this.#titleGenerationAbortController.abort();
|
||||
this.#abortAutolearnCapture();
|
||||
this.#flushPendingIrcAsides();
|
||||
@@ -7106,6 +7122,7 @@ export class AgentSession {
|
||||
logger.warn("Post-prompt tasks still draining at dispose deadline", { error: String(error) });
|
||||
}
|
||||
await this.#drainAutolearnCapture();
|
||||
await this.#memoryBackendTransition;
|
||||
|
||||
const hindsightState = this.getHindsightSessionState();
|
||||
const mnemopiState = setMnemopiSessionState(this, undefined);
|
||||
@@ -7845,13 +7862,126 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the local rollout-memory startup owned by this session. */
|
||||
cancelLocalMemoryStartup(): void {
|
||||
this.#localMemoryStartupAbort?.abort();
|
||||
this.#localMemoryStartupAbort = undefined;
|
||||
}
|
||||
|
||||
/** Start a new local rollout-memory generation and cancel its predecessor. */
|
||||
beginLocalMemoryStartup(): AbortSignal {
|
||||
this.cancelLocalMemoryStartup();
|
||||
const controller = new AbortController();
|
||||
this.#localMemoryStartupAbort = controller;
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
/** Release the local startup slot if `signal` still owns it. */
|
||||
endLocalMemoryStartup(signal: AbortSignal): void {
|
||||
if (this.#localMemoryStartupAbort?.signal === signal) this.#localMemoryStartupAbort = undefined;
|
||||
}
|
||||
|
||||
async #disposeMemoryBackendState(consolidateMnemopi = true): Promise<void> {
|
||||
this.cancelLocalMemoryStartup();
|
||||
const hindsight = this.getHindsightSessionState();
|
||||
if (hindsight) {
|
||||
try {
|
||||
await hindsight.flushRetainQueue();
|
||||
} catch (error) {
|
||||
logger.warn("Memory lifecycle: Hindsight flush failed", { error: String(error) });
|
||||
}
|
||||
this.setHindsightSessionState(undefined);
|
||||
hindsight.dispose();
|
||||
}
|
||||
|
||||
const mnemopi = setMnemopiSessionState(this, undefined);
|
||||
if (mnemopi) {
|
||||
try {
|
||||
await mnemopi.dispose({ consolidate: consolidateMnemopi });
|
||||
} catch (error) {
|
||||
logger.warn("Memory lifecycle: Mnemopi dispose failed", { error: String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the selected memory backend to runtime state, tools, and prompt.
|
||||
* Concurrent settings changes run in order and settle before the next turn.
|
||||
*/
|
||||
async applyMemoryBackend(): Promise<void> {
|
||||
if (this.#isDisposed) return;
|
||||
const transition = this.#memoryBackendTransition.then(() => this.#applyMemoryBackend());
|
||||
this.#memoryBackendTransition = transition.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
await transition;
|
||||
}
|
||||
|
||||
async #applyMemoryBackend(): Promise<void> {
|
||||
if (this.#isDisposed) return;
|
||||
try {
|
||||
await this.#disposeMemoryBackendState();
|
||||
if (this.#memoryAgentDir && this.#memoryTaskDepth === 0 && !this.#isDisposed) {
|
||||
const backend = await resolveMemoryBackend(this.settings);
|
||||
await backend.start({
|
||||
session: this,
|
||||
settings: this.settings,
|
||||
modelRegistry: this.#modelRegistry,
|
||||
agentDir: this.#memoryAgentDir,
|
||||
taskDepth: this.#memoryTaskDepth,
|
||||
});
|
||||
}
|
||||
if (this.#isDisposed) return;
|
||||
await this.#refreshMemoryTools();
|
||||
if (this.#isDisposed) return;
|
||||
await this.refreshBaseSystemPrompt();
|
||||
} catch (error) {
|
||||
await this.#disposeMemoryBackendState(false);
|
||||
if (!this.#isDisposed) {
|
||||
await this.#replaceMemoryTools([]).catch(refreshError => {
|
||||
logger.warn("Failed to remove memory tools after backend apply error", {
|
||||
error: String(refreshError),
|
||||
});
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #refreshMemoryTools(): Promise<void> {
|
||||
const tools = (await this.#createMemoryTools?.()) ?? [];
|
||||
await this.#replaceMemoryTools(tools);
|
||||
}
|
||||
|
||||
async #replaceMemoryTools(tools: AgentTool[]): Promise<void> {
|
||||
const removed = new Set<string>(MEMORY_BACKEND_TOOL_NAMES.filter(name => this.#builtInToolNames.has(name)));
|
||||
const nextActive = this.getEnabledToolNames().filter(name => !removed.has(name));
|
||||
for (const name of removed) {
|
||||
this.#toolRegistry.delete(name);
|
||||
this.#builtInToolNames.delete(name);
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!MEMORY_BACKEND_TOOL_NAMES.some(name => name === tool.name) || this.#toolRegistry.has(tool.name)) {
|
||||
continue;
|
||||
}
|
||||
const wrapped = this.#wrapRuntimeTool(tool);
|
||||
this.#toolRegistry.set(wrapped.name, wrapped);
|
||||
this.#builtInToolNames.add(wrapped.name);
|
||||
nextActive.push(wrapped.name);
|
||||
}
|
||||
await this.#applyActiveToolsByName([...new Set(nextActive)]);
|
||||
}
|
||||
|
||||
/** Rebuild the base system prompt using the current active tool set. */
|
||||
async refreshBaseSystemPrompt(): Promise<void> {
|
||||
if (!this.#rebuildSystemPrompt) return;
|
||||
if (this.#isDisposed || !this.#rebuildSystemPrompt) return;
|
||||
const activeToolNames = this.getActiveToolNames();
|
||||
this.#setActiveToolNames?.(activeToolNames);
|
||||
const previousBaseSystemPrompt = this.#baseSystemPrompt;
|
||||
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
|
||||
if (this.#isDisposed) return;
|
||||
this.#baseSystemPrompt = built.systemPrompt;
|
||||
this.#baseSystemPromptBeforeMemoryPromotion = undefined;
|
||||
if (
|
||||
@@ -9194,6 +9324,8 @@ export class AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
await this.#memoryBackendTransition;
|
||||
if (this.#isDisposed || this.#promptGeneration !== generation) return;
|
||||
const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
|
||||
|
||||
// Emit before_agent_start extension event
|
||||
|
||||
@@ -518,6 +518,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
if (!requestedTools.includes(name)) requestedTools.push(name);
|
||||
}
|
||||
}
|
||||
if (session.settings.get("memory.backend") === "mnemopi" && !requestedTools.includes("memory_edit")) {
|
||||
requestedTools.push("memory_edit");
|
||||
}
|
||||
// Auto-learn tools are gated by `autolearn.enabled` but, like the memory
|
||||
// tools above, must also be force-included into an explicit requestedTools
|
||||
// list so a restricted top-level session whose controller/guidance is
|
||||
@@ -561,6 +564,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
if (name === "retain" || name === "recall" || name === "reflect") {
|
||||
return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
|
||||
}
|
||||
if (name === "memory_edit") return session.settings.get("memory.backend") === "mnemopi";
|
||||
if (name === "manage_skill") return session.settings.get("autolearn.enabled") && (session.taskDepth ?? 0) === 0;
|
||||
if (name === "learn") {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { getMnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/state";
|
||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { resetMemoryForTests } from "@oh-my-pi/pi-mnemopi";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { type } from "arktype";
|
||||
|
||||
function createTool(name: string): AgentTool {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} memory tool`,
|
||||
parameters: type({}),
|
||||
async execute() {
|
||||
return { content: [{ type: "text", text: name }] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession memory backend lifecycle", () => {
|
||||
let authStorage: AuthStorage;
|
||||
let session: AgentSession | undefined;
|
||||
let settings: Settings;
|
||||
let tempDir: TempDir;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = TempDir.createSync("@memory-backend-lifecycle-");
|
||||
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
settings = Settings.isolated({
|
||||
"compaction.enabled": false,
|
||||
"memory.backend": "off",
|
||||
"mnemopi.noEmbeddings": true,
|
||||
"mnemopi.llmMode": "none",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await session?.dispose();
|
||||
session = undefined;
|
||||
resetMemoryForTests();
|
||||
authStorage.close();
|
||||
tempDir.removeSync();
|
||||
});
|
||||
|
||||
function createSession(createMemoryTools: () => Promise<AgentTool[]>): AgentSession {
|
||||
const model = buildModel({
|
||||
id: "mock",
|
||||
name: "mock",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
});
|
||||
const read = createTool("read");
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: ["initial"], tools: [read] },
|
||||
streamFn: createMockModel({ responses: [{ content: ["ok"] }] }).stream,
|
||||
});
|
||||
const toolRegistry = new Map<string, AgentTool>([[read.name, read]]);
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager: SessionManager.inMemory(tempDir.path()),
|
||||
settings,
|
||||
modelRegistry: new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")),
|
||||
memoryAgentDir: tempDir.path(),
|
||||
memoryTaskDepth: 0,
|
||||
createMemoryTools,
|
||||
toolRegistry,
|
||||
builtInToolNames: [read.name],
|
||||
rebuildSystemPrompt: async toolNames => ({
|
||||
systemPrompt: [`backend:${settings.get("memory.backend")};tools:${toolNames.sort().join(",")}`],
|
||||
}),
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
it("switches runtime state, memory tools, and prompt in one apply", async () => {
|
||||
const current = createSession(async () =>
|
||||
settings.get("memory.backend") === "mnemopi" ? [createTool("retain"), createTool("memory_edit")] : [],
|
||||
);
|
||||
|
||||
settings.override("memory.backend", "mnemopi");
|
||||
await current.applyMemoryBackend();
|
||||
|
||||
expect(getMnemopiSessionState(current)).toBeDefined();
|
||||
expect(current.getActiveToolNames()).toEqual(expect.arrayContaining(["read", "retain", "memory_edit"]));
|
||||
expect(current.systemPrompt).toEqual(["backend:mnemopi;tools:memory_edit,read,retain"]);
|
||||
|
||||
settings.override("memory.backend", "off");
|
||||
await current.applyMemoryBackend();
|
||||
|
||||
expect(getMnemopiSessionState(current)).toBeUndefined();
|
||||
expect(current.getActiveToolNames()).toEqual(["read"]);
|
||||
expect(current.getAllToolNames()).toEqual(["read"]);
|
||||
expect(current.systemPrompt).toEqual(["backend:off;tools:read"]);
|
||||
});
|
||||
it("cancels a displaced local startup generation", async () => {
|
||||
const current = createSession(async () => []);
|
||||
const localStartup = current.beginLocalMemoryStartup();
|
||||
|
||||
await current.applyMemoryBackend();
|
||||
|
||||
expect(localStartup.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes concurrent backend applies", async () => {
|
||||
const firstStarted = Promise.withResolvers<void>();
|
||||
const releaseFirst = Promise.withResolvers<void>();
|
||||
let calls = 0;
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
const current = createSession(async () => {
|
||||
calls++;
|
||||
running++;
|
||||
maxRunning = Math.max(maxRunning, running);
|
||||
if (calls === 1) {
|
||||
firstStarted.resolve();
|
||||
await releaseFirst.promise;
|
||||
}
|
||||
running--;
|
||||
return [];
|
||||
});
|
||||
|
||||
const first = current.applyMemoryBackend();
|
||||
await firstStarted.promise;
|
||||
const second = current.applyMemoryBackend();
|
||||
await Promise.resolve();
|
||||
expect(calls).toBe(1);
|
||||
releaseFirst.resolve();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(maxRunning).toBe(1);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -145,7 +145,7 @@ describe("memories runtime", () => {
|
||||
process.env.XDG_STATE_HOME = savedXdgState;
|
||||
});
|
||||
|
||||
test("startup gating skips when disabled or subagent depth", async () => {
|
||||
test("startup gating follows memory.backend and skips subagents", async () => {
|
||||
const disabled = await createFixture({ "memories.enabled": false });
|
||||
const openSpy = vi.spyOn(memoryStorage, "openMemoryDb");
|
||||
startMemoryStartupTask({
|
||||
@@ -156,6 +156,15 @@ describe("memories runtime", () => {
|
||||
taskDepth: 0,
|
||||
});
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
const explicitlyOff = await createFixture({ "memory.backend": "off", "memories.enabled": true });
|
||||
startMemoryStartupTask({
|
||||
session: explicitlyOff.session,
|
||||
settings: explicitlyOff.settings,
|
||||
modelRegistry: explicitlyOff.modelRegistry,
|
||||
agentDir: explicitlyOff.agentDir,
|
||||
taskDepth: 0,
|
||||
});
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
const subagent = await createFixture({ "memories.enabled": true });
|
||||
startMemoryStartupTask({
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
|
||||
@@ -17,13 +17,13 @@ import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state
|
||||
import { mnemopiBackend } from "@oh-my-pi/pi-coding-agent/mnemopi/backend";
|
||||
import { loadMnemopiConfig, type MnemopiBackendConfig } from "@oh-my-pi/pi-coding-agent/mnemopi/config";
|
||||
import {
|
||||
getMnemopiScopedDbPaths,
|
||||
getMnemopiSessionState,
|
||||
loadMnemopi,
|
||||
loadMnemopiCore,
|
||||
MnemopiSessionState,
|
||||
setMnemopiSessionState,
|
||||
} from "@oh-my-pi/pi-coding-agent/mnemopi/state";
|
||||
import type { AgentSessionEventListener } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index";
|
||||
import { MemoryEditTool } from "@oh-my-pi/pi-coding-agent/tools/memory-edit";
|
||||
import { MemoryRecallTool } from "@oh-my-pi/pi-coding-agent/tools/memory-recall";
|
||||
@@ -32,8 +32,7 @@ import { MemoryRetainTool } from "@oh-my-pi/pi-coding-agent/tools/memory-retain"
|
||||
import { resetMemoryForTests } from "@oh-my-pi/pi-mnemopi";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
// Mnemopi is lazy-loaded at runtime; preload it so the sync construction in
|
||||
// registerMnemopiState() and getMnemopiScopedDbPaths() can resolve the module.
|
||||
// Mnemopi is lazy-loaded at runtime; preload it for synchronous state construction.
|
||||
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
||||
|
||||
const TEST_SESSION_ID = "test-session-id";
|
||||
@@ -159,6 +158,7 @@ interface RegisterMnemopiStateOptions {
|
||||
cwd?: string;
|
||||
sessionId?: string;
|
||||
entries?: () => unknown[];
|
||||
listeners?: Set<AgentSessionEventListener>;
|
||||
}
|
||||
|
||||
function registerMnemopiState(
|
||||
@@ -172,12 +172,25 @@ function registerMnemopiState(
|
||||
config: finalConfig,
|
||||
session: {
|
||||
sessionId,
|
||||
settings: Settings.isolated({
|
||||
"memory.backend": "mnemopi",
|
||||
"mnemopi.noEmbeddings": true,
|
||||
"mnemopi.llmMode": "none",
|
||||
}),
|
||||
modelRegistry: {
|
||||
getApiKeyForProvider: async () => undefined,
|
||||
resolver: () => async () => undefined,
|
||||
} as never,
|
||||
sessionManager: {
|
||||
getEntries: options.entries ?? (() => []),
|
||||
getCwd: () => options.cwd ?? "/tmp",
|
||||
} as never,
|
||||
emitNotice: () => {},
|
||||
getHindsightSessionState: () => undefined,
|
||||
subscribe: (listener: AgentSessionEventListener) => {
|
||||
options.listeners?.add(listener);
|
||||
return () => options.listeners?.delete(listener);
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
setMnemopiSessionState(registeredMnemopiState.session as never, registeredMnemopiState);
|
||||
@@ -838,7 +851,7 @@ describe("Mnemopi backend lifecycle", () => {
|
||||
expect(parentRetainSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears every scoped Mnemopi database for per-project-tagged mode", async () => {
|
||||
it("clears scoped Mnemopi data and rehydrates active state", async () => {
|
||||
const config = makeMnemopiConfig({
|
||||
scoping: "per-project-tagged",
|
||||
bank: "project-alpha",
|
||||
@@ -846,33 +859,40 @@ describe("Mnemopi backend lifecycle", () => {
|
||||
retainBank: "project-alpha",
|
||||
recallBanks: ["project-alpha", "default"],
|
||||
});
|
||||
const state = registerMnemopiState(config, { cwd: "/work/project-alpha" });
|
||||
const listeners = new Set<AgentSessionEventListener>();
|
||||
const state = registerMnemopiState(config, { cwd: "/work/project-alpha", listeners });
|
||||
state.rememberInScope("project clear marker", { scope: "bank", extract: false, source: "test" });
|
||||
state.globalMemory?.remember("global clear marker", { scope: "bank", extract: false, source: "test" });
|
||||
const dbPaths = getMnemopiScopedDbPaths(config);
|
||||
for (const dbPath of dbPaths) expect(existsSync(dbPath)).toBe(true);
|
||||
const session = state.session;
|
||||
setMnemopiSessionState(session, state);
|
||||
|
||||
await mnemopiBackend.clear(path.dirname(config.dbPath), "/work/project-alpha", session);
|
||||
|
||||
// The clear() contract: all scoped DB files are deleted. On Windows under
|
||||
// bun:test, SQLite handle release may lag behind the await; poll briefly
|
||||
// before asserting rather than failing on a transient lock.
|
||||
const assertGone = async (p: string): Promise<void> => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
if (!existsSync(p)) return;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
};
|
||||
for (const dbPath of dbPaths) {
|
||||
await assertGone(dbPath);
|
||||
await assertGone(`${dbPath}-wal`);
|
||||
await assertGone(`${dbPath}-shm`);
|
||||
}
|
||||
// Assert state was cleared even if file deletion is still in-flight.
|
||||
expect(getMnemopiSessionState(session)).toBeUndefined();
|
||||
const rehydrated = getMnemopiSessionState(session);
|
||||
if (!rehydrated) throw new Error("Mnemopi state was not rehydrated");
|
||||
expect(rehydrated).not.toBe(state);
|
||||
expect(listeners.size).toBe(1);
|
||||
const remaining = await rehydrated.recallResultsScoped("clear marker");
|
||||
expect(remaining.some(hit => String(hit.content).includes("clear marker"))).toBe(false);
|
||||
expect(rehydrated.rememberScoped("after-clear", { source: "test", scope: "bank", extract: false })).toEqual(
|
||||
expect.any(String),
|
||||
);
|
||||
registeredMnemopiState = rehydrated;
|
||||
});
|
||||
it("attaches listeners when enqueue rehydrates missing state", async () => {
|
||||
const config = makeMnemopiConfig();
|
||||
const listeners = new Set<AgentSessionEventListener>();
|
||||
const seed = registerMnemopiState(config, { listeners });
|
||||
const session = seed.session;
|
||||
setMnemopiSessionState(session, undefined);
|
||||
await seed.dispose({ consolidate: false });
|
||||
registeredMnemopiState = undefined;
|
||||
|
||||
await mnemopiBackend.enqueue(path.dirname(config.dbPath), "/tmp", session);
|
||||
|
||||
registeredMnemopiState = getMnemopiSessionState(session);
|
||||
expect(registeredMnemopiState).toBeDefined();
|
||||
expect(listeners.size).toBe(1);
|
||||
});
|
||||
|
||||
it("clear() skips consolidation before deleting the DBs (#2327 review)", async () => {
|
||||
@@ -911,8 +931,8 @@ describe("Mnemopi backend lifecycle", () => {
|
||||
expect(bank.sleep).not.toHaveBeenCalled();
|
||||
expect(bank.close).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
expect(getMnemopiSessionState(session)).toBeUndefined();
|
||||
registeredMnemopiState = undefined;
|
||||
registeredMnemopiState = getMnemopiSessionState(session);
|
||||
expect(registeredMnemopiState).toBeDefined();
|
||||
});
|
||||
|
||||
it("exposes direct mnemopi runtime status and search/save results", async () => {
|
||||
|
||||
@@ -66,6 +66,17 @@ describe("selector setting side effects", () => {
|
||||
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("applies memory backend changes to the live session", () => {
|
||||
const applyMemoryBackend = vi.fn(async () => {});
|
||||
const controller = new SelectorController({
|
||||
session: { applyMemoryBackend },
|
||||
showError: vi.fn(),
|
||||
} as unknown as InteractiveModeContext);
|
||||
|
||||
controller.handleSettingChange("memory.backend", "mnemopi");
|
||||
|
||||
expect(applyMemoryBackend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
for (const id of ["terminal.showImages", "showImages"]) {
|
||||
for (const visible of [false, true]) {
|
||||
|
||||
Reference in New Issue
Block a user