Spaces:
Running
Running
File size: 18,551 Bytes
0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 21ad36a 897170b 21ad36a 897170b 21ad36a 0ed8124 21ad36a 0ed8124 21ad36a 897170b 0ed8124 399c79e 0ed8124 399c79e 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 897170b 0ed8124 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 | import type { EngineChatTool, EngineChatToolCall } from '../engine';
import {
evaluateHtmlArtifact,
getArtifactDiagnostics,
prepareHtmlArtifact,
type ArtifactDocument,
type ArtifactEvaluation,
} from './artifact-runtime';
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 { prepareHtmlArtifact } from './artifact-runtime';
export interface ToolExecution {
call: EngineChatToolCall;
output: string;
artifact?: ArtifactDocument;
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: 'html_artifact', label: 'HTML artifact', description: 'Build and test any programmatic interface or visualization.', scope: 'local' },
{ name: 'artifact_update', label: 'Update artifact', description: 'Replace an existing artifact in place and rerun it.', 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 artifacts', description: 'List artifacts or read source and the latest live errors.', 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,
},
},
};
export const AGENT_TOOLS: EngineChatTool[] = [
HTML_ARTIFACT_TOOL,
{
type: 'function',
function: {
name: 'artifact_update',
description: 'Replace the complete source of an existing artifact by artifact_id, rerender the same inline block live, and return fresh diagnostics. Use after html_artifact or artifact_test reports an error, or when the user requests a revision.',
parameters: {
type: 'object',
properties: {
artifact_id: { type: 'string', description: 'Stable ID returned by an artifact tool.' },
html: { type: 'string', description: 'Complete replacement HTML, CSS, and optional inline JavaScript.' },
title: { type: 'string', description: 'Optional replacement title.' },
},
required: ['artifact_id', 'html'],
additionalProperties: false,
},
},
},
{
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 artifacts, or inspect one artifact and its latest live console diagnostics. Use before updating an older artifact or when the user asks what failed. Set include_source only when the source is needed for a rewrite.',
parameters: {
type: 'object',
properties: {
artifact_id: { type: 'string', description: 'Artifact ID. Omit to list all artifacts.' },
include_source: { type: 'boolean', description: 'Include the complete current HTML source.' },
},
additionalProperties: false,
},
},
},
{
type: 'function',
function: {
name: 'bar_chart',
description: 'Create and test an accessible bar-chart artifact from labelled non-negative values. Use for comparing magnitudes across categories; use html_artifact 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,
};
}
async function testedArtifact(
source: string,
title: string,
id: string,
signal: AbortSignal | undefined,
createdAt?: number,
): Promise<ArtifactDocument> {
const prepared = prepareHtmlArtifact(source, title, id);
const evaluation = await evaluateHtmlArtifact(prepared, 2500, signal);
return { ...prepared, ...(createdAt ? { createdAt } : {}), evaluation };
}
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 === '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,
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,
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,
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,
bytes: artifact.source.length,
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`);
return {
call,
output: boundedJson({
ok: true,
artifact: {
id: artifact.id,
title: artifact.title,
bytes: artifact.source.length,
evaluation: evaluationResult(getArtifactDiagnostics(artifact)),
...(args.include_source === true ? { source: artifact.source } : {}),
},
}),
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,
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 };
}
}
|