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

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