From fe93f2766c1c85fa7395f3eedb8d57454b66a2d2 Mon Sep 17 00:00:00 2001 From: jules Date: Fri, 1 May 2026 20:08:47 +1000 Subject: [PATCH] Wire AI assistant to arcadia: domain primer, tool calling, admin context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make /ai and /assistant operate as the platform admin's assistant against arcadia-app's API: - Add `arcadia-knowledge.ts` — domain primer (multi-tenant Phoenix backend, tenant lifecycle, platform_admins identity, etc.) baked into every system prompt. - Add `admin-tools.ts` — curated tool registry exposing `list_tenants` and `get_tenant`, callable via OpenAI-native function calling. Tools hit arcadia through `useArcadiaClient()` and inherit the operator's JWT + tenant header. `runLLMToolCalls()` returns `tool` role messages ready to push back into history. - Add `admin-context.ts` — runtime registry pages publish to so the assistant can answer factual questions about live UI state without scraping the DOM. Tenants page registers its summary on mount. - Replace generic Vibespace personas (Atlas/Forge/Inkwell/Pilot/Cursor) with arcadia-flavoured ones: Operator, Auditor, Triage, Analyst, UI Operator. Auto-migrate stored agents from the legacy set. - /assistant: build admin preface (role + primer + persona + ctx) and pass it as the `useChat` system at construction. Pass `tools` on every `send()`. Auto-loop reads `toolCalls` off the streaming assistant message and uses `continueChat()` to push tool results. - /ai: same wiring (this is the canonical admin chat surface; the user prefers its look). - MessageBody renders tool-result cards (role: "tool") and a "Called X" pill on assistant messages with toolCalls. Strips Qwen-style `` XML from prose when the tags were converted to structured calls. - Extend ThreadMessage with the `tool` role + tool-call metadata so conversations round-trip through localStorage. - Tenants page: row actions get `data-action="tenant--{suspend, activate,deactivate}"` (via lib-table-ui's new dataAction prop); registers tenant summary into admin-context. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/components/assistant/message-body.tsx | 46 +++++- app/lib/admin-context.ts | 76 ++++++++++ app/lib/admin-tools.ts | 161 +++++++++++++++++++++ app/lib/agents.ts | 49 ++++--- app/lib/arcadia-knowledge.ts | 35 +++++ app/lib/threads.ts | 8 +- app/routes/ai.tsx | 86 +++++++++-- app/routes/assistant.tsx | 169 ++++++++++++++++------ app/routes/tenants.tsx | 29 +++- 9 files changed, 577 insertions(+), 82 deletions(-) create mode 100644 app/lib/admin-context.ts create mode 100644 app/lib/admin-tools.ts create mode 100644 app/lib/arcadia-knowledge.ts diff --git a/app/components/assistant/message-body.tsx b/app/components/assistant/message-body.tsx index 8e9540c..5003978 100644 --- a/app/components/assistant/message-body.tsx +++ b/app/components/assistant/message-body.tsx @@ -1,30 +1,53 @@ -// Renders an assistant message: markdown for prose, and a small "Ran N -// actions" pill in place of any ```action``` fenced blocks (which are the -// machine-readable instructions the bus has already executed). +// Renders an assistant message: markdown for prose, plus pills for any +// command-bus action blocks or native tool calls attached to the message. +// Tool-role messages (results from a function call) render as a JSON card. import { useMemo } from "react" import ReactMarkdown from "react-markdown" -import { Sparkles } from "lucide-react" +import { Sparkles, Wrench } from "lucide-react" import { extractActionBlocks } from "@crema/action-bus" +import { stripToolCallTags, type ToolCall } from "@crema/llm-ui" const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g -export function MessageBody({ content }: { content: string }) { +export type MessageBodyProps = { + content: string + /** When set, render as a tool-result card. */ + isToolResult?: boolean + /** Native tool calls attached to this assistant message, if any. */ + toolCalls?: ToolCall[] +} + +export function MessageBody({ content, isToolResult, toolCalls }: MessageBodyProps) { const { prose, actionCount } = useMemo(() => { const blocks = extractActionBlocks(content) + const cleaned = stripToolCallTags(content).replace(ACTION_BLOCK_RE, "").trim() return { - prose: content.replace(ACTION_BLOCK_RE, "").trim(), + prose: cleaned, actionCount: blocks.length, } }, [content]) + if (isToolResult) { + return ( +
+
+ + Tool result +
+
+          {content}
+        
+
+ ) + } + return (
{prose && (

{children}

, code: ({ children, className }) => { const isBlock = className?.startsWith("language-") @@ -63,6 +86,15 @@ export function MessageBody({ content }: { content: string }) { Ran {actionCount} action{actionCount > 1 ? "s" : ""} )} + {toolCalls && toolCalls.length > 0 && ( + + + Called {toolCalls.map((c) => c.name).join(", ")} + + )}
) } diff --git a/app/lib/admin-context.ts b/app/lib/admin-context.ts new file mode 100644 index 0000000..6489cba --- /dev/null +++ b/app/lib/admin-context.ts @@ -0,0 +1,76 @@ +// Shared state surface that any admin page can publish to so the assistant +// can read live data without scraping the DOM. +// +// Pages call `useRegisterAdminContext("tenants", { tenants: [...] })` while +// mounted; the assistant calls `getAdminContextSnapshot()` each turn to +// inject a structured snapshot into the system prompt. + +import { useEffect } from "react" + +type Surface = Record + +export type AdminContextSnapshot = { + route: string + surfaces: Record +} + +const surfaces = new Map() + +export function publishAdminSurface(name: string, data: Surface): void { + surfaces.set(name, data) + if (typeof window !== "undefined") { + ;(window as unknown as { __adminContext?: unknown }).__adminContext = getAdminContextSnapshot() + } +} + +export function clearAdminSurface(name: string): void { + surfaces.delete(name) + if (typeof window !== "undefined") { + ;(window as unknown as { __adminContext?: unknown }).__adminContext = getAdminContextSnapshot() + } +} + +export function getAdminContextSnapshot(): AdminContextSnapshot { + const route = typeof window !== "undefined" ? window.location.pathname : "" + return { + route, + surfaces: Object.fromEntries(surfaces.entries()), + } +} + +/** + * Render a snapshot as a markdown block for the LLM system prompt. + * Keeps it compact: route, then one section per surface with JSON. + */ +export function formatAdminContextForPrompt(snapshot = getAdminContextSnapshot()): string { + const sections: string[] = [`Admin context (read-only — for answering factual questions):`] + sections.push(`Route: ${snapshot.route || "?"}`) + const names = Object.keys(snapshot.surfaces) + if (names.length === 0) { + sections.push(`Surfaces: (none registered)`) + } else { + for (const name of names) { + const json = safeJson(snapshot.surfaces[name]) + sections.push(`Surface "${name}":\n${json}`) + } + } + return sections.join("\n\n") +} + +function safeJson(value: unknown): string { + try { + const text = JSON.stringify(value, null, 2) + if (text.length > 4000) return text.slice(0, 4000) + "\n…(truncated)" + return text + } catch { + return "(unserializable)" + } +} + +/** Hook: publish a surface while the component is mounted. */ +export function useRegisterAdminContext(name: string, data: Surface): void { + useEffect(() => { + publishAdminSurface(name, data) + return () => clearAdminSurface(name) + }, [name, data]) +} diff --git a/app/lib/admin-tools.ts b/app/lib/admin-tools.ts new file mode 100644 index 0000000..d19cec3 --- /dev/null +++ b/app/lib/admin-tools.ts @@ -0,0 +1,161 @@ +// 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 { getTenant, listTenants, type Tenant } from "~/lib/arcadia/tenants" + +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 + }, + }, +] + +/** 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, + })) +} + +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 } +} diff --git a/app/lib/agents.ts b/app/lib/agents.ts index 9a058f0..0ff125d 100644 --- a/app/lib/agents.ts +++ b/app/lib/agents.ts @@ -13,39 +13,39 @@ export type Agent = { export const DEFAULT_AGENTS: Agent[] = [ { - id: "generalist", + id: "operator", name: "Atlas", - role: "Generalist", + role: "Platform Operator", prompt: - "You handle anything: chat, planning, summaries, casual questions. Match the user's tone. Keep replies as long as the task deserves — terse for quick questions, detailed when explaining.", + "You're the platform admin's day-to-day operator inside Arcadia Admin. Treat the signed-in user as a senior platform administrator running a multi-tenant Arcadia deployment. Default to action: when the user asks about live data, call a tool; when they ask to do something, suggest the tool call and ask for confirmation if it's a write. Prefer tenant slugs over UUIDs in conversation. Keep replies tight — operators read fast.", }, { - id: "coder", - name: "Forge", - role: "Software engineer", + id: "auditor", + name: "Ledger", + role: "Auditor", prompt: - "You are a senior software engineer. Write idiomatic, well-typed code. Prefer concrete examples over abstract advice. When asked to fix a bug, identify root cause before patching. Use markdown code blocks with language tags. Mention edge cases briefly when relevant.", + "You're an audit-focused assistant inside Arcadia Admin. Specialise in audit logs, access reviews, and 'who did what when' questions. Always cite the actor_type (user / platform_admin / api_key / system) and timestamp when summarising audit entries. Be cautious about claims you can't back with a tool result — call a tool first.", }, { - id: "writer", - name: "Inkwell", - role: "Writer", + id: "triage", + name: "Beacon", + role: "Incident Triage", prompt: - "You are a prose writer. Produce vivid, well-paced text — short stories, copy, emails, essays. Vary sentence length. Show, don't tell. When the user asks for a draft, deliver the draft, not a description of it.", + "You're an incident-triage assistant inside Arcadia Admin. When the user reports a problem (a tenant member can't sign in, a billing call is 402'ing, a webhook is failing), walk the diagnostic tree: identify the tenant, check tenant status, check the user's roles, check the billing-config / api-metering / feature-flag overrides as relevant. Suggest impersonation only when it's the right escalation. Keep a clear hypothesis → check → result rhythm.", }, { - id: "researcher", - name: "Pilot", - role: "Researcher", + id: "analyst", + name: "Tally", + role: "Platform Analyst", prompt: - "You are a careful researcher. Structure answers as: claim → evidence → caveat. Distinguish what is well-established from what is uncertain. Refuse to fabricate citations — if you don't know, say so.", + "You're an analyst inside Arcadia Admin. Answer numerical and aggregate questions across the platform: tenant counts by status, plan distribution, audit-log volume, growth. Always pull live data via tools — never guess from stale snapshots. Present findings in plain prose first, then a small table when the breakdown helps.", }, { id: "ui-driver", name: "Cursor", role: "UI Operator", prompt: - "You specialize in driving this app's UI on the user's behalf. Prefer doing over explaining. When the user asks for an action, emit an action block immediately. When they ask a question about the app, answer concisely and offer to do it.", + "You specialise in driving Arcadia Admin's UI on the operator's behalf. Prefer doing over explaining. When the user asks for an action that maps to a UI element, emit an action block immediately (using `data-action` ids the host has documented). For data questions, prefer tool calls over UI navigation.", }, ] @@ -64,6 +64,14 @@ function isAgent(v: unknown): v is Agent { ) } +// Old Vibespace agent ids — used to auto-migrate operators stuck on the +// generic defaults from before Arcadia Admin had its own personas. +const LEGACY_AGENT_IDS = new Set(["generalist", "coder", "writer", "researcher"]) + +function isLegacyDefaultSet(agents: Agent[]): boolean { + return agents.some((a) => LEGACY_AGENT_IDS.has(a.id)) +} + function readFromStorage(): Agent[] { if (typeof window === "undefined") return DEFAULT_AGENTS try { @@ -72,7 +80,14 @@ function readFromStorage(): Agent[] { const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return DEFAULT_AGENTS const cleaned = parsed.filter(isAgent) - return cleaned.length > 0 ? cleaned : DEFAULT_AGENTS + if (cleaned.length === 0) return DEFAULT_AGENTS + if (isLegacyDefaultSet(cleaned)) { + // Auto-migrate: stored set still contains pre-arcadia personas. + localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_AGENTS)) + localStorage.removeItem(ACTIVE_KEY) + return DEFAULT_AGENTS + } + return cleaned } catch { return DEFAULT_AGENTS } diff --git a/app/lib/arcadia-knowledge.ts b/app/lib/arcadia-knowledge.ts new file mode 100644 index 0000000..59abf23 --- /dev/null +++ b/app/lib/arcadia-knowledge.ts @@ -0,0 +1,35 @@ +// Domain primer baked into the assistant's system prompt so it understands +// what arcadia-app is, what platform admins do, and how the data model fits +// together. Keep this tight — it costs context tokens on every turn. + +export const ARCADIA_KNOWLEDGE = `Arcadia (the backend you administer): + +Arcadia is a multi-tenant SaaS backend (Elixir/Phoenix umbrella, OpenAPI at /api/v1, server-rendered platform UI at /platform/*). This admin app (Arcadia Admin) is one of several clients — it talks to Arcadia over JSON, scoped by an X-Tenant-ID header and a Bearer JWT. + +Core entities and how they relate: + +- **Tenant** — an isolated workspace (a customer org). Identified by a slug (e.g. "acme", "platform-admin", "default") and a UUID id. Owns its own users, roles, billing config, branding, settings. Most data is tenant-scoped. +- **Platform admin** — a separate identity that lives in the platform_admins table, NOT in any tenant. The signed-in operator using this app is one. Can read/write across all tenants. The first one is bootstrapped via /setup; \`is_root: true\` flags the original. +- **User** — a member of a single tenant. Has email + password (or SSO), system roles (\`admin\` / \`user\` / \`viewer\`) plus optional custom roles. Login goes through POST /api/v1/auth/login with the tenant slug in X-Tenant-ID. +- **Role** — permission bundle scoped to a tenant. \`admin\` / \`user\` / \`viewer\` are seeded as system roles per tenant. Permissions are wildcard-ish strings (e.g. \`tenants:read\`, \`*\`). +- **Plan** — subscription tier attached to a tenant: name + limits (seats, storage, API quota). Drives billing. +- **Audit log entry** — append-only record of who did what. \`actor_type\` is one of: \`user\`, \`platform_admin\`, \`api_key\`, \`system\`. Per-tenant and platform-wide entries coexist. +- **Feature flag** — boolean / variant gate. Platform-wide default + per-tenant override. +- **Storage / billing config / SSO IdP / inbound webhook / API quota / data retention policy / approval workflow / announcement** — per-tenant or platform-level configurations the operator can manage. + +Tenant lifecycle (status field): + +- **active** — normal operation. Members can sign in. Default state. +- **suspended** — members blocked from signing in. Reversible: activate to restore. Use for temporary holds (overdue invoice, abuse investigation). +- **deactivated** — stronger stop. Treat as effectively closed; usually flagged as terminal even if technically reversible. Use only when offboarding. + +Things to keep in mind when assisting: + +- Prefer tenant **slugs** in user-facing language ("the acme tenant"); slugs are stable, ids are UUIDs that aren't useful to humans. +- "Platform admin" ≠ "admin role inside a tenant". The first acts cross-tenant; the second is scoped to one tenant. +- Writes are auditable. Suggest the user double-check tenant slug and impact before suspend/deactivate. Deactivate is harsher than suspend — only use when clearly intended. +- The operator can impersonate tenant users for debugging (POST /api/v1/admin/impersonate/:user_id) — surface this when they ask "why can't user X log in". +- Quotas / rate cards / billing config errors usually surface as 402/403 from /api/v1 endpoints — diagnose by checking the tenant's billing-config and api-metering quotas. +- The reference Phoenix app lives at \`reference/arcadia-app/\` in the workspace; its OpenAPI spec is at /api/openapi (sync via \`node ../lib-arcadia-client/scripts/sync-spec.mjs\`). + +When the user asks something that maps to a tool, call it. When they ask about a concept, explain it from this primer in plain language. When they ask to do something destructive, summarise the impact in one sentence and ask for confirmation before suggesting a tool call.` diff --git a/app/lib/threads.ts b/app/lib/threads.ts index 9a44111..782099d 100644 --- a/app/lib/threads.ts +++ b/app/lib/threads.ts @@ -4,10 +4,16 @@ import { useEffect, useSyncExternalStore } from "react" export type ThreadMessage = { - role: "user" | "assistant" + role: "user" | "assistant" | "tool" content: string /** Persona that authored this assistant message (omitted for user msgs). */ agentId?: string + /** Native tool calls attached to an assistant message. */ + toolCalls?: { id: string; name: string; arguments: string }[] + /** Tool role only — id of the matching assistant tool_call. */ + toolCallId?: string + /** Tool role only — function name. */ + name?: string } export type Thread = { diff --git a/app/routes/ai.tsx b/app/routes/ai.tsx index 8217e0c..4a2c240 100644 --- a/app/routes/ai.tsx +++ b/app/routes/ai.tsx @@ -53,7 +53,6 @@ import { } from "~/components/ui/popover" import { useLLMSettings } from "~/lib/llm-settings" import { - composeSystemPrompt, loadActiveAgentId, saveActiveAgentId, useAgents, @@ -62,6 +61,11 @@ import { import { addLibraryItem } from "~/lib/library" import { Avatar, AvatarFallback } from "~/components/ui/avatar" import { pageTitle } from "~/lib/page-meta" +import { useArcadiaClient } from "@crema/arcadia-client" +import type { ToolCall } from "@crema/llm-ui" +import { getOpenAITools, runLLMToolCalls } from "~/lib/admin-tools" +import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge" +import { formatAdminContextForPrompt } from "~/lib/admin-context" const SNAPSHOT_KEY = "crema.ai.snapshot" type StoredMessage = { role: "user" | "assistant"; content: string } @@ -221,12 +225,52 @@ function ChatSurface({ isMock: boolean onRetryProbe: () => void }) { - const baseSystem = - "You are a helpful AI assistant. Be concise, warm, and direct." - const systemPrompt = composeSystemPrompt(baseSystem, activeAgent) - const { messages, setMessages, send, abort, isStreaming, reset } = useChat({ + const persona = activeAgent + ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` + : "" + const systemPrompt = [ + "You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.", + ARCADIA_KNOWLEDGE, + persona, + formatAdminContextForPrompt(), + ] + .filter(Boolean) + .join("\n\n") + const arcadia = useArcadiaClient() + const { messages, setMessages, send, continueChat, abort, isStreaming, reset } = useChat({ system: systemPrompt, }) + + // Auto tool-loop using native function calls. + const toolIterationsRef = useRef(0) + const processedTurnRef = useRef(-1) + const prevStreamingRef = useRef(isStreaming) + const MAX_TOOL_ITERATIONS = 3 + useEffect(() => { + const justFinished = prevStreamingRef.current && !isStreaming + prevStreamingRef.current = isStreaming + if (!justFinished) return + const lastIdx = messages.length - 1 + if (lastIdx < 0) return + const last = messages[lastIdx] + if (last.role !== "assistant") return + if (processedTurnRef.current === lastIdx) return + processedTurnRef.current = lastIdx + const calls = last.toolCalls ?? [] + if (calls.length === 0) { + toolIterationsRef.current = 0 + return + } + if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) return + toolIterationsRef.current += 1 + void (async () => { + const { toolMessages } = await runLLMToolCalls(calls, { arcadia }) + void continueChat(toolMessages, { + system: systemPrompt, + tools: getOpenAITools(), + }) + })() + }, [messages, isStreaming, arcadia, continueChat, systemPrompt]) const { complete: completeOneShot, isLoading: compacting } = useCompletion() const [input, setInput] = useState("") const [showPromptOpen, setShowPromptOpen] = useState(false) @@ -304,12 +348,12 @@ function ChatSurface({ const text = messages[lastUserIdx].content setMessages(messages.slice(0, lastUserIdx)) // Defer so the state flush completes before send() reads `messages`. - setTimeout(() => void send(text), 0) + setTimeout(() => void send(text, { tools: getOpenAITools() }), 0) }, [messages, setMessages, send, isStreaming]) const continueLast = useCallback(() => { if (isStreaming || messages.length === 0) return - void send("Please continue your previous reply.") + void send("Please continue your previous reply.", { tools: getOpenAITools() }) }, [isStreaming, messages.length, send]) const compactConversation = useCallback(async () => { @@ -396,7 +440,7 @@ function ChatSurface({ if (!text || isStreaming) return setInput("") stickRef.current = true - void send(text) + void send(text, { tools: getOpenAITools() }) }, [input, isStreaming, send]) const isEmpty = messages.length === 0 @@ -426,9 +470,16 @@ function ChatSurface({ ) : (
- {messages.map((m, i) => ( - - ))} + {messages + .filter((m) => m.role !== "system") + .map((m, i) => ( + + ))} {isStreaming && messages.at(-1)?.role !== "assistant" && (
@@ -499,10 +550,19 @@ function ChatSurface({ function MessageRow({ role, content, + toolCalls, }: { - role: "user" | "assistant" + role: "user" | "assistant" | "tool" content: string + toolCalls?: ToolCall[] }) { + if (role === "tool") { + return ( +
+ +
+ ) + } if (role === "user") { return (
@@ -520,7 +580,7 @@ function MessageRow({ } return (
- +
) } diff --git a/app/routes/assistant.tsx b/app/routes/assistant.tsx index 695db76..606562d 100644 --- a/app/routes/assistant.tsx +++ b/app/routes/assistant.tsx @@ -31,7 +31,9 @@ const PROBE_TIMEOUT_MS = 3000 // "Available actions" in the system prompt only lists what's on screen NOW; // this catalog tells the model what exists elsewhere so it can plan // multi-step flows (navigate → wait_for → fill → click) in a single block. -const UI_CONTROL_PREFACE = `You are the assistant inside this app and you can drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves. +const UI_CONTROL_PREFACE = `You are the operator's assistant inside Arcadia Admin — the platform-admin web app for the Arcadia multi-tenant SaaS backend (Phoenix, /api/v1). The signed-in user is a platform administrator. Help them inspect and manage tenants, users, billing, audit logs, feature flags, and other platform surfaces. + +You can both answer factual questions about the current state (use the "Admin context" block below) and drive the UI. A virtual cursor is shown to the user — every step should target an element so the cursor visibly moves. Rules: - Prefer \`click nav-\` (e.g. \`click nav-settings\`) over \`navigate \` so the user sees the cursor travel to the sidebar. @@ -42,7 +44,7 @@ Rules: Known action ids across the app (use these even if not in "Available actions" — the page may not be mounted yet): -Sidebar / nav: nav-overview, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle +Sidebar / nav: nav-overview, nav-tenants, nav-resources, nav-activity, nav-assistant, nav-library, nav-settings, sidebar-toggle, mobile-nav-toggle Appbar: appbar-search (input), appbar-scripts, appbar-font-size, appbar-surface, appbar-background, theme-toggle, appbar-notifications, appbar-avatar Account menu (after click appbar-avatar): avatar-profile (→ /profile), avatar-settings, avatar-help, avatar-signout Profile page: profile-avatar-upload, profile-avatar-remove, profile-name, profile-email, profile-title, profile-bio, profile-signature, profile-default-agent, profile-save, profile-revert, profile-reset @@ -54,6 +56,7 @@ Assistant agent picker: assistant-agent (dropdown — click to switch persona) Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-, assistant-thread-rename-, assistant-thread-delete-, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-, assistant-msg-edit-, assistant-msg-speak- Library page: library-search, library-open-, library-copy-, library-download-, library-delete- Resources page: resources-search, resources-new-name, resources-create, resources-status-, resources-delete- +Tenants page: tenants-refresh, tenants-search (input), tenants-create. Per-row (use the tenant's slug — see the "tenants" surface in Admin context for available slugs): tenant--actions (open the kebab first), tenant--suspend, tenant--activate, tenant--deactivate. Recipe to suspend a tenant: click nav-tenants, wait_for tenants-refresh, click tenant--actions, wait_for tenant--suspend, click tenant--suspend. Login page: login-email, login-password, login-submit Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-, notif-dismiss- Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe: @@ -77,6 +80,31 @@ wait_for assistant-ui-control \`\`\`"` +import { formatAdminContextForPrompt } from "~/lib/admin-context" +import { getOpenAITools, runLLMToolCalls } from "~/lib/admin-tools" +import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge" +import { useArcadiaClient } from "@crema/arcadia-client" + +/** + * Always includes domain knowledge + tools + admin context + persona. + * Adds the UI-control DSL/action catalog only when uiControl is on (those rules + * are about driving the cursor, irrelevant when the assistant is in plain Q&A). + */ +function buildAdminPreface(activeAgent: Agent | undefined, uiControl: boolean): string { + const persona = activeAgent + ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` + : "" + const ctx = formatAdminContextForPrompt() + const parts = [ + "You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.", + ARCADIA_KNOWLEDGE, + persona, + ctx, + ] + if (uiControl) parts.push(UI_CONTROL_PREFACE) + return parts.filter(Boolean).join("\n\n") +} + function withTimeout(p: Promise, ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => { @@ -395,8 +423,17 @@ function AssistantSurface({ return () => clearTimeout(t) }, [uiControl]) - const { messages, send, abort, isStreaming, error, reset } = useChat({ - system: systemPrompt, + // Always-on admin system prompt — domain primer + tools + persona, regardless + // of UI Control. Per-call `send(text, {system})` overrides may not always be + // honored across hook re-renders, so anchor it at construction too. + const constructorSystemPrompt = buildSystemPrompt({ + path: typeof window !== "undefined" ? window.location.pathname : "", + preface: buildAdminPreface(activeAgent, uiControl), + includeActions: uiControl, + }) + + const { messages, send, continueChat, abort, isStreaming, error, reset } = useChat({ + system: constructorSystemPrompt, initialMessages: thread.messages as StoredMessage[], }) @@ -408,8 +445,21 @@ function AssistantSurface({ const stamped: ThreadMessage[] = messages.map((m, i) => { const prior = thread.messages[i] if (m.role === "user") return { role: "user", content: m.content } + if (m.role === "tool") { + return { + role: "tool", + content: m.content, + toolCallId: m.toolCallId, + name: m.name, + } + } const agentId = prior?.agentId ?? activeAgentId - return { role: "assistant", content: m.content, agentId } + return { + role: "assistant", + content: m.content, + agentId, + ...(m.toolCalls ? { toolCalls: m.toolCalls } : {}), + } }) updateThread(thread.id, { messages: stamped, @@ -523,6 +573,50 @@ function AssistantSurface({ }, [messages, onRequestRetry, thread.id, thread.pinned]) const handleSendRef = useRef<((t: string) => void) | null>(null) + const arcadia = useArcadiaClient() + const toolIterationsRef = useRef(0) + const processedTurnRef = useRef(-1) + const prevStreamingRef = useRef(isStreaming) + const MAX_TOOL_ITERATIONS = 3 + + // Auto tool-loop using native function calls. When the streaming assistant + // turn ends with toolCalls attached, run them, push tool result messages, + // and continue the chat so the model produces its final answer. + useEffect(() => { + const justFinished = prevStreamingRef.current && !isStreaming + prevStreamingRef.current = isStreaming + if (!justFinished) return + + const lastIdx = messages.length - 1 + if (lastIdx < 0) return + const last = messages[lastIdx] + if (last.role !== "assistant") return + if (processedTurnRef.current === lastIdx) return + processedTurnRef.current = lastIdx + + const calls = last.toolCalls ?? [] + console.debug("[admin-tools] assistant turn finished, tool_calls:", calls) + if (calls.length === 0) { + toolIterationsRef.current = 0 + return + } + if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) { + console.warn("[admin-tools] tool-iteration cap reached, dropping calls", calls) + return + } + + toolIterationsRef.current += 1 + void (async () => { + console.debug("[admin-tools] running tool calls", calls) + const { results, toolMessages } = await runLLMToolCalls(calls, { arcadia }) + console.debug("[admin-tools] tool results", results) + void continueChat(toolMessages, { + system: constructorSystemPrompt, + tools: getOpenAITools(), + maxTokens: responseBudget, + }) + })() + }, [messages, isStreaming, arcadia, continueChat, constructorSystemPrompt, responseBudget]) const [editingIndex, setEditingIndex] = useState(null) const [editingDraft, setEditingDraft] = useState("") const [speakingIndex, setSpeakingIndex] = useState(null) @@ -597,18 +691,12 @@ function AssistantSurface({ }, [thread.id]) const composedSystemPrompt = useMemo(() => { - return uiControl - ? buildSystemPrompt({ - path: - typeof window !== "undefined" ? window.location.pathname : "", - preface: `${UI_CONTROL_PREFACE}\n\n${ - activeAgent - ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` - : "" - }`, - }) - : systemPrompt - }, [uiControl, systemPrompt, activeAgent]) + return buildSystemPrompt({ + path: typeof window !== "undefined" ? window.location.pathname : "", + preface: buildAdminPreface(activeAgent, uiControl), + includeActions: uiControl, + }) + }, [uiControl, activeAgent]) const buildTranscript = useCallback(() => { const lines: string[] = [ @@ -768,22 +856,14 @@ function AssistantSurface({ }, [isStreaming, messages, uiControl]) const handleSend: (text: string) => void = (text: string) => { - const system = uiControl - ? buildSystemPrompt({ - path: window.location.pathname, - preface: `${UI_CONTROL_PREFACE}\n\n${activeAgent ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` : ""}`, - }) - : systemPrompt - const sysTokens = estimateTokens(system) - const historyBudget = Math.max( - 256, - contextTokens - sysTokens - responseBudget, - ) - const userMsg = { role: "user" as const, content: text } - const trimmed = trimMessages([...messages, userMsg], historyBudget) + const system = buildSystemPrompt({ + path: window.location.pathname, + preface: buildAdminPreface(activeAgent, uiControl), + includeActions: uiControl, + }) void send(text, { system, - messages: trimmed, + tools: getOpenAITools(), maxTokens: responseBudget, }) } @@ -800,16 +880,15 @@ function AssistantSurface({ }, []) const usedTokens = useMemo(() => { - const system = uiControl - ? buildSystemPrompt({ - path: typeof window !== "undefined" ? window.location.pathname : "", - preface: UI_CONTROL_PREFACE, - }) - : systemPrompt + const system = buildSystemPrompt({ + path: typeof window !== "undefined" ? window.location.pathname : "", + preface: buildAdminPreface(activeAgent, uiControl), + includeActions: uiControl, + }) const sysT = estimateTokens(system) const histT = messages.reduce((n, m) => n + estimateTokens(m.content), 0) return sysT + histT - }, [messages, uiControl, systemPrompt]) + }, [messages, uiControl, systemPrompt, activeAgent]) const suggestions = uiControl ? [ @@ -946,18 +1025,22 @@ function AssistantSurface({ ) : (
- {m.content ? ( + {m.role === "tool" ? ( + + ) : m.content || (m.role === "assistant" && m.toolCalls?.length) ? ( isUser ? ( {m.content} ) : ( - + ) ) : isStreaming && i === messages.length - 1 ? ( "…" diff --git a/app/routes/tenants.tsx b/app/routes/tenants.tsx index 749f207..27e3413 100644 --- a/app/routes/tenants.tsx +++ b/app/routes/tenants.tsx @@ -36,6 +36,7 @@ import { } from "~/lib/arcadia/tenants" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" +import { useRegisterAdminContext } from "~/lib/admin-context" export const meta = () => pageTitle("Tenants") @@ -131,13 +132,36 @@ export default function TenantsRoute() { header: "", align: "right", cell: (t) => ( - + ), }, ], [arcadia, refresh], ) + const tenantSummary = useMemo( + () => ({ + total: tenants.length, + byStatus: tenants.reduce>((acc, t) => { + acc[t.status] = (acc[t.status] ?? 0) + 1 + return acc + }, {}), + tenants: tenants.map((t) => ({ + id: t.id, + slug: t.slug, + name: t.name, + status: t.status, + plan: t.plan?.name ?? null, + inserted_at: t.inserted_at, + })), + }), + [tenants], + ) + useRegisterAdminContext("tenants", tenantSummary) + const table = useTable({ data: tenants, columns, @@ -304,6 +328,7 @@ function rowActions( id: "suspend", label: "Suspend", icon: , + dataAction: `tenant-${t.slug}-suspend`, onSelect: () => setPending({ kind: "suspend", tenant: t }), }) } else { @@ -311,6 +336,7 @@ function rowActions( id: "activate", label: "Activate", icon: , + dataAction: `tenant-${t.slug}-activate`, onSelect: async () => { try { await activateTenant(arcadia, t.id) @@ -325,6 +351,7 @@ function rowActions( id: "deactivate", label: "Deactivate", destructive: true, + dataAction: `tenant-${t.slug}-deactivate`, onSelect: () => setPending({ kind: "deactivate", tenant: t }), }) return items