@womp/spark-agent 6.1.0 → 6.2.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.
- package/CHANGELOG.md +25 -0
- package/README.md +20 -0
- package/dist/capabilities/flow.js +2 -3
- package/dist/intent-bench-cases.d.ts +42 -0
- package/dist/intent-bench-cases.js +149 -0
- package/dist/intent-bench-cli.d.ts +2 -0
- package/dist/intent-bench-cli.js +122 -0
- package/dist/intent-bench-flow-catalog.d.ts +1 -0
- package/dist/intent-bench-flow-catalog.js +4 -0
- package/dist/intent-bench.d.ts +66 -0
- package/dist/intent-bench.js +555 -0
- package/dist/prompt.d.ts +1 -1
- package/dist/prompt.js +13 -7
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ Notable changes to `@womp/spark-agent`, newest first. Versions follow
|
|
|
4
4
|
[Semantic Versioning](https://semver.org/). Add an entry with each version bump; describe what
|
|
5
5
|
changed for hosts and users, and how to migrate across breaking changes.
|
|
6
6
|
|
|
7
|
+
## 6.2.0 — 2026-10-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `intent-bench` CLI: measures which route Spark takes (scene, OpenSCAD, Geometry Nodes, image,
|
|
12
|
+
image edit, mesh, image-to-mesh, Flow, import, or a reply) for the build-intent QA requests
|
|
13
|
+
under the current creation rules, preview follow-ups and attached-photo cases, in parallel over
|
|
14
|
+
fixtures, with gpt-6-luna by default. See the README.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- An attached image without a request gets a question about what to make of it, not a generation.
|
|
19
|
+
Turning an attached image into 3D generates the mesh from it directly, with no new preview.
|
|
20
|
+
- Characters, creatures, people, food, sculptures, specific vehicles and branded items preview
|
|
21
|
+
first even when simple or stylized, unless the request names a method or has a source image.
|
|
22
|
+
- `flow-graphs` (v5) builds the requested graph with the catalog's nodes when an input, such as a
|
|
23
|
+
conversation upload, cannot be expressed in source, and tells the user which input to set.
|
|
24
|
+
|
|
25
|
+
## 6.1.1 — 2026-10-08
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Image-to-mesh and other generation run inline unless the user asks for Flow or nodes. Spark no
|
|
30
|
+
longer asks the user to open Flow for an attached photo.
|
|
31
|
+
|
|
7
32
|
## 6.1.0 — 2026-10-08
|
|
8
33
|
|
|
9
34
|
### Fixed
|
package/README.md
CHANGED
|
@@ -262,6 +262,26 @@ Progress goes to stderr; one JSON result goes to stdout. Use `yarn --silent agen
|
|
|
262
262
|
stdout. Exit codes: 0 completed, 1 run/transport/export failure, 2 invalid arguments, 3 requires
|
|
263
263
|
user action, 130 interrupted.
|
|
264
264
|
|
|
265
|
+
## Measure intent routing
|
|
266
|
+
|
|
267
|
+
`yarn intent-bench` checks which route Spark takes for the requests in
|
|
268
|
+
`src/intent-bench-cases.ts`: editable scene, OpenSCAD, Geometry Nodes, image, image edit, mesh,
|
|
269
|
+
image-to-mesh, Flow, import, or a reply without acting. It needs no server or renderer. Each run
|
|
270
|
+
gets Swan's skills over fixtures: an empty scene, the case's attachments and earlier turns, and
|
|
271
|
+
Flow open or closed. Photo cases download their image from Womp's media host. The first call that would change the scene, generate, write a graph or
|
|
272
|
+
import is recorded, and the run stops before it takes effect. Material edits do not decide a
|
|
273
|
+
route, since Geometry Nodes prepares its materials in the scene first.
|
|
274
|
+
|
|
275
|
+
```sh
|
|
276
|
+
yarn intent-bench # every case 3 times on gpt-6-luna, 24 at once
|
|
277
|
+
yarn intent-bench --only photo-only,F1 --repeat 10 --model gpt-6-astra
|
|
278
|
+
yarn intent-bench --list
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
It prints each case's routes and pass rate and writes `report.json`, with every run's skills,
|
|
282
|
+
calls, deciding call and reply, under `.spark/intent-bench/`. It exits 1 when any run took a route
|
|
283
|
+
the case does not expect. Model calls are paid; the full set costs about $0.10 on gpt-6-luna.
|
|
284
|
+
|
|
265
285
|
## Run cost estimates
|
|
266
286
|
|
|
267
287
|
The chat displays estimated API token cost in USD after every completed model step, from the
|
|
@@ -11,14 +11,13 @@ export function createFlowCapability(host, catalog) {
|
|
|
11
11
|
skills: [
|
|
12
12
|
{
|
|
13
13
|
name: "flow-graphs",
|
|
14
|
-
version: "
|
|
14
|
+
version: "5",
|
|
15
15
|
description: "Read and edit the open Workbench Flow pipeline: image/mesh generation, processing, and viewers.",
|
|
16
16
|
instructions: `Use womp.flow for the open Workbench pipeline.
|
|
17
|
-
Use Flow for image/mesh generation explicitly requested through Flow/nodes or continued edits to that Flow workflow, unless the user asks for an inline result.
|
|
18
|
-
An open Flow canvas alone does not redirect ordinary scene creation or inline generation.
|
|
19
17
|
Read the current graph and flow-graphs-reference before writing. Supply the complete edited s-expression document and the latest revision. Preserve unrelated nodes and stable node IDs. Omitting a node deletes it; empty source clears the graph.
|
|
20
18
|
Existing node configuration, edge identity, layout, runtime metadata, and generated outputs are retained for unchanged node identities/types. Scalar inputs represented in source are replaced by your document. The host retains preservedInputs for those same nodes. Non-private preserved inputs can be explicitly replaced in the document; never recreate asset objects or cached values as JSON strings. Private settings remain editor-owned: preserve existing private connections unchanged between retained nodes and do not introduce new private literals or connections.
|
|
21
19
|
If read returns source null, explain the diagnostics. Do not replace an unreadable graph as though it were empty.
|
|
20
|
+
Build the requested graph from the catalog's nodes even when an input cannot be expressed in source, such as a conversation upload or an editor-owned image: leave that input unconnected and tell the user which node input to set in the editor.
|
|
22
21
|
The Flow canvas must be open. If unavailable, tell the user to open Flow or Split in a project, or Nodes in the sidebar, and wait for their next message. Never claim a graph was created after a failed call.
|
|
23
22
|
Writes validate types, preserve the runner lifecycle, and await commit. They edit the graph; they do not report that generation jobs completed. Repair compiler errors in this same conversation with a fresh read. On committed/unknown receipts after failure, inspect the graph before retrying; do not replay blindly.`,
|
|
24
23
|
declarations: WOMP_FLOW_DECLARATIONS,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { OpenScadTarget } from "./capabilities.js";
|
|
2
|
+
/** The first action a run commits to, or how it ended without one. */
|
|
3
|
+
export declare const INTENT_ROUTES: readonly ["scene", "openscad", "geometry-nodes", "image", "image-edit", "mesh", "image-to-mesh", "flow", "import", "reply", "undecided", "error"];
|
|
4
|
+
export type IntentRoute = (typeof INTENT_ROUTES)[number];
|
|
5
|
+
/** A photo downloaded from `url`, or a generated solid color. */
|
|
6
|
+
export type IntentImage = {
|
|
7
|
+
url: string;
|
|
8
|
+
} | {
|
|
9
|
+
name: string;
|
|
10
|
+
color: [number, number, number];
|
|
11
|
+
};
|
|
12
|
+
export type IntentTurn = {
|
|
13
|
+
role: "user";
|
|
14
|
+
text?: string;
|
|
15
|
+
images?: IntentImage[];
|
|
16
|
+
}
|
|
17
|
+
/** Earlier assistant text; `generated` images are listed as that turn's generation outputs. */
|
|
18
|
+
| {
|
|
19
|
+
role: "assistant";
|
|
20
|
+
text: string;
|
|
21
|
+
generated?: IntentImage[];
|
|
22
|
+
};
|
|
23
|
+
export type IntentCase = {
|
|
24
|
+
id: string;
|
|
25
|
+
group: "core" | "workspace" | "conversation" | "photo";
|
|
26
|
+
/** Earlier turns; the last user turn is the request under test. */
|
|
27
|
+
turns: IntentTurn[];
|
|
28
|
+
/** Flow is listed whether or not a canvas is open, as in Swan; "unavailable" omits it. */
|
|
29
|
+
flow?: "open" | "closed" | "unavailable";
|
|
30
|
+
/** false omits the 3D stage capabilities (scene, materials, geometry, OpenSCAD, imports). */
|
|
31
|
+
stage?: boolean;
|
|
32
|
+
openScadParts?: OpenScadTarget[];
|
|
33
|
+
/** Acceptable routes. More than one only where the shared prompt allows either. */
|
|
34
|
+
expect: readonly IntentRoute[];
|
|
35
|
+
note?: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The build-intent QA requests (22 Sep 2026), with expectations updated to the creation
|
|
39
|
+
* rules restored in 6.0 (image previews for ambiguous and likeness-dependent requests),
|
|
40
|
+
* plus preview follow-ups and attached-photo cases.
|
|
41
|
+
*/
|
|
42
|
+
export declare const INTENT_CASES: IntentCase[];
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** The first action a run commits to, or how it ended without one. */
|
|
2
|
+
export const INTENT_ROUTES = [
|
|
3
|
+
"scene",
|
|
4
|
+
"openscad",
|
|
5
|
+
"geometry-nodes",
|
|
6
|
+
"image",
|
|
7
|
+
"image-edit",
|
|
8
|
+
"mesh",
|
|
9
|
+
"image-to-mesh",
|
|
10
|
+
"flow",
|
|
11
|
+
"import",
|
|
12
|
+
"reply",
|
|
13
|
+
"undecided",
|
|
14
|
+
"error",
|
|
15
|
+
];
|
|
16
|
+
const user = (text, images) => ({ role: "user", text, images });
|
|
17
|
+
// A takeaway coffee cup on a table, from Womp's media host.
|
|
18
|
+
const cup = { url: "https://media.womp.com/staging-kkp/thumbnails/ma_IMG_3417_1791494968752.webp" };
|
|
19
|
+
const washer = "difference() { cylinder(h = thickness, d = outer_diameter, $fn = 96); translate([0, 0, -1]) cylinder(h = thickness + 2, d = hole_diameter, $fn = 96); }";
|
|
20
|
+
/**
|
|
21
|
+
* The build-intent QA requests (22 Sep 2026), with expectations updated to the creation
|
|
22
|
+
* rules restored in 6.0 (image previews for ambiguous and likeness-dependent requests),
|
|
23
|
+
* plus preview follow-ups and attached-photo cases.
|
|
24
|
+
*/
|
|
25
|
+
export const INTENT_CASES = [
|
|
26
|
+
{ id: "P1", group: "core", turns: [user("Make a washer: 20 mm outer diameter, 8 mm hole, 2 mm thick.")], expect: ["openscad"] },
|
|
27
|
+
{ id: "P2", group: "core", turns: [user("Make a parametric phone stand where I can adjust the width and tilt angle.")], expect: ["openscad"] },
|
|
28
|
+
{ id: "P3", group: "core", turns: [user("Make a storage tray, 120 × 80 × 25 mm.")], expect: ["openscad"] },
|
|
29
|
+
{ id: "G1", group: "core", turns: [user("Make a branching coral with adjustable branch density.")], expect: ["geometry-nodes"] },
|
|
30
|
+
{ id: "G2", group: "core", turns: [user("Make a rippling surface with adjustable wave speed.")], expect: ["geometry-nodes"] },
|
|
31
|
+
{ id: "G3", group: "core", turns: [user("Make an organic vine where I can adjust the curl and leaf spacing.")], expect: ["geometry-nodes"] },
|
|
32
|
+
{
|
|
33
|
+
id: "S1", group: "core", turns: [user("A car")], expect: ["image"],
|
|
34
|
+
note: "Ambiguous creative request: one image preview, then a choice. The 4.3 QA expected scene.",
|
|
35
|
+
},
|
|
36
|
+
{ id: "S2", group: "core", turns: [user("Make a cup of water in the scene.")], expect: ["scene"] },
|
|
37
|
+
{
|
|
38
|
+
id: "S3", group: "core", turns: [user("Make a friendly little mushroom in 3D.")], expect: ["image"],
|
|
39
|
+
note: "A character depends on likeness, so it previews first. The 4.3 QA expected scene.",
|
|
40
|
+
},
|
|
41
|
+
{ id: "S4", group: "core", turns: [user("Make a simple wooden stool in 3D.")], expect: ["scene"], note: "Plain functional objects build directly." },
|
|
42
|
+
{ id: "S5", group: "core", turns: [user("Make a 3D corgi.")], expect: ["image"], note: "Creatures preview first." },
|
|
43
|
+
{ id: "S6", group: "core", turns: [user("Make a 3D model of a Ferrari F40.")], expect: ["image"], note: "Specific vehicles preview first." },
|
|
44
|
+
{ id: "S7", group: "core", turns: [user("a cozy cabin in the woods")], expect: ["image"], note: "Ambiguous creative request." },
|
|
45
|
+
{ id: "I1", group: "core", turns: [user("Generate an image of a red toy car on a white background.")], expect: ["image"] },
|
|
46
|
+
{ id: "I2", group: "core", turns: [user("Make a studio product photo of a blue ceramic cup.")], expect: ["image"] },
|
|
47
|
+
{ id: "I3", group: "core", turns: [user("Make a picture of a small fantasy mushroom house.")], expect: ["image"] },
|
|
48
|
+
{ id: "I4", group: "core", turns: [user("Design a logo for a coffee shop called Bean There.")], expect: ["image"] },
|
|
49
|
+
{ id: "M1", group: "core", turns: [user("Generate a red toy car mesh.")], expect: ["mesh"] },
|
|
50
|
+
{ id: "M2", group: "core", turns: [user("Generate a mesh of a blue ceramic water cup.")], expect: ["mesh"] },
|
|
51
|
+
{ id: "M3", group: "core", turns: [user("Generate a fantasy mushroom house mesh.")], expect: ["mesh"] },
|
|
52
|
+
{ id: "F1", group: "core", flow: "open", turns: [user("Make an image of a red toy car using Flow nodes.")], expect: ["flow"] },
|
|
53
|
+
{ id: "F2", group: "core", flow: "open", turns: [user("Make a ceramic cup mesh using Flow.")], expect: ["flow"] },
|
|
54
|
+
{ id: "F3", group: "core", flow: "open", turns: [user("Set up a nodes workflow that makes an image of a mushroom house.")], expect: ["flow"] },
|
|
55
|
+
{
|
|
56
|
+
id: "I1-flow-open", group: "workspace", flow: "open",
|
|
57
|
+
turns: [user("Generate an image of a red toy car on a white background.")], expect: ["image"],
|
|
58
|
+
note: "An open Flow canvas does not redirect ordinary generation.",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "F1-flow-closed", group: "workspace", flow: "closed",
|
|
62
|
+
turns: [user("Make an image of a red toy car using Flow nodes.")], expect: ["reply"],
|
|
63
|
+
note: "Explains that Flow must be open instead of generating inline.",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "S2-no-stage", group: "workspace", stage: false,
|
|
67
|
+
turns: [user("Make a cup of water in the scene.")], expect: ["reply"],
|
|
68
|
+
note: "Explains that the 3D stage is unavailable instead of switching route.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "P1-follow-up", group: "conversation",
|
|
72
|
+
turns: [
|
|
73
|
+
user("Make a washer: 20 mm outer diameter, 8 mm hole, 2 mm thick."),
|
|
74
|
+
{ role: "assistant", text: "I made the washer as an adjustable part (object 12): 20 mm outer diameter, 8 mm hole and 2 mm thick." },
|
|
75
|
+
user("Make the hole 10 mm."),
|
|
76
|
+
],
|
|
77
|
+
openScadParts: [{
|
|
78
|
+
kind: "part", objectId: 12, parentId: 0, title: "Washer", source: washer,
|
|
79
|
+
params: { outer_diameter: 20, hole_diameter: 8, thickness: 2 },
|
|
80
|
+
}],
|
|
81
|
+
expect: ["openscad"],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "I1-follow-up", group: "conversation",
|
|
85
|
+
turns: [
|
|
86
|
+
user("Generate an image of a red toy car on a white background."),
|
|
87
|
+
{ role: "assistant", text: "Here's your red toy car on a white background.", generated: [{ name: "red-toy-car.png", color: [200, 30, 30] }] },
|
|
88
|
+
user("Make it blue."),
|
|
89
|
+
],
|
|
90
|
+
expect: ["image-edit"],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "ambiguous-yes", group: "conversation",
|
|
94
|
+
turns: [
|
|
95
|
+
user("Generate an image of a red toy car on a white background."),
|
|
96
|
+
{
|
|
97
|
+
role: "assistant",
|
|
98
|
+
text: "Here's your red toy car. I can change its colors, or turn it into a sculpted 3D model.",
|
|
99
|
+
generated: [{ name: "red-toy-car.png", color: [200, 30, 30] }],
|
|
100
|
+
},
|
|
101
|
+
user("Yes"),
|
|
102
|
+
],
|
|
103
|
+
expect: ["reply"],
|
|
104
|
+
note: "Two options were offered, so a bare yes needs clarification.",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "preview-to-mesh", group: "conversation",
|
|
108
|
+
turns: [
|
|
109
|
+
user("A car"),
|
|
110
|
+
{
|
|
111
|
+
role: "assistant",
|
|
112
|
+
text: "Here's a quick preview. Should I refine the image, turn it into a sculpted 3D piece, or build it from individually editable shapes?",
|
|
113
|
+
generated: [{ name: "car-preview.png", color: [40, 90, 200] }],
|
|
114
|
+
},
|
|
115
|
+
user("Turn it into a sculpted 3D piece."),
|
|
116
|
+
],
|
|
117
|
+
expect: ["image-to-mesh"],
|
|
118
|
+
note: "Reuses the preview as the image-to-mesh input.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "preview-to-shapes", group: "conversation",
|
|
122
|
+
turns: [
|
|
123
|
+
user("A car"),
|
|
124
|
+
{
|
|
125
|
+
role: "assistant",
|
|
126
|
+
text: "Here's a quick preview. Should I refine the image, turn it into a sculpted 3D piece, or build it from individually editable shapes?",
|
|
127
|
+
generated: [{ name: "car-preview.png", color: [40, 90, 200] }],
|
|
128
|
+
},
|
|
129
|
+
user("Build it from editable shapes."),
|
|
130
|
+
],
|
|
131
|
+
expect: ["scene"],
|
|
132
|
+
},
|
|
133
|
+
{ id: "photo-3d", group: "photo", turns: [user("make this 3D", [cup])], expect: ["image-to-mesh"] },
|
|
134
|
+
{
|
|
135
|
+
id: "photo-3d-flow-open", group: "photo", flow: "open", turns: [user("make this 3D", [cup])], expect: ["image-to-mesh"],
|
|
136
|
+
note: "Without an explicit Flow request, an attached photo is standard image-to-mesh.",
|
|
137
|
+
},
|
|
138
|
+
{ id: "photo-shapes", group: "photo", turns: [user("build this with editable shapes", [cup])], expect: ["scene"] },
|
|
139
|
+
{
|
|
140
|
+
id: "photo-only", group: "photo", turns: [user(undefined, [cup])], expect: ["reply"],
|
|
141
|
+
note: "A photo alone does not say what to make, so ask. dev0 thread 145108 asked to open Flow instead.",
|
|
142
|
+
},
|
|
143
|
+
{ id: "photo-only-flow-open", group: "photo", flow: "open", turns: [user(undefined, [cup])], expect: ["reply"] },
|
|
144
|
+
{ id: "photo-flow-closed", group: "photo", flow: "closed", turns: [user("turn this into a 3D model using Flow", [cup])], expect: ["reply"] },
|
|
145
|
+
{
|
|
146
|
+
id: "photo-flow-open", group: "photo", flow: "open", turns: [user("turn this into a 3D model using Flow", [cup])], expect: ["flow"],
|
|
147
|
+
note: "Flow has no node for a chat photo; build the graph with what it has and leave the image input to the user.",
|
|
148
|
+
},
|
|
149
|
+
];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { loadEnvFile } from "node:process";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { INTENT_CASES } from "./intent-bench-cases.js";
|
|
8
|
+
import { intentBenchModel, runIntentBench, summarizeIntentRuns } from "./intent-bench.js";
|
|
9
|
+
import { SPARK_MODELS } from "./models.js";
|
|
10
|
+
function positiveInteger(value, name) {
|
|
11
|
+
const number = Number(value);
|
|
12
|
+
if (!Number.isSafeInteger(number) || number < 1)
|
|
13
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
14
|
+
return number;
|
|
15
|
+
}
|
|
16
|
+
async function main() {
|
|
17
|
+
const { values } = parseArgs({ options: {
|
|
18
|
+
model: { type: "string", default: "gpt-6-luna" }, reasoning: { type: "string", default: "medium" },
|
|
19
|
+
repeat: { type: "string", default: "3" }, concurrency: { type: "string", default: "24" },
|
|
20
|
+
only: { type: "string" }, group: { type: "string" }, "max-steps": { type: "string", default: "12" },
|
|
21
|
+
"timeout-seconds": { type: "string", default: "240" }, output: { type: "string" },
|
|
22
|
+
list: { type: "boolean" }, help: { type: "boolean" },
|
|
23
|
+
} });
|
|
24
|
+
if (values.help) {
|
|
25
|
+
console.log(`intent-bench [options]
|
|
26
|
+
|
|
27
|
+
Measures which route Spark takes for ${INTENT_CASES.length} requests: editable scene, OpenSCAD, Geometry Nodes,
|
|
28
|
+
image, image edit, mesh, image-to-mesh, Flow, import, or a reply without acting.
|
|
29
|
+
Runs use Swan's skills over fixtures: no renderer, no generation, no scene changes. The first
|
|
30
|
+
call that would change something is recorded and the run stops there. Makes paid model calls.
|
|
31
|
+
|
|
32
|
+
--model <id> Default: gpt-6-luna. Models: ${SPARK_MODELS.map((model) => model.id).join(", ")}
|
|
33
|
+
--reasoning <effort> Default: medium
|
|
34
|
+
--repeat <n> Runs per case; default: 3
|
|
35
|
+
--concurrency <n> Runs at once; default: 24
|
|
36
|
+
--only <ids> Comma-separated case IDs (--list shows them)
|
|
37
|
+
--group <names> core, workspace, conversation, photo
|
|
38
|
+
--max-steps <n> Model steps per run; default: 12
|
|
39
|
+
--timeout-seconds <n> Per run; default: 240
|
|
40
|
+
--output <directory> New directory only; default: .spark/intent-bench/<timestamp>
|
|
41
|
+
--list List the cases and expected routes`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (values.list) {
|
|
45
|
+
for (const testCase of INTENT_CASES) {
|
|
46
|
+
const prompt = testCase.turns.at(-1);
|
|
47
|
+
const text = prompt.role === "user" ? `${prompt.images?.length ? "[photo] " : ""}${prompt.text ?? ""}` : "";
|
|
48
|
+
console.log(`${testCase.id.padEnd(22)} ${testCase.group.padEnd(13)} ${testCase.expect.join("|").padEnd(22)} ${text}`);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const model = SPARK_MODELS.find((item) => item.id === values.model);
|
|
53
|
+
if (!model)
|
|
54
|
+
throw new Error("Select a supported model using --model. See --help.");
|
|
55
|
+
const reasoning = values.reasoning;
|
|
56
|
+
if (!model.reasoningEfforts.includes(reasoning))
|
|
57
|
+
throw new Error("Unsupported reasoning effort for this model.");
|
|
58
|
+
const only = values.only?.split(",").map((id) => id.trim()).filter(Boolean);
|
|
59
|
+
const groups = values.group?.split(",").map((group) => group.trim()).filter(Boolean);
|
|
60
|
+
const unknown = only?.filter((id) => !INTENT_CASES.some((testCase) => testCase.id === id)) ?? [];
|
|
61
|
+
if (unknown.length)
|
|
62
|
+
throw new Error(`Unknown case IDs: ${unknown.join(", ")}. Use --list.`);
|
|
63
|
+
const cases = INTENT_CASES.filter((testCase) => (!only || only.includes(testCase.id)) && (!groups || groups.includes(testCase.group)));
|
|
64
|
+
if (!cases.length)
|
|
65
|
+
throw new Error("No cases selected.");
|
|
66
|
+
const repeat = positiveInteger(values.repeat, "--repeat");
|
|
67
|
+
const concurrency = positiveInteger(values.concurrency, "--concurrency");
|
|
68
|
+
const maxSteps = positiveInteger(values["max-steps"], "--max-steps");
|
|
69
|
+
const timeoutMs = positiveInteger(values["timeout-seconds"], "--timeout-seconds") * 1000;
|
|
70
|
+
if (existsSync(".env"))
|
|
71
|
+
loadEnvFile();
|
|
72
|
+
const resolved = intentBenchModel({ modelId: model.id, reasoningEffort: reasoning });
|
|
73
|
+
const output = path.resolve(values.output ?? `.spark/intent-bench/${new Date().toISOString().replaceAll(":", "-")}`);
|
|
74
|
+
// Never reuse a result directory or overwrite a previous benchmark.
|
|
75
|
+
await mkdir(path.dirname(output), { recursive: true });
|
|
76
|
+
await mkdir(output);
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const stop = () => controller.abort(new Error("Benchmark interrupted."));
|
|
79
|
+
process.once("SIGINT", stop);
|
|
80
|
+
process.once("SIGTERM", stop);
|
|
81
|
+
const total = cases.length * repeat;
|
|
82
|
+
console.error(`${total} runs (${cases.length} cases × ${repeat}) on ${model.id}/${reasoning}, ${Math.min(concurrency, total)} at once. Results: ${output}`);
|
|
83
|
+
const startedAt = Date.now();
|
|
84
|
+
let done = 0;
|
|
85
|
+
const line = (run) => `${run.pass ? "pass" : "FAIL"} ${run.caseId.padEnd(22)} #${run.repeat} ${run.route.padEnd(14)} ${run.error ?? run.decision?.call ?? ""}`;
|
|
86
|
+
try {
|
|
87
|
+
const runs = await runIntentBench({
|
|
88
|
+
cases, repeat, concurrency, model: resolved, maxSteps, timeoutMs, signal: controller.signal,
|
|
89
|
+
onRun: (run) => console.error(`[${String(++done).padStart(String(total).length)}/${total}] ${line(run)}`),
|
|
90
|
+
});
|
|
91
|
+
const summary = summarizeIntentRuns(cases, runs);
|
|
92
|
+
const passed = runs.filter((run) => run.pass).length;
|
|
93
|
+
const priced = runs.filter((run) => run.costUsd !== null);
|
|
94
|
+
const costUsd = priced.reduce((sum, run) => sum + run.costUsd, 0);
|
|
95
|
+
const durationMs = Date.now() - startedAt;
|
|
96
|
+
await writeFile(path.join(output, "report.json"), `${JSON.stringify({
|
|
97
|
+
model: model.id, reasoning, repeat, maxSteps, startedAt: new Date(startedAt).toISOString(), durationMs,
|
|
98
|
+
passed, runs: runs.length, costUsd: priced.length ? costUsd : null, costStatus: priced.length === runs.length ? "estimated" : priced.length ? "partial" : "unavailable",
|
|
99
|
+
summary, results: runs,
|
|
100
|
+
}, null, 2)}\n`);
|
|
101
|
+
console.log(`\n${"case".padEnd(22)} ${"expected".padEnd(22)} pass routes`);
|
|
102
|
+
for (const item of summary) {
|
|
103
|
+
const routes = Object.entries(item.routes).map(([route, count]) => `${route}×${count}`).join(" ");
|
|
104
|
+
const mark = item.passed === item.runs ? " " : "!";
|
|
105
|
+
console.log(`${mark}${item.id.padEnd(21)} ${item.expect.join("|").padEnd(22)} ${`${item.passed}/${item.runs}`.padEnd(6)} ${routes}`);
|
|
106
|
+
}
|
|
107
|
+
const reliable = summary.filter((item) => item.passed === item.runs).length;
|
|
108
|
+
const gaps = runs.filter((run) => run.fixtureErrors.length).length;
|
|
109
|
+
console.log(`\n${passed}/${runs.length} runs on the expected route (${Math.round((100 * passed) / runs.length)}%), ` +
|
|
110
|
+
`${reliable}/${summary.length} cases on it every time. ${(durationMs / 1000).toFixed(0)} s, ` +
|
|
111
|
+
`${priced.length ? `$${costUsd.toFixed(3)} estimated` : "cost unavailable"}.` +
|
|
112
|
+
(gaps ? `\n${gaps} runs hit a read the fixtures cannot answer (fixtureErrors in the report).` : ""));
|
|
113
|
+
console.log(`Report: ${path.join(output, "report.json")}`);
|
|
114
|
+
if (passed < runs.length)
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
process.removeListener("SIGINT", stop);
|
|
119
|
+
process.removeListener("SIGTERM", stop);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const INTENT_BENCH_FLOW_CATALOG: string;
|
|
@@ -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
|
+
}
|
package/dist/prompt.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SparkSkill } from "./capabilities.js";
|
|
2
|
-
export declare const SPARK_AGENT_PROMPT = "You are Spark, Womp's unified assistant.\nUse read_skill({names:[...]}) to load the required APIs together. Skills provide TypeScript\ndeclarations for documentation; execute_javascript runs JavaScript, with top-level await.\nOnly the listed capabilities are available. Reading a skill does not grant additional permissions.\nUse only the supplied womp APIs and output helpers to access application services and state.\nIf a capability is unavailable, explain what is missing and ask the user to enable it when applicable.\nDo not work around missing capabilities by inspecting page globals, DOM or framework internals,\nor making direct network requests. Native execution is not a security sandbox; it does not expand\nthe supported API or the user's granted capabilities.\nGive each execute_javascript call a concise present-progressive purpose, such as \"Generating a car image\", \"Inspecting the scene\", or \"Importing the model\". Write purpose before code.\nEach execution has fresh local variables. Keep stable object, job, and artifact references.\nUse print for concise, filtered observations; await emitImage or emitFile with a host-issued artifact\nreference to show actual output. emitImage inspects by default; use { inspect: false } to display without adding image bytes to model context.\nCompleted generations are shown automatically to the user; image bytes are intentionally omitted from your context. Acknowledge completion without listing, re-emitting, or inspecting outputs unless the task requires it. Use returned artifact references for follow-up work.\nDo not claim visual verification without inspecting the image. Missing artifact references must be recovered or reported; omitted image bytes alone are not a missing result.\nInspect the scene before editing and verify the committed result.\nPreserve existing content unless the user asks to replace or delete it. Use the latest target revision\nwhen a write requires one. Capabilities own transactions: an entire script is not atomic.\nRecover ordinary tool errors yourself: use confirmed receipts and fresh reads, retry only unresolved work,\npreserve successful changes, and never replay an interrupted script. Do not narrate internal failures\nunless recovery cannot continue. Never claim an uncertain commit rolled back. Never resubmit a job just because waiting timed out.\nComplete related work in this conversation without delegating to another agent. Report limits,\nunsupported actions, cancellation, and incomplete work truthfully. You cannot see what your work\ncosts: model use can be billed, not only generations. Never estimate credits used or call work free;\nreport only credit figures a host message or tool provides. Finish with a concise explanation\nof the result in the user's language; keep implementation details out of that explanation.\n\nCreation intent\n\nResolve the requested outcome from the current message and preceding dialogue, not keyword matches.\nExplicit method choices take precedence. Continue edits in the existing representation unless the user\nasks to change it. Creation defaults do not apply to help, discussion, imports, or account questions.\n\n- Build directly with editable scene shapes for ordinary 3D requests, including \"3D X\" and\n \"make X in 3D\", and when the user wants individually editable shapes or geometry that blends\n with existing objects. Organic appearance alone does not require Geometry Nodes.\n\n- Use OpenSCAD for parametric, measured, mechanical, printable, or real-world-fit objects,\n except when another method is explicit (including primitives with dimensions), or when\n parametric organic/procedural geometry, animation, or Geometry Nodes is requested:\n use Geometry Nodes for those, not Workbench Flow. Do not claim print readiness without validation.\n\n- For mesh wompification, read mesh-wompify and scene-editing together and reconstruct only the specified reference using primitives and curves.\n\n- Use mesh generation when the user explicitly requests a mesh, asks to turn an existing or\n attached image into 3D, or chooses a sculpted result rather than individually editable shapes.\n\n- Use image generation for pictures, logos, concept art, wallpapers, and other clearly visual output.\n\n- For an ambiguous creative request without explicit 3D or printing intent, generate one image\n preview first. Then offer one short, outcome-based choice: refine the image, turn it into a\n sculpted 3D piece, or build it from individually editable shapes. Offer adjustable CAD instead\n of editable shapes when the subject is measured or must fit something real.\n\n- Also preview first when a 3D request
|
|
2
|
+
export declare const SPARK_AGENT_PROMPT = "You are Spark, Womp's unified assistant.\nUse read_skill({names:[...]}) to load the required APIs together. Skills provide TypeScript\ndeclarations for documentation; execute_javascript runs JavaScript, with top-level await.\nOnly the listed capabilities are available. Reading a skill does not grant additional permissions.\nUse only the supplied womp APIs and output helpers to access application services and state.\nIf a capability is unavailable, explain what is missing and ask the user to enable it when applicable.\nDo not work around missing capabilities by inspecting page globals, DOM or framework internals,\nor making direct network requests. Native execution is not a security sandbox; it does not expand\nthe supported API or the user's granted capabilities.\nGive each execute_javascript call a concise present-progressive purpose, such as \"Generating a car image\", \"Inspecting the scene\", or \"Importing the model\". Write purpose before code.\nEach execution has fresh local variables. Keep stable object, job, and artifact references.\nUse print for concise, filtered observations; await emitImage or emitFile with a host-issued artifact\nreference to show actual output. emitImage inspects by default; use { inspect: false } to display without adding image bytes to model context.\nCompleted generations are shown automatically to the user; image bytes are intentionally omitted from your context. Acknowledge completion without listing, re-emitting, or inspecting outputs unless the task requires it. Use returned artifact references for follow-up work.\nDo not claim visual verification without inspecting the image. Missing artifact references must be recovered or reported; omitted image bytes alone are not a missing result.\nInspect the scene before editing and verify the committed result.\nPreserve existing content unless the user asks to replace or delete it. Use the latest target revision\nwhen a write requires one. Capabilities own transactions: an entire script is not atomic.\nRecover ordinary tool errors yourself: use confirmed receipts and fresh reads, retry only unresolved work,\npreserve successful changes, and never replay an interrupted script. Do not narrate internal failures\nunless recovery cannot continue. Never claim an uncertain commit rolled back. Never resubmit a job just because waiting timed out.\nComplete related work in this conversation without delegating to another agent. Report limits,\nunsupported actions, cancellation, and incomplete work truthfully. You cannot see what your work\ncosts: model use can be billed, not only generations. Never estimate credits used or call work free;\nreport only credit figures a host message or tool provides. Finish with a concise explanation\nof the result in the user's language; keep implementation details out of that explanation.\n\nCreation intent\n\nResolve the requested outcome from the current message and preceding dialogue, not keyword matches.\nExplicit method choices take precedence. Continue edits in the existing representation unless the user\nasks to change it. Creation defaults do not apply to help, discussion, imports, or account questions.\n\n- Build directly with editable scene shapes for ordinary 3D requests, including \"3D X\" and\n \"make X in 3D\", and when the user wants individually editable shapes or geometry that blends\n with existing objects. Organic appearance alone does not require Geometry Nodes.\n\n- Use OpenSCAD for parametric, measured, mechanical, printable, or real-world-fit objects,\n except when another method is explicit (including primitives with dimensions), or when\n parametric organic/procedural geometry, animation, or Geometry Nodes is requested:\n use Geometry Nodes for those, not Workbench Flow. Do not claim print readiness without validation.\n\n- For mesh wompification, read mesh-wompify and scene-editing together and reconstruct only the specified reference using primitives and curves.\n\n- Use mesh generation when the user explicitly requests a mesh, asks to turn an existing or\n attached image into 3D, or chooses a sculpted result rather than individually editable shapes.\n\n- Use image generation for pictures, logos, concept art, wallpapers, and other clearly visual output.\n\n- For an ambiguous creative request without explicit 3D or printing intent, generate one image\n preview first. Then offer one short, outcome-based choice: refine the image, turn it into a\n sculpted 3D piece, or build it from individually editable shapes. Offer adjustable CAD instead\n of editable shapes when the subject is measured or must fit something real.\n\n- Also preview first when a 3D request names no method (such as a mesh, editable shapes or CAD),\n has no source image, and is a character, creature, person, food, sculpture, specific vehicle or\n branded item, even a simple or stylized one: its likeness and surface detail decide the result.\n Plain functional objects still build directly.\n\n- An attached image is already the reference. Turning it into 3D generates the mesh from it\n directly, with no new preview. An attached image without a request does not ask for anything\n yet: ask what to make of it before generating or building.\n\n- Generating a result does not authorize importing, converting, or building it in the scene.\n\n- Use Workbench Flow only when the user names Flow or nodes for the request, or continues edits\n to an existing Flow workflow. Otherwise run image and mesh generation, including image-to-mesh\n from an attached photo, inline, whether or not Flow is open; never ask the user to open Flow\n for it. If the user requested Flow and it is closed, explain that it must be open instead of\n silently generating inline.\n\n- After a preview, offer the choice above. After other completed generation, you may briefly\n suggest a relevant next step using available capabilities. Describe its outcome, not internal tools. Suggestions are optional,\n not a mandatory menu, and never authorize actions. Wait for a clear choice and follow existing\n confirmations. Reuse the existing artifact/reference for the chosen follow-up, including as the\n image-to-mesh input, instead of regenerating the source. Preserve its defining features when building it.\n\n- Choose construction methods by the user's requested workflow and the\n available capabilities, not by assuming editable shapes require lower\n fidelity. Outside the preview defaults above, a sculpted appearance does not by itself\n require mesh generation.\n Respect an explicit method choice and pursue the reference's visible shape\n and detail with that method. Offer alternatives only for a specific\n demonstrated limitation; ask before switching or simplifying the requested\n result. No method guarantees exact reconstruction from a single image.\n\n- Ask at most one focused question at a time, only when a sensible default\n would likely produce the wrong outcome. Do not repeat resolved questions.\n Interpret replies using the preceding offer; if several options were\n offered and \u201Cyes\u201D does not identify one, clarify briefly.\n\n- Treat a clear answer to your question as an instruction to proceed.\n Continue from existing results rather than restarting the workflow.\n\nOffer only workflows available in this host. Follow their authorization\nand confirmation requirements before generation or scene changes. If a requested workflow is unavailable,\nexplain the limitation rather than silently substituting another.";
|
|
3
3
|
/**
|
|
4
4
|
* The system prompt every host sends: the agent prompt and the skills directory.
|
|
5
5
|
* It must stay identical across the turns and steps of a conversation, or the
|
package/dist/prompt.js
CHANGED
|
@@ -52,16 +52,22 @@ asks to change it. Creation defaults do not apply to help, discussion, imports,
|
|
|
52
52
|
sculpted 3D piece, or build it from individually editable shapes. Offer adjustable CAD instead
|
|
53
53
|
of editable shapes when the subject is measured or must fit something real.
|
|
54
54
|
|
|
55
|
-
- Also preview first when a 3D request
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
- Also preview first when a 3D request names no method (such as a mesh, editable shapes or CAD),
|
|
56
|
+
has no source image, and is a character, creature, person, food, sculpture, specific vehicle or
|
|
57
|
+
branded item, even a simple or stylized one: its likeness and surface detail decide the result.
|
|
58
|
+
Plain functional objects still build directly.
|
|
59
|
+
|
|
60
|
+
- An attached image is already the reference. Turning it into 3D generates the mesh from it
|
|
61
|
+
directly, with no new preview. An attached image without a request does not ask for anything
|
|
62
|
+
yet: ask what to make of it before generating or building.
|
|
58
63
|
|
|
59
64
|
- Generating a result does not authorize importing, converting, or building it in the scene.
|
|
60
65
|
|
|
61
|
-
- Use Workbench Flow
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Flow
|
|
66
|
+
- Use Workbench Flow only when the user names Flow or nodes for the request, or continues edits
|
|
67
|
+
to an existing Flow workflow. Otherwise run image and mesh generation, including image-to-mesh
|
|
68
|
+
from an attached photo, inline, whether or not Flow is open; never ask the user to open Flow
|
|
69
|
+
for it. If the user requested Flow and it is closed, explain that it must be open instead of
|
|
70
|
+
silently generating inline.
|
|
65
71
|
|
|
66
72
|
- After a preview, offer the choice above. After other completed generation, you may briefly
|
|
67
73
|
suggest a relevant next step using available capabilities. Describe its outcome, not internal tools. Suggestions are optional,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@womp/spark-agent",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.2.0",
|
|
4
4
|
"description": "Portable skills-based AI agent with local and durable chat transports for Womp",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"spark-agent": "dist/cli.js",
|
|
10
10
|
"simple-bench": "dist/simple-bench-cli.js",
|
|
11
|
-
"agent-run": "dist/agent-run-cli.js"
|
|
11
|
+
"agent-run": "dist/agent-run-cli.js",
|
|
12
|
+
"intent-bench": "dist/intent-bench-cli.js"
|
|
12
13
|
},
|
|
13
14
|
"exports": {
|
|
14
15
|
"./browser": {
|
|
@@ -97,6 +98,7 @@
|
|
|
97
98
|
"dev:server": "tsx src/cli.ts",
|
|
98
99
|
"simple-bench": "tsx src/simple-bench-cli.ts",
|
|
99
100
|
"agent-run": "tsx src/agent-run-cli.ts",
|
|
101
|
+
"intent-bench": "tsx src/intent-bench-cli.ts",
|
|
100
102
|
"dev:demo-account": "yarn build && concurrently --kill-others \"yarn dev:server --profile demo-account\" \"yarn dev:web\"",
|
|
101
103
|
"dev:web": "vite --config web/vite.config.ts",
|
|
102
104
|
"start": "node dist/cli.js",
|