Wire AI assistant to arcadia: domain primer, tool calling, admin context

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
  `<tool_call>` 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-<slug>-{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) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-01 20:08:47 +10:00
parent e7cb8c942b
commit fe93f2766c
9 changed files with 577 additions and 82 deletions

View File

@@ -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 (
<div className="rounded-md border border-border/60 bg-muted/40 px-3 py-2 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-medium text-muted-foreground">
<Wrench className="size-3" />
Tool result
</div>
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[11px] leading-snug text-foreground/80">
{content}
</pre>
</div>
)
}
return (
<div className="prose prose-sm max-w-none dark:prose-invert">
{prose && (
<ReactMarkdown
components={{
// Tight overrides — keep paragraphs compact in chat bubbles
p: ({ children }) => <p className="my-1.5 leading-relaxed">{children}</p>,
code: ({ children, className }) => {
const isBlock = className?.startsWith("language-")
@@ -63,6 +86,15 @@ export function MessageBody({ content }: { content: string }) {
Ran {actionCount} action{actionCount > 1 ? "s" : ""}
</span>
)}
{toolCalls && toolCalls.length > 0 && (
<span
className="mt-1 ml-1 inline-flex items-center gap-1.5 rounded-full border border-amber-400/40 bg-amber-400/10 px-2.5 py-0.5 text-xs font-medium text-amber-700 dark:border-amber-300/60 dark:bg-amber-300/10 dark:text-amber-200"
title="Tool call dispatched"
>
<Wrench className="size-3" />
Called {toolCalls.map((c) => c.name).join(", ")}
</span>
)}
</div>
)
}

76
app/lib/admin-context.ts Normal file
View File

@@ -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<string, unknown>
export type AdminContextSnapshot = {
route: string
surfaces: Record<string, Surface>
}
const surfaces = new Map<string, Surface>()
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])
}

161
app/lib/admin-tools.ts Normal file
View File

@@ -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<string, unknown>
}
export type ToolResult = {
name: string
args: Record<string, unknown>
ok: boolean
data?: unknown
error?: string
}
type ToolDef = {
name: string
description: string
parameters: Record<string, unknown> // JSON Schema for OpenAI tool calling
isWrite: boolean
run: (args: Record<string, unknown>, ctx: ToolCtx) => Promise<unknown>
}
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<string, unknown> = {}
try {
parsed = call.arguments ? (JSON.parse(call.arguments) as Record<string, unknown>) : {}
} 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 }
}

View File

@@ -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
}

View File

@@ -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.`

View File

@@ -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 = {

View File

@@ -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({
) : (
<div className="flex-1 px-4 py-6 sm:px-6">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
{messages.map((m, i) => (
<MessageRow key={i} role={m.role} content={m.content} />
))}
{messages
.filter((m) => m.role !== "system")
.map((m, i) => (
<MessageRow
key={i}
role={m.role as "user" | "assistant" | "tool"}
content={m.content}
toolCalls={m.toolCalls}
/>
))}
{isStreaming && messages.at(-1)?.role !== "assistant" && (
<div className="self-start">
<TypingIndicator />
@@ -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 (
<div className="self-start max-w-[80ch]">
<MessageBody content={content} isToolResult />
</div>
)
}
if (role === "user") {
return (
<div className="self-end">
@@ -520,7 +580,7 @@ function MessageRow({
}
return (
<div className="self-start max-w-[80ch]">
<MessageBody content={content} />
<MessageBody content={content} toolCalls={toolCalls} />
</div>
)
}

View File

@@ -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-<page>\` (e.g. \`click nav-settings\`) over \`navigate <path>\` 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-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, 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-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
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-<slug>-actions (open the kebab first), tenant-<slug>-suspend, tenant-<slug>-activate, tenant-<slug>-deactivate. Recipe to suspend a tenant: click nav-tenants, wait_for tenants-refresh, click tenant-<slug>-actions, wait_for tenant-<slug>-suspend, click tenant-<slug>-suspend.
Login page: login-email, login-password, login-submit
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
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<T>(p: Promise<T>, ms: number, signal: AbortSignal): Promise<T> {
return new Promise<T>((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<number | null>(null)
const [editingDraft, setEditingDraft] = useState("")
const [speakingIndex, setSpeakingIndex] = useState<number | null>(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({
) : (
<div
className={
isUser
? "rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm leading-relaxed text-primary-foreground"
: "rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground"
m.role === "tool"
? "text-sm leading-relaxed"
: isUser
? "rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm leading-relaxed text-primary-foreground"
: "rounded-2xl rounded-bl-md bg-muted px-3.5 py-2 text-sm leading-relaxed text-foreground"
}
>
{m.content ? (
{m.role === "tool" ? (
<MessageBody content={m.content} isToolResult />
) : m.content || (m.role === "assistant" && m.toolCalls?.length) ? (
isUser ? (
<span className="whitespace-pre-wrap">
{m.content}
</span>
) : (
<MessageBody content={m.content} />
<MessageBody content={m.content} toolCalls={m.toolCalls} />
)
) : isStreaming && i === messages.length - 1 ? (
"…"

View File

@@ -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) => (
<ActionsCell items={rowActions(t, arcadia, refresh, setPending, setError)} />
<ActionsCell
items={rowActions(t, arcadia, refresh, setPending, setError)}
triggerDataAction={`tenant-${t.slug}-actions`}
/>
),
},
],
[arcadia, refresh],
)
const tenantSummary = useMemo(
() => ({
total: tenants.length,
byStatus: tenants.reduce<Record<string, number>>((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<Tenant>({
data: tenants,
columns,
@@ -304,6 +328,7 @@ function rowActions(
id: "suspend",
label: "Suspend",
icon: <Pause className="size-4" />,
dataAction: `tenant-${t.slug}-suspend`,
onSelect: () => setPending({ kind: "suspend", tenant: t }),
})
} else {
@@ -311,6 +336,7 @@ function rowActions(
id: "activate",
label: "Activate",
icon: <Play className="size-4" />,
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