Add 5 more admin tools + inline write confirmation flow

New tools in admin-tools.ts:
- list_audit_log({limit?}) — recent audit entries (terse: actor, action,
  target, timestamp). Hits /api/v1/admin/audit-log.
- get_platform_stats() — aggregate counts (tenants by status + by plan),
  composed locally from list_tenants until arcadia exposes a real stats
  endpoint.
- list_users({limit?}) — users in the currently-selected tenant via
  /api/v1/users.
- suspend_tenant({slug}) — write tool, suspends a tenant by slug.
- activate_tenant({slug}) — write tool, restores a suspended/deactivated
  tenant.

Inline write confirmation:
- New ConfirmCard component renders below the assistant message that
  proposed a write. Shows tool(args) and Confirm/Deny buttons.
- classifyCalls() splits LLM tool calls into reads/writes. Auto-loop
  runs reads immediately; for any writes, holds them in pendingConfirm
  state instead of dispatching.
- On Confirm: runs writes with allowWrites:true, prepends prior read
  results, continueChat to produce the final answer.
- On Deny: synthesises tool-result messages telling the model the user
  declined; continueChat so it can acknowledge.
- Arcadia-knowledge primer updated to tell the model the user sees an
  inline confirm card automatically — it shouldn't ask in prose first.

Wired into both /ai and /assistant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-01 20:16:41 +10:00
parent fe93f2766c
commit e5cd85fff3
5 changed files with 398 additions and 21 deletions

View File

