@womp/spark-agent 3.0.3 → 4.0.1

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.
Files changed (78) hide show
  1. package/README.md +59 -33
  2. package/dist/agent-run-cli.d.ts +2 -0
  3. package/dist/agent-run-cli.js +30 -0
  4. package/dist/agent-run-input.d.ts +11 -0
  5. package/dist/agent-run-input.js +59 -0
  6. package/dist/agent-run.d.ts +81 -0
  7. package/dist/agent-run.js +234 -0
  8. package/dist/agent-tools.js +3 -2
  9. package/dist/agent.d.ts +0 -2
  10. package/dist/agent.js +4 -36
  11. package/dist/capabilities/account.js +15 -15
  12. package/dist/capabilities/asset-imports.js +50 -50
  13. package/dist/capabilities/assets.js +10 -10
  14. package/dist/capabilities/flow.js +15 -15
  15. package/dist/capabilities/geometry.js +13 -14
  16. package/dist/capabilities/knowledge.js +4 -4
  17. package/dist/capabilities/openscad-instructions.d.ts +1 -1
  18. package/dist/capabilities/openscad-instructions.js +80 -80
  19. package/dist/capabilities/openscad.js +2 -2
  20. package/dist/capabilities/scene.d.ts +11 -3
  21. package/dist/capabilities/scene.js +93 -27
  22. package/dist/capabilities.d.ts +1 -2
  23. package/dist/capabilities.js +0 -1
  24. package/dist/client.d.ts +5 -0
  25. package/dist/client.js +22 -0
  26. package/dist/coordinates.d.ts +0 -7
  27. package/dist/coordinates.js +8 -47
  28. package/dist/generated/scene-declarations.d.ts +1 -1
  29. package/dist/generated/scene-declarations.js +1 -1
  30. package/dist/geometry/schemas/flowir-dialect.json +2 -2
  31. package/dist/index.d.ts +0 -1
  32. package/dist/index.js +0 -1
  33. package/dist/models.d.ts +7 -0
  34. package/dist/models.js +10 -0
  35. package/dist/prompt.d.ts +1 -1
  36. package/dist/prompt.js +53 -1
  37. package/dist/request-limits.d.ts +1 -0
  38. package/dist/request-limits.js +1 -0
  39. package/dist/run-metrics.d.ts +15 -0
  40. package/dist/run-metrics.js +30 -0
  41. package/dist/runtime.d.ts +3 -5
  42. package/dist/scene-metadata.js +1 -1
  43. package/dist/sdk-scene-host.d.ts +11 -13
  44. package/dist/sdk-scene-host.js +1 -0
  45. package/dist/sdk-scene-screenshot.d.ts +4 -6
  46. package/dist/sdk-scene-screenshot.js +14 -15
  47. package/dist/server/artifacts.js +1 -5
  48. package/dist/server/executor.d.ts +0 -1
  49. package/dist/server/executor.js +0 -1
  50. package/dist/server/express.js +17 -5
  51. package/dist/server/scene-actions.d.ts +2 -0
  52. package/dist/server/scene-actions.js +20 -0
  53. package/dist/server/service.d.ts +2 -0
  54. package/dist/server/service.js +10 -0
  55. package/dist/server/sqlite-store.d.ts +3 -1
  56. package/dist/server/sqlite-store.js +26 -3
  57. package/dist/server/standalone.d.ts +2 -0
  58. package/dist/server/standalone.js +54 -3
  59. package/dist/server/store.d.ts +3 -1
  60. package/dist/server/store.js +21 -1
  61. package/dist/server/types.d.ts +5 -0
  62. package/dist/simple-bench-cli.d.ts +2 -0
  63. package/dist/simple-bench-cli.js +64 -0
  64. package/dist/simple-bench-prompts.d.ts +28 -0
  65. package/dist/simple-bench-prompts.js +11 -0
  66. package/dist/simple-bench.d.ts +15 -0
  67. package/dist/simple-bench.js +175 -0
  68. package/dist/types.d.ts +4 -13
  69. package/dist/ui.d.ts +3 -8
  70. package/dist/womp-scene-api.d.ts +14 -3
  71. package/dist/womp-scene-api.js +98 -75
  72. package/package.json +7 -3
  73. package/dist/capabilities/health.d.ts +0 -9
  74. package/dist/capabilities/health.js +0 -27
  75. package/dist/context.d.ts +0 -15
  76. package/dist/context.js +0 -266
  77. package/dist/generated/health-declarations.d.ts +0 -1
  78. package/dist/generated/health-declarations.js +0 -3
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @womp/spark-agent
2
2
 
