Bonsai-Chat-WebGPU / src /lib /agent-tools.ts
Valeriy Selitskiy
Release v1.2.2 interactive chat workspace
21ad36a
Raw
History Blame Contribute Delete
6.96 kB
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<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 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 injectArtifactCsp(html: string): string {
const meta = `<meta http-equiv="Content-Security-Policy" content="${ARTIFACT_CSP}">`;
return `<!doctype html><html><head>${meta}</head><body>${html}</body></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<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): Promise<ToolExecution> {
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 };
}
}