aifirst: lift context/agents/tools runtime to lib-aifirst-ui

The mechanism (context surface registry, persona storage + hooks, tool
parser/dispatcher) is now generic and lives in @crema/aifirst-ui/{context,
agents,tools}. This template keeps only the arcadia-shaped configuration:

- agents.ts — owns DEFAULT_AGENTS + legacy/retired migration sets, calls
  configureAgents() at module load, re-exports the runtime
- admin-tools.ts — keeps the 19 arcadia tool definitions, binds the
  runtime via createToolRuntime(TOOLS), re-exports the bound functions
- admin-context.ts — deleted; 18 routes now import directly from
  @crema/aifirst-ui/context

Routes that import from ~/lib/agents and ~/lib/admin-tools are unchanged
(wrapper modules preserve the existing import surface).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-05 15:18:48 +10:00
parent c968ac0735
commit a286b9cdce
21 changed files with 73 additions and 370 deletions

View File

@@ -1,76 +0,0 @@
// 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])
}

View File

@@ -6,7 +6,10 @@
// raw HTTP — only the menu below.
import type { ArcadiaClient } from "@crema/arcadia-client"
import type { Tool, ToolCall as LLMToolCall } from "@crema/llm-ui"
import {
createToolRuntime,
type ToolDef,
} from "@crema/aifirst-ui/tools"
import {
activateTenant,
@@ -163,30 +166,9 @@ async function kbRead(chunkId: string, corpus: string): Promise<unknown> {
return await res.json()
}
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[] = [
const TOOLS: ToolDef<ToolCtx>[] = [
{
name: "list_tenants",
description:
@@ -927,58 +909,6 @@ interface UserEntry {
roles?: { slug?: string; name?: string }[]
}
/** OpenAI-format tool list to pass into ChatRequest.tools. */
export function getOpenAITools(): Tool[] {
return TOOLS.map((t) => ({
name: t.name,
description: t.description,
parameters: t.parameters,
}))
}
/** Split an LLM tool-call list into reads (run automatically) and writes
* (held for user confirmation). Unknown tools fall into reads so the runner
* can surface a structured "unknown tool" error to the model. */
export function classifyCalls(calls: LLMToolCall[]): {
reads: LLMToolCall[]
writes: LLMToolCall[]
} {
const reads: LLMToolCall[] = []
const writes: LLMToolCall[] = []
for (const c of calls) {
const def = TOOL_BY_NAME.get(c.name)
if (def?.isWrite) writes.push(c)
else reads.push(c)
}
return { reads, writes }
}
/** Synthesise tool-result messages saying the user denied a write call. */
export function buildDenialMessages(
calls: LLMToolCall[],
): { role: "tool"; content: string; toolCallId: string; name: string }[] {
return calls.map((c) => ({
role: "tool",
content: JSON.stringify({
error: "User denied this write. Do not retry without re-asking the user.",
}),
toolCallId: c.id,
name: c.name,
}))
}
/** Pretty-print args for the confirm UI. */
export function formatToolCallArgs(c: LLMToolCall): string {
try {
const parsed = c.arguments ? JSON.parse(c.arguments) : {}
const keys = Object.keys(parsed)
if (keys.length === 0) return ""
return keys.map((k) => `${k}=${JSON.stringify(parsed[k])}`).join(", ")
} catch {
return c.arguments
}
}
function summarize(t: Tenant) {
return {
id: t.id,
@@ -990,62 +920,15 @@ function summarize(t: Tenant) {
}
}
const TOOL_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]))
const runtime = createToolRuntime(TOOLS)
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)"
}
}
export const getOpenAITools = runtime.getOpenAITools
export const classifyCalls = runtime.classifyCalls
export const runLLMToolCalls = runtime.runLLMToolCalls
/** 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 }
}
export {
buildDenialMessages,
formatToolCallArgs,
type ToolCall,
type ToolResult,
} from "@crema/aifirst-ui/tools"

View File

@@ -1,15 +1,9 @@
// Agent personas — named, role-scoped sub-system prompts.
// Each persona stacks on top of the main systemPrompt to specialize the
// assistant for a task. Persisted in localStorage; reactive across tabs.
// Arcadia Admin's agent roster + migration config.
// The persona machinery lives in @crema/aifirst-ui/agents — this file
// just owns the *which personas* config and re-exports the runtime so
// route code keeps importing from "~/lib/agents".
import { useEffect, useSyncExternalStore } from "react"
export type Agent = {
id: string
name: string
role: string
prompt: string
}
import { configureAgents, type Agent } from "@crema/aifirst-ui/agents"
export const DEFAULT_AGENTS: Agent[] = [
{
@@ -49,21 +43,6 @@ export const DEFAULT_AGENTS: Agent[] = [
},
]
const STORAGE_KEY = "crema.agents"
const ACTIVE_KEY = "crema.assistant.activeAgent"
const CHANGE_EVENT = "crema:agents-change"
function isAgent(v: unknown): v is Agent {
return (
!!v &&
typeof v === "object" &&
typeof (v as Agent).id === "string" &&
typeof (v as Agent).name === "string" &&
typeof (v as Agent).role === "string" &&
typeof (v as Agent).prompt === "string"
)
}
// 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"])
@@ -73,104 +52,21 @@ const LEGACY_AGENT_IDS = new Set(["generalist", "coder", "writer", "researcher"]
// so a rename in DEFAULT_AGENTS actually reaches the UI.
const RETIRED_AGENT_NAMES = new Set(["Ledger", "Beacon", "Tally", "Cursor"])
function isLegacyDefaultSet(agents: Agent[]): boolean {
return (
agents.some((a) => LEGACY_AGENT_IDS.has(a.id)) ||
agents.some((a) => RETIRED_AGENT_NAMES.has(a.name))
)
}
configureAgents({
defaults: DEFAULT_AGENTS,
shouldReseed: (stored) =>
stored.some((a) => LEGACY_AGENT_IDS.has(a.id)) ||
stored.some((a) => RETIRED_AGENT_NAMES.has(a.name)),
})
function readFromStorage(): Agent[] {
if (typeof window === "undefined") return DEFAULT_AGENTS
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_AGENTS
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return DEFAULT_AGENTS
const cleaned = parsed.filter(isAgent)
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
}
}
export function loadAgents(): Agent[] {
return readFromStorage()
}
export function saveAgents(next: Agent[]) {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
export function resetAgents() {
saveAgents(DEFAULT_AGENTS)
}
let cached: Agent[] | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY || e.key === ACTIVE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}
function getSnapshot(): Agent[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Agent[] {
return DEFAULT_AGENTS
}
export function useAgents(): Agent[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}
export function loadActiveAgentId(): string {
if (typeof window === "undefined") return DEFAULT_AGENTS[0].id
try {
return localStorage.getItem(ACTIVE_KEY) ?? DEFAULT_AGENTS[0].id
} catch {
return DEFAULT_AGENTS[0].id
}
}
export function saveActiveAgentId(id: string) {
if (typeof window === "undefined") return
localStorage.setItem(ACTIVE_KEY, id)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
export function composeSystemPrompt(
base: string,
agent: Agent | undefined,
): string {
if (!agent) return base
return `${base}\n\nActive persona: ${agent.name}${agent.role}\n${agent.prompt}`
}
export function newAgentId(): string {
return `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
export {
composeSystemPrompt,
loadActiveAgentId,
loadAgents,
newAgentId,
resetAgents,
saveActiveAgentId,
saveAgents,
useAgents,
type Agent,
} from "@crema/aifirst-ui/agents"