3
- Portable AI agent for Womp applications and Kakapo scenes with model-authored JavaScript.
4
-
3
+ Portable AI agent for Womp applications and Kakapo scenes with model-authored JavaScript.
4
+
5
5
  Spark Agent uses AI SDK 7 `UIMessage` and `UIMessageChunk` as its public chat protocol. Generated
6
6
  JavaScript uses the host's executor and capabilities. Native browser execution and isolated QuickJS
7
7
  execution use the same model loop. The host owns conversations, artifacts, authorization, and connections.
@@ -16,7 +16,47 @@ execution use the same model loop. The host owns conversations, artifacts, autho
16
16
  npm install @womp/spark-agent
17
17
  ```
18
18
 
19
- ## Package entry points
19
+ ## Run a prompt from the command line
20
+
21
+ With the Spark server already running:
22
+
23
+ ```sh
24
+ yarn agent-run --prompt "Make a cube" --model gpt-5.6-luna --reasoning max
25
+ ```
26
+
27
+ Use `--conversation <id>` to continue a thread and `--output <new-directory>` to save
28
+ `result.json`, `transcript.json` and original run artifacts under `artifacts/`.
29
+ For local runs, use the git-ignored `.spark/` directory, with a fresh subdirectory per run:
30
+
31
+ ```sh
32
+ yarn agent-run --prompt "Make rainbow-colored boxes" --output .spark/agent-runs/rainbow-boxes
33
+ yarn agent-run --prompt "Model this object using both views" --image front.png --image side.jpg --output .spark/agent-runs/reference-model
34
+ ```
35
+
36
+ Repeat `--image <path>` for local PNG, JPEG, WebP or GIF references, in argument order.
37
+ The required prompt and all images are sent in one user message. Files, format signatures,
38
+ and the full JSON request size (including base64 overhead) are checked before contacting the server.
39
+ The CLI uses the server's default 25 MiB request limit; a custom server or provider may impose
40
+ stricter limits. Images are never resized, and remote URLs are not accepted.
41
+ With `--output`, original bytes are saved under `inputs/`; `input.json` and the result's `input`
42
+ field list ordered original filenames, MIME types and relative paths. The submitted message in
43
+ `transcript.json` uses those paths instead of inline data. Generated artifacts stay in `artifacts/`.
44
+
45
+ Without output, artifacts remain server references. The CLI never clears the scene or
46
+ takes extra screenshots. Defaults are `gpt-6-astra`, `high`, and
47
+ `--api http://127.0.0.1:8787/agent`; unsupported settings are rejected.
48
+
49
+ Progress goes to stderr; one JSON result goes to stdout. Use `yarn --silent agent-run`
50
+ or the installed `agent-run` executable when parsing stdout (Yarn otherwise adds banners).
51
+ Usage comes from the provider, with per-step USD estimates; missing usage is not estimated.
52
+ Exit codes: 0 completed, 1 run/transport/export failure, 2 invalid arguments,
53
+ 3 requires user action (continue in the UI), 130 interrupted. Interrupts attempt to cancel
54
+ only this invocation's run; a failed cancellation is reported and the run may still be active.
55
+ Output directories must not already exist. Artifact manifests include IDs, media types,
56
+ server URLs and relative paths; failed downloads retain their references and report errors.
57
+ Legacy inline base64 uploads in transcripts are replaced with `[inline data omitted]`.
58
+
59
+ ## Package entry points
20
60
 
21
61
  - `@womp/spark-agent` — browser-safe model loop, two tools, persistence hooks, and shared contracts
22
62
  - `@womp/spark-agent/browser` — native JavaScript executor and local chat transport
@@ -60,26 +100,9 @@ the terminal checkpoint. A host run owner must keep consuming when a view detach
60
100
  A failed barrier aborts the loop. The default model-step limit is 100; a limit returns
61
101
  `status: "incomplete"`. A host must not automatically restart to bypass that limit.
62
102
 
