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
30 changes: 25 additions & 5 deletions src/functions/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { VectorIndex } from '../state/vector-index.js'
import type { EmbeddingProvider } from '../types.js'
import { memoryToObservation } from '../state/memory-utils.js'
import { recordAccessBatch } from './access-tracker.js'
import { getAgentId, isAgentScopeIsolated } from '../config.js'
import { logger } from "../logger.js";

let index: SearchIndex | null = null
Expand Down Expand Up @@ -328,6 +329,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
cwd?: string
format?: string
token_budget?: number
agentId?: string
}) => {
const idx = getSearchIndex()

Expand Down Expand Up @@ -363,10 +365,25 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
logger.info('Search index rebuilt', { entries: count })
}

// When filtering by project/cwd, over-fetch from the index so the
const isolated = isAgentScopeIsolated()
const explicitAgentId =
typeof data.agentId === 'string' && data.agentId.trim().length > 0
? data.agentId.trim()
: undefined
const wildcardAgent = explicitAgentId === '*'
const filterAgentId = wildcardAgent
? undefined
: explicitAgentId ?? (isolated ? getAgentId() : undefined)

// When filtering by project/cwd/agent, over-fetch from the index so the
// post-filter still has a chance of returning `effectiveLimit` results.
const filtering = !!(projectFilter || cwdFilter)
const fetchLimit = filtering ? Math.max(effectiveLimit * 10, 100) : effectiveLimit
// Session reads only matter for project/cwd; agentId is filtered later
// off the loaded observation, so don't load sessions for agent-only scope.
const needsSessionFiltering = !!(projectFilter || cwdFilter)
const fetchLimit =
needsSessionFiltering || filterAgentId
? Math.max(effectiveLimit * 10, 100)
: effectiveLimit
const results = idx.search(query, fetchLimit)

