Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions App/memmy-agent/src/memmy-memory/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,19 +148,21 @@ 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);
}
}

override async sessionStart(ctx: AgentHookContext): Promise<void> {
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -573,19 +574,22 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime
return resolved;
}

private async prepareL3Session(ctx: AgentHookContext, sessionKey: string, force: boolean): Promise<void> {
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<void> {
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,
Expand All @@ -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,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> }> = [];
const modelMessages: Record<string, any>[][] = [];
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) => {
Expand All @@ -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",
});
Expand All @@ -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<string, any>[] }) => {
modelMessages.push(messages);
return new LLMResponse({ content: "done" });
}),
},
workspace: profileRoot,
projectStore,
Expand All @@ -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")!;
Expand All @@ -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(/<memmy_l3_world_model version="2">/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",
Expand All @@ -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();
});
});

Expand Down
140 changes: 136 additions & 4 deletions App/memmy-agent/tests/memmy-memory/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand All @@ -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",
Expand All @@ -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 {
Expand All @@ -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-"));
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions Memory/src/server/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,7 +1184,7 @@ function envelopeWithPrincipal<T extends Record<string, unknown>>(
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,
Expand Down Expand Up @@ -1218,7 +1218,7 @@ function strictEnvelopeWithPrincipal(
}
}
const namespace = mergeNamespaces(
mergeNamespaces(requestNamespace, namespaceFromSource(body.source)),
mergeNamespaces(namespaceFromSource(body.source), requestNamespace),
principalNamespace
);
if (!namespace) {
Expand Down
3 changes: 2 additions & 1 deletion Memory/tests/contract/memory-rest-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
Expand Down
Loading