63
- Before each model request Spark applies an estimated input budget (`maxInputTokens`, default 64,000).
64
- It first removes duplicate skill bodies and execution observations, then abbreviates older skill reads
65
- while retaining the latest workflow/reference pair. The agent must read an abbreviated skill again
66
- before using its API. Older completed scripts can be shortened only when their committed receipts and
67
- results remain available and their inputs are not tied to provider reasoning/signatures. Requests,
68
- tool pairs, targets, artifacts, receipts, errors, and unresolved work remain in context; persisted
69
- history is never shortened. If this cannot fit, the run returns `incomplete` with `context-limit` and
70
- an actionable explanation, without another model request or automatic replay.
71
-
72
- When older successful execution results contain images, their binary content can be omitted from model
73
- input while preserving artifact references, receipts, and observations. The latest visual result,
74
- user-provided media, failed/uncertain results, and provider-owned content remain intact. The agent can
75
- inspect an older artifact again with `emitImage` or `emitFile`; stored history retains the full result.
76
- Call metadata copied by the SDK onto local tool results is preserved during this trimming; it does not
77
- prevent compaction of the host-produced image bytes. Original model calls and signatures are unchanged.
78
-
79
- Token sizing is approximate (UTF-8 text bytes / 3 plus an 8,192-token allowance per image/file and
80
- 2,048 for tool schemas/framing). This is an application input budget, not provider capacity metadata.
81
- Hosts should choose it alongside the selected model's output budget and verify their deployed models;
82
- the estimate cannot guarantee a provider accepts every document or image size.
103
+ Spark does not estimate or limit input tokens or automatically shorten conversation history.
104
+ The provider enforces its context limit; rejections follow the normal provider-error path.
105
+ Provider-reported usage and cost tracking are unchanged.
83
106
 
84
107
  Screenshot and file APIs return serializable artifact references. `emitImage` and `emitFile` resolve
85
108
  them through the host's artifact resolver. Keep references resolvable when reloading history, and
@@ -137,12 +160,23 @@ Closing or reloading the page only detaches the SSE subscriber. It does not canc
137
160
  `SparkChatTransport` hides the durable run lifecycle from `useChat`:
138
161
 
139
162
  ```text
140
- POST /agent/conversations/:conversationId/messages
163
+ POST /agent/conversations/:conversationId/messages
164
+ PATCH /agent/conversations/:conversationId/settings
141
165
  GET /agent/runs/:runId/stream?after=0
142
166
  POST /agent/runs/:runId/cancel
143
167
  ```
144
168
 
145
- The SSE response contains ordered persistence envelopes around standard AI SDK chunks:
169
+ Conversation settings contain `modelId` and `reasoningEffort`. The standalone UI saves selector
170
+ changes immediately through `transport.saveConversationSettings(id, settings)` and restores them
171
+ per thread. Older threads fall back to their last recorded model (or the default) and medium effort;
172
+ unsupported choices fall back to supported settings. Assistant footers show the model recorded on
173
+ that turn, independently of the selector.
174
+
175
+ Custom stores must implement `saveConversationSettings` as a settings-only update, and preserve
176
+ stored settings during `saveConversation` so streaming history saves cannot overwrite preferences.
177
+ The built-in memory and SQLite stores do this without a database migration.
178
+
179
+ The SSE response contains ordered persistence envelopes around standard AI SDK chunks:
146
180
 
