castle-web-cli 0.4.78 → 0.4.80

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 (46) hide show
  1. package/dist/agent-prompts.d.ts +4 -1
  2. package/dist/agent-prompts.js +28 -7
  3. package/dist/agent.d.ts +7 -2
  4. package/dist/agent.js +655 -51
  5. package/dist/native/loop.d.ts +2 -0
  6. package/dist/native/loop.js +698 -0
  7. package/dist/native/openrouter.d.ts +55 -0
  8. package/dist/native/openrouter.js +354 -0
  9. package/dist/native/playtest-browser.d.ts +34 -0
  10. package/dist/native/playtest-browser.js +354 -0
  11. package/dist/native/playtest-executor.d.ts +3 -0
  12. package/dist/native/playtest-executor.js +156 -0
  13. package/dist/native/playtest.d.ts +131 -0
  14. package/dist/native/playtest.js +314 -0
  15. package/dist/native/tools.d.ts +38 -0
  16. package/dist/native/tools.js +690 -0
  17. package/dist/native/types.d.ts +40 -0
  18. package/dist/native/types.js +41 -0
  19. package/dist/serve.js +12 -0
  20. package/dist/shell/assets/{index-yGdKhgfZ.js → index-D3unT7do.js} +37 -37
  21. package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
  22. package/dist/shell/index.html +2 -2
  23. package/kits/basic-2d/CLAUDE.md +29 -3
  24. package/kits/basic-2d/behaviors/Layout.jsx +10 -0
  25. package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
  26. package/kits/basic-2d/blueprints/cauldron.scene +22 -0
  27. package/kits/basic-2d/castle.json +5 -7
  28. package/kits/basic-2d/docs/pxart-format.md +4 -3
  29. package/kits/basic-2d/drawings/cauldron.pxart +113 -0
  30. package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
  31. package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
  32. package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
  33. package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
  34. package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
  35. package/kits/basic-2d/editors/editorHistory.js +8 -2
  36. package/kits/basic-2d/editors/inspectorSheet.js +5 -19
  37. package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
  38. package/kits/basic-2d/engine/blueprint.js +423 -0
  39. package/kits/basic-2d/engine/files.js +1 -1
  40. package/kits/basic-2d/engine/scene.js +29 -29
  41. package/kits/basic-2d/engine/ui.jsx +160 -21
  42. package/kits/basic-2d/engine/ui.module.css +155 -13
  43. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  44. package/kits/basic-2d/scenes/main.scene +3 -13
  45. package/package.json +2 -1
  46. package/kits/basic-2d/drawings/pig.pxart +0 -26
