Skip to content
Open
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
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1149,20 +1151,40 @@ 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 |
|------|------------|---------------|-------------|
| `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=<role>` 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=<role>` 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

Expand Down
5 changes: 5 additions & 0 deletions integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,15 @@ 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", ""))

_api(self._base, "session/start", {
"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]:
Expand Down Expand Up @@ -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})

Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions integrations/openclaw/plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
Expand All @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions test/agent-id-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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");
Expand Down
95 changes: 95 additions & 0 deletions test/mcp-agent-id.test.ts
Original file line number Diff line number Diff line change
@@ -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 <T>(_scope: string, _key: string, data: T): Promise<T> => data,
delete: async () => {},
list: async () => [],
};
}

function mockSdk() {
const functions = new Map<string, Function>();
const triggerOverrides = new Map<string, Function>();
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<typeof mockSdk>;

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<string, unknown> | undefined;
sdk.overrideTrigger("mem::remember", async (data: Record<string, unknown>) => {
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<string, unknown> | undefined;
sdk.overrideTrigger("mem::smart-search", async (data: Record<string, unknown>) => {
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");
});
});
58 changes: 57 additions & 1 deletion test/openclaw-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

type Capability = {
promptBuilder?: (params: {
Expand All @@ -25,6 +25,14 @@ function makeApi(overrides: Partial<FakeApi> = {}): 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");
Expand Down Expand Up @@ -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<typeof vi.fn>).mock.calls.find(
([event]) => event === "before_agent_start",
)?.[1] as (event: { prompt: string }) => Promise<unknown>;
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<typeof vi.fn>).mock.calls.find(
([event]) => event === "agent_end",
)?.[1] as (event: Record<string, unknown>) => Promise<unknown>;
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");
});
});