147
181
  ```ts
148
182
  type StoredSparkChunk = {
@@ -330,14 +364,6 @@ Releases use a manual package publication; no separate release service is requir
330
364
  - Custom JavaScript executors must honor cancellation and host-owned user waits. Callback-based
331
365
  scene edits and artifact references work with both the native and QuickJS executors.
332
366
 
333
- ### 0.3.1 context defaults
334
-
335
- - The default input context budget is now 128,000 estimated tokens. Hosts can still
336
- set `maxInputTokens` explicitly; this budget does not change the provider's context limit.
337
- - Images, including images in tool results, use a 512-token estimate instead of the
338
- general 8,192-token file allowance. Encoded media bytes remain excluded from text
339
- estimates. Token accounting is heuristic; no API or storage migration is required.
340
-
341
367
  ### Publishing
342
368
 
343
369
  1. Choose an unused package version after checking `npm view @womp/spark-agent versions --json`.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { agentRunJson, parseAgentRunArgs, runAgent } from "./agent-run.js";
3
+ const controller = new AbortController();
4
+ const interrupt = () => controller.abort(new Error("Interrupted; requesting run cancellation."));
5
+ process.once("SIGINT", interrupt);
6
+ process.once("SIGTERM", interrupt);
7
+ try {
8
+ const options = parseAgentRunArgs(process.argv.slice(2));
9
+ if (!options) {
10
+ process.stderr.write("Usage: agent-run --prompt <text> [--image <path>]... [--model <id>] [--reasoning <level>] [--conversation <id>] [--api <url>] [--output <new-directory>]\nImages: local PNG, JPEG, WebP or GIF, in argument order; 25 MiB maximum encoded request.\nDefaults: gpt-6-astra, high, http://127.0.0.1:8787/agent\n");
11
+ }
12
+ else {
13
+ const result = await runAgent({ ...options, signal: controller.signal, progress: (text) => { process.stderr.write(text); } });
14
+ process.stdout.write(`${agentRunJson(result)}\n`);
15
+ process.exitCode = result.exitCode;
16
+ }
17
+ }
18
+ catch (error) {
19
+ process.stdout.write(`${agentRunJson({
20
+ conversationId: null, runId: null, status: "failed", finalAnswer: "", durationMs: null,
21
+ steps: 0, toolCounts: {}, requiredActions: [], usage: { total: null, steps: [] },
22
+ cost: { currency: "USD", totalUsd: null, status: "unavailable", steps: [] }, artifacts: [],
23
+ errors: [error instanceof Error ? error.message : String(error)], exitCode: 2,
24
+ })}\n`);
25
+ process.exitCode = 2;
26
+ }
27
+ finally {
28
+ process.removeListener("SIGINT", interrupt);
29
+ process.removeListener("SIGTERM", interrupt);
30
+ }
@@ -0,0 +1,11 @@
1
+ import type { SparkRunSettings } from "./server/types.js";
2
+ import type { SparkUIMessage } from "./ui.js";
3
+ export declare function prepareAgentInput(prompt: string, images: string[], settings: SparkRunSettings): Promise<{
4
+ message: SparkUIMessage;
5
+ files: {
6
+ filename: string;
7
+ mediaType: string;
8
+ path: string;
9
+ bytes: Buffer;
10
+ }[];
11
+ }>;
@@ -0,0 +1,59 @@
1
+ import { open } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { DEFAULT_JSON_LIMIT_BYTES } from "./request-limits.js";
4
+ const formats = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif" };
5
+ function matchesFormat(bytes, mediaType) {
6
+ switch (mediaType) {
7
+ case "image/png": return bytes.length >= 45 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
8
+ bytes.toString("ascii", 12, 16) === "IHDR" && bytes.readUInt32BE(16) > 0 && bytes.readUInt32BE(20) > 0 && bytes.toString("ascii", bytes.length - 8, bytes.length - 4) === "IEND";
9
+ case "image/jpeg": return bytes.length >= 20 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 && bytes.at(-2) === 255 && bytes.at(-1) === 217;
10
+ case "image/gif": return bytes.length >= 14 && ["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6)) &&
11
+ bytes.readUInt16LE(6) > 0 && bytes.readUInt16LE(8) > 0 && bytes.at(-1) === 59;
12
+ case "image/webp": return bytes.length >= 30 && bytes.toString("ascii", 0, 4) === "RIFF" &&
13
+ bytes.toString("ascii", 8, 12) === "WEBP" && ["VP8 ", "VP8L", "VP8X"].includes(bytes.toString("ascii", 12, 16)) && bytes.readUInt32LE(4) + 8 === bytes.length;
14
+ default: return false;
15
+ }
16
+ }
17
+ export async function prepareAgentInput(prompt, images, settings) {
18
+ if (!prompt.trim())
19
+ throw new Error("--prompt must contain text.");
20
+ const message = { id: crypto.randomUUID(), role: "user", parts: [{ type: "text", text: prompt }] };
21
+ const files = [];
22
+ // Match SparkChatTransport's submit body, including JSON escaping and UTF-8 text.
23
+ let requestBytes = Buffer.byteLength(JSON.stringify({ trigger: "submit-message", message, settings }));
24
+ const checkSize = (size) => {
25
+ if (size > DEFAULT_JSON_LIMIT_BYTES)
26
+ throw new Error("Request exceeds 25 MiB including prompt, images, base64 overhead and JSON. Use smaller inputs; images are not resized.");
27
+ };
28
+ checkSize(requestBytes);
29
+ for (const image of images) {
30
+ if (!image.trim() || /^(?:[a-z][a-z0-9+.-]*:\/\/|data:|file:)/iu.test(image))
31
+ throw new Error("--image requires a local file path, not a URL.");
32
+ const extension = path.extname(image).toLowerCase();
33
+ const mediaType = formats[extension];
34
+ if (!mediaType)
35
+ throw new Error(`Unsupported image format for ${image}. Use PNG, JPEG, WebP or GIF.`);
36
+ const filename = path.basename(image);
37
+ const part = { type: "file", filename, mediaType, url: `data:${mediaType};base64,` };
38
+ const overhead = 1 + Buffer.byteLength(JSON.stringify(part)); // Array comma and the file part without base64.
39
+ const file = await open(image, "r").catch((error) => { throw new Error(`Cannot read image ${image}: ${error.message}`); });
40
+ try {
41
+ const stat = await file.stat();
42
+ if (!stat.isFile() || stat.size === 0)
43
+ throw new Error(`Image ${image} must be a nonempty regular file.`);
44
+ checkSize(requestBytes + overhead + 4 * Math.ceil(stat.size / 3));
45
+ const bytes = await file.readFile();
46
+ requestBytes += overhead + 4 * Math.ceil(bytes.length / 3);
47
+ checkSize(requestBytes);
48
+ if (!matchesFormat(bytes, mediaType))
49
+ throw new Error(`Invalid or truncated ${mediaType} file: ${image}`);
50
+ part.url += bytes.toString("base64");
51
+ message.parts.push(part);
52
+ files.push({ filename, mediaType, path: `inputs/${files.length + 1}${extension}`, bytes });
53
+ }
54
+ finally {
55
+ await file.close();
56
+ }
57
+ }
58
+ return { message, files };
59
+ }
@@ -0,0 +1,81 @@
1
+ import { type ReasoningEffort } from "./models.js";
2
+ import type { SparkRunSettings } from "./server/types.js";
3
+ import type { SparkArtifact } from "./ui.js";
4
+ export declare function parseAgentRunArgs(args: string[]): {
5
+ prompt: string;
6
+ api: string;
7
+ conversation: string | undefined;
8
+ output: string | undefined;
9
+ images: string[];
10
+ settings: {
11
+ modelId: "gpt-6-astra" | "gpt-5.6-luna" | "gemini-3.8-flash" | "claude-opus-5" | "claude-fable-5";
12
+ reasoningEffort: ReasoningEffort;
13
+ };
14
+ } | null;
15
+ type RunOptions = {
16
+ prompt: string;
17
+ api: string;
18
+ conversation?: string;
19
+ output?: string;
20
+ images?: string[];
21
+ settings: SparkRunSettings;
22
+ signal?: AbortSignal;
23
+ fetch?: typeof fetch;
24
+ progress?: (text: string) => void;
25
+ };
26
+ type ArtifactReference = SparkArtifact & {
27
+ path?: string;
28
+ error?: string;
29
+ };
30
+ export declare function agentRunJson(value: unknown, space?: number): string;
31
+ export declare function runAgent(options: RunOptions): Promise<{
32
+ artifacts: ArtifactReference[];
33
+ errors: string[];
34
+ exitCode: number;
35
+ input?: {
36
+ messageId: string;
37
+ prompt: string;
38
+ images: {
39
+ path?: string | undefined;
40
+ filename: string;
41
+ mediaType: string;
42
+ }[];
43
+ } | undefined;
44
+ conversationId: string | null;
45
+ runId: string | null;
46
+ status: "queued" | "running" | "requires_action" | "completed" | "failed" | "cancelled" | "incomplete";
47
+ finalAnswer: string;
48
+ durationMs: number | null;
49
+ steps: number;
50
+ toolCounts: Record<string, number>;
51
+ requiredActions: import("ai").UIMessagePart<import("./ui.js").SparkDataParts, import("./ui.js").SparkUITools>[];
52
+ usage: {
53
+ total: unknown;
54
+ steps: {
55
+ step: number;
56
+ usage: {} | null;
57
+ }[];
58
+ };
59
+ cost: {
60
+ currency: string;
61
+ totalUsd: number | null;
62
+ status: "unavailable" | "partial" | "estimated";
63
+ steps: {
64
+ step: number;
65
+ cost: {
66
+ modelId: string;
67
+ pricingDate: string;
68
+ totalUsd: number | null;
69
+ lines: {
70
+ label: string;
71
+ tokens: number;
72
+ usdPerMillion: number;
73
+ usd: number;
74
+ }[];
75
+ source?: string | undefined;
76
+ reason?: string | undefined;
77
+ } | null;
78
+ }[];
79
+ };
80
+ }>;
81
+ export {};
@@ -0,0 +1,234 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { SparkChatTransport } from "./client.js";
5
+ import { DEFAULT_MODEL_ID, SPARK_MODELS } from "./models.js";
6
+ import { createBenchMetrics } from "./run-metrics.js";
7
+ import { prepareAgentInput } from "./agent-run-input.js";
8
+ export function parseAgentRunArgs(args) {
9
+ const { values } = parseArgs({ args, options: {
10
+ prompt: { type: "string" }, model: { type: "string", default: DEFAULT_MODEL_ID },
11
+ reasoning: { type: "string", default: "high" }, conversation: { type: "string" },
12
+ api: { type: "string", default: "http://127.0.0.1:8787/agent" },
13
+ output: { type: "string" }, image: { type: "string", multiple: true }, help: { type: "boolean" },
14
+ } });
15
+ if (values.help)
16
+ return null;
17
+ if (!values.prompt?.trim())
18
+ throw new Error("--prompt must contain text.");
19
+ const model = SPARK_MODELS.find((item) => item.id === values.model);
20
+ if (!model)
21
+ throw new Error(`Unknown model: ${values.model}`);
22
+ if (!model.reasoningEfforts.includes(values.reasoning)) {
23
+ throw new Error(`${model.id} supports reasoning: ${model.reasoningEfforts.join(", ")}`);
24
+ }
25
+ if (values.conversation !== undefined && !values.conversation.trim())
26
+ throw new Error("--conversation must not be empty.");
27
+ if (values.output !== undefined && !values.output.trim())
28
+ throw new Error("--output must not be empty.");
29
+ const api = new URL(values.api);
30
+ if (!["http:", "https:"].includes(api.protocol) || api.username || api.password || api.search || api.hash) {
31
+ throw new Error("--api must be an HTTP(S) URL without credentials, query or fragment.");
32
+ }
33
+ return {
34
+ prompt: values.prompt, api: api.href.replace(/\/$/u, ""), conversation: values.conversation,
35
+ output: values.output, images: values.image ?? [],
36
+ settings: { modelId: model.id, reasoningEffort: values.reasoning },
37
+ };
38
+ }
39
+ // Older threads may contain inline uploads; exports keep references, not embedded bytes.
40
+ export function agentRunJson(value, space) {
41
+ return JSON.stringify(value, (_key, item) => typeof item === "string" && /^data:[^,]*;base64,/iu.test(item) ? "[inline data omitted]" : item, space);
42
+ }
43
+ export async function runAgent(options) {
44
+ const { message, files } = await prepareAgentInput(options.prompt, options.images ?? [], options.settings);
45
+ const input = {
46
+ messageId: message.id, prompt: options.prompt,
47
+ images: files.map(({ filename, mediaType, path }) => ({ filename, mediaType, ...(options.output ? { path } : {}) })),
48
+ };
49
+ const metrics = createBenchMetrics();
50
+ const artifacts = new Map();
51
+ const errors = [];
52
+ let conversation;
53
+ let run;
54
+ let status = "failed";
55
+ let started;
56
+ let durationMs = null;
57
+ let answer = "";
58
+ let usage = null;
59
+ let requiredActions = [];
60
+ let outputReady = false;
61
+ let cleanup = false;
62
+ const request = (input, init) => (options.fetch ?? fetch)(input, {
63
+ ...init,
64
+ signal: AbortSignal.any([
65
+ ...(!cleanup && options.signal ? [options.signal] : []),
66
+ ...(init?.signal ? [init.signal] : []),
67
+ // Streaming requests keep running until a terminal event or interruption.
68
+ ...(!String(input).includes("/stream?") ? [AbortSignal.timeout(30_000)] : []),
69
+ ]),
70
+ });
71
+ const transport = new SparkChatTransport({
72
+ api: options.api, fetch: request, getSettings: () => options.settings,
73
+ onRun: (value) => {
74
+ run = value;
75
+ status = value.status;
76
+ if (value.settings.modelId !== options.settings.modelId || value.settings.reasoningEffort !== options.settings.reasoningEffort) {
77
+ throw new Error("Server returned different model/reasoning settings; cancelling the run.");
78
+ }
79
+ },
80
+ });
81
+ const terminal = () => !["queued", "running"].includes(status);
82
+ try {
83
+ if (options.output) {
84
+ await mkdir(path.dirname(path.resolve(options.output)), { recursive: true });
85
+ await mkdir(options.output);
86
+ outputReady = true;
87
+ if (files.length) {
88
+ await mkdir(path.join(options.output, "inputs"));
89
+ for (const file of files)
90
+ await writeFile(path.join(options.output, file.path), file.bytes);
91
+ await writeFile(path.join(options.output, "input.json"), agentRunJson(input, 2));
92
+ }
93
+ }
94
+ options.signal?.throwIfAborted();
95
+ conversation = options.conversation
96
+ ? await transport.getConversation(options.conversation)
97
+ : await transport.createConversation({ title: options.prompt.slice(0, 100) });
98
+ started = performance.now();
99
+ const stream = await transport.sendMessages({
100
+ trigger: "submit-message", chatId: conversation.id, messageId: undefined,
101
+ messages: [message],
102
+ abortSignal: options.signal,
103
+ });
104
+ for await (const value of stream) {
105
+ const chunk = value;
106
+ metrics.accept(chunk);
107
+ if (chunk.type === "text-delta") {
108
+ answer += chunk.delta;
109
+ options.progress?.(chunk.delta);
110
+ }
111
+ if (chunk.type === "tool-input-available")
112
+ options.progress?.(`\n[tool: ${chunk.toolName}]\n`);
113
+ if (chunk.type === "data-artifact")
114
+ artifacts.set(chunk.data.id, chunk.data);
115
+ if (chunk.type === "error")
116
+ errors.push(chunk.errorText);
117
+ if (chunk.type === "data-run-status") {
118
+ status = chunk.data.status;
119
+ options.progress?.(`\n[${status}]\n`);
120
+ if (chunk.data.error)
121
+ errors.push(chunk.data.error);
122
+ if (terminal())
123
+ break;
124
+ }
125
+ }
126
+ if (!terminal())
127
+ throw new Error("Run stream ended without a terminal status.");
128
+ }
129
+ catch (error) {
130
+ durationMs = started === undefined ? null : Math.round(performance.now() - started);
131
+ errors.push(error instanceof Error ? error.message : String(error));
132
+ cleanup = true;
133
+ if (run && conversation && !terminal()) {
134
+ try {
135
+ status = (await transport.getRun(run.id)).status;
136
+ if (!terminal())
137
+ status = (await transport.cancelRun(conversation.id))?.status ?? "incomplete";
138
+ }
139
+ catch (error) {
140
+ status = "incomplete";
141
+ errors.push(`Cancellation failed: ${String(error)}`);
142
+ }
143
+ }
144
+ }
145
+ finally {
146
+ durationMs ??= started === undefined ? null : Math.round(performance.now() - started);
147
+ cleanup = true;
148
+ }
149
+ if (run && conversation) {
150
+ try {
151
+ conversation = await transport.getConversation(conversation.id);
152
+ const reply = conversation.messages.find((message) => message.id === run.assistantMessageId);
153
+ if (reply) {
154
+ answer = reply.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n\n");
155
+ usage = reply.metadata?.usage ?? null;
156
+ if (reply.metadata?.error)
157
+ errors.push(reply.metadata.error);
158
+ if (status === "requires_action")
159
+ requiredActions = reply.parts.filter((part) => "state" in part && (part.state === "input-available" || part.state === "approval-requested"));
160
+ for (const part of reply.parts) {
161
+ if (part.type === "data-artifact")
162
+ artifacts.set(part.data.id, part.data);
163
+ if (part.type === "tool-execute_javascript" && part.state === "output-available") {
164
+ for (const item of part.output.artifacts)
165
+ artifacts.set(item.artifact.id, item.artifact);
166
+ }
167
+ }
168
+ }
169
+ else if (status === "completed")
170
+ errors.push("Completed assistant message is missing from the transcript.");
171
+ }
172
+ catch (error) {
173
+ errors.push(`Transcript retrieval failed: ${String(error)}`);
174
+ }
175
+ }
176
+ if (outputReady) {
177
+ try {
178
+ const transcript = conversation ? { ...conversation, messages: conversation.messages.map((item) => {
179
+ if (item.id !== message.id || !files.length)
180
+ return item;
181
+ return { ...item, parts: message.parts.map((part, index) => part.type === "file" ? { ...part, url: files[index - 1].path } : part) };
182
+ }) } : null;
183
+ await writeFile(path.join(options.output, "transcript.json"), agentRunJson(transcript, 2));
184
+ await mkdir(path.join(options.output, "artifacts"));
185
+ let artifactIndex = 0;
186
+ for (const artifact of artifacts.values()) {
187
+ artifactIndex++;
188
+ try {
189
+ const url = new URL(artifact.url, `${options.api}/`);
190
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
191
+ throw new Error("Artifact must have an HTTP(S) reference without embedded credentials.");
192
+ const response = await request(url.href);
193
+ if (!response.ok)
194
+ throw new Error(`Download returned HTTP ${response.status}`);
195
+ const extension = path.extname(artifact.name ?? "").match(/^\.[a-zA-Z0-9]{1,10}$/u)?.[0]
196
+ ?? ({ "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "application/pdf": ".pdf" }[artifact.mediaType] ?? ".bin");
197
+ const relative = `artifacts/${artifactIndex}${extension}`;
198
+ await writeFile(path.join(options.output, relative), new Uint8Array(await response.arrayBuffer()));
199
+ artifact.path = relative;
200
+ }
201
+ catch (error) {
202
+ artifact.error = String(error);
203
+ errors.push(`Artifact ${artifact.id}: ${String(error)}`);
204
+ }
205
+ }
206
+ }
207
+ catch (error) {
208
+ errors.push(`Output failed: ${String(error)}`);
209
+ }
210
+ }
211
+ const summary = metrics.snapshot();
212
+ const details = metrics.details();
213
+ const result = {
214
+ conversationId: conversation?.id ?? null, runId: run?.id ?? null, status,
215
+ finalAnswer: answer, durationMs, steps: summary.steps, toolCounts: details.toolCounts, requiredActions,
216
+ usage: { total: usage, steps: details.steps.map(({ step, usage }) => ({ step, usage: usage ?? null })) },
217
+ cost: { currency: "USD", totalUsd: summary.costUsd,
218
+ status: summary.costUsd !== null && !["completed", "requires_action"].includes(status) ? "partial" : summary.costStatus,
219
+ steps: details.steps.map(({ step, cost }) => ({ step, cost: cost ?? null })) },
220
+ ...(files.length ? { input } : {}),
221
+ artifacts: Array.from(artifacts.values()), errors: Array.from(new Set(errors)),
222
+ exitCode: options.signal?.aborted ? 130 : errors.length ? 1 : status === "completed" ? 0 : status === "requires_action" ? 3 : 1,
223
+ };
224
+ if (outputReady) {
225
+ try {
226
+ await writeFile(path.join(options.output, "result.json"), agentRunJson(result, 2));
227
+ }
228
+ catch (error) {
229
+ result.errors.push(`Result output failed: ${String(error)}`);
230
+ result.exitCode = 1;
231
+ }
232
+ }
233
+ return result;
234
+ }
@@ -20,6 +20,7 @@ const artifactRefSchema = z.object({
20
20
  });
21
21
  export function createSparkTools(options) {
22
22
  const { controller, persistence } = options;
23
+ const executionTimeoutMs = options.executionTimeoutMs ?? 60_000;
23
24
  const persist = async (action) => {
24
25
  if (action === undefined)
25
26
  return;
@@ -58,7 +59,7 @@ export function createSparkTools(options) {
58
59
  }),
59
60
  }),
60
61
  execute_javascript: tool({
61
- description: "Run an async JavaScript body against the APIs discovered through skills. Use print, emitImage, and emitFile for output. Each call has a fresh local scope; capabilities own their commit boundaries.",
62
+ description: `Run an async JavaScript body against the APIs discovered through skills. Use print, emitImage, and emitFile for output. Each call has a fresh local scope; capabilities own their commit boundaries. Execution budget: ${executionTimeoutMs} ms per call, excluding registered user/service waits (not all awaits).`,
62
63
  inputSchema: z.object({
63
64
  purpose: z.string().min(1).max(240),
64
65
  code: z.string().min(1).max(60_000),
@@ -160,7 +161,7 @@ export function createSparkTools(options) {
160
161
  },
161
162
  emitImage: (ref) => emit(ref, "image"),
162
163
  emitFile: (ref) => emit(ref, "file"),
163
- }, options.executionTimeoutMs ?? 60_000, signal, executionWait);
164
+ }, executionTimeoutMs, signal, executionWait);
164
165
  await Promise.all(pending);
165
166
  signal.throwIfAborted();
166
167
  return {
package/dist/agent.d.ts CHANGED
@@ -25,8 +25,6 @@ export type SparkAgentOptions = {
25
25
  instructions?: string;
26
26
  maxSteps?: number;
27
27
  maxOutputTokens?: number;
28
- /** Estimated input budget, including instructions and tool framing. Defaults to 64,000. */
29
- maxInputTokens?: number;
30
28
  executionTimeoutMs?: number;
31
29
  persistence?: SparkPersistence;
32
30
  prepareMessages?(messages: ModelMessage[]): Promise<ModelMessage[]>;