// Curated tool surface the assistant can call. The LLM emits a fenced // ```tool block with one JSON object per line; we parse, execute via // arcadia-client, and feed results back as the next user turn. // // Each tool is a named function with documented args. The LLM never sees // raw HTTP — only the menu below. import type { ArcadiaClient } from "@crema/arcadia-core-client" import { createToolRuntime, type ToolDef, } from "@crema/aifirst-ui/tools" import { activateTenant, deactivateTenant, getTenant, listTenants, suspendTenant, type Tenant, } from "~/lib/arcadia/tenants" import { assignRole, createUser, deleteUser, removeRole, setUserStatus, updateUser, type UserStatus, } from "~/lib/arcadia/users" import { listMemberships } from "~/lib/arcadia/memberships" import { listRoles } from "~/lib/arcadia/roles" import { revokeUserApiKey } from "~/lib/arcadia/api-keys" import { createRAGClient } from "@crema/lexical-rag-ui" import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas" import { searchAdmin, SearchAdminError } from "~/lib/search-admin" // Lazy singleton — first tool call fetches /docs-index.json, subsequent // calls reuse the parsed MiniSearch instance. const docsClient = createRAGClient("/docs-index.json") // Server-side Tantivy backend (arcadia-search). // // URL: comes from window.__ARCADIA_SEARCH_URL (override hook) or // VITE_ARCADIA_SEARCH_URL build-time env, defaulting to localhost. // // Token (resolution order): // 1. window.__ARCADIA_SEARCH_TOKEN — runtime override hook for tests/devtools. // 2. VITE_ARCADIA_SEARCH_TOKEN — build-time service-principal token. // Required when arcadia-search runs in AUTH_MODE=jwt and arcadia-admin // talks to a remote arcadia whose JWT signing secret arcadia-search // doesn't share. Issue this once from arcadia-admin's service-principal // tooling and wire it through `.env.local`. // 3. operator session JWT — works only when arcadia-search shares the // JWT signing secret with the arcadia issuing the operator's session // (i.e. local arcadia-core + local arcadia-search with matching keys). // 4. "dev" literal — only accepted by AUTH_MODE=dev backends. function readEnv(key: string): string | undefined { if (typeof import.meta === "undefined") return undefined return (import.meta as unknown as { env?: Record }) .env?.[key] } const KB_BASE_URL: string = (typeof window !== "undefined" && (window as unknown as { __ARCADIA_SEARCH_URL?: string }).__ARCADIA_SEARCH_URL) || readEnv("VITE_ARCADIA_SEARCH_URL") || "http://127.0.0.1:7800" const KB_SERVICE_TOKEN: string | undefined = readEnv("VITE_ARCADIA_SEARCH_TOKEN") type TokenSource = "override" | "service" | "session" | "dev" function kbAuthToken(): { token: string; source: TokenSource } { if (typeof window !== "undefined") { const override = (window as unknown as { __ARCADIA_SEARCH_TOKEN?: string }) .__ARCADIA_SEARCH_TOKEN if (override) return { token: override, source: "override" } } if (KB_SERVICE_TOKEN) return { token: KB_SERVICE_TOKEN, source: "service" } if (typeof window === "undefined") return { token: "dev", source: "dev" } try { const stored = window.sessionStorage.getItem("arcadia_access_token") if (stored) return { token: stored, source: "session" } } catch { // fall through } return { token: "dev", source: "dev" } } // True when the operator's session JWT was minted by an arcadia other than // the one hosting search — i.e. signing keys almost certainly don't match // and a session-token fallback will 401 silently. We treat any non-localhost // arcadia URL as "remote" for this heuristic. function isRemoteArcadia(): boolean { const url = readEnv("VITE_ARCADIA_URL") ?? "" if (!url) return false return !/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(url) } function kbAuthHint(source: TokenSource): string { if (source === "service" || source === "override") { return "VITE_ARCADIA_SEARCH_TOKEN was rejected — verify it's signed with the secret arcadia-search expects (JWT_HMAC_SECRET) and hasn't expired." } if (source === "session" && isRemoteArcadia()) { return "Set VITE_ARCADIA_SEARCH_TOKEN in arcadia-admin/.env.local to a service-principal JWT signed with arcadia-search's JWT_HMAC_SECRET. The operator session JWT (from a remote arcadia) won't validate against a locally-keyed arcadia-search." } if (source === "session") { return "Operator session JWT was rejected — arcadia-search's JWT_HMAC_SECRET likely doesn't match the arcadia that issued the session. Either align secrets or set VITE_ARCADIA_SEARCH_TOKEN." } return "arcadia-search rejected the dev fallback — it's running in AUTH_MODE=jwt. Set VITE_ARCADIA_SEARCH_TOKEN or restart arcadia-search with AUTH_MODE=dev for local testing." } async function kbFetch(input: string, init?: RequestInit): Promise { const { token, source } = kbAuthToken() const res = await fetch(input, { ...init, headers: { ...(init?.headers ?? {}), Authorization: `Bearer ${token}`, }, }) if (res.status === 401 || res.status === 403) { throw new Error( `arcadia-search rejected the request (${res.status}). ${kbAuthHint(source)}`, ) } return res } type KBHit = { chunk_id: string title: string source_path: string heading_path: string tags: string[] snippet: string score: number mtime: string } async function kbSearch( query: string, corpus: string, limit: number, tags?: string[], ): Promise<{ count: number; hits: KBHit[] }> { const res = await kbFetch(`${KB_BASE_URL}/search`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, corpus, limit, tags }), }) if (!res.ok) { throw new Error(`arcadia-search ${res.status}: ${await res.text()}`) } return (await res.json()) as { count: number; hits: KBHit[] } } async function kbRead(chunkId: string, corpus: string): Promise { const url = `${KB_BASE_URL}/chunks/${encodeURIComponent(chunkId)}?corpus=${encodeURIComponent(corpus)}` const res = await kbFetch(url) if (res.status === 404) return null if (!res.ok) { throw new Error(`arcadia-search ${res.status}: ${await res.text()}`) } return await res.json() } type ToolCtx = { arcadia: ArcadiaClient } const TOOLS: ToolDef[] = [ { name: "list_tenants", description: "List every tenant on this arcadia deployment. Returns id, slug, name, status, plan, inserted_at. Call this for any question about tenant counts, statuses, or which tenants exist.", parameters: { type: "object", properties: {}, additionalProperties: false, }, isWrite: false, run: async (_args, { arcadia }) => { const tenants = await listTenants(arcadia) return tenants.map(summarize) }, }, { name: "get_tenant", description: "Fetch a single tenant by slug (preferred) or id. Returns the tenant summary or null if not found.", parameters: { type: "object", properties: { slug: { type: "string", description: "The tenant's slug (e.g. 'acme', 'platform-admin')." }, id: { type: "string", description: "The tenant's UUID. Use only when the slug is unknown." }, }, additionalProperties: false, }, isWrite: false, run: async (args, { arcadia }) => { const slug = typeof args.slug === "string" ? args.slug : null const id = typeof args.id === "string" ? args.id : null if (!slug && !id) throw new Error("get_tenant requires { slug } or { id }") if (id) { try { return summarize(await getTenant(arcadia, id)) } catch { return null } } const tenants = await listTenants(arcadia) const found = tenants.find((t) => t.slug === slug) return found ? summarize(found) : null }, }, { name: "get_platform_stats", description: "Aggregate platform stats. Returns total tenant count and a breakdown by status (active / suspended / deactivated / etc). Call this for big-picture questions like 'how is the platform doing'.", parameters: { type: "object", properties: {}, additionalProperties: false, }, isWrite: false, run: async (_args, { arcadia }) => { const tenants = await listTenants(arcadia) const byStatus: Record = {} for (const t of tenants) { byStatus[t.status] = (byStatus[t.status] ?? 0) + 1 } const byPlan: Record = {} for (const t of tenants) { const plan = t.plan?.name ?? "(no plan)" byPlan[plan] = (byPlan[plan] ?? 0) + 1 } return { tenants_total: tenants.length, tenants_by_status: byStatus, tenants_by_plan: byPlan, } }, }, { name: "list_audit_log", description: "Recent audit log entries from the platform. Returns a terse summary per entry: actor_type, actor_id, action, target, inserted_at. Call this for 'who did what' questions or to investigate a recent change.", parameters: { type: "object", properties: { limit: { type: "integer", description: "Max entries to return (default 25, max 100).", minimum: 1, maximum: 100, }, }, additionalProperties: false, }, isWrite: false, run: async (args, { arcadia }) => { const limit = typeof args.limit === "number" ? Math.min(100, Math.max(1, args.limit)) : 25 const res = await arcadia.GET<{ data: AuditEntry[] }>( `/api/v1/admin/audit-log?limit=${limit}`, ) const entries = res.data ?? [] return entries.map((e) => ({ actor_type: e.actor_type, actor_id: e.actor_id ?? null, action: e.action, target: e.target_type ? `${e.target_type}#${e.target_id ?? "?"}` : null, inserted_at: e.inserted_at, })) }, }, { name: "list_users", description: "List users in the currently-selected tenant context. Returns email, name, roles, status. Note: this is scoped to whichever tenant the assistant is currently logged into; use get_tenant first if you need to confirm which tenant.", parameters: { type: "object", properties: { limit: { type: "integer", description: "Max users to return (default 50, max 200).", minimum: 1, maximum: 200, }, }, additionalProperties: false, }, isWrite: false, run: async (args, { arcadia }) => { const limit = typeof args.limit === "number" ? Math.min(200, Math.max(1, args.limit)) : 50 const res = await arcadia.GET<{ data: UserEntry[] }>(`/api/v1/users?per_page=${limit}`) const users = res.data ?? [] return users.map((u) => ({ id: u.id, email: u.email, name: [u.first_name, u.last_name].filter(Boolean).join(" ") || null, roles: u.roles?.map((r) => r.slug ?? r.name).filter(Boolean) ?? [], verified: u.email_verified ?? null, inserted_at: u.inserted_at, })) }, }, { name: "suspend_tenant", description: "Suspend a tenant by slug. Members can no longer sign in until reactivated. Use for temporary holds (overdue invoice, abuse investigation). REVERSIBLE via activate_tenant. Requires user confirmation before executing.", parameters: { type: "object", properties: { slug: { type: "string", description: "The tenant's slug." }, }, required: ["slug"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const slug = typeof args.slug === "string" ? args.slug : null if (!slug) throw new Error("suspend_tenant requires { slug }") const tenants = await listTenants(arcadia) const target = tenants.find((t) => t.slug === slug) if (!target) throw new Error(`No tenant with slug "${slug}"`) const updated = await suspendTenant(arcadia, target.id) return summarize(updated) }, }, { name: "activate_tenant", description: "Re-activate a previously suspended (or deactivated) tenant by slug. Restores member sign-in. Requires user confirmation before executing.", parameters: { type: "object", properties: { slug: { type: "string", description: "The tenant's slug." }, }, required: ["slug"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const slug = typeof args.slug === "string" ? args.slug : null if (!slug) throw new Error("activate_tenant requires { slug }") const tenants = await listTenants(arcadia) const target = tenants.find((t) => t.slug === slug) if (!target) throw new Error(`No tenant with slug "${slug}"`) const updated = await activateTenant(arcadia, target.id) return summarize(updated) }, }, { name: "deactivate_tenant", description: "Permanently deactivate a tenant by slug. Stronger than suspend — also revokes API keys and disables billing. Reversible only via activate_tenant. Use when a tenant is closing the account, not for short-term holds. Requires user confirmation before executing.", parameters: { type: "object", properties: { slug: { type: "string", description: "The tenant's slug." }, }, required: ["slug"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const slug = typeof args.slug === "string" ? args.slug : null if (!slug) throw new Error("deactivate_tenant requires { slug }") const tenants = await listTenants(arcadia) const target = tenants.find((t) => t.slug === slug) if (!target) throw new Error(`No tenant with slug "${slug}"`) const updated = await deactivateTenant(arcadia, target.id) return summarize(updated) }, }, { name: "set_user_status", description: "Change a user's status to active, inactive, or suspended. Suspended users cannot sign in; inactive users are hidden from default lists but retain their data. Pass the user's id (UUID). Requires user confirmation before executing.", parameters: { type: "object", properties: { user_id: { type: "string", description: "User UUID." }, status: { type: "string", enum: ["active", "inactive", "suspended"], description: "Target status.", }, }, required: ["user_id", "status"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const userId = typeof args.user_id === "string" ? args.user_id : null const status = typeof args.status === "string" ? (args.status as UserStatus) : null if (!userId || !status) throw new Error("set_user_status requires { user_id, status }") const updated = await setUserStatus(arcadia, userId, status) return { id: updated.id, email: updated.email, status: updated.status, full_name: updated.full_name, } }, }, { name: "delete_user", description: "Permanently delete a user by id. Cascades to their memberships and API keys. NOT reversible — prefer set_user_status with 'inactive' or 'suspended' unless the user explicitly asks for permanent deletion. Requires user confirmation before executing.", parameters: { type: "object", properties: { user_id: { type: "string", description: "User UUID." }, }, required: ["user_id"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const userId = typeof args.user_id === "string" ? args.user_id : null if (!userId) throw new Error("delete_user requires { user_id }") await deleteUser(arcadia, userId) return { id: userId, deleted: true } }, }, { name: "list_memberships", description: "List user-to-tenant memberships. Returns user/tenant pairs with role assignments and primary-membership flag. Filter by tenant_slug to answer 'who's in tenant X', or by user_id to answer 'which tenants does user Y belong to'.", parameters: { type: "object", properties: { tenant_slug: { type: "string", description: "Optional: filter to a single tenant by slug.", }, user_id: { type: "string", description: "Optional: filter to a single user by UUID.", }, }, additionalProperties: false, }, isWrite: false, run: async (args, { arcadia }) => { const slug = typeof args.tenant_slug === "string" ? args.tenant_slug : null const userId = typeof args.user_id === "string" ? args.user_id : null const all = await listMemberships(arcadia) const filtered = all.filter((m) => { if (slug && m.tenant?.slug !== slug) return false if (userId && m.user?.id !== userId) return false return true }) return filtered.map((m) => ({ id: m.id, tenant: m.tenant ? { slug: m.tenant.slug, name: m.tenant.name } : null, user: m.user ? { id: m.user.id, email: m.user.email, name: [m.user.first_name, m.user.last_name].filter(Boolean).join(" ") || null, } : null, status: m.status, is_primary: m.is_primary, roles: m.roles.map((r) => r.slug), joined_at: m.joined_at, })) }, }, { name: "list_roles", description: "List every role defined in the current tenant. Returns slug, name, description, permission set, and is_system flag. Use to answer 'what roles are available' or before assigning a role.", parameters: { type: "object", properties: {}, additionalProperties: false, }, isWrite: false, run: async (_args, { arcadia }) => { const roles = await listRoles(arcadia) return roles.map((r) => ({ id: r.id, slug: r.slug, name: r.name, description: r.description, permissions: r.permissions, is_system: r.is_system, })) }, }, { name: "create_user", description: "Create a new user in the current tenant. Pass email (required) plus optional first_name, last_name, status, password, and role_ids. If password is omitted the user must set one via the password-reset flow. Requires user confirmation before executing.", parameters: { type: "object", properties: { email: { type: "string", description: "User email address." }, first_name: { type: "string" }, last_name: { type: "string" }, status: { type: "string", enum: ["active", "inactive", "suspended"], }, password: { type: "string", description: "Optional initial password. Omit to require the user to use the password-reset flow.", }, role_ids: { type: "array", items: { type: "string" }, description: "Optional UUIDs of roles to assign on creation.", }, }, required: ["email"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const email = typeof args.email === "string" ? args.email : null if (!email) throw new Error("create_user requires { email }") const created = await createUser(arcadia, { email, first_name: typeof args.first_name === "string" ? args.first_name : undefined, last_name: typeof args.last_name === "string" ? args.last_name : undefined, status: typeof args.status === "string" ? (args.status as UserStatus) : undefined, password: typeof args.password === "string" ? args.password : undefined, role_ids: Array.isArray(args.role_ids) ? (args.role_ids.filter((r) => typeof r === "string") as string[]) : undefined, }) return { id: created.id, email: created.email, full_name: created.full_name, status: created.status, roles: created.roles.map((r) => r.slug), } }, }, { name: "update_user", description: "Update a user's name or email by id. For status changes use set_user_status; for role assignment use assign_role/remove_role. Requires user confirmation before executing.", parameters: { type: "object", properties: { user_id: { type: "string", description: "User UUID." }, email: { type: "string" }, first_name: { type: "string" }, last_name: { type: "string" }, }, required: ["user_id"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const userId = typeof args.user_id === "string" ? args.user_id : null if (!userId) throw new Error("update_user requires { user_id }") const patch: Record = {} if (typeof args.email === "string") patch.email = args.email if (typeof args.first_name === "string") patch.first_name = args.first_name if (typeof args.last_name === "string") patch.last_name = args.last_name if (Object.keys(patch).length === 0) throw new Error("update_user needs at least one field to change") const updated = await updateUser(arcadia, userId, patch) return { id: updated.id, email: updated.email, full_name: updated.full_name, status: updated.status, } }, }, { name: "assign_role", description: "Grant a role to a user by user_id and role_id. Idempotent — re-granting an existing role is a no-op. Use list_roles first to find the role's id. Requires user confirmation before executing.", parameters: { type: "object", properties: { user_id: { type: "string", description: "User UUID." }, role_id: { type: "string", description: "Role UUID." }, }, required: ["user_id", "role_id"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const userId = typeof args.user_id === "string" ? args.user_id : null const roleId = typeof args.role_id === "string" ? args.role_id : null if (!userId || !roleId) throw new Error("assign_role requires { user_id, role_id }") const updated = await assignRole(arcadia, userId, roleId) return { id: updated.id, email: updated.email, roles: updated.roles.map((r) => r.slug), } }, }, { name: "remove_role", description: "Revoke a role from a user by user_id and role_id. Idempotent — removing a role the user doesn't have is a no-op. Requires user confirmation before executing.", parameters: { type: "object", properties: { user_id: { type: "string", description: "User UUID." }, role_id: { type: "string", description: "Role UUID." }, }, required: ["user_id", "role_id"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const userId = typeof args.user_id === "string" ? args.user_id : null const roleId = typeof args.role_id === "string" ? args.role_id : null if (!userId || !roleId) throw new Error("remove_role requires { user_id, role_id }") const updated = await removeRole(arcadia, userId, roleId) return { id: updated.id, email: updated.email, roles: updated.roles.map((r) => r.slug), } }, }, { name: "revoke_api_key", description: "Revoke a user's API key by id. The key stops working immediately and cannot be un-revoked — the user must mint a new one. Use for compromised keys or offboarding. Requires user confirmation before executing.", parameters: { type: "object", properties: { user_id: { type: "string", description: "Owner user UUID." }, key_id: { type: "string", description: "API key UUID." }, reason: { type: "string", description: "Optional audit-log reason for the revocation.", }, }, required: ["user_id", "key_id"], additionalProperties: false, }, isWrite: true, run: async (args, { arcadia }) => { const userId = typeof args.user_id === "string" ? args.user_id : null const keyId = typeof args.key_id === "string" ? args.key_id : null const reason = typeof args.reason === "string" ? args.reason : undefined if (!userId || !keyId) throw new Error("revoke_api_key requires { user_id, key_id }") await revokeUserApiKey(arcadia, userId, keyId, reason) return { user_id: userId, key_id: keyId, revoked: true } }, }, { name: "search_docs", description: "Search the arcadia-core documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.", parameters: { type: "object", properties: { query: { type: "string", description: "Lexical search query. Use specific terms from the docs (endpoint names, schema fields, concept names) — paraphrase poorly.", }, limit: { type: "integer", description: "Max passages to return. Default 5, cap 10.", minimum: 1, maximum: 10, }, }, required: ["query"], additionalProperties: false, }, isWrite: false, run: async (args) => { const query = typeof args.query === "string" ? args.query.trim() : "" if (!query) throw new Error("search_docs requires a non-empty { query }") const limit = Math.min( 10, Math.max(1, typeof args.limit === "number" ? args.limit : 5), ) const hits = await docsClient.search(query, { limit }) // Tool-shape parity with the previous searchDocs() return: collapse // tags[] back to category for now so the agent's prior expectations // and any cached examples still parse cleanly. return { query, count: hits.length, hits: hits.map((h) => ({ id: h.id, title: h.title, sourcePath: h.sourcePath, category: h.tags[0] ?? "", excerpt: h.excerpt, score: h.score, })), } }, }, { name: "search_kb", description: "Lexical (BM25) search over the arcadia-search Tantivy backend. Returns chunks with snippets + chunk_ids that can be passed to `read_chunk` to expand. Prefer this over `search_docs` (browser) when you need richer hits or when the content wouldn't be in the bundled docs.\n\nKnown corpora on the platform-admin tenant:\n- `docs` — arcadia-core architecture/ops docs (same as the browser RAG, server-hosted for parity).\n- `operator-tools` — arcadia-search + arcadia-admin documentation (admin sidecar, deploy script, search admin UI, MULTI_TENANT, RAG, AI_FIRST, LIBS, LLM_PROXY_CONTRACT).\n- `files` — markdown/text files uploaded by tenant users via arcadia-core.\n\nIf you're not sure what's available, call `list_search_corpora` first. Operators can add new corpora via the `/search` route.", parameters: { type: "object", properties: { query: { type: "string", description: "Lexical search query." }, corpus: { type: "string", description: "Which indexed corpus to search. See list_search_corpora for the live set; common values: `docs`, `operator-tools`, `files`.", }, limit: { type: "integer", description: "Max hits. Default 5, cap 20.", minimum: 1, maximum: 20, }, tags: { type: "array", items: { type: "string" }, description: "Optional tag filter — return only hits whose chunk has at least one matching tag.", }, }, required: ["query", "corpus"], additionalProperties: false, }, isWrite: false, run: async (args) => { const query = typeof args.query === "string" ? args.query.trim() : "" const corpus = typeof args.corpus === "string" ? args.corpus.trim() : "" if (!query) throw new Error("search_kb requires a non-empty { query }") if (!corpus) throw new Error("search_kb requires a { corpus } name") const limit = Math.min(20, Math.max(1, typeof args.limit === "number" ? args.limit : 5)) const tags = Array.isArray(args.tags) ? (args.tags as string[]) : undefined return await kbSearch(query, corpus, limit, tags) }, }, { name: "read_chunk", description: "Fetch the full body of one chunk by id from the arcadia-search backend, after `search_kb` returned it as a snippet. Use this to expand a hit when the snippet looked promising but you need more context to answer.", parameters: { type: "object", properties: { chunk_id: { type: "string", description: "The chunk_id from a prior search_kb hit." }, corpus: { type: "string", description: "Same corpus the chunk came from." }, }, required: ["chunk_id", "corpus"], additionalProperties: false, }, isWrite: false, run: async (args) => { const chunkId = typeof args.chunk_id === "string" ? args.chunk_id : "" const corpus = typeof args.corpus === "string" ? args.corpus : "" if (!chunkId || !corpus) { throw new Error("read_chunk requires { chunk_id, corpus }") } const result = await kbRead(chunkId, corpus) if (result === null) { return { error: "chunk not found", chunk_id: chunkId, corpus } } return result }, }, { name: "list_search_corpora", description: "Enumerate the corpora currently configured on the arcadia-search admin sidecar. Returns each tenant's corpora with build status (indexed?, num_docs). Call this when you don't know what corpora exist before invoking `search_kb`, or when the user asks what knowledge is available. Requires the search admin token to be configured.", parameters: { type: "object", properties: {}, additionalProperties: false, }, isWrite: false, run: async () => { try { const tenantsRes = await searchAdmin.listTenants() const tenants = await Promise.all( tenantsRes.tenants.map(async (t) => { try { const c = await searchAdmin.listCorpora(t.id) return { tenant: t.id, corpora: c.corpora.map((cc) => ({ corpus: cc.corpus, indexed: cc.indexed, num_docs: cc.num_docs, })), } } catch { return { tenant: t.id, corpora: [] } } }), ) return { tenants } } catch (err) { if (err instanceof SearchAdminError) { return { error: `search-admin ${err.status}: ${err.message}`, hint: "VITE_ARCADIA_SEARCH_ADMIN_TOKEN may be unset, or the sidecar (default :7801) may be down.", } } throw err } }, }, { name: "rebuild_search_corpus", description: "Trigger a synchronous rebuild of one corpus on arcadia-search. Use when the operator says the index is stale, after they've uploaded new files, or when search_kb returned suspiciously few/old hits. Returns chunk_count and built_at on success. The operator confirms before the rebuild runs (rebuilds can take seconds–minutes depending on corpus size).", parameters: { type: "object", properties: { tenant: { type: "string", description: "Search tenant id (e.g. `platform-admin`). See list_search_corpora for available tenants.", }, corpus: { type: "string", description: "Corpus name within that tenant (e.g. `docs`, `operator-tools`, `files`).", }, }, required: ["tenant", "corpus"], additionalProperties: false, }, isWrite: true, run: async (args) => { const tenant = typeof args.tenant === "string" ? args.tenant.trim() : "" const corpus = typeof args.corpus === "string" ? args.corpus.trim() : "" if (!tenant || !corpus) { throw new Error("rebuild_search_corpus requires { tenant, corpus }") } try { return await searchAdmin.rebuild(tenant, corpus) } catch (err) { if (err instanceof SearchAdminError) { return { error: `search-admin ${err.status}: ${err.message}` } } throw err } }, }, { name: "get_block_schema", description: `Fetch the full JSON schema + example for a rich-output block kind so you can emit it correctly in your reply. Call this the first time in a thread that you intend to render a particular kind. Available kinds: ${Object.entries( BLOCK_INDEX, ) .map(([k, v]) => `${k} (${v})`) .join(", ")}.`, parameters: { type: "object", properties: { kind: { type: "string", description: "The block kind to fetch the schema for.", enum: Object.keys(BLOCK_INDEX), }, }, required: ["kind"], additionalProperties: false, }, isWrite: false, run: async (args) => { const kind = typeof args.kind === "string" ? args.kind : "" const schema = getBlockSchema(kind) if (!schema) { return { error: `Unknown block kind "${kind}". Available: ${Object.keys(BLOCK_INDEX).join(", ")}.`, } } return { kind, schema } }, }, ] interface AuditEntry { actor_type: string actor_id?: string action: string target_type?: string target_id?: string inserted_at: string } interface UserEntry { id: string email: string first_name?: string last_name?: string email_verified?: boolean inserted_at: string roles?: { slug?: string; name?: string }[] } function summarize(t: Tenant) { return { id: t.id, slug: t.slug, name: t.name, status: t.status, plan: t.plan?.name ?? null, inserted_at: t.inserted_at, } } const runtime = createToolRuntime(TOOLS) export const getOpenAITools = runtime.getOpenAITools export const classifyCalls = runtime.classifyCalls export const runLLMToolCalls = runtime.runLLMToolCalls export { buildDenialMessages, formatToolCallArgs, type ToolCall, type ToolResult, } from "@crema/aifirst-ui/tools"