Adds the agent-facing surface for the new Tantivy lexical search service (arcadia-search). Sits alongside the existing search_docs (browser MiniSearch) — agent picks based on tool description. - admin-tools.ts: new search_kb(query, corpus, limit?, tags?) and read_chunk(chunk_id, corpus) tools. KB_BASE_URL honors window.__ARCADIA_SEARCH_URL runtime override + VITE_ARCADIA_SEARCH_URL build env, defaults to localhost:7800. Token resolved per-call from sessionStorage.arcadia_access_token (matching lib-arcadia-client's storage convention) with "dev" fallback for unauthenticated dev. - assistant.tsx: system-prompt section telling the agent when to pick search_docs (browser, bundled) vs search_kb (server, dynamic + expandable via read_chunk). - ai.tsx: reindexKB() helper + "reindex kb (docs)" button on the empty state, next to the existing block-preview button. Toasts on start/success/failure. Wired with data-action="kb-reindex-docs" so the agent can also trigger via the command bus. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
590 lines
20 KiB
TypeScript
590 lines
20 KiB
TypeScript
// 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 {
|
|
activateTenant,
|
|
getTenant,
|
|
listTenants,
|
|
suspendTenant,
|
|
type Tenant,
|
|
} from "~/lib/arcadia/tenants"
|
|
import { createRAGClient } from "@crema/lexical-rag-ui"
|
|
import { BLOCK_INDEX, getBlockSchema } from "~/lib/block-schemas"
|
|
|
|
// Lazy singleton — first tool call fetches /docs-index.json, subsequent
|
|
// calls reuse the parsed MiniSearch instance.
|
|
const docsClient = createRAGClient("/docs-index.json")
|
|
|
|
// Server-side Tantivy backend (arcadia-search).
|
|
//
|
|
// URL: comes from window.__ARCADIA_SEARCH_URL (override hook) or
|
|
// VITE_ARCADIA_SEARCH_URL build-time env, defaulting to localhost.
|
|
//
|
|
// Token: prefer the real arcadia access token (sessionStorage —
|
|
// matches lib-arcadia-client's storage convention). Fall back to "dev"
|
|
// when missing, which only works against AUTH_MODE=dev backends. In
|
|
// production, arcadia-search runs in JWT mode and the dev fallback
|
|
// gets rejected with 401 — surfacing the missing-login as a clear
|
|
// error rather than silently using the wrong identity.
|
|
const KB_BASE_URL: string =
|
|
(typeof window !== "undefined" &&
|
|
(window as unknown as { __ARCADIA_SEARCH_URL?: string }).__ARCADIA_SEARCH_URL) ||
|
|
(typeof import.meta !== "undefined" &&
|
|
(import.meta as unknown as { env?: { VITE_ARCADIA_SEARCH_URL?: string } }).env
|
|
?.VITE_ARCADIA_SEARCH_URL) ||
|
|
"http://127.0.0.1:7800"
|
|
|
|
function kbAuthToken(): string {
|
|
if (typeof window === "undefined") return "dev"
|
|
try {
|
|
return window.sessionStorage.getItem("arcadia_access_token") ?? "dev"
|
|
} catch {
|
|
return "dev"
|
|
}
|
|
}
|
|
|
|
type KBHit = {
|
|
chunk_id: string
|
|
title: string
|
|
source_path: string
|
|
heading_path: string
|
|
tags: string[]
|
|
snippet: string
|
|
score: number
|
|
mtime: string
|
|
}
|
|
|
|
async function kbSearch(
|
|
query: string,
|
|
corpus: string,
|
|
limit: number,
|
|
tags?: string[],
|
|
): Promise<{ count: number; hits: KBHit[] }> {
|
|
const res = await fetch(`${KB_BASE_URL}/search`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${kbAuthToken()}`,
|
|
},
|
|
body: JSON.stringify({ query, corpus, limit, tags }),
|
|
})
|
|
if (!res.ok) {
|
|
throw new Error(`arcadia-search ${res.status}: ${await res.text()}`)
|
|
}
|
|
return (await res.json()) as { count: number; hits: KBHit[] }
|
|
}
|
|
|
|
async function kbRead(chunkId: string, corpus: string): Promise<unknown> {
|
|
const url = `${KB_BASE_URL}/chunks/${encodeURIComponent(chunkId)}?corpus=${encodeURIComponent(corpus)}`
|
|
const res = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${kbAuthToken()}` },
|
|
})
|
|
if (res.status === 404) return null
|
|
if (!res.ok) {
|
|
throw new Error(`arcadia-search ${res.status}: ${await res.text()}`)
|
|
}
|
|
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[] = [
|
|
{
|
|
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
|
|
},
|
|
},
|
|
{
|
|
name: "get_platform_stats",
|
|
description:
|
|
"Aggregate platform stats. Returns total tenant count and a breakdown by status (active / suspended / deactivated / etc). Call this for big-picture questions like 'how is the platform doing'.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {},
|
|
additionalProperties: false,
|
|
},
|
|
isWrite: false,
|
|
run: async (_args, { arcadia }) => {
|
|
const tenants = await listTenants(arcadia)
|
|
const byStatus: Record<string, number> = {}
|
|
for (const t of tenants) {
|
|
byStatus[t.status] = (byStatus[t.status] ?? 0) + 1
|
|
}
|
|
const byPlan: Record<string, number> = {}
|
|
for (const t of tenants) {
|
|
const plan = t.plan?.name ?? "(no plan)"
|
|
byPlan[plan] = (byPlan[plan] ?? 0) + 1
|
|
}
|
|
return {
|
|
tenants_total: tenants.length,
|
|
tenants_by_status: byStatus,
|
|
tenants_by_plan: byPlan,
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "list_audit_log",
|
|
description:
|
|
"Recent audit log entries from the platform. Returns a terse summary per entry: actor_type, actor_id, action, target, inserted_at. Call this for 'who did what' questions or to investigate a recent change.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
limit: {
|
|
type: "integer",
|
|
description: "Max entries to return (default 25, max 100).",
|
|
minimum: 1,
|
|
maximum: 100,
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
isWrite: false,
|
|
run: async (args, { arcadia }) => {
|
|
const limit = typeof args.limit === "number" ? Math.min(100, Math.max(1, args.limit)) : 25
|
|
const res = await arcadia.GET<{ data: AuditEntry[] }>(
|
|
`/api/v1/admin/audit-log?limit=${limit}`,
|
|
)
|
|
const entries = res.data ?? []
|
|
return entries.map((e) => ({
|
|
actor_type: e.actor_type,
|
|
actor_id: e.actor_id ?? null,
|
|
action: e.action,
|
|
target: e.target_type ? `${e.target_type}#${e.target_id ?? "?"}` : null,
|
|
inserted_at: e.inserted_at,
|
|
}))
|
|
},
|
|
},
|
|
{
|
|
name: "list_users",
|
|
description:
|
|
"List users in the currently-selected tenant context. Returns email, name, roles, status. Note: this is scoped to whichever tenant the assistant is currently logged into; use get_tenant first if you need to confirm which tenant.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
limit: {
|
|
type: "integer",
|
|
description: "Max users to return (default 50, max 200).",
|
|
minimum: 1,
|
|
maximum: 200,
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
isWrite: false,
|
|
run: async (args, { arcadia }) => {
|
|
const limit = typeof args.limit === "number" ? Math.min(200, Math.max(1, args.limit)) : 50
|
|
const res = await arcadia.GET<{ data: UserEntry[] }>(`/api/v1/users?per_page=${limit}`)
|
|
const users = res.data ?? []
|
|
return users.map((u) => ({
|
|
id: u.id,
|
|
email: u.email,
|
|
name: [u.first_name, u.last_name].filter(Boolean).join(" ") || null,
|
|
roles: u.roles?.map((r) => r.slug ?? r.name).filter(Boolean) ?? [],
|
|
verified: u.email_verified ?? null,
|
|
inserted_at: u.inserted_at,
|
|
}))
|
|
},
|
|
},
|
|
{
|
|
name: "suspend_tenant",
|
|
description:
|
|
"Suspend a tenant by slug. Members can no longer sign in until reactivated. Use for temporary holds (overdue invoice, abuse investigation). REVERSIBLE via activate_tenant. Requires user confirmation before executing.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
slug: { type: "string", description: "The tenant's slug." },
|
|
},
|
|
required: ["slug"],
|
|
additionalProperties: false,
|
|
},
|
|
isWrite: true,
|
|
run: async (args, { arcadia }) => {
|
|
const slug = typeof args.slug === "string" ? args.slug : null
|
|
if (!slug) throw new Error("suspend_tenant requires { slug }")
|
|
const tenants = await listTenants(arcadia)
|
|
const target = tenants.find((t) => t.slug === slug)
|
|
if (!target) throw new Error(`No tenant with slug "${slug}"`)
|
|
const updated = await suspendTenant(arcadia, target.id)
|
|
return summarize(updated)
|
|
},
|
|
},
|
|
{
|
|
name: "activate_tenant",
|
|
description:
|
|
"Re-activate a previously suspended (or deactivated) tenant by slug. Restores member sign-in. Requires user confirmation before executing.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
slug: { type: "string", description: "The tenant's slug." },
|
|
},
|
|
required: ["slug"],
|
|
additionalProperties: false,
|
|
},
|
|
isWrite: true,
|
|
run: async (args, { arcadia }) => {
|
|
const slug = typeof args.slug === "string" ? args.slug : null
|
|
if (!slug) throw new Error("activate_tenant requires { slug }")
|
|
const tenants = await listTenants(arcadia)
|
|
const target = tenants.find((t) => t.slug === slug)
|
|
if (!target) throw new Error(`No tenant with slug "${slug}"`)
|
|
const updated = await activateTenant(arcadia, target.id)
|
|
return summarize(updated)
|
|
},
|
|
},
|
|
{
|
|
name: "search_docs",
|
|
description:
|
|
"Search the arcadia-app documentation (architecture, API surface, deploy/setup guides) for passages relevant to a question. Use this for conceptual or procedural questions where the live API tools won't help — e.g. 'how does multi-tenant isolation work', 'how do I deploy to production', 'what is the modular monolith pattern'. Returns up to `limit` ranked passages with title, sourcePath, and excerpt. Cite results by sourcePath in your reply.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
query: {
|
|
type: "string",
|
|
description:
|
|
"Lexical search query. Use specific terms from the docs (endpoint names, schema fields, concept names) — paraphrase poorly.",
|
|
},
|
|
limit: {
|
|
type: "integer",
|
|
description: "Max passages to return. Default 5, cap 10.",
|
|
minimum: 1,
|
|
maximum: 10,
|
|
},
|
|
},
|
|
required: ["query"],
|
|
additionalProperties: false,
|
|
},
|
|
isWrite: false,
|
|
run: async (args) => {
|
|
const query = typeof args.query === "string" ? args.query.trim() : ""
|
|
if (!query) throw new Error("search_docs requires a non-empty { query }")
|
|
const limit = Math.min(
|
|
10,
|
|
Math.max(1, typeof args.limit === "number" ? args.limit : 5),
|
|
)
|
|
const hits = await docsClient.search(query, { limit })
|
|
// Tool-shape parity with the previous searchDocs() return: collapse
|
|
// tags[] back to category for now so the agent's prior expectations
|
|
// and any cached examples still parse cleanly.
|
|
return {
|
|
query,
|
|
count: hits.length,
|
|
hits: hits.map((h) => ({
|
|
id: h.id,
|
|
title: h.title,
|
|
sourcePath: h.sourcePath,
|
|
category: h.tags[0] ?? "",
|
|
excerpt: h.excerpt,
|
|
score: h.score,
|
|
})),
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "search_kb",
|
|
description:
|
|
"Lexical (BM25) search over the arcadia-search Tantivy backend. Use for the LARGER, server-hosted knowledge corpora — the same arcadia docs the browser RAG serves are indexed here as `corpus=docs` for parity, and additional corpora (uploaded files, runbooks, etc.) will land here as they're added. Returns chunks with snippets + chunk_ids that can be passed to `read_chunk` to expand. Prefer this over `search_docs` (browser) when you need richer hits or when the user is asking about content that wouldn't be in the bundled docs (e.g. uploaded files).",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string", description: "Lexical search query." },
|
|
corpus: {
|
|
type: "string",
|
|
description:
|
|
"Which indexed corpus to search. `docs` is the parity corpus (arcadia documentation). New corpora are added by the operator.",
|
|
},
|
|
limit: {
|
|
type: "integer",
|
|
description: "Max hits. Default 5, cap 20.",
|
|
minimum: 1,
|
|
maximum: 20,
|
|
},
|
|
tags: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
description:
|
|
"Optional tag filter — return only hits whose chunk has at least one matching tag.",
|
|
},
|
|
},
|
|
required: ["query", "corpus"],
|
|
additionalProperties: false,
|
|
},
|
|
isWrite: false,
|
|
run: async (args) => {
|
|
const query = typeof args.query === "string" ? args.query.trim() : ""
|
|
const corpus = typeof args.corpus === "string" ? args.corpus.trim() : ""
|
|
if (!query) throw new Error("search_kb requires a non-empty { query }")
|
|
if (!corpus) throw new Error("search_kb requires a { corpus } name")
|
|
const limit = Math.min(20, Math.max(1, typeof args.limit === "number" ? args.limit : 5))
|
|
const tags = Array.isArray(args.tags) ? (args.tags as string[]) : undefined
|
|
return await kbSearch(query, corpus, limit, tags)
|
|
},
|
|
},
|
|
{
|
|
name: "read_chunk",
|
|
description:
|
|
"Fetch the full body of one chunk by id from the arcadia-search backend, after `search_kb` returned it as a snippet. Use this to expand a hit when the snippet looked promising but you need more context to answer.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
chunk_id: { type: "string", description: "The chunk_id from a prior search_kb hit." },
|
|
corpus: { type: "string", description: "Same corpus the chunk came from." },
|
|
},
|
|
required: ["chunk_id", "corpus"],
|
|
additionalProperties: false,
|
|
},
|
|
isWrite: false,
|
|
run: async (args) => {
|
|
const chunkId = typeof args.chunk_id === "string" ? args.chunk_id : ""
|
|
const corpus = typeof args.corpus === "string" ? args.corpus : ""
|
|
if (!chunkId || !corpus) {
|
|
throw new Error("read_chunk requires { chunk_id, corpus }")
|
|
}
|
|
const result = await kbRead(chunkId, corpus)
|
|
if (result === null) {
|
|
return { error: "chunk not found", chunk_id: chunkId, corpus }
|
|
}
|
|
return result
|
|
},
|
|
},
|
|
{
|
|
name: "get_block_schema",
|
|
description: `Fetch the full JSON schema + example for a rich-output block kind so you can emit it correctly in your reply. Call this the first time in a thread that you intend to render a particular kind. Available kinds: ${Object.entries(
|
|
BLOCK_INDEX,
|
|
)
|
|
.map(([k, v]) => `${k} (${v})`)
|
|
.join(", ")}.`,
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
kind: {
|
|
type: "string",
|
|
description: "The block kind to fetch the schema for.",
|
|
enum: Object.keys(BLOCK_INDEX),
|
|
},
|
|
},
|
|
required: ["kind"],
|
|
additionalProperties: false,
|
|
},
|
|
isWrite: false,
|
|
run: async (args) => {
|
|
const kind = typeof args.kind === "string" ? args.kind : ""
|
|
const schema = getBlockSchema(kind)
|
|
if (!schema) {
|
|
return {
|
|
error: `Unknown block kind "${kind}". Available: ${Object.keys(BLOCK_INDEX).join(", ")}.`,
|
|
}
|
|
}
|
|
return { kind, schema }
|
|
},
|
|
},
|
|
]
|
|
|
|
interface AuditEntry {
|
|
actor_type: string
|
|
actor_id?: string
|
|
action: string
|
|
target_type?: string
|
|
target_id?: string
|
|
inserted_at: string
|
|
}
|
|
|
|
interface UserEntry {
|
|
id: string
|
|
email: string
|
|
first_name?: string
|
|
last_name?: string
|
|
email_verified?: boolean
|
|
inserted_at: string
|
|
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,
|
|
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 }
|
|
}
|