@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.
- package/README.md +59 -33
- package/dist/agent-run-cli.d.ts +2 -0
- package/dist/agent-run-cli.js +30 -0
- package/dist/agent-run-input.d.ts +11 -0
- package/dist/agent-run-input.js +59 -0
- package/dist/agent-run.d.ts +81 -0
- package/dist/agent-run.js +234 -0
- package/dist/agent-tools.js +3 -2
- package/dist/agent.d.ts +0 -2
- package/dist/agent.js +4 -36
- package/dist/capabilities/account.js +15 -15
- package/dist/capabilities/asset-imports.js +50 -50
- package/dist/capabilities/assets.js +10 -10
- package/dist/capabilities/flow.js +15 -15
- package/dist/capabilities/geometry.js +13 -14
- package/dist/capabilities/knowledge.js +4 -4
- package/dist/capabilities/openscad-instructions.d.ts +1 -1
- package/dist/capabilities/openscad-instructions.js +80 -80
- package/dist/capabilities/openscad.js +2 -2
- package/dist/capabilities/scene.d.ts +11 -3
- package/dist/capabilities/scene.js +93 -27
- package/dist/capabilities.d.ts +1 -2
- package/dist/capabilities.js +0 -1
- package/dist/client.d.ts +5 -0
- package/dist/client.js +22 -0
- package/dist/coordinates.d.ts +0 -7
- package/dist/coordinates.js +8 -47
- package/dist/generated/scene-declarations.d.ts +1 -1
- package/dist/generated/scene-declarations.js +1 -1
- package/dist/geometry/schemas/flowir-dialect.json +2 -2
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/models.d.ts +7 -0
- package/dist/models.js +10 -0
- package/dist/prompt.d.ts +1 -1
- package/dist/prompt.js +53 -1
- package/dist/request-limits.d.ts +1 -0
- package/dist/request-limits.js +1 -0
- package/dist/run-metrics.d.ts +15 -0
- package/dist/run-metrics.js +30 -0
- package/dist/runtime.d.ts +3 -5
- package/dist/scene-metadata.js +1 -1
- package/dist/sdk-scene-host.d.ts +11 -13
- package/dist/sdk-scene-host.js +1 -0
- package/dist/sdk-scene-screenshot.d.ts +4 -6
- package/dist/sdk-scene-screenshot.js +14 -15
- package/dist/server/artifacts.js +1 -5
- package/dist/server/executor.d.ts +0 -1
- package/dist/server/executor.js +0 -1
- package/dist/server/express.js +17 -5
- package/dist/server/scene-actions.d.ts +2 -0
- package/dist/server/scene-actions.js +20 -0
- package/dist/server/service.d.ts +2 -0
- package/dist/server/service.js +10 -0
- package/dist/server/sqlite-store.d.ts +3 -1
- package/dist/server/sqlite-store.js +26 -3
- package/dist/server/standalone.d.ts +2 -0
- package/dist/server/standalone.js +54 -3
- package/dist/server/store.d.ts +3 -1
- package/dist/server/store.js +21 -1
- package/dist/server/types.d.ts +5 -0
- package/dist/simple-bench-cli.d.ts +2 -0
- package/dist/simple-bench-cli.js +64 -0
- package/dist/simple-bench-prompts.d.ts +28 -0
- package/dist/simple-bench-prompts.js +11 -0
- package/dist/simple-bench.d.ts +15 -0
- package/dist/simple-bench.js +175 -0
- package/dist/types.d.ts +4 -13
- package/dist/ui.d.ts +3 -8
- package/dist/womp-scene-api.d.ts +14 -3
- package/dist/womp-scene-api.js +98 -75
- package/package.json +7 -3
- package/dist/capabilities/health.d.ts +0 -9
- package/dist/capabilities/health.js +0 -27
- package/dist/context.d.ts +0 -15
- package/dist/context.js +0 -266
- package/dist/generated/health-declarations.d.ts +0 -1
- 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
|
-
##
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
package/dist/agent-tools.js
CHANGED
|
@@ -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:
|
|
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
|
-
},
|
|
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[]>;
|