diff --git a/App/memmy-agent/src/memmy-memory/hook.ts b/App/memmy-agent/src/memmy-memory/hook.ts index 1ea3a922..0516c967 100644 --- a/App/memmy-agent/src/memmy-memory/hook.ts +++ b/App/memmy-agent/src/memmy-memory/hook.ts @@ -148,11 +148,13 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime const sessionKey = this.sessionKeyFromContext(ctx); if (!sessionKey) return; try { - await this.prepareL3Session(ctx, sessionKey, false); + await this.ensureSession(ctx, sessionKey); + const state = this.sessionStateBySessionKey.get(sessionKey); + if (state?.protocol === "v2") await this.loadL3Context(sessionKey, state); this.clearMemoryUnavailable(sessionKey); } catch (error) { this.rememberUnavailableL3(sessionKey); - this.warnMemoryUnavailable(sessionKey, "session-start", error); + this.warnMemoryUnavailable(sessionKey, "recall", error); } } @@ -160,7 +162,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime const sessionKey = this.sessionKeyFromContext(ctx); if (!sessionKey) return; try { - await this.prepareL3Session(ctx, sessionKey, false); + await this.ensureSession(ctx, sessionKey); this.clearMemoryUnavailable(sessionKey); } catch (error) { this.warnMemoryUnavailable(sessionKey, "session-start", error); @@ -351,7 +353,6 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime throughL1MemoryId: head.throughL1MemoryId, }); } - await this.loadL3Context(sessionKey, state); this.clearMemoryUnavailable(sessionKey); } catch (error) { this.warnMemoryUnavailable(sessionKey, "recall", error); @@ -573,19 +574,22 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime return resolved; } - private async prepareL3Session(ctx: AgentHookContext, sessionKey: string, force: boolean): Promise { - await this.ensureSession(ctx, sessionKey); - const state = this.sessionStateBySessionKey.get(sessionKey); - if (!state || state.protocol !== "v2") return; - if (!force && state.l3Cache.loadedAt) return; - await this.loadL3Context(sessionKey, state); - } - private async loadL3Context(sessionKey: string, state: MemmyMemorySessionState): Promise { const response = await this.client.l3WorldModelContext( state.memorySessionId, this.l3Envelope(sessionKey, state), ); + const loadedAt = new Date().toISOString(); + const current = state.l3Cache; + if ( + response.memoryId !== null + && current.status === "loaded" + && current.memoryId === response.memoryId + && current.memoryVersion === response.memoryVersion + ) { + state.l3Cache = { ...current, loadedAt }; + return; + } state.l3Cache = { sessionId: state.memorySessionId, projectId: response.projectId, @@ -594,7 +598,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime memoryVersion: response.memoryVersion, renderedContext: response.renderedContext, sourceMemoryIds: [...response.sourceMemoryIds], - loadedAt: new Date().toISOString(), + loadedAt, }; } diff --git a/App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts b/App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts index 64a70008..c3031501 100644 --- a/App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts +++ b/App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts @@ -99,13 +99,16 @@ describe("AgentLoop memmy memory integration", () => { expect(loop.context.buildSystemPrompt()).toContain("# File Memory"); }); - it("carries host project/cwd into Session open, then uses only Memory's returned project ID", async () => { + it("refreshes project L3 before every Turn while keeping one Memory Session and project scope", async () => { const profileRoot = tempRoot(); const projectRoot = tempRoot(); const memmyHome = tempRoot(); const projectStore = new ProjectStore({ filePath: path.join(profileRoot, "projects.json") }); const project = projectStore.add(projectRoot, "existing"); const requests: Array<{ path: string; body: Record }> = []; + const modelMessages: Record[][] = []; + let contextVersion = 0; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.stubEnv("MEMMY_MEMORY_URL", "http://memory.test"); vi.stubEnv("MEMMY_HOME", memmyHome); vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => { @@ -117,16 +120,20 @@ describe("AgentLoop memmy memory integration", () => { return response({ sessionId: "memory-session-1", projectId: "memory-project-1", resumed: false }); } if (url.pathname.endsWith("/context")) { + contextVersion += 1; + if (contextVersion === 3) { + return response({ error: { message: "context unavailable" } }, 503); + } return response({ schemaVersion: 2, projectId: "memory-project-1", memoryId: "world-model-1", - memoryVersion: 1, - renderedContext: "项目契约:保持现有架构。", + memoryVersion: contextVersion, + renderedContext: `L3_V${contextVersion}`, sourceMemoryIds: ["l1-old"], generalRulesAndSafetyConstraints: null, projectEnvironmentProfile: "语言:TypeScript", - projectContract: "保持现有架构。", + projectContract: `L3_V${contextVersion}`, domainKnowledge: null, serverTime: "2026-08-19T00:00:00.000Z", }); @@ -151,7 +158,10 @@ describe("AgentLoop memmy memory integration", () => { provider: { generation: { maxTokens: 256 }, getDefaultModel: () => "test-model", - chatWithRetry: vi.fn(async () => new LLMResponse({ content: "done" })), + chatWithRetry: vi.fn(async ({ messages }: { messages: Record[] }) => { + modelMessages.push(messages); + return new LLMResponse({ content: "done" }); + }), }, workspace: profileRoot, projectStore, @@ -167,6 +177,20 @@ describe("AgentLoop memmy memory integration", () => { content: "continue the project", metadata: { webui: true }, })); + await loop.processMessage(new InboundMessage({ + channel: "websocket", + chatId: "memory-project", + senderId: "user", + content: "continue with the latest project context", + metadata: { webui: true }, + })); + await loop.processMessage(new InboundMessage({ + channel: "websocket", + chatId: "memory-project", + senderId: "user", + content: "continue while Memory context is temporarily unavailable", + metadata: { webui: true }, + })); await loop.closeRuntimeTools(); const opened = requests.find((request) => request.path === "/api/v1/sessions/open")!; @@ -177,10 +201,27 @@ describe("AgentLoop memmy memory integration", () => { namespace: { sessionKey: "websocket:memory-project", userId: "loop-user" }, }); expect(JSON.stringify(opened.body)).not.toContain(project.id); + expect(requests.filter((request) => request.path === "/api/v1/sessions/open")).toHaveLength(1); + expect(requests.filter((request) => request.path.endsWith("/context"))).toHaveLength(3); + + const agentPrompts = modelMessages + .map((messages) => messages.find((message) => message.role === "system")?.content) + .filter((content): content is string => typeof content === "string" && content.includes("L3_V")); + expect(agentPrompts).toHaveLength(3); + expect(agentPrompts[0]).toContain("L3_V1"); + expect(agentPrompts[0]).not.toContain("L3_V2"); + expect(agentPrompts[1]).toContain("L3_V2"); + expect(agentPrompts[1]).not.toContain("L3_V1"); + expect(agentPrompts[2]).toContain("L3_V2"); + expect(agentPrompts[2]).not.toContain("L3_V1"); + for (const prompt of agentPrompts) { + expect(prompt.match(//gu)).toHaveLength(1); + } + const scopedRequests = requests.filter((request) => request.path === "/api/v1/turns/start" || request.path.includes("/complete") || request.path.endsWith("/close") ); - expect(scopedRequests).toHaveLength(3); + expect(scopedRequests).toHaveLength(7); for (const request of scopedRequests) { expect(request.body.namespace).toMatchObject({ projectId: "memory-project-1", @@ -189,6 +230,7 @@ describe("AgentLoop memmy memory integration", () => { expect(JSON.stringify(request.body)).not.toContain(project.id); } expect(requests.filter((request) => request.path.endsWith("/close"))).toHaveLength(1); + warn.mockRestore(); }); }); diff --git a/App/memmy-agent/tests/memmy-memory/hook.test.ts b/App/memmy-agent/tests/memmy-memory/hook.test.ts index 847cf2ee..2a00aaf4 100644 --- a/App/memmy-agent/tests/memmy-memory/hook.test.ts +++ b/App/memmy-agent/tests/memmy-memory/hook.test.ts @@ -66,7 +66,7 @@ function fakeV2Client() { } describe("MemmyMemoryHook", () => { - it("loads one v2 Session snapshot before prompt construction and reuses it on ordinary turns", async () => { + it("opens a v2 Session at sessionStart and reads L3 once before every prompt build", async () => { const client = fakeV2Client(); const workspace = mkdtempSync(join(tmpdir(), "memmy-v2-hook-")); const memmyHome = mkdtempSync(join(tmpdir(), "memmy-v2-home-")); @@ -85,12 +85,15 @@ describe("MemmyMemoryHook", () => { }; const lifecycle = new AgentHookContext({ sessionKey: spec.sessionKey, spec }); + await hook.sessionStart(lifecycle); + expect(client.l3WorldModelContext).not.toHaveBeenCalled(); + await hook.beforeBuildSystemPrompt(lifecycle); await hook.beforeBuildSystemPrompt(lifecycle); expect(client.health).toHaveBeenCalledTimes(1); expect(client.openSession).toHaveBeenCalledTimes(1); - expect(client.l3WorldModelContext).toHaveBeenCalledTimes(1); + expect(client.l3WorldModelContext).toHaveBeenCalledTimes(2); expect(client.openSession.mock.calls[0]![0]).toMatchObject({ l3WorldModelProtocolVersion: 2, l3WorldModelTransition: "allow_legacy_rollover", @@ -117,7 +120,7 @@ describe("MemmyMemoryHook", () => { finalContent: "完成", stopReason: "completed", }); - expect(client.l3WorldModelContext).toHaveBeenCalledTimes(1); + expect(client.l3WorldModelContext).toHaveBeenCalledTimes(2); expect(client.startTurn.mock.calls[0]![1].namespace.projectId).toBe(`ws_${"a".repeat(64)}`); expect(client.startTurn.mock.calls[0]![1].namespace).not.toHaveProperty("workspacePath"); } finally { @@ -128,7 +131,128 @@ describe("MemmyMemoryHook", () => { } }); - it("refreshes L3 only after successful token compaction", async () => { + it("preserves an identical L3 snapshot, replaces a changed snapshot, and removes an empty snapshot", async () => { + const client = fakeV2Client(); + const workspace = mkdtempSync(join(tmpdir(), "memmy-v2-snapshot-")); + try { + const hook = new MemmyMemoryHook(client as any, { workspace, userId: "v2-user" }); + const spec = { + sessionKey: "websocket:v2-snapshot", + hostProjectId: "local-project-id", + workspace, + }; + const lifecycle = new AgentHookContext({ sessionKey: spec.sessionKey, spec }); + + client.l3WorldModelContext + .mockResolvedValueOnce({ + sessionId: "memory-v2-session", + projectId: `ws_${"a".repeat(64)}`, + memoryId: "l3-memory-1", + memoryVersion: 1, + renderedContext: "L3_V1", + sourceMemoryIds: ["l1-1"], + }) + .mockResolvedValueOnce({ + sessionId: "memory-v2-session", + projectId: `ws_${"a".repeat(64)}`, + memoryId: "l3-memory-1", + memoryVersion: 1, + renderedContext: "SHOULD_NOT_REPLACE_IDENTICAL_SNAPSHOT", + sourceMemoryIds: ["l1-2"], + }) + .mockResolvedValueOnce({ + sessionId: "memory-v2-session", + projectId: `ws_${"a".repeat(64)}`, + memoryId: "l3-memory-1", + memoryVersion: 2, + renderedContext: "L3_V2", + sourceMemoryIds: ["l1-1", "l1-2"], + }) + .mockResolvedValueOnce({ + sessionId: "memory-v2-session", + projectId: `ws_${"a".repeat(64)}`, + memoryId: null, + memoryVersion: null, + renderedContext: "", + sourceMemoryIds: [], + } as any); + + await hook.beforeBuildSystemPrompt(lifecycle); + const firstPrompt = new SystemPromptBuildContext({ sessionKey: spec.sessionKey }); + hook.onBuildSystemPrompt(firstPrompt); + expect(firstPrompt.getSection("memmy-l3-world-model")?.content).toContain("L3_V1"); + + await hook.beforeBuildSystemPrompt(lifecycle); + const identicalPrompt = new SystemPromptBuildContext({ sessionKey: spec.sessionKey }); + hook.onBuildSystemPrompt(identicalPrompt); + expect(identicalPrompt.getSection("memmy-l3-world-model")?.content).toContain("L3_V1"); + expect(identicalPrompt.getSection("memmy-l3-world-model")?.content).not.toContain("SHOULD_NOT_REPLACE"); + + await hook.beforeBuildSystemPrompt(lifecycle); + const updatedPrompt = new SystemPromptBuildContext({ sessionKey: spec.sessionKey }); + hook.onBuildSystemPrompt(updatedPrompt); + expect(updatedPrompt.getSection("memmy-l3-world-model")?.content).toContain("L3_V2"); + expect(updatedPrompt.sections.filter((section) => section.id === "memmy-l3-world-model")).toHaveLength(1); + + await hook.beforeBuildSystemPrompt(lifecycle); + hook.onBuildSystemPrompt(updatedPrompt); + expect(updatedPrompt.getSection("memmy-l3-world-model")).toBeNull(); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it("retries an unavailable L3 read and preserves the last successful snapshot", async () => { + const client = fakeV2Client(); + const workspace = mkdtempSync(join(tmpdir(), "memmy-v2-recovery-")); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + try { + const hook = new MemmyMemoryHook(client as any, { workspace, userId: "v2-user" }); + const spec = { + sessionKey: "websocket:v2-recovery", + hostProjectId: "local-project-id", + workspace, + }; + const lifecycle = new AgentHookContext({ sessionKey: spec.sessionKey, spec }); + const response = (memoryVersion: number, renderedContext: string) => ({ + sessionId: "memory-v2-session", + projectId: `ws_${"a".repeat(64)}`, + memoryId: "l3-memory-1", + memoryVersion, + renderedContext, + sourceMemoryIds: ["l1-1"], + }); + + client.l3WorldModelContext + .mockRejectedValueOnce(new Error("context unavailable")) + .mockResolvedValueOnce(response(1, "L3_V1")) + .mockRejectedValueOnce(new Error("context unavailable again")) + .mockResolvedValueOnce(response(2, "L3_V2")); + + await hook.beforeBuildSystemPrompt(lifecycle); + const prompt = new SystemPromptBuildContext({ sessionKey: spec.sessionKey }); + hook.onBuildSystemPrompt(prompt); + expect(prompt.getSection("memmy-l3-world-model")).toBeNull(); + + await hook.beforeBuildSystemPrompt(lifecycle); + hook.onBuildSystemPrompt(prompt); + expect(prompt.getSection("memmy-l3-world-model")?.content).toContain("L3_V1"); + + await hook.beforeBuildSystemPrompt(lifecycle); + hook.onBuildSystemPrompt(prompt); + expect(prompt.getSection("memmy-l3-world-model")?.content).toContain("L3_V1"); + + await hook.beforeBuildSystemPrompt(lifecycle); + hook.onBuildSystemPrompt(prompt); + expect(prompt.getSection("memmy-l3-world-model")?.content).toContain("L3_V2"); + expect(client.l3WorldModelContext).toHaveBeenCalledTimes(4); + } finally { + warn.mockRestore(); + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it("freezes a successful token compaction without reloading L3", async () => { const client = fakeV2Client(); const workspace = mkdtempSync(join(tmpdir(), "memmy-v2-bridge-")); const memmyHome = mkdtempSync(join(tmpdir(), "memmy-v2-bridge-home-")); @@ -162,6 +286,14 @@ describe("MemmyMemoryHook", () => { })); expect(client.l3WorldModelTraceHead).toHaveBeenCalledTimes(1); expect(client.l3WorldModelBoundary).toHaveBeenCalledTimes(1); + expect(client.l3WorldModelContext).toHaveBeenCalledTimes(1); + + const prompt = new SystemPromptBuildContext({ sessionKey: spec.sessionKey }); + hook.onBuildSystemPrompt(prompt); + hook.onBuildSystemPrompt(prompt); + expect(prompt.sections.filter((section) => section.id === "memmy-l3-world-model")).toHaveLength(1); + + await hook.beforeBuildSystemPrompt(lifecycle); expect(client.l3WorldModelContext).toHaveBeenCalledTimes(2); } finally { if (previousMemmyHome === undefined) delete process.env.MEMMY_HOME; diff --git a/Memory/src/server/http.ts b/Memory/src/server/http.ts index a3369ba9..f679faf0 100644 --- a/Memory/src/server/http.ts +++ b/Memory/src/server/http.ts @@ -1184,7 +1184,7 @@ function envelopeWithPrincipal>( principal: AuthPrincipal ): T & RequestEnvelope { const existing = isRecord(body.namespace) ? body.namespace as unknown as RuntimeNamespace : undefined; - const namespace = mergeNamespaces(mergeNamespaces(existing, namespaceFromSource(body.source)), principal.namespace); + const namespace = mergeNamespaces(mergeNamespaces(namespaceFromSource(body.source), existing), principal.namespace); assertNamespaceScope(existing, principal.namespace); return { ...body, @@ -1218,7 +1218,7 @@ function strictEnvelopeWithPrincipal( } } const namespace = mergeNamespaces( - mergeNamespaces(requestNamespace, namespaceFromSource(body.source)), + mergeNamespaces(namespaceFromSource(body.source), requestNamespace), principalNamespace ); if (!namespace) { diff --git a/Memory/tests/contract/memory-rest-service.test.ts b/Memory/tests/contract/memory-rest-service.test.ts index fddffc10..92d5dbf0 100644 --- a/Memory/tests/contract/memory-rest-service.test.ts +++ b/Memory/tests/contract/memory-rest-service.test.ts @@ -177,7 +177,7 @@ describe("MemoryService / REST contract", () => { const client = new MemoryRestClient({ endpoint: `http://127.0.0.1:${address.port}` }); const namespace = { source: "codex", - profileId: "default", + profileId: "matrix-profile", sessionKey: "codex:rest-v2", userId: "rest-v2-user" } as const; @@ -192,6 +192,7 @@ describe("MemoryService / REST contract", () => { workspaceHostId: "c".repeat(64) }) as { sessionId: string; projectId: string }; expect(opened.projectId).toMatch(/^ws_/u); + expect(new Repositories(db.db).runtime.getSession(opened.sessionId)?.profileId).toBe("matrix-profile"); const envelope = { requestId: "91ae733d-25af-4ab0-8cbd-49c447b34d98", adapterId: "codex-memory",