diff --git a/AGENTS.md b/AGENTS.md index 985934c..3c08490 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ CI runs `typecheck` + `test` — both must pass before any push. - `src/mount-config.ts` / `src/config.ts` — mount config + engine config - `src/routes/` — Hono routes (`add`, `search`, `list`) +- `src/tools/` — Interchange `defineTool` factories (`@corbits/memory/tools`); + HTTP clients for mounted routes (env credentials; no in-process plane) - `src/services/` — capture / search / transform internals (not public verbs) - `src/ports/` — `DocumentStore` / `SourceProvider` + fakes - `src/core/` — embed/rerank clients, merge, arktype schemas diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6e1855f..de59243 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -73,9 +73,10 @@ exposes the same three verbs. Returns an in-process `Memory` (`add`, `search`, `list`, `close`) for host workers and ingestion modules that already resolved identity. -**Agent tools are not in this package.** Routes are OpenAPI-described -(`describeRoute`). The host mounts `@corbitsdev/hono-openapi-mcp` (or any -OpenAPI→tools bridge) so agents call these routes under Interchange auth. +**Agent tools live in this package** as thin HTTP clients +(`@corbits/memory/tools` / `interchange.tools`): `defineTool` factories that +`fetch` the mounted routes with install credentials. They do not import the +in-process plane. OpenAPI→MCP remains an optional host bridge. ## Provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index fa02ede..19f99b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Interchange `defineTool` factories at `@corbits/memory/tools` (`memory_add`, + `memory_search`, `memory_list`) — HTTP clients for mounted hub routes with + install env `memoryBaseUrl` / `memoryTenantId` / `memoryAuthToken`. Declared + via `package.json` `interchange.tools` and `exports["./tools"]`. + ### Changed - **Breaking:** package and public surface renamed from `@corbits/knowledge-engine` diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 556318b..8e489d2 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -487,13 +487,19 @@ Each route is guarded with `grantGuard(deps, action)`, which applies the host's | Method + path | Grant action | Request body | Response | |---|---|---|---| | `POST /api/tenants/:tenantId/memory/add` | `add` | `{ title, text, access_tags?, share? }` | `200 { documentId }`; `400` on validation | -| `POST /api/tenants/:tenantId/memory/search` | `search` | `{ query, limit?, kinds?, entity_ids? }` (limit 1–50; `kinds`/`entity_ids` narrow every retrieval channel — lexical and dense — to a document `kind` or linked entity id before fusion; unset or `[]` = unfiltered) | `200 { items[], evidence?, degraded? }`; `400` on bad input | -| `GET /api/tenants/:tenantId/memory/list` | `search` | — | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope, filtered with grant-tag access (`canAccessDocument`). One event per document (active live version). | +| `POST /api/tenants/:tenantId/memory/search` | `search` | `{ query, limit?, kinds?, entity_ids?, sources?, includeEvidence? }` (limit 1–50; `kinds`/`entity_ids`/`sources` narrow retrieval before fusion; unset or `[]` = unfiltered; `includeEvidence` adds a short evidence string when true) | `200 { items[], evidence?, degraded? }`; `400` on bad input | +| `GET /api/tenants/:tenantId/memory/list` | `search` | query `?limit=` (1–100, string on the wire) | `200 { events: [{ at, title, source, tenantId, principalId }] }` — durable recent documents for the caller's scope, filtered with grant-tag access (`canAccessDocument`). One event per document (active live version). | `registerMemoryRoutes` and `createMemory({ app })` register the three HTTP routes. -Agent tools are a host concern — mount `@corbitsdev/hono-openapi-mcp` (or any -OpenAPI→tools bridge) against the same app. The plane surface is only -`add` / `search` / `list` (plus `close`); inference stays on the host. +Agent tools ship in this package as Interchange `defineTool` factories +(`@corbits/memory/tools` / `interchange.tools`): thin HTTP clients that call the +mounted routes with install env (`memoryBaseUrl`, `memoryTenantId`, +`memoryAuthToken`). They do not import the plane. Host checklist: agent principal +needs `memory:add` and/or `memory:search` grants; Bearer token only (no session +cookie path); tool results are JSON strings; pass `AbortSignal` if you need hang +protection — the client has no default timeout. OpenAPI→MCP remains an optional +host bridge. The plane surface is only `add` / `search` / `list` (plus `close`); +inference stays on the host. @@ -518,8 +524,8 @@ Document access is Interchange authz — **not** a mini-ACL. - Write path: `resolveAccessTags` always writes `memory.owner:` and merges optional `accessTags` / share sugar (`tenant`, peer `principals`, explicit `tags`). Stored on `knowledge.document.access_tags`. -- Read path (find + recent): `canAccessDocument` — creator always allowed; - otherwise `authorize(grantStore, principal, tenant, tag, "find")` for any +- Read path (search + list): `canAccessDocument` — creator always allowed; + otherwise `authorize(grantStore, principal, tenant, tag, "search")` for any tag on the document. - SQL retrieval is **tenant-scoped only**. Document access is grant-tag post-filter in the plane (`canAccessDocument`); there is no SQL mini-ACL. diff --git a/PRODUCT.md b/PRODUCT.md index a31d3cc..e4e070d 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -18,6 +18,7 @@ never creates one; it mounts onto yours. | `loadMemoryConfig()` | Config from env | | `runMemoryMigrations(url)` | Apply pgvector schema | | `registerMemoryRoutes` | Low-level HTTP only (optional) | +| `@corbits/memory/tools` | Interchange `defineTool` factories (`memory_add` / `memory_search` / `memory_list`) | ### Verbs @@ -53,9 +54,10 @@ Agent / ingestion module ``` 1. **Mount** — host passes `app` + the same grant store it already uses. -2. **Tools** — host exposes the OpenAPI routes as agent tools (e.g. - `@corbitsdev/hono-openapi-mcp`). Agents call add/search/list as the - authenticated principal. +2. **Tools** — install `@corbits/memory/tools` (`defineTool` factories) on a + workflow with env credentials (`memoryBaseUrl`, `memoryTenantId`, + `memoryAuthToken`). Tools HTTP-call the mounted routes; identity is the + hub-authenticated principal. OpenAPI→MCP remains an optional host bridge. 3. **Ingestion** — host modules (webhooks, batch jobs) call the routes or the returned plane with a resolved principal. diff --git a/README.md b/README.md index 0cb4496..4bab4c6 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Memory for [Interchange](https://github.com/corbitsdev) hubs: **add**, **search* Mount it on the hub. Routes land under `/api/tenants/:tenantId/memory/*`, so the hub’s existing `createResolveTenant` middleware supplies principal + tenant -— same as workflows, assets, and agents. Agents and ingestion modules call those -routes (tools / OpenAPI→MCP, or in-process from a host worker). That’s the -product. +— same as workflows, assets, and agents. Workflow agents install the package’s +`defineTool` factories; ingestion modules call the same routes or the in-process +plane. That’s the product. Requires Bun 1.2+. @@ -20,7 +20,7 @@ bun add git+https://github.com/corbitsdev/corbits-memory.git ``` Peer stack you already have on an Interchange hub: `@intx/authz`, `@intx/hub-api`, -`hono`. +`hono`. Agent tools also need `@intx/agent` (declared as a direct dependency). ## Mount (≈5 lines) @@ -53,17 +53,54 @@ Missing grant → **403**. ```http POST /api/tenants/:tenantId/memory/add { "title", "text", "access_tags"?, "share"? } -POST /api/tenants/:tenantId/memory/search { "query", "limit"? } +POST /api/tenants/:tenantId/memory/search { "query", "limit"?, "kinds"?, "entity_ids"?, "sources"?, "includeEvidence"? } GET /api/tenants/:tenantId/memory/list ?limit= ``` -## Who calls the routes +## Workflow agent tools -1. **Agent tools** — routes are OpenAPI-described (`hono-openapi`). On the host, - mount `@corbitsdev/hono-openapi-mcp` (or any OpenAPI→tools bridge) so agents - get tools that hit the memory paths under Interchange auth. -2. **Ingestion modules** — host workers that already resolved identity call the - same plane in-process (no HTTP hop): +This package exports Interchange `defineTool` factories at +`@corbits/memory/tools` (also `package.json` → `interchange.tools`). Each tool +is a thin HTTP client: install credentials in agent env, call the mounted hub +routes. No plane inject, no model-supplied identity. + +| Factory id | Tool name | HTTP | +| --- | --- | --- | +| `@corbits/memory/add` | `memory_add` | `POST …/memory/add` | +| `@corbits/memory/search` | `memory_search` | `POST …/memory/search` | +| `@corbits/memory/list` | `memory_list` | `GET …/memory/list` | + +**Env keys** (declared on each factory’s `requires`): + +| Key | Meaning | +| --- | --- | +| `memoryBaseUrl` | Hub **origin** only, e.g. `https://hub.example` (no `/api/...` path) | +| `memoryTenantId` | Tenant path segment (must match the principal’s tenant on the hub) | +| `memoryAuthToken` | Bearer token the hub accepts for that agent principal | + +**Host checklist** + +1. Mount routes: `createMemory({ app, grantStore, … })` under the hub tenant tree. +2. Grant the agent principal `memory:add` and/or `memory:search` (`list` uses `search`). +3. For peer/space share visibility, also grant `search` on the relevant document tags (see `docs/AUTHZ-DOCUMENT-ACCESS.md`). +4. Install factories on the workflow and set the three env keys above. +5. Auth is **Bearer only** on the tool client — session cookies are not sent. +6. Tool results are **JSON strings** (`stringTool`); pass `AbortSignal` if you need hang protection (no default client timeout). + +```ts +import { memoryAdd, memorySearch, memoryList } from "@corbits/memory/tools"; + +// On a workflow / agent definition — install like any open tool package: +// tools: [memoryAdd, memorySearch, memoryList] +// and supply memoryBaseUrl / memoryTenantId / memoryAuthToken in agent env. +``` + +OpenAPI→MCP remains available as an alternative host bridge; the shipped +`defineTool`s are the primary install path for workflow agents. + +## Ingestion (in-process) + +Host workers that already resolved identity can call the plane without HTTP: ```ts await memory.add({ diff --git a/bun.lock b/bun.lock index 682997b..eeeb56d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "company-knowledge-engine", "dependencies": { + "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", diff --git a/docs/AUTHZ-DOCUMENT-ACCESS.md b/docs/AUTHZ-DOCUMENT-ACCESS.md index 83e77b5..af1a31e 100644 --- a/docs/AUTHZ-DOCUMENT-ACCESS.md +++ b/docs/AUTHZ-DOCUMENT-ACCESS.md @@ -79,9 +79,9 @@ Tag minting is **not** grant minting. For peer share to work in product: 1. When Alice adds with `share: { principals: ["bob"] }`, the document is tagged `memory.owner:alice` and `memory.owner:bob`. -2. Bob sees it only if the host has granted Bob `find` on `memory.owner:bob` +2. Bob sees it only if the host has granted Bob `search` on `memory.owner:bob` (or a pattern that matches). **Recommended host bootstrap:** every principal - receives `find` (and optionally `add` side-effects as you prefer) on + receives `search` (and optionally `add` side-effects as you prefer) on `memory.owner:` at signup, or a single pattern grant such as `memory.owner:*` only if that matches your tenancy model. 3. Space/tenant tags work the same way: host must issue grants on @@ -100,7 +100,7 @@ Deny is expressed as **absence of allow** (or an explicit deny grant in the host ### Capability (unchanged) ```ts -authorize(grantStore, principalId, tenantId, "memory", "find"|"add") +authorize(grantStore, principalId, tenantId, "memory", "search"|"add") // effect must be "allow" ``` @@ -111,7 +111,7 @@ function canSeeDocument(doc, principalId, grantStore, tenantId): if doc.createdByPrincipalId === principalId: return true // creator for tag of doc.accessTags: - r = authorize(grantStore, principalId, tenantId, tag, "find") + r = authorize(grantStore, principalId, tenantId, tag, "search") if r.effect === "allow": return true return false @@ -120,7 +120,7 @@ function canSeeDocument(doc, principalId, grantStore, tenantId): **SQL / store path:** prefer expand-then-filter: 1. `collectGrants(principalId, tenantId)` once per request. -2. Keep allow-grants whose `action` matches `find` (exact or pattern). +2. Keep allow-grants whose `action` matches `search` (exact or pattern). 3. Document is visible if creator **or** any `accessTags[i]` is matched by any allow grant resource pattern (`matchPattern(grant.resource, tag)`), and not denied by a more specific deny. This keeps evaluation inside Interchange authz semantics (specificity, conditions, deny). diff --git a/package.json b/package.json index 0633878..d754bed 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,11 @@ "exports": { ".": "./src/index.ts", "./migrations": "./src/migrations.ts", - "./config": "./src/mount-config.ts" + "./config": "./src/mount-config.ts", + "./tools": "./src/tools/index.ts" + }, + "interchange": { + "tools": "./src/tools/index.ts" }, "license": "LGPL-2.1-only", "type": "module", @@ -20,6 +24,7 @@ "test:coverage": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text ./src" }, "dependencies": { + "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", diff --git a/src/http-bodies.ts b/src/http-bodies.ts new file mode 100644 index 0000000..716a957 --- /dev/null +++ b/src/http-bodies.ts @@ -0,0 +1,101 @@ +/** + * Shared request bodies for hub memory HTTP and defineTool factories. + * Keep route validators and tool arg parsers on the same schemas. + * + * GET list uses a string query param on the wire (`ListQuery`); tools use + * numeric `ListArgs`. Bounds are shared via `limits.ts` and `parseListLimitString`. + */ +import { type } from "arktype"; + +import { + LIST_LIMIT_MAX, + LIST_LIMIT_MIN, + SEARCH_LIMIT_MAX, + SEARCH_LIMIT_MIN, +} from "./limits.ts"; + +export const ShareBody = type({ + "tenant?": "boolean", + "principals?": "string[]", + "tags?": "string[]", +}); + +export const AddRequest = type({ + title: "string >= 1", + text: "string >= 1", + "access_tags?": "string[]", + "share?": ShareBody, +}); + +export type AddRequest = typeof AddRequest.infer; + +export const SearchRequest = type({ + query: "string >= 1", + "limit?": type(`${SEARCH_LIMIT_MIN} <= number.integer <= ${SEARCH_LIMIT_MAX}`), + "kinds?": "string[]", + "entity_ids?": "string[]", + "sources?": "string[]", + "includeEvidence?": "boolean", +}); + +export type SearchRequest = typeof SearchRequest.infer; + +/** HTTP query schema for GET /memory/list (string limit from the URL). */ +export const ListQuery = type({ + "limit?": "string", +}); + +export type ListQuery = typeof ListQuery.infer; + +/** Tool-arg shape for memory_list (numeric limit after LLM coerce). */ +export const ListArgs = type({ + "limit?": type(`${LIST_LIMIT_MIN} <= number.integer <= ${LIST_LIMIT_MAX}`), +}); + +export type ListArgs = typeof ListArgs.infer; + +/** + * Parse a list `limit` query string into a bounded integer. + * Returns `undefined` for missing/empty; `null` for invalid/out-of-range. + */ +export function parseListLimitString( + raw: string | undefined, +): number | undefined | null { + if (raw === undefined || raw === "") return undefined; + const n = Number(raw); + if ( + !Number.isInteger(n) || + n < LIST_LIMIT_MIN || + n > LIST_LIMIT_MAX + ) { + return null; + } + return n; +} + +/** Coerce LLM-stringified integers before arktype number.integer checks. */ +export function coerceOptionalLimitArg( + args: Record, +): Record { + const raw = args["limit"]; + if (raw === undefined || typeof raw === "number") return args; + if (typeof raw === "string" && raw.trim() !== "") { + const n = Number(raw); + if (Number.isFinite(n)) { + return { ...args, limit: n }; + } + } + return args; +} + +export function parseWithArk( + schema: (data: unknown) => T | type.errors, + data: unknown, + label: string, +): T { + const parsed = schema(data); + if (parsed instanceof type.errors) { + throw new Error(`${label}: ${parsed.summary}`); + } + return parsed; +} diff --git a/src/limits.ts b/src/limits.ts new file mode 100644 index 0000000..2f860a2 --- /dev/null +++ b/src/limits.ts @@ -0,0 +1,7 @@ +/** Search limit bounds (hybrid search + HTTP/tool args). */ +export const SEARCH_LIMIT_MIN = 1; +export const SEARCH_LIMIT_MAX = 50; + +/** List/timeline limit bounds (GET list + memory_list tool). */ +export const LIST_LIMIT_MIN = 1; +export const LIST_LIMIT_MAX = 100; diff --git a/src/memory.ts b/src/memory.ts index 54e5e58..0d00076 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -41,6 +41,13 @@ import type { DocumentStoreSearchParams, SourceProvider, } from "./ports/types.ts"; +import { + LIST_LIMIT_MAX, + LIST_LIMIT_MIN, + SEARCH_LIMIT_MAX, + SEARCH_LIMIT_MIN, +} from "./limits.ts"; + // (drizzle select was used briefly for grant-tag load; raw sql keeps unit-test // mocks simple and matches the rest of the engine store.) @@ -53,6 +60,13 @@ export type { LiveSearchItem, SourceProvider, } from "./ports/types.ts"; +export { + SEARCH_LIMIT_MIN, + SEARCH_LIMIT_MAX, + LIST_LIMIT_MIN, + LIST_LIMIT_MAX, +} from "./limits.ts"; + export { resolveAccessTags, ownerTag, @@ -78,14 +92,6 @@ export type MemoryIdentity = { tenantId: string; }; -/** Green find limit bounds (stricter than hybridSearch's internal MAX_K). */ -export const SEARCH_LIMIT_MIN = 1; -export const SEARCH_LIMIT_MAX = 50; - -/** Green recent limit bounds (matches timeline service default/cap). */ -export const LIST_LIMIT_MIN = 1; -export const LIST_LIMIT_MAX = 100; - export type MemorySearchParams = MemoryIdentity & { query: string; /** Max items to return (1–50). Default 8. */ diff --git a/src/routes/add.ts b/src/routes/add.ts index 365c110..7e5a979 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -5,26 +5,12 @@ import { type } from "arktype"; import { formatCaughtError, log } from "../log.ts"; import { resolveAccessTags, type ShareSugar } from "../grant-tags.ts"; +import { AddRequest } from "../http-bodies.ts"; import { MemoryError } from "../memory.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; -const ShareBody = type({ - "tenant?": "boolean", - "principals?": "string[]", - "tags?": "string[]", -}); - -const AddRequest = type({ - title: "string >= 1", - text: "string >= 1", - /** Explicit resource tags (grant-pattern space). */ - "access_tags?": "string[]", - /** Share sugar — mints tags only. */ - "share?": ShareBody, -}); - const AddResponse = type({ documentId: "string", }); diff --git a/src/routes/list.ts b/src/routes/list.ts index 117299a..b07a6b5 100644 --- a/src/routes/list.ts +++ b/src/routes/list.ts @@ -4,6 +4,7 @@ import { describeRoute, resolver, validator } from "hono-openapi"; import { type } from "arktype"; import { formatCaughtError, log } from "../log.ts"; +import { ListQuery, parseListLimitString } from "../http-bodies.ts"; import { MemoryError, LIST_LIMIT_MAX, @@ -12,10 +13,6 @@ import { import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; -const ListQuery = type({ - "limit?": "string", -}); - const ListResponse = type({ events: type({ at: "string", @@ -26,19 +23,6 @@ const ListResponse = type({ }).array(), }); -function parseLimit(raw: string | undefined): number | undefined { - if (raw === undefined || raw === "") return undefined; - const n = Number(raw); - if ( - !Number.isInteger(n) || - n < LIST_LIMIT_MIN || - n > LIST_LIMIT_MAX - ) { - return undefined; - } - return n; -} - export function mountListRoute(app: Hono, deps: RouteDeps): void { app.get( "/api/tenants/:tenantId/memory/list", @@ -65,11 +49,8 @@ export function mountListRoute(app: Hono, deps: RouteDeps): void { async (c) => { const { scopeId, subjectId } = caller(c); const rawLimit = c.req.valid("query").limit; - if ( - rawLimit !== undefined && - rawLimit !== "" && - parseLimit(rawLimit) === undefined - ) { + const parsedLimit = parseListLimitString(rawLimit); + if (parsedLimit === null) { return c.json( { error: `limit must be an integer from ${LIST_LIMIT_MIN} to ${LIST_LIMIT_MAX}`, @@ -77,7 +58,7 @@ export function mountListRoute(app: Hono, deps: RouteDeps): void { 400, ); } - const limit = parseLimit(rawLimit); + const limit = parsedLimit; try { const events = await deps.memory.list({ tenantId: scopeId, diff --git a/src/routes/search.ts b/src/routes/search.ts index e030bfb..2dc0481 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -4,6 +4,7 @@ import { describeRoute, resolver, validator } from "hono-openapi"; import { type } from "arktype"; import { formatCaughtError, log } from "../log.ts"; +import { SearchRequest } from "../http-bodies.ts"; import { MemoryError } from "../memory.ts"; import type { RouteDeps } from "./deps.ts"; import { caller, grantGuard, requirePrincipal } from "./deps.ts"; @@ -15,14 +16,6 @@ import { caller, grantGuard, requirePrincipal } from "./deps.ts"; // An empty array on either field is equivalent to omitting it — "no filter" // — not "match nothing", and does not satisfy the requirement that an empty // `query` be paired with a non-empty structured filter. -const SearchRequest = type({ - query: "string >= 1", - "limit?": "1 <= number.integer <= 50", - "kinds?": "string[]", - "entity_ids?": "string[]", - "sources?": "string[]", - "includeEvidence?": "boolean", -}); const SearchResponse = type({ items: type({ diff --git a/src/tools/add.ts b/src/tools/add.ts new file mode 100644 index 0000000..08e4373 --- /dev/null +++ b/src/tools/add.ts @@ -0,0 +1,87 @@ +import { AddRequest, parseWithArk } from "../http-bodies.ts"; +import type { MemoryAddBody } from "./client.ts"; +import { defineMemoryHttpTool } from "./install.ts"; + +function parseAddArgs(args: Record): MemoryAddBody { + const parsed = parseWithArk(AddRequest, args, "memory_add"); + // Forward only schema fields so identity keys never ride the wire. + const body: MemoryAddBody = { + title: parsed.title, + text: parsed.text, + }; + if (parsed.access_tags !== undefined) { + body.access_tags = parsed.access_tags; + } + if (parsed.share !== undefined) { + // Rebuild share field-by-field — arktype keeps undeclared nested keys. + const share: NonNullable = {}; + if (parsed.share.tenant !== undefined) { + share.tenant = parsed.share.tenant; + } + if (parsed.share.principals !== undefined) { + share.principals = parsed.share.principals; + } + if (parsed.share.tags !== undefined) { + share.tags = parsed.share.tags; + } + body.share = share; + } + return body; +} + +/** + * Installable tool: POST /api/tenants/:tenantId/memory/add. + * + * Tenant and auth come from env (`memoryTenantId`, `memoryAuthToken`); + * model args never carry identity. + */ +export const memoryAdd = defineMemoryHttpTool({ + id: "@corbits/memory/add", + name: "memory_add", + description: + "Store a note in tenant memory. Returns { documentId }. " + + "Identity is the authenticated principal on the hub; do not " + + "pass tenant or principal ids.", + inputSchema: { + type: "object", + properties: { + title: { + type: "string", + description: "Short title for the document", + }, + text: { + type: "string", + description: "Full body text to store", + }, + access_tags: { + type: "array", + items: { type: "string" }, + description: + "Optional grant-pattern tags controlling document visibility", + }, + share: { + type: "object", + properties: { + tenant: { type: "boolean" }, + principals: { + type: "array", + items: { type: "string" }, + }, + tags: { + type: "array", + items: { type: "string" }, + }, + }, + description: + "Optional share sugar that mints access tags (tenant / principals / tags)", + }, + }, + required: ["title", "text"], + additionalProperties: false, + }, + async handle(client, args, signal) { + const body = parseAddArgs(args); + const result = await client.add(body, signal); + return JSON.stringify(result); + }, +}); diff --git a/src/tools/client.ts b/src/tools/client.ts new file mode 100644 index 0000000..37cef5e --- /dev/null +++ b/src/tools/client.ts @@ -0,0 +1,171 @@ +/** + * Thin HTTP client for mounted hub memory routes. + * + * Tools never touch the in-process plane — they only call + * `/api/tenants/:tenantId/memory/*` with credentials from install env. + * + * Pass `signal` on each call (or via the tool runner) so a hung hub can be + * cancelled; this client does not invent a default timeout. + */ +import type { AddRequest, SearchRequest } from "../http-bodies.ts"; + +export type MemoryHttpConfig = { + baseUrl: string; + tenantId: string; + authToken: string; + fetch?: typeof globalThis.fetch; +}; + +/** Wire body for POST /memory/add — same shape as shared AddRequest. */ +export type MemoryAddBody = AddRequest; + +/** Wire body for POST /memory/search — same shape as shared SearchRequest. */ +export type MemorySearchBody = SearchRequest; + +export type MemoryHttpClient = { + add(body: MemoryAddBody, signal?: AbortSignal): Promise; + search(body: MemorySearchBody, signal?: AbortSignal): Promise; + list(limit?: number, signal?: AbortSignal): Promise; +}; + +/** Cap hub error text embedded in tool errors (avoid huge/secret-ish dumps). */ +const MAX_ERROR_DETAIL_CHARS = 512; + +function stripTrailingSlashes(url: string): string { + let out = url; + while (out.endsWith("/")) { + out = out.slice(0, -1); + } + return out; +} + +function clipErrorDetail(text: string): string { + const t = text.trim(); + if (t.length <= MAX_ERROR_DETAIL_CHARS) return t; + return `${t.slice(0, MAX_ERROR_DETAIL_CHARS)}…`; +} + +export function createMemoryHttpClient( + config: MemoryHttpConfig, +): MemoryHttpClient { + const base = stripTrailingSlashes(config.baseUrl); + const root = `${base}/api/tenants/${encodeURIComponent(config.tenantId)}/memory`; + const doFetch = config.fetch ?? globalThis.fetch.bind(globalThis); + + async function request( + path: string, + init: { + method: string; + body?: string; + signal?: AbortSignal; + }, + ): Promise { + const headers: Record = { + Authorization: `Bearer ${config.authToken}`, + Accept: "application/json", + }; + if (init.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + + const fetchInit: RequestInit = { + method: init.method, + headers, + }; + if (init.body !== undefined) { + fetchInit.body = init.body; + } + if (init.signal !== undefined) { + fetchInit.signal = init.signal; + } + + const res = await doFetch(`${root}${path}`, fetchInit); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + const detail = + clipErrorDetail(text) || res.statusText || "request failed"; + throw new Error(`memory HTTP ${res.status}: ${detail}`); + } + + const text = await res.text().catch(() => ""); + if (!text.trim()) { + return {}; + } + try { + return JSON.parse(text) as unknown; + } catch (cause) { + throw new Error( + `memory HTTP ${res.status}: invalid JSON response`, + { cause }, + ); + } + } + + return { + add(body, signal) { + return request("/add", { + method: "POST", + body: JSON.stringify(body), + ...(signal !== undefined ? { signal } : {}), + }); + }, + search(body, signal) { + return request("/search", { + method: "POST", + body: JSON.stringify(body), + ...(signal !== undefined ? { signal } : {}), + }); + }, + list(limit, signal) { + const qs = + limit !== undefined + ? `?limit=${encodeURIComponent(String(limit))}` + : ""; + return request(`/list${qs}`, { + method: "GET", + ...(signal !== undefined ? { signal } : {}), + }); + }, + }; +} + +/** Env keys declared by every memory tool factory via `requires`. */ +export const MEMORY_TOOL_ENV_KEYS = [ + "memoryBaseUrl", + "memoryTenantId", + "memoryAuthToken", +] as const; + +export type MemoryToolEnvKeys = (typeof MEMORY_TOOL_ENV_KEYS)[number]; + +export type MemoryToolEnv = { + memoryBaseUrl: string; + memoryTenantId: string; + memoryAuthToken: string; + /** + * Optional host/test inject. Not part of `requires` — agents never set this. + */ + memoryFetch?: typeof globalThis.fetch; +}; + +export function readMemoryToolEnv(env: MemoryToolEnv): MemoryHttpConfig { + const baseUrl = env.memoryBaseUrl; + const tenantId = env.memoryTenantId; + const authToken = env.memoryAuthToken; + if (typeof baseUrl !== "string" || baseUrl.length === 0) { + throw new Error("memoryBaseUrl must be a non-empty string"); + } + if (typeof tenantId !== "string" || tenantId.length === 0) { + throw new Error("memoryTenantId must be a non-empty string"); + } + if (typeof authToken !== "string" || authToken.length === 0) { + throw new Error("memoryAuthToken must be a non-empty string"); + } + return { + baseUrl, + tenantId, + authToken, + ...(env.memoryFetch !== undefined ? { fetch: env.memoryFetch } : {}), + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..0249836 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,18 @@ +/** + * Interchange `defineTool` factories for hub memory routes. + * + * Install on a workflow like any other open tool package. Each factory + * requires env: `memoryBaseUrl`, `memoryTenantId`, `memoryAuthToken`. + * Tools call `/api/tenants/:tenantId/memory/*` over HTTP — no plane DI. + */ + +export { memoryAdd } from "./add.ts"; +export { memorySearch } from "./search.ts"; +export { memoryList } from "./list.ts"; +export { + createMemoryHttpClient, + MEMORY_TOOL_ENV_KEYS, + type MemoryHttpClient, + type MemoryHttpConfig, + type MemoryToolEnv, +} from "./client.ts"; diff --git a/src/tools/install.ts b/src/tools/install.ts new file mode 100644 index 0000000..688dd62 --- /dev/null +++ b/src/tools/install.ts @@ -0,0 +1,62 @@ +import { + createToolRunner, + defineTool, + stringTool, + type BaseEnv, +} from "@intx/agent"; + +import { + createMemoryHttpClient, + MEMORY_TOOL_ENV_KEYS, + readMemoryToolEnv, + type MemoryHttpClient, + type MemoryToolEnv, +} from "./client.ts"; + +export type MemoryInstallEnv = BaseEnv & MemoryToolEnv; + +type JSONSchemaObject = { + type: "object"; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +}; + +/** + * Shared defineTool shell for memory HTTP tools. + * Credentials from env; model args never carry identity. + */ +export function defineMemoryHttpTool(opts: { + id: string; + name: string; + description: string; + inputSchema: JSONSchemaObject; + handle: ( + client: MemoryHttpClient, + args: Record, + signal: AbortSignal | undefined, + ) => Promise; +}) { + return defineTool({ + id: opts.id, + requires: MEMORY_TOOL_ENV_KEYS, + factory(env) { + const client = createMemoryHttpClient(readMemoryToolEnv(env)); + const runner = createToolRunner([ + stringTool({ + definition: { + name: opts.name, + description: opts.description, + inputSchema: opts.inputSchema, + }, + handler: async (args, signal) => + opts.handle(client, args, signal), + }), + ]); + return { + definitions: runner.definitions, + run: (call, signal) => runner.run(call, signal), + }; + }, + }); +} diff --git a/src/tools/list.ts b/src/tools/list.ts new file mode 100644 index 0000000..bf8c328 --- /dev/null +++ b/src/tools/list.ts @@ -0,0 +1,46 @@ +import { + coerceOptionalLimitArg, + ListArgs, + parseWithArk, +} from "../http-bodies.ts"; +import { LIST_LIMIT_MAX, LIST_LIMIT_MIN } from "../limits.ts"; +import { defineMemoryHttpTool } from "./install.ts"; + +function parseListLimit(args: Record): number | undefined { + const parsed = parseWithArk( + ListArgs, + coerceOptionalLimitArg(args), + "memory_list", + ); + return parsed.limit; +} + +/** + * Installable tool: GET /api/tenants/:tenantId/memory/list. + * + * Tenant and auth come from env; model args never carry identity. + */ +export const memoryList = defineMemoryHttpTool({ + id: "@corbits/memory/list", + name: "memory_list", + description: + "List recent documents visible to the authenticated principal " + + "in this tenant's memory.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "integer", + minimum: LIST_LIMIT_MIN, + maximum: LIST_LIMIT_MAX, + description: `Max events to return (${LIST_LIMIT_MIN}–${LIST_LIMIT_MAX})`, + }, + }, + additionalProperties: false, + }, + async handle(client, args, signal) { + const limit = parseListLimit(args); + const result = await client.list(limit, signal); + return JSON.stringify(result); + }, +}); diff --git a/src/tools/search.ts b/src/tools/search.ts new file mode 100644 index 0000000..77dd9b8 --- /dev/null +++ b/src/tools/search.ts @@ -0,0 +1,82 @@ +import { + coerceOptionalLimitArg, + parseWithArk, + SearchRequest, +} from "../http-bodies.ts"; +import { SEARCH_LIMIT_MAX, SEARCH_LIMIT_MIN } from "../limits.ts"; +import type { MemorySearchBody } from "./client.ts"; +import { defineMemoryHttpTool } from "./install.ts"; + +function parseSearchArgs(args: Record): MemorySearchBody { + const parsed = parseWithArk( + SearchRequest, + coerceOptionalLimitArg(args), + "memory_search", + ); + const body: MemorySearchBody = { query: parsed.query }; + if (parsed.limit !== undefined) body.limit = parsed.limit; + if (parsed.kinds !== undefined) body.kinds = parsed.kinds; + if (parsed.entity_ids !== undefined) body.entity_ids = parsed.entity_ids; + if (parsed.sources !== undefined) body.sources = parsed.sources; + if (parsed.includeEvidence !== undefined) { + body.includeEvidence = parsed.includeEvidence; + } + return body; +} + +/** + * Installable tool: POST /api/tenants/:tenantId/memory/search. + * + * Tenant and auth come from env; model args never carry identity. + */ +export const memorySearch = defineMemoryHttpTool({ + id: "@corbits/memory/search", + name: "memory_search", + description: + "Hybrid semantic + keyword search over tenant memory. " + + "Returns ranked items (and optional evidence). Identity is " + + "the authenticated principal on the hub.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Search query text", + }, + limit: { + type: "integer", + minimum: SEARCH_LIMIT_MIN, + maximum: SEARCH_LIMIT_MAX, + description: `Max hits to return (${SEARCH_LIMIT_MIN}–${SEARCH_LIMIT_MAX})`, + }, + kinds: { + type: "array", + items: { type: "string" }, + description: "Optional document kind filter", + }, + entity_ids: { + type: "array", + items: { type: "string" }, + description: "Optional entity-id filter", + }, + sources: { + type: "array", + items: { type: "string" }, + description: + 'Optional channel filter (e.g. "local" and/or live source ids)', + }, + includeEvidence: { + type: "boolean", + description: + "Include evidence strength on the response (hub default true)", + }, + }, + required: ["query"], + additionalProperties: false, + }, + async handle(client, args, signal) { + const body = parseSearchArgs(args); + const result = await client.search(body, signal); + return JSON.stringify(result); + }, +}); diff --git a/src/tools/tools.test.ts b/src/tools/tools.test.ts new file mode 100644 index 0000000..09f7fd5 --- /dev/null +++ b/src/tools/tools.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { BaseEnv } from "@intx/agent"; + +import { memoryAdd } from "./add.ts"; +import { memorySearch } from "./search.ts"; +import { memoryList } from "./list.ts"; +import { + createMemoryHttpClient, + MEMORY_TOOL_ENV_KEYS, + readMemoryToolEnv, + type MemoryToolEnv, +} from "./client.ts"; + +const BASE = "https://hub.example"; +const TENANT = "tenant-abc"; +const TOKEN = "tok-xyz"; + +/** Factories only read memory* keys; cast a minimal env for tests. */ +function toolEnv(overrides?: Partial): BaseEnv & MemoryToolEnv { + return { + memoryBaseUrl: overrides?.memoryBaseUrl ?? BASE, + memoryTenantId: overrides?.memoryTenantId ?? TENANT, + memoryAuthToken: overrides?.memoryAuthToken ?? TOKEN, + ...(overrides?.memoryFetch !== undefined + ? { memoryFetch: overrides.memoryFetch } + : {}), + } as BaseEnv & MemoryToolEnv; +} + +type Captured = { + url: string; + method: string; + headers: Headers; + body: string | null; +}; + +function makeFetchMock( + respond: (req: Captured) => { status: number; json?: unknown; text?: string }, +) { + const calls: Captured[] = []; + const fetchMock = mock( + async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const method = init?.method ?? "GET"; + const headers = new Headers(init?.headers); + const body = + typeof init?.body === "string" + ? init.body + : init?.body == null + ? null + : String(init.body); + const captured: Captured = { url, method, headers, body }; + calls.push(captured); + const r = respond(captured); + if (r.text !== undefined) { + return new Response(r.text, { + status: r.status, + headers: { "Content-Type": "text/plain" }, + }); + } + return new Response(JSON.stringify(r.json ?? {}), { + status: r.status, + headers: { "Content-Type": "application/json" }, + }); + }, + ); + return { calls, fetchMock: fetchMock as unknown as typeof fetch }; +} + +describe("MEMORY_TOOL_ENV_KEYS", () => { + test("lists the three credential keys", () => { + expect([...MEMORY_TOOL_ENV_KEYS]).toEqual([ + "memoryBaseUrl", + "memoryTenantId", + "memoryAuthToken", + ]); + }); +}); + +describe("readMemoryToolEnv", () => { + test("rejects empty base url", () => { + expect(() => + readMemoryToolEnv({ + memoryBaseUrl: "", + memoryTenantId: TENANT, + memoryAuthToken: TOKEN, + }), + ).toThrow(/memoryBaseUrl/); + }); + + test("rejects empty tenant and token", () => { + expect(() => + readMemoryToolEnv({ + memoryBaseUrl: BASE, + memoryTenantId: "", + memoryAuthToken: TOKEN, + }), + ).toThrow(/memoryTenantId/); + expect(() => + readMemoryToolEnv({ + memoryBaseUrl: BASE, + memoryTenantId: TENANT, + memoryAuthToken: "", + }), + ).toThrow(/memoryAuthToken/); + }); +}); + +describe("createMemoryHttpClient", () => { + test("POSTs add under tenant path with Bearer auth", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { documentId: "doc-1" }, + })); + const client = createMemoryHttpClient({ + baseUrl: `${BASE}///`, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + const out = await client.add({ title: "t", text: "body" }); + expect(out).toEqual({ documentId: "doc-1" }); + expect(calls).toHaveLength(1); + const c = calls[0]!; + expect(c.method).toBe("POST"); + expect(c.url).toBe(`${BASE}/api/tenants/${TENANT}/memory/add`); + expect(c.headers.get("Authorization")).toBe(`Bearer ${TOKEN}`); + expect(c.headers.get("Content-Type")).toBe("application/json"); + const parsed = JSON.parse(c.body ?? "{}") as Record; + expect(parsed).toEqual({ title: "t", text: "body" }); + expect(parsed).not.toHaveProperty("tenantId"); + expect(parsed).not.toHaveProperty("principalId"); + }); + + test("GET list with limit query", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { events: [] }, + })); + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + await client.list(5); + expect(calls[0]!.method).toBe("GET"); + expect(calls[0]!.url).toBe( + `${BASE}/api/tenants/${TENANT}/memory/list?limit=5`, + ); + expect(calls[0]!.body).toBeNull(); + }); + + test("surfaces non-2xx as Error", async () => { + const { fetchMock } = makeFetchMock(() => ({ + status: 403, + json: { error: "forbidden" }, + })); + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + await expect(client.search({ query: "x" })).rejects.toThrow( + /memory HTTP 403/, + ); + }); + + test("clips long error response bodies", async () => { + const long = "e".repeat(800); + const { fetchMock } = makeFetchMock(() => ({ + status: 500, + text: long, + })); + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + try { + await client.list(); + expect.unreachable("expected throw"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + expect(msg).toMatch(/memory HTTP 500:/); + expect(msg.length).toBeLessThan(600); + expect(msg.endsWith("…")).toBe(true); + } + }); + + test("rejects invalid JSON on 2xx", async () => { + const { fetchMock } = makeFetchMock(() => ({ + status: 200, + text: "not-json", + })); + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + await expect(client.list()).rejects.toThrow(/invalid JSON/); + }); + + test("empty 2xx body becomes {}", async () => { + const { fetchMock } = makeFetchMock(() => ({ + status: 200, + text: "", + })); + const client = createMemoryHttpClient({ + baseUrl: BASE, + tenantId: TENANT, + authToken: TOKEN, + fetch: fetchMock, + }); + expect(await client.list()).toEqual({}); + }); +}); + +describe("memoryAdd factory", () => { + test("declares id and requires", () => { + expect(memoryAdd.id).toBe("@corbits/memory/add"); + expect([...memoryAdd.requires]).toEqual([...MEMORY_TOOL_ENV_KEYS]); + }); + + test("happy path: body has no identity fields", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { documentId: "doc-9" }, + })); + const bundle = memoryAdd(toolEnv({ memoryFetch: fetchMock })); + expect(bundle.definitions.map((d) => d.name)).toEqual(["memory_add"]); + const result = await bundle.run( + { + id: "call-1", + name: "memory_add", + arguments: { title: "note", text: "hello" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(result.content).toBe(JSON.stringify({ documentId: "doc-9" })); + const body = JSON.parse(calls[0]!.body ?? "{}") as Record; + expect(body).not.toHaveProperty("tenantId"); + expect(body).not.toHaveProperty("principalId"); + expect(body).not.toHaveProperty("tenant_id"); + expect(body).not.toHaveProperty("principal_id"); + }); + + test("strips adversarial identity args from wire body", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { documentId: "doc-x" }, + })); + const bundle = memoryAdd(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-adv", + name: "memory_add", + arguments: { + title: "note", + text: "hello", + tenantId: "evil-tenant", + principalId: "evil-principal", + tenant_id: "evil", + principal_id: "evil", + }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + const body = JSON.parse(calls[0]!.body ?? "{}") as Record; + expect(body).toEqual({ title: "note", text: "hello" }); + expect(calls[0]!.url).toContain(`/tenants/${TENANT}/`); + }); + + test("rebuilds share without nested extras", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { documentId: "doc-share" }, + })); + const bundle = memoryAdd(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-share", + name: "memory_add", + arguments: { + title: "note", + text: "hello", + share: { + tenant: true, + principalId: "nested-evil", + authToken: "should-not-wire", + tags: ["team:eng"], + }, + }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + const body = JSON.parse(calls[0]!.body ?? "{}") as { + share?: Record; + }; + expect(body.share).toEqual({ tenant: true, tags: ["team:eng"] }); + expect(body.share).not.toHaveProperty("principalId"); + expect(body.share).not.toHaveProperty("authToken"); + }); + + test("rejects empty title", async () => { + const { fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { documentId: "x" }, + })); + const bundle = memoryAdd(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-bad", + name: "memory_add", + arguments: { title: "", text: "body" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + }); +}); + +describe("memorySearch factory", () => { + test("POSTs search with query only", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { items: [], evidence: "none" }, + })); + const bundle = memorySearch(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-2", + name: "memory_search", + arguments: { query: "standup notes" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(calls[0]!.url).toBe( + `${BASE}/api/tenants/${TENANT}/memory/search`, + ); + expect(JSON.parse(calls[0]!.body ?? "{}")).toEqual({ + query: "standup notes", + }); + }); + + test("coerces string limit and rejects out-of-range", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { items: [] }, + })); + const bundle = memorySearch(toolEnv({ memoryFetch: fetchMock })); + const ok = await bundle.run( + { + id: "call-lim", + name: "memory_search", + arguments: { query: "q", limit: "5" }, + }, + new AbortController().signal, + ); + expect(ok.isError).toBeFalsy(); + expect(JSON.parse(calls[0]!.body ?? "{}")).toEqual({ + query: "q", + limit: 5, + }); + + const bad = await bundle.run( + { + id: "call-lim-bad", + name: "memory_search", + arguments: { query: "q", limit: 99 }, + }, + new AbortController().signal, + ); + expect(bad.isError).toBe(true); + }); + + test("strips adversarial identity args from search wire body", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { items: [] }, + })); + const bundle = memorySearch(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-search-adv", + name: "memory_search", + arguments: { + query: "q", + tenantId: "evil-tenant", + principalId: "evil-principal", + authToken: "nope", + }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + const body = JSON.parse(calls[0]!.body ?? "{}") as Record; + expect(body).toEqual({ query: "q" }); + expect(calls[0]!.url).toContain(`/tenants/${TENANT}/`); + }); +}); + +describe("memoryList factory", () => { + test("GETs list without identity in query", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { + events: [ + { + at: "2026-01-01", + title: "a", + source: "local", + tenantId: TENANT, + principalId: "p", + }, + ], + }, + })); + const bundle = memoryList(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-3", + name: "memory_list", + arguments: { limit: 10 }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(calls[0]!.url).toContain("/memory/list?limit=10"); + expect(calls[0]!.url).not.toContain("principal"); + }); + + test("ignores adversarial identity args on list", async () => { + const { calls, fetchMock } = makeFetchMock(() => ({ + status: 200, + json: { events: [] }, + })); + const bundle = memoryList(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { + id: "call-list-adv", + name: "memory_list", + arguments: { + limit: 3, + tenantId: "evil", + principalId: "evil", + }, + }, + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + expect(calls[0]!.url).toBe( + `${BASE}/api/tenants/${TENANT}/memory/list?limit=3`, + ); + expect(calls[0]!.url).not.toContain("evil"); + }); + + test("HTTP error becomes isError tool result", async () => { + const { fetchMock } = makeFetchMock(() => ({ + status: 401, + json: { error: "unauthorized" }, + })); + const bundle = memoryList(toolEnv({ memoryFetch: fetchMock })); + const result = await bundle.run( + { id: "call-4", name: "memory_list", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + }); +});