Spaces:
Running
Running
| import type { EngineChatTool, EngineChatToolCall } from '../engine'; | |
| import { | |
| evaluateHtmlArtifact, | |
| getArtifactDiagnostics, | |
| prepareArtifactDeployment, | |
| prepareHtmlArtifact, | |
| type ArtifactDocument, | |
| type ArtifactEvaluation, | |
| type ArtifactFileInput, | |
| } from './artifact-runtime'; | |
| import { normalizeArtifactPath } from './artifact-workspace'; | |
| import { generateBarChartHtml, generatePieChartHtml } from './chart-tools'; | |
| import { deleteMemory, getMemory, listMemory, putMemory } from './idb'; | |
| import { runJavaScript } from './js-sandbox'; | |
| import { searchWikipedia } from './web-search'; | |
| export type { ArtifactDocument, ArtifactEvaluation } from './artifact-runtime'; | |
| export { prepareArtifactDeployment, prepareHtmlArtifact } from './artifact-runtime'; | |
| export interface ToolExecution { | |
| call: EngineChatToolCall; | |
| output: string; | |
| artifact?: ArtifactDocument; | |
| relocateArtifact?: boolean; | |
| failed: boolean; | |
| } | |
| export interface AgentToolContext { | |
| artifacts: Map<string, ArtifactDocument>; | |
| } | |
| export interface ToolCatalogEntry { | |
| name: string; | |
| label: string; | |
| description: string; | |
| scope: 'local' | 'network'; | |
| } | |
| const MAX_TOOL_RESULT_CHARACTERS = 64 * 1024; | |
| export const TOOL_CATALOG: ToolCatalogEntry[] = [ | |
| { name: 'artifact_deploy', label: 'Deploy artifact', description: 'Create and test a persistent multi-file web deployment.', scope: 'local' }, | |
| { name: 'artifact_write', label: 'Write artifact files', description: 'Add, replace, or delete files and test the new deployment.', scope: 'local' }, | |
| { name: 'artifact_test', label: 'Test artifact', description: 'Rerun an artifact and collect console and DOM diagnostics.', scope: 'local' }, | |
| { name: 'artifact_inspect', label: 'Inspect artifact files', description: 'List deployments or read selected files and live diagnostics.', scope: 'local' }, | |
| { name: 'bar_chart', label: 'Bar chart', description: 'Turn labelled numeric data into an accessible bar-chart artifact.', scope: 'local' }, | |
| { name: 'pie_chart', label: 'Pie chart', description: 'Turn parts of a whole into an accessible pie-chart artifact.', scope: 'local' }, | |
| { name: 'js_eval', label: 'JavaScript eval', description: 'Run deterministic calculations in a disposable worker.', scope: 'local' }, | |
| { name: 'memory', label: 'Local memory', description: 'Store and retrieve user-approved notes in this browser.', scope: 'local' }, | |
| { name: 'web_search', label: 'Web search · Wikipedia', description: 'Search Wikipedia after confirmation; the query leaves this browser.', scope: 'network' }, | |
| ]; | |
| export const HTML_ARTIFACT_TOOL: EngineChatTool = { | |
| type: 'function', | |
| function: { | |
| name: 'html_artifact', | |
| description: 'Create and immediately test a self-contained HTML/CSS/JavaScript artifact. Use for any programmatic visualization, interactive UI, webpage, mini-app, diagram, or custom visual output. The artifact runs in an opaque-origin sandbox: external resources and browser storage are blocked, navigation primitives are rejected, and console plus DOM diagnostics are returned.', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| html: { type: 'string', description: 'Complete self-contained HTML, CSS, and optional inline JavaScript.' }, | |
| title: { type: 'string', description: 'Short artifact title.' }, | |
| }, | |
| required: ['html'], | |
| additionalProperties: false, | |
| }, | |
| }, | |
| }; | |
| const MAX_ARTIFACT_TOOL_FILE_SLOTS = 12; | |
| function artifactFileSlotProperties() { | |
| return Object.fromEntries(Array.from({ length: MAX_ARTIFACT_TOOL_FILE_SLOTS }, (_, index) => { | |
| const slot = index + 1; | |
| return [ | |
| [`file_${slot}_path`, { | |
| type: 'string', | |
| description: `Relative path for file ${slot}. Use consecutive slots and never start a path with /.`, | |
| }], | |
| [`file_${slot}_content`, { | |
| type: 'string', | |
| description: `Complete UTF-8 source for file ${slot}, without Markdown fences or custom wrapper markers.`, | |
| }], | |
| ]; | |
| }).flat()); | |
| } | |
| export const ARTIFACT_DEPLOY_TOOL: EngineChatTool = { | |
| type: 'function', | |
| function: { | |
| name: 'artifact_deploy', | |
| description: 'Create and immediately test one persistent multi-file web deployment. Use for every webpage, mini-app, diagram, custom visualization, or programmatic interface. Provide browser-ready HTML/CSS/JavaScript with no build step or packages. Every local URL and module import must be relative (./ or ../), never root-absolute. The opaque-origin sandbox blocks external network, storage, navigation, popups, workers, and parent access; console, resource, promise, and DOM diagnostics are returned.', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| title: { type: 'string', description: 'Short deployment title.' }, | |
| entry_path: { type: 'string', description: 'HTML entry path. Defaults to index.html.' }, | |
| ...artifactFileSlotProperties(), | |
| }, | |
| required: ['file_1_path', 'file_1_content'], | |
| additionalProperties: false, | |
| }, | |
| }, | |
| }; | |
| export const ARTIFACT_WRITE_TOOL: EngineChatTool = { | |
| type: 'function', | |
| function: { | |
| name: 'artifact_write', | |
| description: 'Edit an existing deployment by stable artifact_id, then publish and test a new immutable snapshot. Use to repair diagnostics or revise a site. Upsert only changed files, optionally delete obsolete paths, and keep all resource URLs relative. The updated deployment stays the same artifact but moves to this point in the agent transcript.', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| artifact_id: { type: 'string', description: 'Stable deployment ID returned by artifact_deploy or a chart tool.' }, | |
| ...artifactFileSlotProperties(), | |
| delete_paths: { | |
| type: 'string', | |
| description: 'Relative file paths to remove, one path per line.', | |
| }, | |
| entry_path: { type: 'string', description: 'Optional replacement HTML entry path.' }, | |
| title: { type: 'string', description: 'Optional replacement title.' }, | |
| }, | |
| required: ['artifact_id'], | |
| additionalProperties: false, | |
| }, | |
| }, | |
| }; | |
| export const AGENT_TOOLS: EngineChatTool[] = [ | |
| ARTIFACT_DEPLOY_TOOL, | |
| ARTIFACT_WRITE_TOOL, | |
| { | |
| type: 'function', | |
| function: { | |
| name: 'artifact_test', | |
| description: 'Rerun an existing artifact by ID without changing its source. Use to reproduce current console errors, unhandled rejections, and basic DOM output before or after a repair.', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| artifact_id: { type: 'string', description: 'Stable artifact ID.' }, | |
| }, | |
| required: ['artifact_id'], | |
| additionalProperties: false, | |
| }, | |
| }, | |
| }, | |
| { | |
| type: 'function', | |
| function: { | |
| name: 'artifact_inspect', | |
| description: 'List identifiable deployments, or inspect one deployment file tree, selected text files, and its latest live diagnostics. Use before artifact_write or when investigating a failure.', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| artifact_id: { type: 'string', description: 'Artifact ID. Omit to list all artifacts.' }, | |
| paths: { | |
| type: 'array', | |
| description: 'Exact relative paths to read. Omit to return only the file manifest.', | |
| items: { type: 'string' }, | |
| maxItems: 12, | |
| }, | |
| }, | |
| additionalProperties: false, | |
| }, | |
| }, | |
| }, | |
| { | |
| type: 'function', | |
| function: { | |
| name: 'bar_chart', | |
| description: 'Create and test an accessible bar-chart deployment from labelled non-negative values. Use for comparing magnitudes across categories; use artifact_deploy for bespoke or interactive visualizations.', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| title: { type: 'string', description: 'Chart title.' }, | |
| labels: { type: 'array', description: 'Category labels.', items: { type: 'string' }, minItems: 1, maxItems: 32 }, | |
| values: { type: 'array', description: 'Non-negative numeric values aligned with labels.', items: { type: 'number', minimum: 0 }, minItems: 1, maxItems: 32 }, | |
| colors: { type: 'array', description: 'Optional #RRGGBB colors aligned with labels.', items: { type: 'string' } }, | |
| }, | |
| required: ['title', 'labels', 'values'], | |
| additionalProperties: false, | |
| }, | |
| }, | |
| }, | |
| { | |
| type: 'function', | |
| function: { | |
| name: 'pie_chart', | |
| description: 'Create and test an accessible pie-chart artifact from labelled non-negative parts. Use only for parts of one positive total; prefer bar_chart for precise comparison.', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| title: { type: 'string', description: 'Chart title.' }, | |
| labels: { type: 'array', description: 'Slice labels.', items: { type: 'string' }, minItems: 1, maxItems: 32 }, | |
| values: { type: 'array', description: 'Non-negative slice values aligned with labels.', items: { type: 'number', minimum: 0 }, minItems: 1, maxItems: 32 }, | |
| colors: { type: 'array', description: 'Optional #RRGGBB colors aligned with labels.', items: { type: 'string' } }, | |
| }, | |
| required: ['title', 'labels', 'values'], | |
| additionalProperties: false, | |
| }, | |
| }, | |
| }, | |
| { | |
| type: 'function', | |
| function: { | |
| name: 'js_eval', | |
| description: 'Evaluate deterministic JavaScript in a disposable worker with no network, DOM, or browser storage. Use for calculations, parsing, transformations, and verification that are awkward to do mentally. Pass either one JavaScript expression or a function body with an explicit return.', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| source: { type: 'string', description: 'One JavaScript expression, or a function body that uses 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. Use only for durable user preferences or facts that the user asks to remember; writes and deletes require confirmation.', | |
| 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, | |
| }, | |
| }, | |
| }, | |
| { | |
| type: 'function', | |
| function: { | |
| name: 'web_search', | |
| description: 'Search the public Wikipedia knowledge index after operator confirmation. Use for factual background or references that may benefit from retrieval. This first provider is not a general web crawler and should not be described as exhaustive or real-time web coverage.', | |
| parameters: { | |
| type: 'object', | |
| properties: { | |
| query: { type: 'string', description: 'Concise search query sent to Wikipedia after confirmation.' }, | |
| language: { type: 'string', enum: ['en', 'ru'], description: 'Wikipedia language; inferred from the query when omitted.' }, | |
| limit: { type: 'number', description: 'Number of results from 1 to 8.' }, | |
| }, | |
| required: ['query'], | |
| additionalProperties: false, | |
| }, | |
| }, | |
| }, | |
| ]; | |
| function isRecord(value: unknown): value is Record<string, unknown> { | |
| 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 stringArray(value: unknown, field: string): string[] { | |
| if (!Array.isArray(value)) throw new Error(`${field} must be an array`); | |
| return value.map((item) => String(item)); | |
| } | |
| function numberArray(value: unknown, field: string): number[] { | |
| if (!Array.isArray(value)) throw new Error(`${field} must be an array`); | |
| return value.map((item) => Number(item)); | |
| } | |
| function parseArguments(call: EngineChatToolCall): Record<string, unknown> { | |
| 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 evaluationResult(evaluation: ArtifactEvaluation | undefined) { | |
| if (!evaluation) return null; | |
| return { | |
| status: evaluation.status, | |
| errors: evaluation.entries.filter((entry) => entry.level === 'error'), | |
| warnings: evaluation.entries.filter((entry) => entry.level === 'warn'), | |
| console: evaluation.entries, | |
| dom: evaluation.dom ?? null, | |
| durationMs: Math.round(evaluation.durationMs), | |
| testedAt: evaluation.testedAt, | |
| }; | |
| } | |
| function artifactFileInputs(value: unknown, field: string): ArtifactFileInput[] { | |
| if (typeof value === 'string') return parseArtifactFileBundle(value, field); | |
| if (Array.isArray(value)) { | |
| return value.map((item, index) => { | |
| if (!isRecord(item)) throw new Error(`${field}[${index}] must be an object`); | |
| if (typeof item.content !== 'string') throw new Error(`${field}[${index}].content must be text`); | |
| return { | |
| path: requiredString(item.path, `${field}[${index}].path`), | |
| content: item.content, | |
| }; | |
| }); | |
| } | |
| throw new Error(`${field} must be a raw file bundle`); | |
| } | |
| export function artifactFileInputsFromArguments( | |
| args: Record<string, unknown>, | |
| field: string, | |
| ): ArtifactFileInput[] { | |
| const files: ArtifactFileInput[] = []; | |
| for (let slot = 1; slot <= MAX_ARTIFACT_TOOL_FILE_SLOTS; slot += 1) { | |
| const pathKey = `file_${slot}_path`; | |
| const contentKey = `file_${slot}_content`; | |
| const path = args[pathKey]; | |
| const content = args[contentKey]; | |
| if (path === undefined && content === undefined) continue; | |
| if (path === undefined || content === undefined) { | |
| throw new Error(`${field} must provide ${pathKey} and ${contentKey} together`); | |
| } | |
| if (typeof content !== 'string') throw new Error(`${field}.${contentKey} must be text`); | |
| files.push({ | |
| path: requiredString(path, `${field}.${pathKey}`), | |
| content, | |
| }); | |
| } | |
| if (files.length > 0) return files; | |
| // Keep chats created by older deployments executable after a page refresh. | |
| if (args.files !== undefined) return artifactFileInputs(args.files, `${field}.files`); | |
| return []; | |
| } | |
| export function parseArtifactFileBundle(value: string, field = 'files'): ArtifactFileInput[] { | |
| const files: ArtifactFileInput[] = []; | |
| let currentPath: string | null = null; | |
| let currentContent: string[] = []; | |
| const lines = value.replace(/\r\n?/g, '\n').split('\n'); | |
| for (const line of lines) { | |
| if (currentPath === null) { | |
| if (!line.trim()) continue; | |
| const opener = line.match(/^<<<FILE[ \t]+(.+?)>>>[ \t]*$/); | |
| if (!opener) { | |
| throw new Error(`${field} must start each file with <<<FILE relative/path>>>`); | |
| } | |
| currentPath = requiredString(opener[1], `${field}.path`); | |
| currentContent = []; | |
| continue; | |
| } | |
| if (/^<<<END FILE>>>[ \t]*$/.test(line)) { | |
| files.push({ path: currentPath, content: currentContent.join('\n') }); | |
| currentPath = null; | |
| currentContent = []; | |
| continue; | |
| } | |
| currentContent.push(line); | |
| } | |
| if (currentPath !== null) { | |
| throw new Error(`${field} file ${currentPath} is missing <<<END FILE>>>`); | |
| } | |
| if (files.length === 0) { | |
| throw new Error(`${field} must contain at least one file envelope`); | |
| } | |
| if (files.length > 48) { | |
| throw new Error(`${field} exceeds the 48-file deployment limit`); | |
| } | |
| return files; | |
| } | |
| function artifactPathList(value: unknown, field: string): string[] { | |
| if (Array.isArray(value)) return stringArray(value, field); | |
| if (typeof value === 'string') { | |
| return value.split(/\r?\n/).map((path) => path.trim()).filter(Boolean); | |
| } | |
| throw new Error(`${field} must contain one path per line`); | |
| } | |
| function artifactResult(artifact: ArtifactDocument) { | |
| const files = artifact.files ?? []; | |
| return { | |
| artifactId: artifact.id, | |
| title: artifact.title, | |
| entryPath: artifact.entryPath ?? 'index.html', | |
| fileCount: files.length, | |
| bytes: files.reduce((total, file) => total + file.bytes, 0), | |
| files: files.map(({ path, mime, bytes }) => ({ path, mime, bytes })), | |
| workspace: { | |
| storage: artifact.workspaceStorage ?? 'snapshot', | |
| ...(artifact.workspaceWarning ? { warning: artifact.workspaceWarning } : {}), | |
| }, | |
| evaluation: evaluationResult(artifact.evaluation), | |
| }; | |
| } | |
| async function testedDeployment( | |
| files: ArtifactFileInput[], | |
| title: string, | |
| entryPath: string, | |
| id: string, | |
| signal: AbortSignal | undefined, | |
| createdAt?: number, | |
| ): Promise<ArtifactDocument> { | |
| const prepared = prepareArtifactDeployment(files, title, entryPath, id); | |
| const evaluation = await evaluateHtmlArtifact(prepared, 3000, signal); | |
| return { ...prepared, ...(createdAt ? { createdAt } : {}), evaluation }; | |
| } | |
| async function testedArtifact( | |
| source: string, | |
| title: string, | |
| id: string, | |
| signal: AbortSignal | undefined, | |
| createdAt?: number, | |
| ): Promise<ArtifactDocument> { | |
| return testedDeployment( | |
| [{ path: 'index.html', content: source }], | |
| title, | |
| 'index.html', | |
| id, | |
| signal, | |
| createdAt, | |
| ); | |
| } | |
| async function executeMemory(args: Record<string, unknown>): Promise<unknown> { | |
| 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, | |
| context: AgentToolContext = { artifacts: new Map() }, | |
| ): Promise<ToolExecution> { | |
| try { | |
| if (signal?.aborted) throw new DOMException('The tool operation was aborted.', 'AbortError'); | |
| const args = parseArguments(call); | |
| if (call.function.name === 'artifact_deploy') { | |
| const files = artifactFileInputsFromArguments(args, 'artifact_deploy'); | |
| if (files.length === 0) throw new Error('artifact_deploy must contain at least one file pair'); | |
| const artifact = await testedDeployment( | |
| files, | |
| typeof args.title === 'string' ? args.title : 'Untitled artifact', | |
| typeof args.entry_path === 'string' ? args.entry_path : 'index.html', | |
| crypto.randomUUID(), | |
| signal, | |
| ); | |
| context.artifacts.set(artifact.id, artifact); | |
| return { | |
| call, | |
| artifact, | |
| relocateArtifact: true, | |
| output: boundedJson({ ok: true, ...artifactResult(artifact) }), | |
| failed: false, | |
| }; | |
| } | |
| if (call.function.name === 'artifact_write') { | |
| const artifactId = requiredString(args.artifact_id, 'artifact_write.artifact_id'); | |
| const current = context.artifacts.get(artifactId); | |
| if (!current) throw new Error(`artifact ${artifactId} is not available in this chat`); | |
| const replacements = artifactFileInputsFromArguments(args, 'artifact_write'); | |
| const deletePaths = args.delete_paths === undefined | |
| ? [] | |
| : artifactPathList(args.delete_paths, 'artifact_write.delete_paths').map(normalizeArtifactPath); | |
| if (replacements.length === 0 && deletePaths.length === 0 | |
| && args.title === undefined && args.entry_path === undefined) { | |
| throw new Error('artifact_write must change files, title, or entry_path'); | |
| } | |
| const files = new Map((current.files ?? [{ | |
| path: current.entryPath ?? 'index.html', | |
| content: current.source, | |
| }]).map((file) => [file.path, { path: file.path, content: file.content }])); | |
| for (const path of deletePaths) files.delete(path); | |
| for (const replacement of replacements) { | |
| const path = normalizeArtifactPath(replacement.path); | |
| if (deletePaths.includes(path)) throw new Error(`${path} cannot be written and deleted in one artifact_write`); | |
| files.set(path, { path, content: replacement.content }); | |
| } | |
| const artifact = await testedDeployment( | |
| [...files.values()], | |
| typeof args.title === 'string' ? args.title : current.title, | |
| typeof args.entry_path === 'string' ? args.entry_path : current.entryPath ?? 'index.html', | |
| current.id, | |
| signal, | |
| current.createdAt, | |
| ); | |
| context.artifacts.set(artifact.id, artifact); | |
| return { | |
| call, | |
| artifact, | |
| relocateArtifact: true, | |
| output: boundedJson({ ok: true, updated: true, ...artifactResult(artifact) }), | |
| failed: false, | |
| }; | |
| } | |
| if (call.function.name === 'html_artifact') { | |
| const artifact = await testedArtifact( | |
| requiredString(args.html, 'html_artifact.html'), | |
| typeof args.title === 'string' ? args.title : 'Untitled artifact', | |
| call.id, | |
| signal, | |
| ); | |
| context.artifacts.set(artifact.id, artifact); | |
| return { | |
| call, | |
| artifact, | |
| relocateArtifact: true, | |
| output: boundedJson({ ok: true, artifactId: artifact.id, title: artifact.title, bytes: artifact.source.length, evaluation: evaluationResult(artifact.evaluation) }), | |
| failed: false, | |
| }; | |
| } | |
| if (call.function.name === 'artifact_update') { | |
| const artifactId = requiredString(args.artifact_id, 'artifact_update.artifact_id'); | |
| const current = context.artifacts.get(artifactId); | |
| if (!current) throw new Error(`artifact ${artifactId} is not available in this chat`); | |
| const artifact = await testedArtifact( | |
| requiredString(args.html, 'artifact_update.html'), | |
| typeof args.title === 'string' ? args.title : current.title, | |
| current.id, | |
| signal, | |
| current.createdAt, | |
| ); | |
| context.artifacts.set(artifact.id, artifact); | |
| return { | |
| call, | |
| artifact, | |
| relocateArtifact: true, | |
| output: boundedJson({ ok: true, artifactId: artifact.id, title: artifact.title, updated: true, bytes: artifact.source.length, evaluation: evaluationResult(artifact.evaluation) }), | |
| failed: false, | |
| }; | |
| } | |
| if (call.function.name === 'artifact_test') { | |
| const artifactId = requiredString(args.artifact_id, 'artifact_test.artifact_id'); | |
| const current = context.artifacts.get(artifactId); | |
| if (!current) throw new Error(`artifact ${artifactId} is not available in this chat`); | |
| const evaluation = await evaluateHtmlArtifact(current, 2500, signal); | |
| const artifact = { ...current, evaluation }; | |
| context.artifacts.set(artifact.id, artifact); | |
| return { | |
| call, | |
| artifact, | |
| relocateArtifact: false, | |
| output: boundedJson({ ok: true, artifactId, evaluation: evaluationResult(evaluation) }), | |
| failed: false, | |
| }; | |
| } | |
| if (call.function.name === 'artifact_inspect') { | |
| const artifactId = typeof args.artifact_id === 'string' ? args.artifact_id.trim() : ''; | |
| if (!artifactId) { | |
| return { | |
| call, | |
| output: boundedJson({ | |
| ok: true, | |
| artifacts: [...context.artifacts.values()].map((artifact) => ({ | |
| id: artifact.id, | |
| title: artifact.title, | |
| entryPath: artifact.entryPath ?? 'index.html', | |
| fileCount: artifact.files?.length ?? 1, | |
| bytes: (artifact.files ?? []).reduce((total, file) => total + file.bytes, artifact.files ? 0 : artifact.source.length), | |
| workspaceStorage: artifact.workspaceStorage ?? 'snapshot', | |
| evaluation: evaluationResult(getArtifactDiagnostics(artifact)), | |
| })), | |
| }), | |
| failed: false, | |
| }; | |
| } | |
| const artifact = context.artifacts.get(artifactId); | |
| if (!artifact) throw new Error(`artifact ${artifactId} is not available in this chat`); | |
| const requestedPaths = Array.isArray(args.paths) | |
| ? stringArray(args.paths, 'artifact_inspect.paths').map(normalizeArtifactPath) | |
| : args.include_source === true ? [artifact.entryPath ?? 'index.html'] : []; | |
| const files = artifact.files ?? [{ | |
| path: artifact.entryPath ?? 'index.html', | |
| content: artifact.source, | |
| mime: 'text/html; charset=utf-8', | |
| bytes: new TextEncoder().encode(artifact.source).byteLength, | |
| }]; | |
| return { | |
| call, | |
| output: boundedJson({ | |
| ok: true, | |
| artifact: { | |
| ...artifactResult(artifact), | |
| evaluation: evaluationResult(getArtifactDiagnostics(artifact)), | |
| ...(requestedPaths.length > 0 ? { | |
| contents: requestedPaths.map((path) => { | |
| const file = files.find((candidate) => candidate.path === path); | |
| if (!file) throw new Error(`artifact file does not exist: ${path}`); | |
| return { path: file.path, content: file.content }; | |
| }), | |
| } : {}), | |
| }, | |
| }), | |
| failed: false, | |
| }; | |
| } | |
| if (call.function.name === 'bar_chart' || call.function.name === 'pie_chart') { | |
| const title = requiredString(args.title, `${call.function.name}.title`); | |
| const input = { | |
| title, | |
| labels: stringArray(args.labels, `${call.function.name}.labels`), | |
| values: numberArray(args.values, `${call.function.name}.values`), | |
| ...(Array.isArray(args.colors) ? { colors: stringArray(args.colors, `${call.function.name}.colors`) } : {}), | |
| }; | |
| const source = call.function.name === 'bar_chart' | |
| ? generateBarChartHtml(input) | |
| : generatePieChartHtml(input); | |
| const artifact = await testedArtifact(source, title, call.id, signal); | |
| context.artifacts.set(artifact.id, artifact); | |
| return { | |
| call, | |
| artifact, | |
| relocateArtifact: true, | |
| output: boundedJson({ ok: true, artifactId: artifact.id, title, chart: call.function.name, evaluation: evaluationResult(artifact.evaluation) }), | |
| 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 }; | |
| } | |
| if (call.function.name === 'web_search') { | |
| const query = requiredString(args.query, 'web_search.query'); | |
| if (!window.confirm(`Allow Bonsai to send the search query “${query}” to Wikipedia?`)) { | |
| throw new Error('operator declined network search'); | |
| } | |
| const result = await searchWikipedia( | |
| query, | |
| typeof args.language === 'string' ? args.language : undefined, | |
| typeof args.limit === 'number' ? args.limit : undefined, | |
| signal, | |
| ); | |
| return { call, output: boundedJson({ ok: true, ...result }), 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 }; | |
| } | |
| } | |