@@ -63,9 +63,15 @@ 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 {
buildDenialMessages,
classifyCalls,
getOpenAITools,
runLLMToolCalls,
} from "~/lib/admin-tools"
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
import { formatAdminContextForPrompt } from "~/lib/admin-context"
import { ConfirmCard } from "~/components/assistant/confirm-card"
const SNAPSHOT_KEY = "crema.ai.snapshot"
type StoredMessage = { role: "user" | "assistant"; content: string }
@@ -241,11 +247,21 @@ function ChatSurface({
system: systemPrompt,
})
// Auto tool-loop using native function calls.
// Auto tool-loop using native function calls. Reads run automatically;
// writes are held in `pendingConfirm` until the operator clicks Confirm
// or Deny in the inline ConfirmCard.
const toolIterationsRef = useRef(0)
const processedTurnRef = useRef(-1)
const prevStreamingRef = useRef(isStreaming)
const MAX_TOOL_ITERATIONS = 3
const [pendingConfirm, setPendingConfirm] = useState<{
/** Message index that emitted the write calls. */
afterIndex: number
writes: ToolCall[]
readMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
} | null>(null)
const [confirmBusy, setConfirmBusy] = useState(false)
useEffect(() => {
const justFinished = prevStreamingRef.current && !isStreaming
prevStreamingRef.current = isStreaming
@@ -264,13 +280,50 @@ function ChatSurface({
if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) return
toolIterationsRef.current += 1
void (async () => {
const { toolMessages } = await runLLMToolCalls(calls, { arcadia })
void continueChat(toolMessages, {
const { reads, writes } = classifyCalls(calls)
const { toolMessages: readMsgs } =
reads.length > 0
? await runLLMToolCalls(reads, { arcadia })
: { toolMessages: [] }
if (writes.length > 0) {
setPendingConfirm({ afterIndex: lastIdx, writes, readMessages: readMsgs })
return
}
void continueChat(readMsgs, {
system: systemPrompt,
tools: getOpenAITools(),
})
})()
}, [messages, isStreaming, arcadia, continueChat, systemPrompt])
const onConfirmWrites = useCallback(async () => {
if (!pendingConfirm) return
setConfirmBusy(true)
try {
const { toolMessages: writeMsgs } = await runLLMToolCalls(
pendingConfirm.writes,
{ arcadia },
{ allowWrites: true },
)
void continueChat([...pendingConfirm.readMessages, ...writeMsgs], {
system: systemPrompt,
tools: getOpenAITools(),
})
} finally {
setPendingConfirm(null)
setConfirmBusy(false)
}
}, [pendingConfirm, arcadia, continueChat, systemPrompt])
const onDenyWrites = useCallback(() => {
if (!pendingConfirm) return
const denials = buildDenialMessages(pendingConfirm.writes)
void continueChat([...pendingConfirm.readMessages, ...denials], {
system: systemPrompt,
tools: getOpenAITools(),
})
setPendingConfirm(null)
}, [pendingConfirm, continueChat, systemPrompt])
const { complete: completeOneShot, isLoading: compacting } = useCompletion()
const [input, setInput] = useState("")
const [showPromptOpen, setShowPromptOpen] = useState(false)
@@ -473,12 +526,21 @@ function ChatSurface({
{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}
/>
<div key={i} className="contents">
<MessageRow
role={m.role as "user" | "assistant" | "tool"}
content={m.content}
toolCalls={m.toolCalls}
/>
{pendingConfirm?.afterIndex === i && (
<ConfirmCard
calls={pendingConfirm.writes}
onConfirm={onConfirmWrites}
onDeny={onDenyWrites}
busy={confirmBusy}
/>
)}
</div>
))}
{isStreaming && messages.at(-1)?.role !== "assistant" && (
<div className="self-start">

View File

@@ -81,9 +81,16 @@ wait_for assistant-ui-control
import { formatAdminContextForPrompt } from "~/lib/admin-context"
import { getOpenAITools, runLLMToolCalls } from "~/lib/admin-tools"
import {
buildDenialMessages,
classifyCalls,
getOpenAITools,
runLLMToolCalls,
} from "~/lib/admin-tools"
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
import { useArcadiaClient } from "@crema/arcadia-client"
import { ConfirmCard } from "~/components/assistant/confirm-card"
import type { ToolCall } from "@crema/llm-ui"
/**
* Always includes domain knowledge + tools + admin context + persona.
@@ -579,9 +586,16 @@ function AssistantSurface({
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.
// Auto tool-loop using native function calls. Reads run automatically;
// writes are held in `pendingConfirm` until the operator clicks Confirm
// or Deny in the inline ConfirmCard.
const [pendingConfirm, setPendingConfirm] = useState<{
afterIndex: number
writes: ToolCall[]
readMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
} | null>(null)
const [confirmBusy, setConfirmBusy] = useState(false)
useEffect(() => {
const justFinished = prevStreamingRef.current && !isStreaming
prevStreamingRef.current = isStreaming
@@ -595,7 +609,6 @@ function AssistantSurface({
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
@@ -607,16 +620,53 @@ function AssistantSurface({
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, {
const { reads, writes } = classifyCalls(calls)
const { toolMessages: readMsgs } =
reads.length > 0
? await runLLMToolCalls(reads, { arcadia })
: { toolMessages: [] }
if (writes.length > 0) {
setPendingConfirm({ afterIndex: lastIdx, writes, readMessages: readMsgs })
return
}
void continueChat(readMsgs, {
system: constructorSystemPrompt,
tools: getOpenAITools(),
maxTokens: responseBudget,
})
})()
}, [messages, isStreaming, arcadia, continueChat, constructorSystemPrompt, responseBudget])
const onConfirmWrites = useCallback(async () => {
if (!pendingConfirm) return
setConfirmBusy(true)
try {
const { toolMessages: writeMsgs } = await runLLMToolCalls(
pendingConfirm.writes,
{ arcadia },
{ allowWrites: true },
)
void continueChat([...pendingConfirm.readMessages, ...writeMsgs], {
system: constructorSystemPrompt,
tools: getOpenAITools(),
maxTokens: responseBudget,
})
} finally {
setPendingConfirm(null)
setConfirmBusy(false)
}
}, [pendingConfirm, arcadia, continueChat, constructorSystemPrompt, responseBudget])
const onDenyWrites = useCallback(() => {
if (!pendingConfirm) return
const denials = buildDenialMessages(pendingConfirm.writes)
void continueChat([...pendingConfirm.readMessages, ...denials], {
system: constructorSystemPrompt,
tools: getOpenAITools(),
maxTokens: responseBudget,
})
setPendingConfirm(null)
}, [pendingConfirm, continueChat, constructorSystemPrompt, responseBudget])
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [editingDraft, setEditingDraft] = useState("")
const [speakingIndex, setSpeakingIndex] = useState<number | null>(null)
@@ -1109,6 +1159,14 @@ function AssistantSurface({
</div>
)}
</div>
{pendingConfirm?.afterIndex === i && (
<ConfirmCard
calls={pendingConfirm.writes}
onConfirm={onConfirmWrites}
onDeny={onDenyWrites}
busy={confirmBusy}
/>
)}
</div>
)
})}