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.
- package/dist/agent-prompts.d.ts +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +690 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-yGdKhgfZ.js → index-D3unT7do.js} +37 -37
- package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +4 -3
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +8 -2
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +155 -13
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- 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
|
+
}
|