diff --git a/README.md b/README.md index a0673a30..73a733be 100644 --- a/README.md +++ b/README.md @@ -1138,9 +1138,11 @@ Quality vs cost tradeoff for memory work: compression is a summarization task wi Sources: [OpenRouter pricing for Sonnet 4.6](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing), [DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro), [DeepSeek pricing notes](https://api-docs.deepseek.com/quick_start/pricing/). -### Multi-agent memory (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) +### Multi-agent memory (`AGENT_ID`, `AGENTMEMORY_AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) -In multi-agent setups where several roles share one agentmemory server (architect / developer / reviewer / researcher / support-agent), `AGENT_ID` tags every write with the role that made it. `AGENTMEMORY_AGENT_SCOPE` controls whether recall filters by that tag. +In multi-agent setups where several roles share one agentmemory server (architect / developer / reviewer / researcher / support-agent), `agentId` tags each write with the role or profile that made it. `AGENTMEMORY_AGENT_SCOPE` controls whether recall filters by that tag. + +Use `AGENT_ID` as the server default in `~/.agentmemory/.env`: ```env TEAM_ID=company @@ -1149,6 +1151,26 @@ AGENT_ID=architect AGENTMEMORY_AGENT_SCOPE=isolated # optional; default "shared" ``` +Use `AGENTMEMORY_AGENT_ID` in an integration or MCP process env when one agentmemory server is shared by multiple clients and each client should identify itself: + +Hermes/OpenClaw respective .env file: + +```env +AGENTMEMORY_AGENT_ID=architect +``` + +MCP: + +```json +{ + "env": { + "AGENTMEMORY_AGENT_ID": "reviewer" + } +} +``` + +The standalone MCP server reads `AGENTMEMORY_AGENT_ID` and forwards it automatically in `memory_save` and `memory_smart_search`, so MCP clients do not need to add `agentId` to every tool call. Direct integrations that support this env var also forward it in their request bodies. If both `AGENTMEMORY_AGENT_ID` and `AGENT_ID` are present in the same process, `AGENTMEMORY_AGENT_ID` wins. + Two modes: | Mode | Tag writes | Filter recall | When to use | @@ -1156,13 +1178,13 @@ Two modes: | `shared` (default) | yes | no | Cross-agent context with audit trail. Architect can see what developer noted, but every row records who said it. | | `isolated` | yes | yes | Strict separation. Architect never sees developer's observations / memories / sessions. | -What gets tagged when `AGENT_ID` is set: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. The role flows from `api::session::start` → `mem::observe` → `mem::compress` → KV. +What gets tagged when an agent id is set: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. The role flows from `api::session::start` → `mem::observe` → `mem::compress` → KV. -What gets filtered in isolated mode: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. Each endpoint accepts `?agentId=` to override per-request, and `?agentId=*` to opt out of the env scope entirely. `/memories` also accepts `?includeOrphans=true` to surface pre-AGENT_ID memories whose `agentId` is undefined. +What gets filtered in isolated mode: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. Each endpoint accepts `?agentId=` to override per-request, and `?agentId=*` to opt out of the env scope entirely. `/memories` also accepts `?includeOrphans=true` to surface pre-agent-id memories whose `agentId` is undefined. Per-call override at the SDK / REST layer: every mutating endpoint (`/session/start`, `/remember`) accepts an `agentId` field in the request body that wins over the env. Useful for runtimes routing many roles through one server process. -When `AGENT_ID` is unset, memory remains unscoped (legacy behavior, no tags, no filters). +When no agent id is set, memory remains unscoped (legacy behavior, no tags, no filters). ### Ports diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py index 2933632d..42d02a85 100644 --- a/integrations/hermes/__init__.py +++ b/integrations/hermes/__init__.py @@ -189,6 +189,7 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: self._base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL) self._session_id = session_id self._project = kwargs.get("cwd", os.getcwd()) + self._agent_id = kwargs.get("agent_identity") or os.environ.get("AGENTMEMORY_AGENT_ID") if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1": _check_plaintext_bearer_guard(self._base, os.environ.get("AGENTMEMORY_SECRET", "")) @@ -196,6 +197,7 @@ def initialize(self, session_id: str, **kwargs: Any) -> None: "sessionId": session_id, "project": self._project, "cwd": self._project, + **({"agentId": self._agent_id} if self._agent_id else {}), }) def get_config_schema(self) -> list[dict]: @@ -321,6 +323,7 @@ def handle_tool_call(self, name: str, args: dict) -> str: result = _api(self._base, "remember", { "content": args["content"], "type": args.get("type", "fact"), + **({"agentId": self._agent_id} if self._agent_id else {}), }) return json.dumps(result or {"success": False}) @@ -350,6 +353,7 @@ def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None: "project": self._project, "cwd": self._project, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + **({"agentId": self._agent_id} if self._agent_id else {}), "data": { "tool_name": "conversation", "tool_input": user[:500], @@ -378,6 +382,7 @@ def on_memory_write(self, action: str, target: str, content: str, **kwargs: Any) _api_bg(self._base, "remember", { "content": content, "type": "fact", + **({"agentId": self._agent_id} if self._agent_id else {}), }) def shutdown(self, **kwargs: Any) -> None: diff --git a/integrations/openclaw/plugin.mjs b/integrations/openclaw/plugin.mjs index 332189d7..8e23b590 100644 --- a/integrations/openclaw/plugin.mjs +++ b/integrations/openclaw/plugin.mjs @@ -159,6 +159,7 @@ const plugin = { timeout_ms: api.pluginConfig?.timeout_ms || DEFAULT_TIMEOUT_MS, }; const client = createClient(cfg, api); + const agentId = (process.env.AGENTMEMORY_AGENT_ID || "").trim().slice(0, 128) || undefined; if (typeof api.registerMemoryCapability === "function") { api.registerMemoryCapability({ @@ -182,6 +183,7 @@ const plugin = { const result = await client.postJson("/agentmemory/smart-search", { query: prompt, limit: 5, + ...(agentId ? { agentId } : {}), }); const block = formatResults(result?.results || []); if (!block) return; @@ -204,6 +206,7 @@ const plugin = { hookType: "post_tool_use", sessionId, timestamp: new Date().toISOString(), + ...(agentId ? { agentId } : {}), data: { tool_name: "conversation", tool_input: userText.slice(0, 1000), diff --git a/src/config.ts b/src/config.ts index 4162eefa..cdebf740 100644 --- a/src/config.ts +++ b/src/config.ts @@ -281,7 +281,9 @@ export function loadAgentScope(): { mode: "shared" | "isolated"; } | null { const env = getMergedEnv(); - const raw = env["AGENT_ID"]; + const raw = hasRealValue(env["AGENTMEMORY_AGENT_ID"]) + ? env["AGENTMEMORY_AGENT_ID"] + : env["AGENT_ID"]; if (!raw) return null; const agentId = raw.trim().slice(0, 128); if (!agentId) return null; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e2144567..898f5618 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,6 +10,7 @@ import type { } from "../types.js"; import { getVisibleTools } from "./tools-registry.js"; import { timingSafeCompare } from "../auth.js"; +import { getAgentId } from "../config.js"; type McpResponse = { status_code: number; @@ -177,12 +178,14 @@ export function registerMcpEndpoints( ? args.project.trim() : undefined; + const agentId = getAgentId(); const result = await sdk.trigger({ function_id: "mem::remember", payload: { content: args.content, type, concepts, files, ...(project !== undefined && { project }), + ...(agentId ? { agentId } : {}), } }); return { status_code: 200, @@ -263,12 +266,14 @@ export function registerMcpEndpoints( } const expandIds = parseCsvList(args.expandIds).slice(0, 20); const limit = Math.max(1, Math.min(100, asNumber(args.limit, 10) ?? 10)); + const agentId = getAgentId(); const result = await sdk.trigger({ function_id: "mem::smart-search", payload: { query: args.query, expandIds, limit, + ...(agentId ? { agentId } : {}), }, }); return { diff --git a/test/agent-id-scope.test.ts b/test/agent-id-scope.test.ts index b74e5c74..1de84e22 100644 --- a/test/agent-id-scope.test.ts +++ b/test/agent-id-scope.test.ts @@ -8,13 +8,17 @@ vi.mock("../src/logger.js", () => ({ describe("loadAgentScope (#554)", () => { const ORIG = process.env["AGENT_ID"]; + const ORIG_AGENTMEMORY = process.env["AGENTMEMORY_AGENT_ID"]; beforeEach(() => { vi.resetModules(); delete process.env["AGENT_ID"]; + delete process.env["AGENTMEMORY_AGENT_ID"]; }); afterEach(() => { if (ORIG === undefined) delete process.env["AGENT_ID"]; else process.env["AGENT_ID"] = ORIG; + if (ORIG_AGENTMEMORY === undefined) delete process.env["AGENTMEMORY_AGENT_ID"]; + else process.env["AGENTMEMORY_AGENT_ID"] = ORIG_AGENTMEMORY; }); it("returns null when AGENT_ID is unset", async () => { @@ -30,6 +34,14 @@ describe("loadAgentScope (#554)", () => { expect(getAgentId()).toBe("architect"); }); + it("prefers AGENTMEMORY_AGENT_ID over AGENT_ID", async () => { + process.env["AGENT_ID"] = "server-default"; + process.env["AGENTMEMORY_AGENT_ID"] = "integration-profile"; + const { loadAgentScope, getAgentId } = await import("../src/config.js"); + expect(loadAgentScope()).toEqual({ agentId: "integration-profile", mode: "shared" }); + expect(getAgentId()).toBe("integration-profile"); + }); + it("trims whitespace and rejects empty after trim", async () => { process.env["AGENT_ID"] = " "; const { loadAgentScope } = await import("../src/config.js"); diff --git a/test/mcp-agent-id.test.ts b/test/mcp-agent-id.test.ts new file mode 100644 index 00000000..d01a9cad --- /dev/null +++ b/test/mcp-agent-id.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerMcpEndpoints } from "../src/mcp/server.js"; + +function mockKV() { + return { + get: async () => null, + set: async (_scope: string, _key: string, data: T): Promise => data, + delete: async () => {}, + list: async () => [], + }; +} + +function mockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const handler = triggerOverrides.get(id); + if (!handler) throw new Error(`No trigger override: ${id}`); + return handler(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +function makeReq(body: unknown) { + return { body, headers: {}, query_params: {} }; +} + +describe("MCP agentId forwarding", () => { + const originalAgentId = process.env.AGENT_ID; + const originalAgentmemoryAgentId = process.env.AGENTMEMORY_AGENT_ID; + let sdk: ReturnType; + + beforeEach(() => { + vi.resetModules(); + delete process.env.AGENT_ID; + delete process.env.AGENTMEMORY_AGENT_ID; + sdk = mockSdk(); + registerMcpEndpoints(sdk as never, mockKV() as never); + }); + + afterEach(() => { + if (originalAgentId === undefined) delete process.env.AGENT_ID; + else process.env.AGENT_ID = originalAgentId; + if (originalAgentmemoryAgentId === undefined) delete process.env.AGENTMEMORY_AGENT_ID; + else process.env.AGENTMEMORY_AGENT_ID = originalAgentmemoryAgentId; + }); + + it("passes AGENTMEMORY_AGENT_ID to memory_save", async () => { + process.env.AGENTMEMORY_AGENT_ID = "mcp-profile"; + let payload: Record | undefined; + sdk.overrideTrigger("mem::remember", async (data: Record) => { + payload = data; + return { success: true }; + }); + + const fn = sdk.getFunction("mcp::tools::call")!; + await fn(makeReq({ name: "memory_save", arguments: { content: "remember this" } })); + + expect(payload?.agentId).toBe("mcp-profile"); + }); + + it("passes AGENTMEMORY_AGENT_ID to memory_smart_search", async () => { + process.env.AGENTMEMORY_AGENT_ID = "mcp-profile"; + let payload: Record | undefined; + sdk.overrideTrigger("mem::smart-search", async (data: Record) => { + payload = data; + return { results: [] }; + }); + + const fn = sdk.getFunction("mcp::tools::call")!; + await fn(makeReq({ name: "memory_smart_search", arguments: { query: "auth" } })); + + expect(payload?.agentId).toBe("mcp-profile"); + }); +}); diff --git a/test/openclaw-plugin.test.ts b/test/openclaw-plugin.test.ts index ed95d493..6bbcd213 100644 --- a/test/openclaw-plugin.test.ts +++ b/test/openclaw-plugin.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; type Capability = { promptBuilder?: (params: { @@ -25,6 +25,14 @@ function makeApi(overrides: Partial = {}): FakeApi { }; } +const originalAgentmemoryAgentId = process.env.AGENTMEMORY_AGENT_ID; + +afterEach(() => { + vi.restoreAllMocks(); + if (originalAgentmemoryAgentId === undefined) delete process.env.AGENTMEMORY_AGENT_ID; + else process.env.AGENTMEMORY_AGENT_ID = originalAgentmemoryAgentId; +}); + describe("openclaw plugin — memory capability registration (closes #286 follow-up)", () => { it("calls api.registerMemoryCapability with a promptBuilder when the host supports it", async () => { const mod = await import("../integrations/openclaw/plugin.mjs"); @@ -59,4 +67,52 @@ describe("openclaw plugin — memory capability registration (closes #286 follow const lines = capability.promptBuilder?.({ availableTools: new Set() }) ?? []; expect(lines.join("\n")).toMatch(/memory\.internal:9999/); }); + + it("passes AGENTMEMORY_AGENT_ID to before_agent_start smart-search", async () => { + process.env.AGENTMEMORY_AGENT_ID = "openclaw-profile"; + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + } as Response); + + const mod = await import("../integrations/openclaw/plugin.mjs"); + const plugin = (mod as unknown as { default: { register(api: FakeApi): void } }).default; + const api = makeApi(); + plugin.register(api); + + const handler = (api.on as ReturnType).mock.calls.find( + ([event]) => event === "before_agent_start", + )?.[1] as (event: { prompt: string }) => Promise; + await handler({ prompt: "auth issue" }); + + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); + expect(body.agentId).toBe("openclaw-profile"); + }); + + it("passes AGENTMEMORY_AGENT_ID to agent_end observe", async () => { + process.env.AGENTMEMORY_AGENT_ID = "openclaw-profile"; + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response); + + const mod = await import("../integrations/openclaw/plugin.mjs"); + const plugin = (mod as unknown as { default: { register(api: FakeApi): void } }).default; + const api = makeApi(); + plugin.register(api); + + const handler = (api.on as ReturnType).mock.calls.find( + ([event]) => event === "agent_end", + )?.[1] as (event: Record) => Promise; + await handler({ + success: true, + messages: [ + { role: "user", content: "remember this" }, + { role: "assistant", content: "saved" }, + ], + }); + + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)); + expect(body.agentId).toBe("openclaw-profile"); + }); });