// 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 type { Tool, ToolCall as LLMToolCall } from "@crema/llm-ui" import { activateTenant, getTenant, listTenants, suspendTenant, type Tenant, } from "~/lib/arcadia/tenants" import { createRAGClient } from "@crema/lexical-rag-ui" import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas" // Lazy singleton — first tool call fetches /docs-index.json, subsequent // calls reuse the parsed MiniSearch instance. const docsClient = createRAGClient("/docs-index.json") export type ToolCall = { name: string args: Record } export type ToolResult = { name: string args: Record ok: boolean data?: unknown error?: string } type ToolDef = { name: string description: string parameters: Record // JSON Schema for OpenAI tool calling isWrite: boolean run: (args: Record, ctx: ToolCtx) => Promise } 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: "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: "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 }[] } /** OpenAI-format tool list to pass into ChatRequest.tools. */ export function getOpenAITools(): Tool[] { return TOOLS.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters, })) } /** Split an LLM tool-call list into reads (run automatically) and writes * (held for user confirmation). Unknown tools fall into reads so the runner * can surface a structured "unknown tool" error to the model. */ export function classifyCalls(calls: LLMToolCall[]): { reads: LLMToolCall[] writes: LLMToolCall[] } { const reads: LLMToolCall[] = [] const writes: LLMToolCall[] = [] for (const c of calls) { const def = TOOL_BY_NAME.get(c.name) if (def?.isWrite) writes.push(c) else reads.push(c) } return { reads, writes } } /** Synthesise tool-result messages saying the user denied a write call. */ export function buildDenialMessages( calls: LLMToolCall[], ): { role: "tool"; content: string; toolCallId: string; name: string }[] { return calls.map((c) => ({ role: "tool", content: JSON.stringify({ error: "User denied this write. Do not retry without re-asking the user.", }), toolCallId: c.id, name: c.name, })) } /** Pretty-print args for the confirm UI. */ export function formatToolCallArgs(c: LLMToolCall): string { try { const parsed = c.arguments ? JSON.parse(c.arguments) : {} const keys = Object.keys(parsed) if (keys.length === 0) return "" return keys.map((k) => `${k}=${JSON.stringify(parsed[k])}`).join(", ") } catch { return c.arguments } } 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 TOOL_BY_NAME = new Map(TOOLS.map((t) => [t.name, t])) function safeJson(value: unknown): string { try { const text = JSON.stringify(value, null, 2) if (text.length > 6000) return text.slice(0, 6000) + "\n…(truncated)" return text } catch { return "(unserializable)" } } /** Run a list of provider-native tool calls and return `tool` role messages * ready to push back into useChat history. */ export async function runLLMToolCalls( calls: LLMToolCall[], ctx: ToolCtx, opts: { allowWrites?: boolean } = {}, ): Promise<{ results: ToolResult[] toolMessages: { role: "tool"; content: string; toolCallId: string; name: string }[] }> { const results: ToolResult[] = [] const toolMessages: { role: "tool"; content: string; toolCallId: string; name: string }[] = [] for (const call of calls) { const def = TOOL_BY_NAME.get(call.name) let parsed: Record = {} try { parsed = call.arguments ? (JSON.parse(call.arguments) as Record) : {} } catch { const err = `Could not parse arguments JSON: ${call.arguments}` results.push({ name: call.name, args: {}, ok: false, error: err }) toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name }) continue } if (!def) { const err = `Unknown tool: ${call.name}` results.push({ name: call.name, args: parsed, ok: false, error: err }) toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name }) continue } if (def.isWrite && !opts.allowWrites) { const err = "Write tools require user confirmation." results.push({ name: call.name, args: parsed, ok: false, error: err }) toolMessages.push({ role: "tool", content: JSON.stringify({ error: err }), toolCallId: call.id, name: call.name }) continue } try { const data = await def.run(parsed, ctx) results.push({ name: call.name, args: parsed, ok: true, data }) toolMessages.push({ role: "tool", content: safeJson(data), toolCallId: call.id, name: call.name }) } catch (err) { const msg = err instanceof Error ? err.message : String(err) results.push({ name: call.name, args: parsed, ok: false, error: msg }) toolMessages.push({ role: "tool", content: JSON.stringify({ error: msg }), toolCallId: call.id, name: call.name }) } } return { results, toolMessages } }