@@ -0,0 +1,698 @@
1
+ // The native agent loop: runAgentNative(opts) is the Path-B counterpart to
2
+ // agent.ts's runAgentCli -- same normalized hooks in, same CliRunResult-
3
+ // shaped payload out (see native/types.ts for the exact field mapping this
4
+ // implies once wired together). One call = one turn: build [system, user],
5
+ // stream an assistant message, execute any tool_calls, append tool results,
6
+ // repeat until the model replies with no tool_calls, the run times out, or
7
+ // the iteration cap trips.
8
+ //
9
+ // Turns are stateless by design (mirrors agent.ts: a fresh prompt is
10
+ // rebuilt every turn) -- the message array built here lives only for the
11
+ // duration of this one call and is discarded when it resolves.
12
+ //
13
+ // Observability: when opts.logPath is set, every run appends structured
14
+ // JSONL events (init/assistant/tool_result/eviction/retry/result -- see
15
+ // createRunLogger) to the SAME file locations the CLI backends use for their
16
+ // raw stream logs, so "check the logs" works identically across backends.
17
+ import * as fs from "fs";
18
+ import * as path from "path";
19
+ import { streamChatCompletion, } from "./openrouter.js";
20
+ import { toolSchemasForRole, executeTool, activityLabelForCall, } from "./tools.js";
21
+ // Safety valve against a model that never stops calling tools -- distinct
22
+ // from timeoutMs, which bounds wall-clock time regardless of iteration count.
23
+ const MAX_ITERATIONS = 40;
24
+ const TOUCHED_FILE_LIMIT = 10;
25
+ function touchedFileList(files) {
26
+ const sorted = [...files].sort();
27
+ if (sorted.length <= TOUCHED_FILE_LIMIT)
28
+ return sorted;
29
+ return [...sorted.slice(0, TOUCHED_FILE_LIMIT), `+${sorted.length - TOUCHED_FILE_LIMIT} more`];
30
+ }
31
+ // playtest frames never hit the TOUCHED_FILE_LIMIT truncation above (a run
32
+ // is capped at PLAYTEST_MAX_CALLS_PER_RUN calls x PLAYTEST_MAX_SHOTS frames
33
+ // each -- at most 24 -- small enough to list in full for the task card).
34
+ function playtestFrameList(frames) {
35
+ return [...frames].sort();
36
+ }
37
+ // -- run logging ----------------------------------------------------------------
38
+ // Structured JSONL, one event per line, appended (task retry attempts share
39
+ // one log.jsonl, same as the CLI backends). Bulky free-text fields (tool
40
+ // args/results) are truncated to LOG_FIELD_CAP chars -- the log is for
41
+ // diagnosing a bad run's shape, not replaying its full payloads. Assistant
42
+ // message text is NOT truncated: it is the narrative the user saw and is
43
+ // already bounded by the model's own output.
44
+ const LOG_FIELD_CAP = 2_000;
45
+ function truncateForLog(raw) {
46
+ if (raw.length <= LOG_FIELD_CAP)
47
+ return raw;
48
+ return `${raw.slice(0, LOG_FIELD_CAP)}... (+${raw.length - LOG_FIELD_CAP} chars)`;
49
+ }
50
+ // Best-effort by design: a logging hiccup (disk full, bad path) must cost the
51
+ // log, never the run.
52
+ function createRunLogger(logPath) {
53
+ if (!logPath)
54
+ return { write: () => { }, end: () => Promise.resolve() };
55
+ let stream = null;
56
+ try {
57
+ stream = fs.createWriteStream(logPath, { flags: "a" });
58
+ stream.on("error", () => {
59
+ stream = null;
60
+ });
61
+ }
62
+ catch {
63
+ stream = null;
64
+ }
65
+ return {
66
+ write(entry) {
67
+ try {
68
+ stream?.write(JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
69
+ }
70
+ catch {
71
+ /* never fail the run over logging */
72
+ }
73
+ },
74
+ end() {
75
+ return new Promise((resolve) => {
76
+ try {
77
+ if (stream)
78
+ stream.end(() => resolve());
79
+ else
80
+ resolve();
81
+ }
82
+ catch {
83
+ resolve();
84
+ }
85
+ });
86
+ },
87
+ };
88
+ }
89
+ // -- image attachments ------------------------------------------------------------
90
+ const ATTACHMENT_MIME = {
91
+ ".png": "image/png",
92
+ ".jpg": "image/jpeg",
93
+ ".jpeg": "image/jpeg",
94
+ ".gif": "image/gif",
95
+ ".webp": "image/webp",
96
+ };
97
+ // The initial user message: plain string without attachments; with them, an
98
+ // array of one text part + one image_url part per image (base64 data URI --
99
+ // OpenRouter forwards data URIs to any provider). A missing/unreadable/
100
+ // non-image attachment fails the RUN, loudly -- these files were saved by the
101
+ // serve itself at enqueue, so a failure here is a real bug, and silently
102
+ // dropping an image the user pasted would be worse than an error message.
103
+ function buildUserContent(cwd, prompt, attachments) {
104
+ if (!attachments || attachments.length === 0)
105
+ return { content: prompt };
106
+ const parts = [{ type: "text", text: prompt }];
107
+ for (const rel of attachments) {
108
+ const mime = ATTACHMENT_MIME[path.extname(rel).toLowerCase()];
109
+ if (!mime) {
110
+ return { content: prompt, error: `attachment is not a supported image type: ${rel}` };
111
+ }
112
+ try {
113
+ const data = fs.readFileSync(path.resolve(cwd, rel));
114
+ parts.push({ type: "image_url", image_url: { url: `data:${mime};base64,${data.toString("base64")}` } });
115
+ }
116
+ catch (e) {
117
+ return {
118
+ content: prompt,
119
+ error: `could not read attachment ${rel}: ${e instanceof Error ? e.message : String(e)}`,
120
+ };
121
+ }
122
+ }
123
+ return { content: parts };
124
+ }
125
+ // -- context growth management -------------------------------------------------
126
+ // A long task otherwise grows the message array until the model's context
127
+ // limit kills the run mid-flight. Crude-but-effective policy, checked before
128
+ // every request: approximate the transcript's token count (chars / 4), and
129
+ // when it exceeds the budget, evict the OLDEST tool-result contents first --
130
+ // each replaced by a short stub that keeps the tool_call_id pairing intact,
131
+ // so no provider ever sees an orphaned/missing tool result. The synthetic
132
+ // user messages carrying view_image results evict the same way (they are
133
+ // tool payloads in all but role). Never evicted: the system message, the
134
+ // INITIAL user prompt (with its attachments), assistant text/tool_calls,
135
+ // the most recent KEEP_RECENT_TOOL_RESULTS results (the model's working
136
+ // set), and already-stubbed entries. Deliberately conservative: if evicting every
137
+ // eligible result still leaves us over budget, we proceed anyway and let the
138
+ // provider be the final arbiter.
139
+ //
140
+ // Budget: 100k approx-tokens (400k chars) by default -- comfortably inside
141
+ // every model we'd route (128k contexts and up) while leaving output
142
+ // headroom. CASTLE_SMITH_CONTEXT_TOKENS overrides it (tests / bigger-context
143
+ // models), same env-override pattern as CASTLE_OPENROUTER_URL; read per run,
144
+ // not hoisted, for the same reason.
145
+ const APPROX_CHARS_PER_TOKEN = 4;
146
+ const DEFAULT_CONTEXT_TOKEN_BUDGET = 100_000;
147
+ const KEEP_RECENT_TOOL_RESULTS = 4;
148
+ // Flat per-image estimate: vision encoders bill roughly 1-2k tokens per
149
+ // image regardless of file size, so counting base64 length like text would
150
+ // wildly overshoot and evict the whole transcript for one screenshot.
151
+ const IMAGE_PART_TOKEN_ESTIMATE = 1_500;
152
+ const EVICTION_STUB_PREFIX = "[result evicted to save context: ";
153
+ function contextTokenBudget() {
154
+ return Number(process.env.CASTLE_SMITH_CONTEXT_TOKENS) || DEFAULT_CONTEXT_TOKEN_BUDGET;
155
+ }
156
+ function approxMessageTokens(m) {
157
+ let chars = 0;
158
+ if (typeof m.content === "string")
159
+ chars += m.content.length;
160
+ let imageTokens = 0;
161
+ if (Array.isArray(m.content)) {
162
+ for (const part of m.content) {
163
+ if (part.type === "text")
164
+ chars += part.text.length;
165
+ else
166
+ imageTokens += IMAGE_PART_TOKEN_ESTIMATE;
167
+ }
168
+ }
169
+ for (const tc of m.tool_calls ?? []) {
170
+ chars += tc.function.name.length + tc.function.arguments.length;
171
+ }
172
+ // Small flat overhead per message for role/ids/JSON scaffolding.
173
+ return Math.ceil(chars / APPROX_CHARS_PER_TOKEN) + imageTokens + 8;
174
+ }
175
+ function approxTranscriptTokens(messages) {
176
+ return messages.reduce((sum, m) => sum + approxMessageTokens(m), 0);
177
+ }
178
+ // Mutates `messages` in place; returns what was evicted (for the run log).
179
+ // `labels` maps tool_call_id -> a short human label ("read_file game.js")
180
+ // recorded when the call executed. `imageLabels` marks the synthetic
181
+ // user messages carrying view_image results (see runToolCalls) -- they are
182
+ // tool-result payloads in all but role, so they evict exactly like text
183
+ // results: the image_url part (the ~1,500-token payload) is dropped and the
184
+ // message content becomes a text stub, keeping message order intact. The
185
+ // last-KEEP_RECENT window is counted over BOTH kinds together, oldest first.
186
+ function evictOldToolResults(messages, labels, imageLabels) {
187
+ const budget = contextTokenBudget();
188
+ if (approxTranscriptTokens(messages) <= budget)
189
+ return [];
190
+ const evictable = messages.filter((m) => (m.role === "tool" && typeof m.content === "string" && !m.content.startsWith(EVICTION_STUB_PREFIX)) ||
191
+ // An already-evicted image message has string content, so this array
192
+ // check also keeps it from being re-evicted.
193
+ (imageLabels.has(m) && Array.isArray(m.content)));
194
+ const evicted = [];
195
+ const cutoff = evictable.length - KEEP_RECENT_TOOL_RESULTS;
196
+ for (let i = 0; i < cutoff; i++) {
197
+ if (approxTranscriptTokens(messages) <= budget)
198
+ break;
199
+ const m = evictable[i];
200
+ const imageLabel = imageLabels.get(m);
201
+ if (imageLabel && Array.isArray(m.content)) {
202
+ // Weight ~= what leaves the request payload (the base64 URI).
203
+ const chars = m.content.reduce((sum, part) => sum + (part.type === "image_url" ? part.image_url.url.length : part.text.length), 0);
204
+ m.content = `${EVICTION_STUB_PREFIX}${imageLabel}, ${((chars * 0.75) / 1024).toFixed(1)}KB image]`;
205
+ evicted.push({ label: imageLabel, chars });
206
+ }
207
+ else {
208
+ const chars = m.content.length;
209
+ const label = labels.get(m.tool_call_id ?? "") ?? "tool result";
210
+ m.content = `${EVICTION_STUB_PREFIX}${label}, ${(chars / 1024).toFixed(1)}KB]`;
211
+ evicted.push({ label, chars });
212
+ }
213
+ }
214
+ return evicted;
215
+ }
216
+ // -- usage ----------------------------------------------------------------------
217
+ // Per-run usage is the SUM over every request the tool loop made -- each
218
+ // iteration re-sends the whole growing transcript, so summing is what makes
219
+ // the run's numbers comparable to the cumulative usage the claude CLI
220
+ // reports for a whole run (and makes cache_read growth across iterations
221
+ // visible instead of hidden).
222
+ function addUsage(total, next) {
223
+ if (!next)
224
+ return total;
225
+ if (!total)
226
+ return { ...next };
227
+ const out = { ...total };
228
+ for (const key of [
229
+ "input_tokens",
230
+ "output_tokens",
231
+ "cache_creation_input_tokens",
232
+ "cache_read_input_tokens",
233
+ ]) {
234
+ const v = next[key];
235
+ if (typeof v === "number")
236
+ out[key] = (out[key] ?? 0) + v;
237
+ }
238
+ return out;
239
+ }
240
+ // -- reasoning effort + output cap ----------------------------------------------
241
+ // Motivating incident (2026-07, real run, google/gemini-3.5-flash, task
242
+ // role): after a playtest tool result carrying 3 screenshot images, the
243
+ // model silently generated for 4m51s before replying, with the run's total
244
+ // output (67,601 tokens) dwarfing every visible assistant text summed
245
+ // together (well under 10K) -- roughly 55-60K tokens of HIDDEN reasoning on
246
+ // that one multimodal iteration, and the eventual reply began with
247
+ // degenerate text. Usage was only logged once per whole run at the time, so
248
+ // pinning the spiral to a single iteration took manual forensics (see the
249
+ // per-iteration "usage" log event below, added for exactly this reason).
250
+ //
251
+ // Two independent guards against a repeat: cap how hard a model is allowed
252
+ // to "think" per role (reasoning.effort), and cap how much any one
253
+ // completion call can generate at all (max_tokens), so a model that ignores
254
+ // the effort hint (or a provider that doesn't honor it) still can't run away
255
+ // indefinitely.
256
+ //
257
+ // Per-role effort table, not a single global default, so tuning one role
258
+ // (or adding a third) is a one-line change instead of a call-site hunt.
259
+ // Router turns are short read-only planning/dispatch steps -- "medium" is
260
+ // already generous for that. Task turns do the real problem-solving, but
261
+ // give them the LOWER "low" effort specifically because they are the ones
262
+ // that see playtest screenshots (the multimodal case that triggered the
263
+ // incident above) -- "low" still lets a model reason, just not runaway.
264
+ const REASONING_EFFORT = {
265
+ router: "medium",
266
+ task: "low",
267
+ };
268
+ // Hard per-completion-call output cap (OpenAI-shaped `max_tokens`, sent to
269
+ // every request regardless of role). Reasoning tokens count against this on
270
+ // most providers (see openrouter.ts's request-body comment), so this is the
271
+ // backstop for a model that ignores REASONING_EFFORT entirely.
272
+ //
273
+ // Sized so it can NEVER clip a legitimate large file write delivered via a
274
+ // tool-call's arguments: task agents write whole files up to ~16KB, which
275
+ // is only ~4-5K tokens, and tool-call argument tokens count as regular
276
+ // output (not reasoning) -- so even a multi-file batch in one turn stays
277
+ // well under this cap. 16384 leaves generous headroom above that while still
278
+ // bounding a runaway generation to well under a minute instead of ~5.
279
+ const MAX_COMPLETION_TOKENS = 16_384;
280
+ // Short system framing establishing tool-use expectations; the actual
281
+ // persona/rules/deck context live in `opts.prompt` (the "user" message,
282
+ // already built by buildRouterPrompt/buildTaskPrompt) exactly as they do for
283
+ // the CLI backends today -- this loop does not duplicate them.
284
+ const ROLE_FRAMING = {
285
+ router: [
286
+ "You are an autonomous coding agent running in a tool-calling loop.",
287
+ "You have READ-ONLY tools: read_file, list_files, grep, view_image. You cannot write, edit, or run commands.",
288
+ "If the instructions below require making a change, follow them exactly as written (e.g. dispatching a background task) rather than attempting the change yourself.",
289
+ // Smith-specific because it names the exact "== deck source ==" heading
290
+ // buildRouterPrompt (agent-prompts.ts) emits for its deckContents section
291
+ // -- cursor/claude never get that section (they read files with their
292
+ // own tools), so telling them to look for it would be pointing at
293
+ // nothing. Motivating incident: small/greenfield-deck router turns
294
+ // routinely re-read a file whose full contents were already inlined, or
295
+ // spent a whole turn on tool calls when the prompt already had every
296
+ // fact needed for a reply.
297
+ "If the prompt below has a \"== deck source ==\" section, those ARE the complete current contents of the deck's text files right now -- do not re-read those files with read_file or grep. If everything you need for your reply is already in the prompt (rules, deck source, transcript, task board), skip tool calls entirely and reply directly.",
298
+ "Reply with your final message text once you are done; do not narrate tool calls in that text.",
299
+ ].join(" "),
300
+ // The view_image nudge lives HERE (smith-only framing), not in the shared
301
+ // buildTaskPrompt text the CLI backends also see -- CLI agents view images
302
+ // with their own Read tool and naming a smith-specific tool there would
303
+ // misdirect them.
304
+ task: [
305
+ "You are an autonomous coding agent running in a tool-calling loop, with full tool access: read_file, write_file, edit_file, list_files, grep, bash, view_image, playtest, restart.",
306
+ "Use your tools to actually make the changes described below -- do not just describe what you would do.",
307
+ // Names the exact "Deck source" heading buildTaskPrompt (agent-prompts.ts)
308
+ // emits for its deckContents section -- smith-only, same as the router's
309
+ // equivalent nudge above, for the same reason (cursor/claude read files
310
+ // themselves and never see this section). Motivating incident: smith
311
+ // task agents re-reading a file whose full contents were already
312
+ // inlined at task start, paying a redundant tool round trip for
313
+ // something already sitting in context.
314
+ "If the prompt below has a \"Deck source\" section, those ARE the complete current contents of the deck's text files as of task start -- do not re-read those files with read_file before editing them, edit them directly. If the whole task is already satisfied by what's in the prompt, you may skip straight to editing/writing without a preliminary read.",
315
+ "If your task references an image file path (e.g. a user attachment under .castle/agent/attachments/), look at it with view_image before building.",
316
+ "After you change behavior or visuals, playtest the deck before reporting done -- SEE it running rather than trusting a read of the code (a frozen animation or a silently broken interaction reads fine as source but is obvious in a screenshot). Use actions when the task is specifically about interactivity (movement, input handling, collisions). If a playtest result warns the capture was starved (barely rendering), that is a capture failure, not proof the game is broken -- retry it once rather than concluding the game is broken from it.",
317
+ // The view_image nudge above and this one share the same reasoning for
318
+ // living HERE rather than in buildTaskPrompt's shared text: that text
319
+ // also renders for cursor/claude, whose tasks reload the deck by
320
+ // actually running `npm run restart` in a real shell (they have no
321
+ // native restart tool, and naming one there would misdirect them into
322
+ // hallucinating a tool call that doesn't exist for their backend).
323
+ "Prefer the restart tool over running `npm run restart` in bash -- it does the same reload in this same process, instantly, without spawning a node process just to send one message.",
324
+ "Work to completion, then reply with your final message text.",
325
+ ].join(" "),
326
+ };
327
+ function buildSystemMessage(opts) {
328
+ const parts = [ROLE_FRAMING[opts.role]];
329
+ if (opts.systemReminder)
330
+ parts.push(opts.systemReminder);
331
+ return parts.join("\n\n");
332
+ }
333
+ function parseToolArgs(raw) {
334
+ if (!raw || !raw.trim())
335
+ return { args: {} };
336
+ let parsed;
337
+ try {
338
+ parsed = JSON.parse(raw);
339
+ }
340
+ catch (e) {
341
+ return { args: {}, error: `malformed tool-call arguments JSON: ${e instanceof Error ? e.message : String(e)}` };
342
+ }
343
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
344
+ return { args: {}, error: "tool-call arguments must be a JSON object" };
345
+ }
346
+ return { args: parsed };
347
+ }
348
+ // Short "tool + primary arg" label used in eviction stubs and log lines,
349
+ // e.g. "read_file game.js", "bash npm run restart".
350
+ function toolCallLabel(name, args) {
351
+ const detail = typeof args.path === "string"
352
+ ? args.path
353
+ : typeof args.command === "string"
354
+ ? args.command.trim().split(/\s+/).slice(0, 4).join(" ")
355
+ : typeof args.pattern === "string"
356
+ ? args.pattern
357
+ : "";
358
+ return detail ? `${name} ${detail}` : name;
359
+ }
360
+ // Tools that must NOT run concurrently with anything else in their batch:
361
+ // bash can have arbitrary side effects (start a server, install deps, run a
362
+ // build another call depends on) and playtest ties up the serve's one shared
363
+ // browser. restart must observe every write that came before it in the SAME
364
+ // batch (models commonly emit "write_file A, write_file B, restart" in one
365
+ // turn) and nothing after it should start before the reload it triggers --
366
+ // without a barrier here it would run concurrently with its own batch's
367
+ // writes, and only looks safe today because those writes happen to execute
368
+ // synchronously ahead of it; an async write tool would let restart race the
369
+ // very writes it's meant to reload. Every other tool call in the loop's set
370
+ // (read/write/edit/list_files/grep/view_image) only touches the filesystem,
371
+ // so distinct calls are safe to run at once -- see groupToolCalls below.
372
+ const BARRIER_TOOLS = new Set(["bash", "playtest", "restart"]);
373
+ // Splits one assistant turn's tool_calls into ordered groups: each maximal
374
+ // run of non-barrier calls is one group (executed concurrently -- see
375
+ // runToolCalls), and each barrier call gets its own singleton group (run
376
+ // alone, blocking the group after it). Models commonly emit dependent
377
+ // batches like "write fileA, write fileB, then bash to run them" -- the
378
+ // writes are safe to parallelize, but nothing after the bash call should
379
+ // start before it resolves, and the bash call itself shouldn't race the
380
+ // writes it depends on.
381
+ function groupToolCalls(toolCalls) {
382
+ const groups = [];
383
+ let current = [];
384
+ for (const call of toolCalls) {
385
+ if (BARRIER_TOOLS.has(call.function?.name ?? "")) {
386
+ if (current.length > 0)
387
+ groups.push(current);
388
+ current = [];
389
+ groups.push([call]);
390
+ }
391
+ else {
392
+ current.push(call);
393
+ }
394
+ }
395
+ if (current.length > 0)
396
+ groups.push(current);
397
+ return groups;
398
+ }
399
+ // Executes one tool call and reports its own activity label -- everything
400
+ // error handling and result-shape wise is identical to the old sequential
401
+ // loop; only the caller (runToolCalls) changed, to run several of these
402
+ // concurrently within a group. Bookkeeping shared across calls (filesTouched,
403
+ // the labels map, the log) is intentionally NOT touched in here -- the
404
+ // caller applies it after Promise.all resolves, walking the group in its
405
+ // ORIGINAL order, so concurrent completion order never affects what lands in
406
+ // the message array or the log.
407
+ async function runOneToolCall(call, role, ctx, onActivity) {
408
+ const name = call.function?.name ?? "";
409
+ const { args, error } = parseToolArgs(call.function?.arguments ?? "");
410
+ onActivity?.(error ? "Working" : activityLabelForCall(name, args));
411
+ const result = error ? { ok: false, output: `Error: ${error}` } : await executeTool(name, args, role, ctx);
412
+ // A richer completion label (e.g. playtest's frame/console summary),
413
+ // shown as its OWN feed line right before the generic activity clears. Two
414
+ // calls finishing close together can interleave these live labels -- a
415
+ // cosmetic race in the feed line, not in anything that reaches the model.
416
+ if (result.activitySummary)
417
+ onActivity?.(result.activitySummary);
418
+ onActivity?.(null);
419
+ return { call, name, args, result };
420
+ }
421
+ async function runToolCalls(toolCalls, role, ctx, filesTouched, playtestFrames, labels, imageLabels, log, onActivity) {
422
+ const results = [];
423
+ // Images a call produced this batch (view_image, playtest, ...). Delivered
424
+ // as synthetic role:"user" messages AFTER all the batch's tool results --
425
+ // role:"tool" content is text-only in practice across OpenRouter's
426
+ // providers (see the delivery note on viewImageRun in tools.ts), and every
427
+ // tool_call_id must be answered contiguously after the assistant turn, so
428
+ // the images can't interleave with the results either.
429
+ const imageMessages = [];
430
+ for (const group of groupToolCalls(toolCalls)) {
431
+ // Promise.all resolves its array in the SAME order as the input array
432
+ // regardless of which call actually finished first, so a singleton group
433
+ // (every barrier call, and the common case of one plain tool call) costs
434
+ // nothing extra by going through this same path.
435
+ const resolved = await Promise.all(group.map((call) => runOneToolCall(call, role, ctx, onActivity)));
436
+ for (const { call, name, args, result } of resolved) {
437
+ labels.set(call.id, toolCallLabel(name, args));
438
+ if (result.filesTouched)
439
+ for (const f of result.filesTouched)
440
+ filesTouched.add(f);
441
+ if (result.playtestFrames)
442
+ for (const f of result.playtestFrames)
443
+ playtestFrames.add(f);
444
+ log.write({
445
+ type: "tool_result",
446
+ id: call.id,
447
+ name,
448
+ ok: result.ok,
449
+ output: truncateForLog(result.output),
450
+ ...(result.images && result.images.length > 0
451
+ ? {
452
+ imageCount: result.images.length,
453
+ imageChars: result.images.reduce((sum, img) => sum + img.dataUrl.length, 0),
454
+ }
455
+ : {}),
456
+ });
457
+ // A dedicated event for a successful playtest call, separate from the
458
+ // generic tool_result above -- args + frame paths + the short digest
459
+ // summary in one line, so a run's playtest activity can be grepped for
460
+ // without wading through every other tool's truncated output.
461
+ if (name === "playtest" && result.ok) {
462
+ log.write({
463
+ type: "playtest",
464
+ id: call.id,
465
+ args,
466
+ frames: result.playtestFrames ?? [],
467
+ summary: result.activitySummary ?? "",
468
+ });
469
+ }
470
+ results.push({ role: "tool", tool_call_id: call.id, content: result.output });
471
+ for (const img of result.images ?? []) {
472
+ const imageMessage = {
473
+ role: "user",
474
+ content: [
475
+ { type: "text", text: `Image from ${img.label}:` },
476
+ { type: "image_url", image_url: { url: img.dataUrl } },
477
+ ],
478
+ };
479
+ imageLabels.set(imageMessage, img.label);
480
+ imageMessages.push(imageMessage);
481
+ }
482
+ }
483
+ }
484
+ return [...results, ...imageMessages];
485
+ }
486
+ export async function runAgentNative(opts) {
487
+ const log = createRunLogger(opts.logPath);
488
+ const toolSchemas = toolSchemasForRole(opts.role);
489
+ log.write({
490
+ type: "init",
491
+ role: opts.role,
492
+ model: opts.model,
493
+ tools: toolSchemas.map((t) => t.function.name),
494
+ promptChars: opts.prompt.length,
495
+ ...(opts.attachments && opts.attachments.length > 0 ? { attachments: opts.attachments } : {}),
496
+ });
497
+ try {
498
+ const result = await runLoop(opts, toolSchemas, log);
499
+ log.write({
500
+ type: "result",
501
+ ok: !result.error && !result.crashed,
502
+ ...(result.error ? { error: result.error } : {}),
503
+ ...(result.crashed ? { crashed: true } : {}),
504
+ ...(result.usage ? { usage: result.usage } : {}),
505
+ ...(result.filesTouched && result.filesTouched.length > 0
506
+ ? { filesTouched: result.filesTouched }
507
+ : {}),
508
+ ...(result.playtestFrames && result.playtestFrames.length > 0
509
+ ? { playtestFrames: result.playtestFrames }
510
+ : {}),
511
+ });
512
+ return result;
513
+ }
514
+ finally {
515
+ await log.end();
516
+ }
517
+ }
518
+ async function runLoop(opts, toolSchemas, log) {
519
+ const controller = new AbortController();
520
+ // The only two things that ever abort `controller`; once aborted, whichever
521
+ // of these fired first determines the final error wording below (there is
522
+ // no third case, so we don't need a separate "was it external" flag).
523
+ let timeoutFired = false;
524
+ const timer = setTimeout(() => {
525
+ timeoutFired = true;
526
+ controller.abort();
527
+ }, opts.timeoutMs);
528
+ if (opts.signal) {
529
+ if (opts.signal.aborted)
530
+ controller.abort();
531
+ else
532
+ opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
533
+ }
534
+ const userContent = buildUserContent(opts.cwd, opts.prompt, opts.attachments);
535
+ if (userContent.error) {
536
+ clearTimeout(timer);
537
+ return { text: "", error: userContent.error };
538
+ }
539
+ const messages = [
540
+ { role: "system", content: buildSystemMessage(opts) },
541
+ { role: "user", content: userContent.content },
542
+ ];
543
+ const toolCtx = {
544
+ deckDir: opts.cwd,
545
+ signal: controller.signal,
546
+ restart: opts.restart,
547
+ // Fresh per run: the PLAYTEST_MAX_CALLS_PER_RUN budget resets every
548
+ // runAgentNative call (one task attempt), never persisted across retries.
549
+ playtest: opts.playtest
550
+ ? {
551
+ executor: opts.playtest.executor,
552
+ serveUrl: opts.playtest.serveUrl,
553
+ framesDir: opts.playtest.framesDir,
554
+ callCount: { value: 0 },
555
+ // First-run browser-download surfacing: every progress label is a
556
+ // live activity line AND a task feed row (see runTaskAgentIn's
557
+ // onActivity handler in agent.ts) -- an unexplained ~90s stall
558
+ // looks like a crash, so the download announces itself. The
559
+ // lifecycle additionally gets structured transcript events and a
560
+ // completion feed row with elapsed time.
561
+ onProgress: (label) => opts.onActivity?.(label),
562
+ onInstallEvent: (evt) => {
563
+ log.write({ type: "browser-install", ...evt });
564
+ if (evt.phase === "finished") {
565
+ opts.onActivity?.(`Playtest browser downloaded in ${Math.max(1, Math.round(evt.elapsedMs / 1000))}s`);
566
+ }
567
+ else if (evt.phase === "failed") {
568
+ opts.onActivity?.(`Playtest browser download failed after ${Math.max(1, Math.round(evt.elapsedMs / 1000))}s`);
569
+ }
570
+ },
571
+ }
572
+ : undefined,
573
+ };
574
+ const filesTouched = new Set();
575
+ const playtestFrames = new Set();
576
+ const toolLabels = new Map();
577
+ // Synthetic view_image carrier messages, by identity -- see runToolCalls
578
+ // and evictOldToolResults.
579
+ const imageLabels = new Map();
580
+ let finalText = "";
581
+ let totalUsage;
582
+ // Once our own timeout/external-abort fires, whatever streamChatCompletion
583
+ // reports is moot -- the abort IS the reason for stopping, so we always
584
+ // attribute the final error to it (rather than a network error that might
585
+ // just be the abort surfacing as a fetch failure).
586
+ const abortResult = () => {
587
+ if (!controller.signal.aborted)
588
+ return null;
589
+ return {
590
+ text: finalText,
591
+ error: timeoutFired ? "agent run timed out" : "agent run stopped",
592
+ usage: totalUsage,
593
+ filesTouched: touchedFileList(filesTouched),
594
+ playtestFrames: playtestFrameList(playtestFrames),
595
+ };
596
+ };
597
+ try {
598
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
599
+ const aborted = abortResult();
600
+ if (aborted)
601
+ return aborted;
602
+ const evicted = evictOldToolResults(messages, toolLabels, imageLabels);
603
+ if (evicted.length > 0) {
604
+ log.write({
605
+ type: "eviction",
606
+ evicted: evicted.map((e) => e.label),
607
+ freedChars: evicted.reduce((sum, e) => sum + e.chars, 0),
608
+ });
609
+ }
610
+ const iterationStartedAt = Date.now();
611
+ const streamResult = await streamChatCompletion({
612
+ apiKey: opts.apiKey,
613
+ model: opts.model,
614
+ messages,
615
+ tools: toolSchemas,
616
+ reasoningEffort: REASONING_EFFORT[opts.role],
617
+ maxTokens: MAX_COMPLETION_TOKENS,
618
+ signal: controller.signal,
619
+ onDelta: opts.onDelta,
620
+ onThinking: opts.onThinking,
621
+ onRetry: (info) => log.write({ type: "retry", ...info }),
622
+ });
623
+ const wallMs = Date.now() - iterationStartedAt;
624
+ totalUsage = addUsage(totalUsage, streamResult.usage);
625
+ // One line per iteration (completion call), additive to -- and never
626
+ // replacing -- the end-of-run aggregate `result` event's summed usage
627
+ // (see addUsage above). This is the observability the incident this
628
+ // file cites was missing: with usage logged only once per whole run,
629
+ // attributing a spike to ONE iteration required manual forensics.
630
+ // Logged unconditionally (even on a crashed/errored/aborted call) so
631
+ // wallMs alone still flags a slow iteration when no usage came back.
632
+ log.write({
633
+ type: "usage",
634
+ iteration,
635
+ input_tokens: streamResult.usage?.input_tokens,
636
+ output_tokens: streamResult.usage?.output_tokens,
637
+ cache_read_input_tokens: streamResult.usage?.cache_read_input_tokens,
638
+ cache_creation_input_tokens: streamResult.usage?.cache_creation_input_tokens,
639
+ reasoning_tokens: streamResult.reasoningTokens,
640
+ wallMs,
641
+ });
642
+ const abortedAfter = abortResult();
643
+ if (abortedAfter)
644
+ return abortedAfter;
645
+ if (!streamResult.message) {
646
+ return {
647
+ text: finalText,
648
+ error: streamResult.error ?? "openrouter stream ended without a final response",
649
+ usage: totalUsage,
650
+ filesTouched: touchedFileList(filesTouched),
651
+ playtestFrames: playtestFrameList(playtestFrames),
652
+ crashed: streamResult.crashed,
653
+ };
654
+ }
655
+ finalText = streamResult.message.content;
656
+ const toolCalls = streamResult.message.tool_calls ?? [];
657
+ log.write({
658
+ type: "assistant",
659
+ iteration,
660
+ text: streamResult.message.content,
661
+ ...(toolCalls.length > 0
662
+ ? {
663
+ toolCalls: toolCalls.map((tc) => ({
664
+ id: tc.id,
665
+ name: tc.function.name,
666
+ args: truncateForLog(tc.function.arguments),
667
+ })),
668
+ }
669
+ : {}),
670
+ });
671
+ if (toolCalls.length === 0) {
672
+ return {
673
+ text: finalText,
674
+ usage: totalUsage,
675
+ filesTouched: touchedFileList(filesTouched),
676
+ playtestFrames: playtestFrameList(playtestFrames),
677
+ };
678
+ }
679
+ messages.push({
680
+ role: "assistant",
681
+ content: streamResult.message.content || null,
682
+ tool_calls: toolCalls,
683
+ });
684
+ const toolResults = await runToolCalls(toolCalls, opts.role, toolCtx, filesTouched, playtestFrames, toolLabels, imageLabels, log, opts.onActivity);
685
+ messages.push(...toolResults);
686
+ }
687
+ }
688
+ finally {
689
+ clearTimeout(timer);
690
+ }
691
+ return {
692
+ text: finalText,
693
+ error: "agent exceeded the maximum number of tool-call iterations",
694
+ usage: totalUsage,
695
+ filesTouched: touchedFileList(filesTouched),
696
+ playtestFrames: playtestFrameList(playtestFrames),
697
+ };
698
+ }