// Resolve session -> project/cwd once per sessionId we touch.
Expand Down Expand Up @@ -395,10 +412,11 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
// First pass: filter by session (sequential — benefits from session cache).
// Memory entries with a synthetic sessionId take a secondary KV.memories
// path so project filtering works correctly for them too.
const candidateCap = filterAgentId ? fetchLimit : effectiveLimit
const candidates: typeof results = []
for (const r of results) {
if (candidates.length >= effectiveLimit) break
if (filtering) {
if (candidates.length >= candidateCap) break
if (needsSessionFiltering) {
const s = await loadSession(r.sessionId)
if (s) {
if (projectFilter && s.project !== projectFilter) continue
Expand Down Expand Up @@ -448,13 +466,15 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
for (let i = 0; i < candidates.length; i++) {
const obs = obsResults[i]
if (obs) {
if (filterAgentId && obs.agentId !== filterAgentId) continue
enriched.push({
observation: obs,
score: candidates[i].score,
sessionId: candidates[i].sessionId,
})
}
}
if (enriched.length > effectiveLimit) enriched.length = effectiveLimit

void recordAccessBatch(
kv,
Expand Down
23 changes: 21 additions & 2 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, isAgentScopeIsolated } from "../config.js";

type McpResponse = {
status_code: number;
Expand Down Expand Up @@ -117,6 +118,9 @@ export function registerMcpEndpoints(
limit: typeof args.limit === "number" ? args.limit : 10,
format,
token_budget: tokenBudget,
...(typeof args.agentId === "string" && args.agentId.trim()
? { agentId: args.agentId.trim() }
: {}),
} });
const text =
format === "narrative" &&
Expand Down Expand Up @@ -1635,14 +1639,29 @@ export function registerMcpEndpoints(
},
};
}
const recallAgentId =
typeof promptArgs.agentId === "string" && promptArgs.agentId.trim()
? promptArgs.agentId.trim()
: undefined;
const wildcardAgent = recallAgentId === "*";
const filterAgentId = wildcardAgent
? undefined
: recallAgentId ?? (isAgentScopeIsolated() ? getAgentId() : undefined);
const searchResult = await sdk
.trigger({
function_id: "mem::search",
payload: { query: taskDesc, limit: 10 },
payload: {
query: taskDesc,
limit: 10,
...(recallAgentId ? { agentId: recallAgentId } : {}),
},
})
.catch(() => ({ results: [] }));
const memories = await kv.list<Memory>(KV.memories);
const relevant = memories.filter((m) => m.isLatest).slice(0, 5);
const relevant = memories
.filter((m) => m.isLatest)
.filter((m) => !filterAgentId || m.agentId === filterAgentId)
.slice(0, 5);
return {
status_code: 200,
body: {
Expand Down
1 change: 1 addition & 0 deletions src/state/memory-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export function memoryToObservation(memory: Memory): CompressedObservation {
concepts: memory.concepts,
files: memory.files,
importance: memory.strength,
agentId: memory.agentId,
};
}
9 changes: 9 additions & 0 deletions src/triggers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ export function registerApiTriggers(
cwd?: string;
format?: string;
token_budget?: number;
agentId?: string;
}>,
): Promise<Response> => {
const body = (req.body ?? {}) as Record<string, unknown>;
Expand Down Expand Up @@ -407,6 +408,13 @@ export function registerApiTriggers(
body: { error: "token_budget must be a positive integer" },
};
}
const requestAgentId =
typeof body.agentId === "string" && body.agentId.trim().length > 0
? body.agentId.trim().slice(0, 128)
: typeof req.query_params?.["agentId"] === "string" &&
req.query_params["agentId"].trim().length > 0
? req.query_params["agentId"].trim().slice(0, 128)
: undefined;
const payload = {
query: body.query.trim(),
limit: body.limit as number | undefined,
Expand All @@ -417,6 +425,7 @@ export function registerApiTriggers(
? body.format.trim().toLowerCase()
: undefined,
token_budget: body.token_budget as number | undefined,
...(requestAgentId ? { agentId: requestAgentId } : {}),
};
const result = await sdk.trigger({ function_id: "mem::search", payload: payload });
return { status_code: 200, body: result };
Expand Down
1 change: 1 addition & 0 deletions test/cross-project-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ vi.mock("../src/functions/access-tracker.js", () => ({

vi.mock("../src/config.js", () => ({
getAgentId: () => undefined,
isAgentScopeIsolated: () => false,
}));

import { registerRememberFunction } from "../src/functions/remember.js";
Expand Down
222 changes: 222 additions & 0 deletions test/search-agent-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
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 { registerSearchFunction, getSearchIndex } from "../src/functions/search.js";
import { KV } from "../src/state/schema.js";
import type { CompressedObservation, Memory, Session } from "../src/types.js";

function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> =>
(store.get(scope)?.get(key) as T) ?? null,
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}

function mockSdk() {
const functions = 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 fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}

// #817: mem::search must honor AGENTMEMORY_AGENT_SCOPE=isolated so the
// REST /search endpoint and the memory_recall / recall_context MCP tools
// (which all route through mem::search) don't leak memories across agents.
describe("mem::search agent-scope isolation (#817)", () => {
const ORIG_ID = process.env["AGENT_ID"];
const ORIG_MODE = process.env["AGENTMEMORY_AGENT_SCOPE"];

let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;

async function search(payload: Record<string, unknown>) {
return (await sdk.trigger("mem::search", {
format: "compact",
...payload,
})) as { results: Array<{ obsId: string }> };
}

beforeEach(async () => {
delete process.env["AGENT_ID"];
delete process.env["AGENTMEMORY_AGENT_SCOPE"];

sdk = mockSdk();
kv = mockKV();
registerSearchFunction(sdk as never, kv as never);

// Two sessions owned by two different agents, each with one matching
// observation. The query hits both, so agentId is the only thing that
// can separate them.
const sessionA: Session = {
id: "ses_a",
project: "demo",
cwd: "/tmp/demo",
startedAt: "2026-01-01T00:00:00Z",
status: "completed",
observationCount: 1,
agentId: "agent_a",
};
const sessionB: Session = {
id: "ses_b",
project: "demo",
cwd: "/tmp/demo",
startedAt: "2026-01-02T00:00:00Z",
status: "completed",
observationCount: 1,
agentId: "agent_b",
};
await kv.set(KV.sessions, sessionA.id, sessionA);
await kv.set(KV.sessions, sessionB.id, sessionB);

const obsA: CompressedObservation = {
id: "obs_a",
sessionId: "ses_a",
timestamp: "2026-01-01T00:00:00Z",
type: "decision",
title: "Secret marker alpha",
facts: ["alpha private fact"],
narrative: "secret marker private to agent_a",
concepts: ["secret"],
files: [],
importance: 8,
agentId: "agent_a",
};
const obsB: CompressedObservation = {
id: "obs_b",
sessionId: "ses_b",
timestamp: "2026-01-02T00:00:00Z",
type: "decision",
title: "Secret marker beta",
facts: ["beta private fact"],
narrative: "secret marker private to agent_b",
concepts: ["secret"],
files: [],
importance: 8,
agentId: "agent_b",
};
await kv.set(KV.observations("ses_a"), obsA.id, obsA);
await kv.set(KV.observations("ses_b"), obsB.id, obsB);

// Module-level SearchIndex singleton leaks across tests; reset so each
// case triggers a fresh rebuild from the mock KV.
getSearchIndex().clear();
});
Comment on lines +69 to +133
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consider improving type safety of mock injection.

The test setup correctly seeds agent-scoped sessions and observations, and properly clears the SearchIndex singleton to prevent cross-test pollution. However, line 75 uses as never to bypass TypeScript's type checking when passing mocks to registerSearchFunction.

While this works, it could hide type mismatches if the mock implementations drift from the actual interfaces.

🔧 Suggested improvement

Consider typing the mocks to properly match ISdk and StateKV interfaces, or use a type assertion that preserves some checking:

-    registerSearchFunction(sdk as never, kv as never);
+    registerSearchFunction(sdk as any, kv as any);

Or better yet, properly type the mock factories to return the correct interfaces (though this may require additional method stubs).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/search-agent-scope.test.ts` around lines 69 - 133, The test is bypassing
TypeScript checks by passing mocks with "as never" to registerSearchFunction;
update the mock factories so mockSdk() returns ISdk and mockKV() returns StateKV
(or use a narrower assertion like mockSdk() as unknown as ISdk) and then call
registerSearchFunction(sdk, kv) without "as never"; ensure mockSdk and mockKV
signatures and returned objects implement the methods used by
registerSearchFunction so the compiler enforces interface compatibility.


afterEach(() => {
if (ORIG_ID === undefined) delete process.env["AGENT_ID"];
else process.env["AGENT_ID"] = ORIG_ID;
if (ORIG_MODE === undefined) delete process.env["AGENTMEMORY_AGENT_SCOPE"];
else process.env["AGENTMEMORY_AGENT_SCOPE"] = ORIG_MODE;
});

it("shared mode (default) returns hits from every agent", async () => {
const { results } = await search({ query: "secret marker" });
const ids = results.map((r) => r.obsId).sort();
expect(ids).toEqual(["obs_a", "obs_b"]);
});

it("isolated mode returns only the env AGENT_ID's hits", async () => {
process.env["AGENT_ID"] = "agent_b";
process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated";

const { results } = await search({ query: "secret marker" });
expect(results.map((r) => r.obsId)).toEqual(["obs_b"]);
});

it("isolated mode does not leak another agent's memory", async () => {
process.env["AGENT_ID"] = "agent_b";
process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated";

const { results } = await search({ query: "secret marker" });
expect(results.some((r) => r.obsId === "obs_a")).toBe(false);
});

it("explicit agentId override wins over the env default", async () => {
process.env["AGENT_ID"] = "agent_b";
process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated";

const { results } = await search({ query: "secret marker", agentId: "agent_a" });
expect(results.map((r) => r.obsId)).toEqual(["obs_a"]);
});

it("wildcard agentId '*' opts out of isolation and returns all agents", async () => {
process.env["AGENT_ID"] = "agent_b";
process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated";

const { results } = await search({ query: "secret marker", agentId: "*" });
const ids = results.map((r) => r.obsId).sort();
expect(ids).toEqual(["obs_a", "obs_b"]);
});

// The #817 repro uses /remember, not raw observations. Remembered
// Memory rows are indexed via memoryToObservation(); agentId must
// survive that conversion or isolated recall drops the owner's own
// memories (and the cross-agent filter can't distinguish them).
it("isolated mode scopes memories saved via KV.memories by agentId", async () => {
const memA: Memory = {
id: "mem_a",
title: "Remembered marker alpha",
content: "secret marker remembered by agent_a",
type: "fact",
concepts: ["secret"],
files: [],
strength: 1,
createdAt: "2026-01-03T00:00:00Z",
isLatest: true,
agentId: "agent_a",
} as Memory;
const memB: Memory = {
id: "mem_b",
title: "Remembered marker beta",
content: "secret marker remembered by agent_b",
type: "fact",
concepts: ["secret"],
files: [],
strength: 1,
createdAt: "2026-01-04T00:00:00Z",
isLatest: true,
agentId: "agent_b",
} as Memory;
await kv.set(KV.memories, memA.id, memA);
await kv.set(KV.memories, memB.id, memB);
getSearchIndex().clear();

process.env["AGENT_ID"] = "agent_b";
process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated";

const { results } = await search({ query: "secret marker remembered" });
const ids = results.map((r) => r.obsId);
expect(ids).toContain("mem_b"); // own remembered memory still recalled
expect(ids).not.toContain("mem_a"); // other agent's is not leaked
});
});