@womp/spark-agent 6.1.1 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ // Swan's Flow node catalog (`flowNodeCatalog()` in swan-frontend src/llm/flowbot/editableDocument.ts),
2
+ // copied from crow-backend src/utils/agent/flow-node-catalog.generated.ts on 2026-10-08 for
3
+ // intent-bench runs. Refresh it from there when the Flow node surface changes.
4
+ export const INTENT_BENCH_FLOW_CATALOG = "Connect only the listed input and output handles. Set literal-only settings with values, never refs. generateImage exposes Image1 through ImageN according to :num_images (default 1); use Image1, not its hidden array outputs.\n\nai.llm\nConnectable inputs:\n System: base.string\n Prompt: base.string\n Image1: res.bitmap\n Image2: res.bitmap\n Image3: res.bitmap\n Image4: res.bitmap\nConnectable outputs:\n Result: base.string\nLiteral-only settings (no connections):\n Model: enum:ai.llmModel\n\nai.llmText\nConnectable inputs:\n System: base.string\n Prompt1: base.string\n Prompt2: base.string\n Prompt3: base.string\n Prompt4: base.string\nConnectable outputs:\n Result: base.string\nLiteral-only settings (no connections):\n Model: enum:ai.llmModel\n\nai.removeImageBackground\nConnectable inputs:\n Image: res.image\n Model: enum:ai.processImage.removeBackgroundModel\n Sensitivity: base.float\nConnectable outputs:\n Image: res.bitmap\nLiteral-only settings (no connections):\n Temporary: base.bool\n\nai.upscaleImage\nConnectable inputs:\n Image: res.bitmap\n Model: enum:ai.upscaleModel\n Scale: base.float\nConnectable outputs:\n Image: res.bitmap\nLiteral-only settings (no connections):\n Temporary: base.bool\n\nbase.input.string\nConnectable inputs:\n Value: base.string\nConnectable outputs:\n Result: base.string\nLiteral-only settings (no connections):\n\n\ndb.scriptInstance\nConnectable inputs:\n\nConnectable outputs:\n Result: db.scriptInstance\nLiteral-only settings (no connections):\n\n\next.ai.generateImage\nConnectable inputs:\n Prompt: base.string\n Image1: res.bitmap\n Image2: res.bitmap\n Image3: res.bitmap\n Image4: res.bitmap\nConnectable outputs:\n Image1: res.bitmap\n Image2: res.bitmap - only with :num_images 2 or more\n Image3: res.bitmap - only with :num_images 3 or more\n Image4: res.bitmap - only with :num_images 4 or more\nLiteral-only settings (no connections):\n PromptPreset: base.string\n AiWorkflow: enum:db.aiWorkflowId\n Seed: base.float\n RandomSeed: base.bool\n NumImages: base.float\n Temporary: base.bool\n\next.ai.generateMesh\nConnectable inputs:\n Image: res.bitmap\n Back: res.bitmap\n Left: res.bitmap\n Right: res.bitmap\nConnectable outputs:\n Mesh: res.mesh\nLiteral-only settings (no connections):\n AiWorkflow: enum:db.aiWorkflowId\n Seed: base.float\n RandomSeed: base.bool\n\next.edit.paper\nConnectable inputs:\n Background: res.image\n Overlays: res.image[]\n Width: base.float\n Height: base.float\n Title: base.string\nConnectable outputs:\n Image: res.bitmap\nLiteral-only settings (no connections):\n\n\next.scene.area\nConnectable inputs:\n Script: db.scriptInstance\nConnectable outputs:\n AreaId: enum:scene.areaId\nLiteral-only settings (no connections):\n ProjectId: enum:db.projectId\n AreaId: enum:scene.areaId\n Hide: base.bool\n\nscene.importAsset\nConnectable inputs:\n Image: res.bitmap\n Mesh: res.mesh\n AreaId: enum:scene.areaId\nConnectable outputs:\n AreaId: enum:scene.areaId\nLiteral-only settings (no connections):\n ProjectId: enum:db.projectId\n AssetType: enum:db.assetType\n LibraryOnly: base.bool\n\nscene.printEstimate\nConnectable inputs:\n Target: enum:scene.areaId | res.mesh[]\n Material: base.string\n Color: base.string\n Scale: base.float\n Thickness: base.float\n DisableAIPreview: base.bool\nConnectable outputs:\n Preview: res.image\nLiteral-only settings (no connections):\n ProjectId: enum:db.projectId\n\nviewer.images\nConnectable inputs:\n Images: res.bitmap[]\nConnectable outputs:\n Image: res.bitmap\nLiteral-only settings (no connections):\n ImageIndex: base.float\n\nviewer.meshes\nConnectable inputs:\n Meshes: res.mesh[]\nConnectable outputs:\n Mesh: res.mesh\nLiteral-only settings (no connections):\n MeshIndex: base.float";
@@ -0,0 +1,66 @@
1
+ import { type SparkAgentOptions } from "./agent.js";
2
+ import type { IntentCase, IntentRoute } from "./intent-bench-cases.js";
3
+ import type { SparkRunSettings } from "./server/types.js";
4
+ export type IntentDecision = {
5
+ route: IntentRoute;
6
+ /** The first call that would have changed something; the run stopped there. */
7
+ call: string;
8
+ detail?: unknown;
9
+ /** The JavaScript it ran in. */
10
+ code?: string;
11
+ };
12
+ /** A scene edit made in the same call as another route's write belongs to that route. */
13
+ export declare function intentRoute(decision: IntentDecision): IntentRoute;
14
+ export type IntentRun = {
15
+ caseId: string;
16
+ repeat: number;
17
+ route: IntentRoute;
18
+ pass: boolean;
19
+ decision?: IntentDecision;
20
+ /** Skills the model loaded, in order. */
21
+ skills: string[];
22
+ /** Agent API calls the fixtures served, in order. */
23
+ calls: string[];
24
+ /** Fixture reads that could not answer; the run may not reflect production. */
25
+ fixtureErrors: string[];
26
+ text: string;
27
+ /** Thinking, skill loads, code and its outcome, and replies, in order. */
28
+ transcript: string[];
29
+ steps: number;
30
+ costUsd: number | null;
31
+ durationMs: number;
32
+ error?: string;
33
+ };
34
+ /** A solid-color PNG, so conversation cases need no image files. */
35
+ export declare function solidPng([r, g, b]: [number, number, number], size?: number): Uint8Array;
36
+ export type IntentModel = Pick<SparkAgentOptions, "model" | "modelId" | "providerOptions" | "maxOutputTokens">;
37
+ /** The standalone server's provider settings for a Spark model. */
38
+ export declare function intentBenchModel(settings: SparkRunSettings): IntentModel;
39
+ export declare function runIntentCase(options: {
40
+ testCase: IntentCase;
41
+ repeat: number;
42
+ model: IntentModel;
43
+ maxSteps: number;
44
+ timeoutMs: number;
45
+ signal?: AbortSignal;
46
+ }): Promise<IntentRun>;
47
+ export type IntentCaseSummary = {
48
+ id: string;
49
+ expect: readonly IntentRoute[];
50
+ runs: number;
51
+ passed: number;
52
+ routes: Partial<Record<IntentRoute, number>>;
53
+ note?: string;
54
+ };
55
+ export declare function summarizeIntentRuns(cases: readonly IntentCase[], runs: readonly IntentRun[]): IntentCaseSummary[];
56
+ /** Runs every case `repeat` times, at most `concurrency` at once. */
57
+ export declare function runIntentBench(options: {
58
+ cases: readonly IntentCase[];
59
+ repeat: number;
60
+ concurrency: number;
61
+ model: IntentModel;
62
+ maxSteps: number;
63
+ timeoutMs: number;
64
+ signal?: AbortSignal;
65
+ onRun?(run: IntentRun): void;
66
+ }): Promise<IntentRun[]>;
@@ -0,0 +1,555 @@
1
+ import { crc32, deflateSync } from "node:zlib";
2
+ import { createAnthropic } from "@ai-sdk/anthropic";
3
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
4
+ import { createOpenAI } from "@ai-sdk/openai";
5
+ import { runSparkAgent } from "./agent.js";
6
+ import { composeCapabilities, createAccountCapability, createAssetsCapability, createFlowCapability, createGeometryCapability, createKnowledgeCapability, createMaterialsCapability, createOpenScadCapability, createOperationsCapability, createSceneCapability, } from "./capabilities.js";
7
+ import { geometryReference } from "./geometry.js";
8
+ import { INTENT_BENCH_FLOW_CATALOG } from "./intent-bench-flow-catalog.js";
9
+ import { sparkModelOptions } from "./model-options.js";
10
+ import { getSparkModel } from "./models.js";
11
+ import { QuickJSJavaScriptExecutor } from "./quickjs-executor.js";
12
+ import { createBenchMetrics } from "./run-metrics.js";
13
+ import { createWompSceneApi } from "./womp-scene-api.js";
14
+ /**
15
+ * Skills that edit the scene to prepare their own target in the same call: Geometry Nodes
16
+ * creates an Area in scene.access and reads it as its target, for example.
17
+ */
18
+ const PREPARED_WRITES = [
19
+ [/\bwomp\.geometry\.(?:read|write)\s*\(/u, "geometry-nodes"],
20
+ [/\bwomp\.openscad\.(?:create|update)\s*\(/u, "openscad"],
21
+ ];
22
+ /** A scene edit made in the same call as another route's write belongs to that route. */
23
+ export function intentRoute(decision) {
24
+ const { route, code } = decision;
25
+ if (route !== "scene" || code === undefined)
26
+ return route;
27
+ return PREPARED_WRITES.find(([pattern]) => pattern.test(code))?.[1] ?? "scene";
28
+ }
29
+ const FLOW_CLOSED = "Open the Flow canvas before using flow-graphs.";
30
+ const GENERATION_ROUTES = {
31
+ "text-to-image": "image",
32
+ "image-to-image": "image-edit",
33
+ "upscale-image": "image-edit",
34
+ "remove-image-background": "image-edit",
35
+ "text-to-mesh": "mesh",
36
+ "image-to-mesh": "image-to-mesh",
37
+ };
38
+ /** A solid-color PNG, so conversation cases need no image files. */
39
+ export function solidPng([r, g, b], size = 64) {
40
+ const chunk = (type, data) => {
41
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
42
+ const length = Buffer.alloc(4);
43
+ length.writeUInt32BE(data.length);
44
+ const crc = Buffer.alloc(4);
45
+ crc.writeUInt32BE(crc32(body));
46
+ return Buffer.concat([length, body, crc]);
47
+ };
48
+ const header = Buffer.alloc(13);
49
+ header.writeUInt32BE(size, 0);
50
+ header.writeUInt32BE(size, 4);
51
+ header.set([8, 2, 0, 0, 0], 8);
52
+ const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: size }, () => [r, g, b]).flat())]);
53
+ return new Uint8Array(Buffer.concat([
54
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
55
+ chunk("IHDR", header),
56
+ chunk("IDAT", deflateSync(Buffer.concat(Array.from({ length: size }, () => row)))),
57
+ chunk("IEND", Buffer.alloc(0)),
58
+ ]));
59
+ }
60
+ // Each photo is downloaded once per process and shared by every run that attaches it.
61
+ const downloads = new Map();
62
+ async function download(url) {
63
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
64
+ const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() ?? "";
65
+ if (!response.ok || !mediaType.startsWith("image/")) {
66
+ throw new Error(`Cannot download the case image ${url}: HTTP ${response.status} ${mediaType}`.trim());
67
+ }
68
+ return { name: decodeURIComponent(new URL(url).pathname.split("/").pop()), mediaType, data: new Uint8Array(await response.arrayBuffer()) };
69
+ }
70
+ async function loadImage(image) {
71
+ if ("color" in image)
72
+ return { name: image.name, mediaType: "image/png", data: solidPng(image.color) };
73
+ let loaded = downloads.get(image.url);
74
+ if (!loaded) {
75
+ loaded = download(image.url);
76
+ downloads.set(image.url, loaded);
77
+ loaded.catch(() => downloads.delete(image.url));
78
+ }
79
+ return loaded;
80
+ }
81
+ class IntentDecided extends Error {
82
+ decision;
83
+ constructor(decision) {
84
+ super(`Intent recorded: ${decision.route}`);
85
+ this.decision = decision;
86
+ this.name = "IntentDecided";
87
+ }
88
+ }
89
+ /**
90
+ * Swan's capability set over fixtures. Reads answer from an empty scene and the case's
91
+ * conversation; the first call that would change the scene, generate, write a graph or
92
+ * import is the decision, and stops the run before it takes effect.
93
+ */
94
+ async function intentFixtures(testCase, controller) {
95
+ const calls = [];
96
+ const fixtureErrors = [];
97
+ let decision;
98
+ // Spark runs one execute_javascript call at a time.
99
+ let code;
100
+ const note = (call) => calls.push(call);
101
+ const decide = (route, call, detail) => {
102
+ note(call);
103
+ decision ??= { route, call, detail, ...(code === undefined ? {} : { code }) };
104
+ controller.abort(new IntentDecided(decision));
105
+ throw new IntentDecided(decision);
106
+ };
107
+ /** An error production would also return, such as a node that does not exist. */
108
+ const refuse = (call, message) => {
109
+ note(call);
110
+ throw new Error(message);
111
+ };
112
+ /** A read these fixtures cannot answer; the run may then differ from production. */
113
+ const missing = (call, message) => {
114
+ fixtureErrors.push(call);
115
+ return refuse(call, message);
116
+ };
117
+ const files = new Map();
118
+ const assets = [];
119
+ const messages = [];
120
+ const addFile = async (image, messageId, source) => {
121
+ const loaded = await loadImage(image);
122
+ const id = `${source}-${messageId}-${files.size + 1}`;
123
+ files.set(id, { artifact: { id, url: `fixture://${id}`, mediaType: loaded.mediaType, name: loaded.name }, data: loaded.data });
124
+ assets.push({ artifact: { id, mediaType: loaded.mediaType, name: loaded.name }, messageId, kind: "image", source });
125
+ return loaded;
126
+ };
127
+ for (const [index, turn] of testCase.turns.entries()) {
128
+ const messageId = 1001 + index;
129
+ if (turn.role === "assistant") {
130
+ for (const image of turn.generated ?? [])
131
+ await addFile(image, messageId, "generated");
132
+ messages.push({ role: "assistant", content: turn.text });
133
+ continue;
134
+ }
135
+ const content = [];
136
+ for (const image of turn.images ?? []) {
137
+ const loaded = await addFile(image, messageId, "uploaded");
138
+ content.push({ type: "file", data: loaded.data, mediaType: loaded.mediaType, filename: loaded.name });
139
+ }
140
+ if (turn.text)
141
+ content.push({ type: "text", text: turn.text });
142
+ messages.push({ role: "user", content });
143
+ }
144
+ const screenshot = { id: "scene-screenshot", mediaType: "image/png", name: "scene.png" };
145
+ files.set(screenshot.id, { artifact: { ...screenshot, url: "fixture://scene-screenshot" }, data: solidPng([228, 228, 228]) });
146
+ // An empty scene: the root group and the default material. Materials are prepared for
147
+ // whichever route follows (Geometry Nodes assigns them too), so material edits apply here
148
+ // and do not decide the route; a run that only edits materials is a scene edit.
149
+ const transform = { position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } };
150
+ // The root, plus the case's OpenSCAD parts from earlier turns.
151
+ const nodes = [
152
+ { id: 0, kind: "group", name: "", parentId: null, pickable: true, transform },
153
+ ...(testCase.openScadParts ?? []).map((part) => ({
154
+ id: part.objectId, kind: "openScad", name: part.title, parentId: part.parentId, pickable: true, transform,
155
+ })),
156
+ ];
157
+ const node = (id) => nodes.find((item) => item.id === id) ?? refuse(`scene.getNode(${id})`, `Node ${id} does not exist.`);
158
+ const materials = new Map([
159
+ [1, { id: 1, data: { color: { x: 0.74, y: 0.74, z: 0.74 }, roughness: 0.22, metalness: 0 }, shaders: [] }],
160
+ ]);
161
+ const ext = { materialsExt: { 1: { name: "Default Material", tier: 0 } } };
162
+ const materialEdits = [];
163
+ const material = (id) => materials.get(id) ?? refuse(`scene.getMaterial(${id})`, `Material ${id} does not exist.`);
164
+ const sceneReads = {
165
+ getNode: (id) => structuredClone(node(id)),
166
+ getNodeChildren: (id) => nodes.filter((item) => item.id !== 0 && item.parentId === id).map((item) => structuredClone(item)),
167
+ listNodes: () => nodes.map((item) => structuredClone(item)),
168
+ listNodeSummaries: () => nodes.map(({ id, name, kind, parentId }) => ({ id, name, kind, parentId })),
169
+ getSceneExt: () => structuredClone(ext),
170
+ getSceneProperties: () => ({}),
171
+ listMaterials: () => [...materials.values()].map((item) => structuredClone(item)),
172
+ getMaterial: (id) => structuredClone(material(id)),
173
+ createMaterial: (data) => {
174
+ const id = Math.max(...materials.keys()) + 1;
175
+ materials.set(id, { id, data: structuredClone(data), shaders: [] });
176
+ return structuredClone(material(id));
177
+ },
178
+ updateMaterial: (id, patch) => {
179
+ const item = material(id);
180
+ item.data = { ...item.data, ...structuredClone(patch) };
181
+ return structuredClone(item);
182
+ },
183
+ deleteMaterial: (id) => {
184
+ const item = material(id);
185
+ materials.delete(id);
186
+ return item;
187
+ },
188
+ // Metadata: material names here; node metadata needs a node, which only a decision creates.
189
+ patchSceneExt: (operations) => {
190
+ for (const { op, path, value } of operations) {
191
+ const keys = path.slice(1).split("/");
192
+ const last = keys.pop();
193
+ let parent = ext;
194
+ for (const key of keys)
195
+ parent = (parent[key] ??= {});
196
+ if (op === "remove")
197
+ delete parent[last];
198
+ else
199
+ parent[last] = structuredClone(value);
200
+ }
201
+ return undefined;
202
+ },
203
+ };
204
+ const scene = new Proxy(sceneReads, {
205
+ get: (target, key) => typeof key !== "string" ? undefined : target[key] ?? ((...args) => decide("scene", `scene.${key}`, args)),
206
+ });
207
+ const MATERIAL_EDITS = new Set(["createMaterial", "updateMaterial", "deleteMaterial", "materialImport"]);
208
+ const sceneCall = (method, args, action) => {
209
+ if (method === "getNodeBoundingBox")
210
+ return action();
211
+ if (!MATERIAL_EDITS.has(method))
212
+ return decide("scene", `scene.${method}`, args);
213
+ note(`scene.${method}`);
214
+ materialEdits.push({ call: `scene.${method}`, args });
215
+ return action();
216
+ };
217
+ const bounds = { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 }, center: { x: 0, y: 0, z: 0 }, size: { x: 0, y: 0, z: 0 } };
218
+ const draft = () => createWompSceneApi({
219
+ scene: scene,
220
+ engine: { getNodeBoundingBox: async () => bounds },
221
+ rootId: 0,
222
+ getDefaultMaterialId: () => 1,
223
+ fonts: [],
224
+ requireNode: (id) => {
225
+ node(id);
226
+ },
227
+ importLibraryMaterial: async (name) => {
228
+ const id = Math.max(...materials.keys()) + 1;
229
+ materials.set(id, { id, data: { name }, shaders: [] });
230
+ return id;
231
+ },
232
+ trackNode: () => undefined,
233
+ trackMaterial: () => undefined,
234
+ untrackMaterial: () => undefined,
235
+ listMaterialIds: () => [...materials.keys()],
236
+ requireMaterial: (id) => {
237
+ material(id);
238
+ },
239
+ call: sceneCall,
240
+ callAsync: sceneCall,
241
+ print: () => undefined,
242
+ });
243
+ const flow = testCase.flow ?? "closed";
244
+ const stage = testCase.stage ?? true;
245
+ const bindings = {
246
+ assets: createAssetsCapability({
247
+ list: async (options) => {
248
+ note("assets.list");
249
+ const items = assets.filter((item) => (options.kind === undefined || item.kind === options.kind) &&
250
+ (options.source === undefined || item.source === options.source) &&
251
+ (options.messageId === undefined || item.messageId === options.messageId));
252
+ return { items: items.slice(options.from, options.from + options.count), nextFrom: null };
253
+ },
254
+ }, stage
255
+ ? {
256
+ importMesh: async (request) => decide("import", "assets.importMesh", request),
257
+ importImage: async (request) => decide("import", "assets.importImage", request),
258
+ retryMeshImport: async (request) => decide("import", "assets.retryMeshImport", request),
259
+ readImport: async (key) => refuse("assets.readImport", `No import with key ${key}.`),
260
+ finishImport: async (key) => refuse("assets.finishImport", `No import with key ${key}.`),
261
+ }
262
+ : undefined, stage
263
+ ? {
264
+ importTexture: async (request) => decide("import", "assets.importTexture", request),
265
+ readTextureImport: async (key) => refuse("assets.readTextureImport", `No texture import with key ${key}.`),
266
+ }
267
+ : undefined),
268
+ operations: createOperationsCapability({
269
+ catalog: async () => {
270
+ note("operations.catalog");
271
+ return {
272
+ models: [
273
+ { id: "womp-image", name: "Womp Image", requiresSubscription: false, operations: ["text-to-image", "image-to-image", "upscale-image", "remove-image-background"] },
274
+ { id: "womp-mesh", name: "Womp Mesh", requiresSubscription: false, operations: ["text-to-mesh", "image-to-mesh"] },
275
+ ],
276
+ defaults: { image: "womp-image", editing: "womp-image", mesh: "womp-mesh" },
277
+ confirmationRequired: false,
278
+ };
279
+ },
280
+ list: async () => {
281
+ note("operations.list");
282
+ return { jobs: [], nextFrom: null };
283
+ },
284
+ start: async (request) => decide(GENERATION_ROUTES[request.type], `operations.generate:${request.type}`, request),
285
+ read: async (messageId) => missing("operations.read", `No job ${messageId}.`),
286
+ wait: async (messageId) => missing("operations.wait", `No job ${messageId}.`),
287
+ }),
288
+ account: createAccountCapability({ read: async () => missing("account.read", "Account information is unavailable right now.") }),
289
+ knowledge: createKnowledgeCapability({ search: async () => missing("knowledge.search", "Help search is unavailable right now.") }),
290
+ };
291
+ if (flow !== "unavailable") {
292
+ bindings.flow = createFlowCapability({
293
+ read: async () => {
294
+ if (flow === "closed")
295
+ return refuse("flow.read", FLOW_CLOSED);
296
+ note("flow.read");
297
+ return { flowId: "intent-bench", source: "", revision: "1", preservedInputs: {}, diagnostics: [] };
298
+ },
299
+ write: async (options) => (flow === "closed" ? refuse("flow.write", FLOW_CLOSED) : decide("flow", "flow.write", options)),
300
+ }, INTENT_BENCH_FLOW_CATALOG);
301
+ }
302
+ if (stage) {
303
+ Object.assign(bindings, {
304
+ scene: createSceneCapability({
305
+ health: async () => {
306
+ note("scene.health");
307
+ return { status: "within_limits", freshness: "snapshot", revisionVerified: false, affectedAreaIds: [] };
308
+ },
309
+ access: async (callback) => {
310
+ note("scene.access");
311
+ return callback(draft());
312
+ },
313
+ screenshot: async () => {
314
+ note("scene.screenshot");
315
+ return screenshot;
316
+ },
317
+ }),
318
+ materials: createMaterialsCapability({
319
+ // An empty node catalog: enough to plan plain colors, not procedural layers.
320
+ catalog: async () => {
321
+ note("materials.catalog");
322
+ fixtureErrors.push("materials.catalog (no shader nodes)");
323
+ return { operations: [], nodes: {}, textureCount: 0 };
324
+ },
325
+ read: async () => {
326
+ note("materials.read");
327
+ return {
328
+ materials: [...materials.keys()].map((id) => ({ id, shaderIds: [], objectIds: [] })),
329
+ material: { id: 1, data: structuredClone(material(1).data), layers: [] },
330
+ diagnostics: [],
331
+ };
332
+ },
333
+ write: async (options) => decide("scene", "materials.write", options),
334
+ status: async () => {
335
+ note("materials.status");
336
+ return { remaining: 0, pendingResources: {}, rendering: "unverified", explanation: "No material work is pending." };
337
+ },
338
+ }),
339
+ geometry: createGeometryCapability({
340
+ // The scene root is the creation container.
341
+ read: async (objectId) => {
342
+ if (objectId !== 0)
343
+ return refuse("geometry.read", `Object ${objectId} has no Geometry Nodes graph.`);
344
+ note("geometry.read");
345
+ return { kind: "container", objectId: 0, title: "Scene", source: null, instances: [], diagnostics: [] };
346
+ },
347
+ write: async (options) => decide("geometry-nodes", "geometry.write", options),
348
+ }, geometryReference()),
349
+ openscad: createOpenScadCapability({
350
+ read: async (objectId) => {
351
+ const part = objectId === 0
352
+ ? { kind: "container", objectId: 0, parentId: 0, title: "Scene", source: null, params: {} }
353
+ : testCase.openScadParts?.find((item) => item.objectId === objectId);
354
+ if (!part)
355
+ return refuse("openscad.read", `Object ${objectId} is not an OpenSCAD part.`);
356
+ note("openscad.read");
357
+ return structuredClone(part);
358
+ },
359
+ create: async (options) => decide("openscad", "openscad.create", options),
360
+ update: async (options) => decide("openscad", "openscad.update", options),
361
+ }),
362
+ });
363
+ }
364
+ const executor = new QuickJSJavaScriptExecutor();
365
+ return {
366
+ capabilities: composeCapabilities(bindings),
367
+ artifacts: {
368
+ resolve: async (reference) => {
369
+ const file = files.get(reference.id);
370
+ if (!file)
371
+ throw new Error(`Artifact unavailable: ${reference.id}`);
372
+ return file;
373
+ },
374
+ },
375
+ messages,
376
+ messageId: String(1000 + testCase.turns.length),
377
+ executor: {
378
+ execute: (...args) => {
379
+ code = args[0];
380
+ return executor.execute(...args);
381
+ },
382
+ },
383
+ result: () => ({
384
+ calls,
385
+ fixtureErrors,
386
+ decision: decision ?? (materialEdits[0] && { route: "scene", call: materialEdits[0].call, detail: materialEdits[0].args }),
387
+ }),
388
+ };
389
+ }
390
+ /** The standalone server's provider settings for a Spark model. */
391
+ export function intentBenchModel(settings) {
392
+ const selected = getSparkModel(settings.modelId);
393
+ const key = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GEMINI_API_KEY" }[selected.provider];
394
+ const apiKey = process.env[key]?.trim();
395
+ if (!apiKey)
396
+ throw new Error(`${key} is required in the environment or .env.`);
397
+ const model = selected.provider === "anthropic" ? createAnthropic({ apiKey })(selected.id)
398
+ : selected.provider === "google" ? createGoogleGenerativeAI({ apiKey })(selected.id)
399
+ : createOpenAI({ apiKey }).responses(selected.id);
400
+ return {
401
+ model,
402
+ modelId: selected.id,
403
+ providerOptions: sparkModelOptions(selected.id, selected.provider, settings.reasoningEffort),
404
+ maxOutputTokens: 48_000,
405
+ };
406
+ }
407
+ export async function runIntentCase(options) {
408
+ const { testCase } = options;
409
+ const startedAt = Date.now();
410
+ const controller = new AbortController();
411
+ const onAbort = () => controller.abort(options.signal?.reason);
412
+ options.signal?.addEventListener("abort", onAbort, { once: true });
413
+ const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${options.timeoutMs / 1000} s.`)), options.timeoutMs);
414
+ const fixtures = await intentFixtures(testCase, controller);
415
+ const metrics = createBenchMetrics();
416
+ const skills = [];
417
+ let streamed = "";
418
+ const transcript = [];
419
+ let open;
420
+ const append = (kind, delta) => {
421
+ if (open?.kind !== kind) {
422
+ if (open)
423
+ transcript.push(`[${open.kind}] ${open.text.trim()}`);
424
+ open = { kind, text: "" };
425
+ }
426
+ open.text += delta;
427
+ };
428
+ const entry = (line) => {
429
+ if (open)
430
+ transcript.push(`[${open.kind}] ${open.text.trim()}`);
431
+ open = undefined;
432
+ transcript.push(line);
433
+ };
434
+ let completed;
435
+ let error;
436
+ try {
437
+ const run = runSparkAgent({
438
+ ...options.model,
439
+ messages: fixtures.messages,
440
+ capabilities: fixtures.capabilities,
441
+ executor: fixtures.executor,
442
+ artifacts: fixtures.artifacts,
443
+ runId: `intent-${testCase.id}-${options.repeat}`,
444
+ messageId: fixtures.messageId,
445
+ signal: controller.signal,
446
+ maxSteps: options.maxSteps,
447
+ });
448
+ const reader = run.stream.getReader();
449
+ try {
450
+ for (;;) {
451
+ const { done, value } = await reader.read();
452
+ if (done)
453
+ break;
454
+ const chunk = value;
455
+ metrics.accept(chunk);
456
+ if (chunk.type === "text-delta") {
457
+ streamed += chunk.delta;
458
+ append("reply", chunk.delta);
459
+ }
460
+ if (chunk.type === "reasoning-delta")
461
+ append("thinking", chunk.delta);
462
+ if (chunk.type === "tool-input-available") {
463
+ const input = chunk.input;
464
+ entry(chunk.toolName === "execute_javascript"
465
+ ? `[run] ${String(input.purpose ?? "")}
466
+ ${String(input.code ?? "")}`
467
+ : `[${chunk.toolName}] ${JSON.stringify(input)}`);
468
+ }
469
+ if (chunk.type === "tool-output-available") {
470
+ const output = chunk.output;
471
+ entry(`[result] ${output?.ok === false ? `error: ${String(output.error)}` : JSON.stringify(output?.logs ?? output).slice(0, 600)}`);
472
+ }
473
+ if (chunk.type === "tool-output-error")
474
+ entry(`[result] error: ${chunk.errorText}`);
475
+ if (chunk.type === "tool-input-available" && chunk.toolName === "read_skill") {
476
+ const names = chunk.input.names;
477
+ if (Array.isArray(names))
478
+ skills.push(...names.filter((name) => typeof name === "string"));
479
+ }
480
+ }
481
+ }
482
+ catch {
483
+ // The run's result reports why the stream stopped.
484
+ }
485
+ if (open)
486
+ transcript.push(`[${open.kind}] ${open.text.trim()}`);
487
+ const result = await run.result;
488
+ if (result.status === "completed")
489
+ completed = result.text;
490
+ }
491
+ catch (caught) {
492
+ if (!(controller.signal.reason instanceof IntentDecided)) {
493
+ const reason = controller.signal.aborted ? controller.signal.reason : caught;
494
+ error = reason instanceof Error ? reason.message : String(reason);
495
+ }
496
+ }
497
+ finally {
498
+ clearTimeout(timer);
499
+ options.signal?.removeEventListener("abort", onAbort);
500
+ }
501
+ const { calls, fixtureErrors, decision } = fixtures.result();
502
+ const route = decision
503
+ ? intentRoute(decision)
504
+ : error ? "error" : completed !== undefined ? "reply" : "undecided";
505
+ const snapshot = metrics.snapshot();
506
+ return {
507
+ caseId: testCase.id,
508
+ repeat: options.repeat,
509
+ route,
510
+ pass: testCase.expect.includes(route),
511
+ ...(decision ? { decision } : {}),
512
+ skills,
513
+ calls,
514
+ fixtureErrors,
515
+ text: (completed ?? streamed).trim(),
516
+ transcript,
517
+ steps: snapshot.steps,
518
+ costUsd: snapshot.costUsd,
519
+ durationMs: Date.now() - startedAt,
520
+ ...(error ? { error } : {}),
521
+ };
522
+ }
523
+ export function summarizeIntentRuns(cases, runs) {
524
+ return cases.map((testCase) => {
525
+ const own = runs.filter((run) => run.caseId === testCase.id);
526
+ const routes = {};
527
+ for (const run of own)
528
+ routes[run.route] = (routes[run.route] ?? 0) + 1;
529
+ return {
530
+ id: testCase.id,
531
+ expect: testCase.expect,
532
+ runs: own.length,
533
+ passed: own.filter((run) => run.pass).length,
534
+ routes,
535
+ ...(testCase.note ? { note: testCase.note } : {}),
536
+ };
537
+ });
538
+ }
539
+ /** Runs every case `repeat` times, at most `concurrency` at once. */
540
+ export async function runIntentBench(options) {
541
+ const jobs = options.cases.flatMap((testCase) => Array.from({ length: options.repeat }, (_, repeat) => ({ testCase, repeat })));
542
+ const runs = [];
543
+ let next = 0;
544
+ const worker = async () => {
545
+ while (next < jobs.length && !options.signal?.aborted) {
546
+ const job = jobs[next++];
547
+ const run = await runIntentCase({ ...job, model: options.model, maxSteps: options.maxSteps, timeoutMs: options.timeoutMs, signal: options.signal });
548
+ runs.push(run);
549
+ options.onRun?.(run);
550
+ }
551
+ };
552
+ await Promise.all(Array.from({ length: Math.min(options.concurrency, jobs.length) }, worker));
553
+ const order = new Map(options.cases.map((testCase, index) => [testCase.id, index]));
554
+ return runs.sort((a, b) => order.get(a.caseId) - order.get(b.caseId) || a.repeat - b.repeat);
555
+ }
@@ -22,6 +22,16 @@ async function liveInput(tools, part) {
22
22
  const validated = await asSchema(tool.inputSchema).validate?.(input);
23
23
  return validated?.success ? { ...part, input: validated.value } : part;
24
24
  }
25
+ /** How the worker reads the conditions the user confirmed for the goal a message starts. */
26
+ function goalConditionsText({ conditions }) {
27
+ const list = (measurable) => conditions.filter((condition) => condition.measurable === measurable).map(({ text }) => `\n- ${text}`).join("");
28
+ const measured = list(true);
29
+ const reviewed = list(false);
30
+ return "[The user confirmed this request as a goal. Keep working until it is met; do not ask whether to continue."
31
+ + (measured ? `\nConditions an independent check will verify. Verify each with concrete tool results before claiming completion:${measured}` : "")
32
+ + (reviewed ? `\nPreferences the user will review themselves:${reviewed}` : "")
33
+ + "]";
34
+ }
25
35
  /** UI artifact previews already represented by a tool result are not model output. */
26
36
  export async function convertSparkMessages(messages, tools) {
27
37
  const history = await Promise.all(messages.map(async (message) => {
@@ -53,6 +63,10 @@ export async function convertSparkMessages(messages, tools) {
53
63
  return convertToModelMessages(history, {
54
64
  tools,
55
65
  convertDataPart: (part) => {
66
+ if (part.type === "data-goal") {
67
+ const goal = sparkDataPartSchemas.goal.safeParse(part.data);
68
+ return goal.success ? { type: "text", text: goalConditionsText(goal.data) } : undefined;
69
+ }
56
70
  if (part.type !== "data-selection")
57
71
  return undefined;
58
72
  const selection = sparkDataPartSchemas.selection.safeParse(part.data);