import type { EngineChatTool, EngineChatToolCall } from '../engine'; import { deleteMemory, getMemory, listMemory, putMemory } from './idb'; import { runJavaScript } from './js-sandbox'; export interface ArtifactDocument { id: string; title: string; source: string; sandboxedSource: string; createdAt: number; } export interface ToolExecution { call: EngineChatToolCall; output: string; artifact?: ArtifactDocument; failed: boolean; } const MAX_ARTIFACT_BYTES = 256 * 1024; const MAX_TOOL_RESULT_CHARACTERS = 64 * 1024; const ARTIFACT_CSP = [ "default-src 'none'", "base-uri 'none'", "connect-src 'none'", "form-action 'none'", "frame-src 'none'", "img-src data: blob:", "font-src data:", "media-src data: blob:", "style-src 'unsafe-inline'", "script-src 'none'", ].join('; '); export const HTML_ARTIFACT_TOOL: EngineChatTool = { type: 'function', function: { name: 'html_artifact', description: 'Create a self-contained HTML/CSS artifact and open it in a network-isolated preview. Return complete HTML source, not a Markdown code block. JavaScript is never executed; use js_eval for deterministic computation.', parameters: { type: 'object', properties: { html: { type: 'string', description: 'Complete, self-contained HTML/CSS source. Script elements remain visible in the code view but are not executed.' }, title: { type: 'string', description: 'Short artifact title.' }, }, required: ['html'], additionalProperties: false, }, }, }; export const AGENT_TOOLS: EngineChatTool[] = [ HTML_ARTIFACT_TOOL, { type: 'function', function: { name: 'js_eval', description: 'Evaluate deterministic JavaScript in a disposable worker with no network or browser storage. Return the result explicitly.', parameters: { type: 'object', properties: { source: { type: 'string', description: 'JavaScript function body. Use return to emit a result.' }, }, required: ['source'], additionalProperties: false, }, }, }, { type: 'function', function: { name: 'memory', description: 'Store, retrieve, list, or delete small user-approved notes in this browser profile.', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['put', 'get', 'list', 'delete'] }, key: { type: 'string', description: 'Memory key for put, get, or delete.' }, value: { type: 'string', description: 'Memory value for put.' }, }, required: ['action'], additionalProperties: false, }, }, }, ]; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function requiredString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) throw new Error(`${field} must be a non-empty string`); return value; } function parseArguments(call: EngineChatToolCall): Record { const parsed: unknown = JSON.parse(call.function.arguments); if (!isRecord(parsed)) throw new Error('tool arguments must be a JSON object'); return parsed; } function boundedJson(value: unknown): string { const serialized = JSON.stringify(value); if (serialized.length <= MAX_TOOL_RESULT_CHARACTERS) return serialized; return JSON.stringify({ ok: false, error: 'tool result exceeded 64 KiB and was truncated', preview: serialized.slice(0, MAX_TOOL_RESULT_CHARACTERS - 256), }); } function injectArtifactCsp(html: string): string { const meta = ``; return `${meta}${html}`; } export function prepareHtmlArtifact( html: string, title = 'Untitled artifact', id: string = crypto.randomUUID(), ): ArtifactDocument { const bytes = new TextEncoder().encode(html).byteLength; if (bytes > MAX_ARTIFACT_BYTES) throw new Error('html_artifact source exceeds 256 KiB'); return { id, title: title.trim().slice(0, 120) || 'Untitled artifact', source: html, sandboxedSource: injectArtifactCsp(html), createdAt: Date.now(), }; } async function executeMemory(args: Record): Promise { const action = requiredString(args.action, 'memory.action'); if (action === 'list') { return { action, entries: (await listMemory()).slice(0, 100).map((record) => ({ key: record.key, valuePreview: record.value.slice(0, 512), updatedAt: record.updatedAt, })), }; } const key = requiredString(args.key, 'memory.key'); if (action === 'put') { const value = requiredString(args.value, 'memory.value'); if (!window.confirm(`Allow Bonsai to store the local memory note “${key}”?`)) { throw new Error('operator declined memory write'); } return { action, record: await putMemory(key, value) }; } if (action === 'get') { return { action, record: await getMemory(key) ?? null }; } if (action === 'delete') { if (!window.confirm(`Allow Bonsai to delete the local memory note “${key}”?`)) { throw new Error('operator declined memory deletion'); } await deleteMemory(key); return { action, key, deleted: true }; } throw new Error(`unsupported memory action: ${action}`); } export async function executeAgentTool(call: EngineChatToolCall, signal?: AbortSignal): Promise { try { if (signal?.aborted) throw new DOMException('The tool operation was aborted.', 'AbortError'); const args = parseArguments(call); if (call.function.name === 'html_artifact') { const artifact = prepareHtmlArtifact( requiredString(args.html, 'html_artifact.html'), typeof args.title === 'string' ? args.title : undefined, call.id, ); return { call, artifact, output: boundedJson({ ok: true, artifactId: artifact.id, title: artifact.title, bytes: artifact.source.length }), failed: false, }; } if (call.function.name === 'js_eval') { const result = await runJavaScript(requiredString(args.source, 'js_eval.source'), 5000, signal); return { call, output: boundedJson({ ok: true, ...result }), failed: false }; } if (call.function.name === 'memory') { const result = await executeMemory(args); if (signal?.aborted) throw new DOMException('The tool operation was aborted.', 'AbortError'); return { call, output: boundedJson({ ok: true, ...(result as object) }), failed: false }; } throw new Error(`unsupported tool: ${call.function.name}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { call, output: boundedJson({ ok: false, error: message }), failed: true }; } }