The Phoenix auth/identity/tenancy backend repo is being renamed arcadia-app → arcadia-core (its primary OTP app is already arcadia_core). Updates prose, doc paths, and git.sky-ai.com repo URLs. Deliberately leaves the Rust crate arcadia-app-client and host arcadia-app.internal (handled separately), and the kept namespace (issuer/release "arcadia"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
935 lines
34 KiB
TypeScript
935 lines
34 KiB
TypeScript
// 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<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-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"
|