Files
arcadia-admin/app/lib/admin-tools.ts
jules a286b9cdce aifirst: lift context/agents/tools runtime to lib-aifirst-ui
The mechanism (context surface registry, persona storage + hooks, tool
parser/dispatcher) is now generic and lives in @crema/aifirst-ui/{context,
agents,tools}. This template keeps only the arcadia-shaped configuration:

- agents.ts — owns DEFAULT_AGENTS + legacy/retired migration sets, calls
  configureAgents() at module load, re-exports the runtime
- admin-tools.ts — keeps the 19 arcadia tool definitions, binds the
  runtime via createToolRuntime(TOOLS), re-exports the bound functions
- admin-context.ts — deleted; 18 routes now import directly from
  @crema/aifirst-ui/context

Routes that import from ~/lib/agents and ~/lib/admin-tools are unchanged
(wrapper modules preserve the existing import surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:18:48 +10:00

935 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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-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-app + 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<string, string | undefined> })
.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<Response> {
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<unknown> {
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<ToolCtx>[] = [
{
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<string, number> = {}
for (const t of tenants) {
byStatus[t.status] = (byStatus[t.status] ?? 0) + 1
}
const byPlan: Record<string, number> = {}
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<string, string> = {}
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-app 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-app 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-app.\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 secondsminutes 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"