@robmclarty/volley 0.9.0 → 0.10.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/CHANGELOG.md +30 -0
- package/README.md +63 -17
- package/dist/cli.js +873 -555
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +35 -7
- package/dist/index.js +732 -506
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/builder/presets/harness_append_acp.md +4 -3
- package/src/builder/presets/harness_append_local.md +3 -2
package/dist/cli.js
CHANGED
|
@@ -73,6 +73,284 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
|
|
|
73
73
|
if (warning !== null) warn(warning);
|
|
74
74
|
}
|
|
75
75
|
//#endregion
|
|
76
|
+
//#region src/builder/countdown.ts
|
|
77
|
+
/** How many of a session's last steps carry the countdown: five, or a tenth
|
|
78
|
+
* of a long budget, but never more than half of it, so a short session still
|
|
79
|
+
* starts without one. */
|
|
80
|
+
function countdown_window(max_steps) {
|
|
81
|
+
return Math.min(Math.max(5, Math.ceil(max_steps / 10)), Math.floor(max_steps / 2));
|
|
82
|
+
}
|
|
83
|
+
/** The note for a turn with `steps_left` steps left, this one included.
|
|
84
|
+
* `check` names how to run the check. On the last step fascicle runs only a
|
|
85
|
+
* call that ends the session, which for the builder is `finish`. */
|
|
86
|
+
function countdown_note(steps_left, check) {
|
|
87
|
+
if (steps_left <= 1) return "[volley] This is the last step of this session, and only finish runs on it. Call finish now, with a summary that says what's done and what isn't verified.";
|
|
88
|
+
return `[volley] ${String(steps_left)} steps left in this session, this one included, and on the last one only finish runs. If you haven't run ${check} since your last edit, run it now, then fix what it reports or call finish.`;
|
|
89
|
+
}
|
|
90
|
+
/** The `prepare_step` hook that adds the countdown to a session of
|
|
91
|
+
* `max_steps` steps. */
|
|
92
|
+
function step_countdown(max_steps, check) {
|
|
93
|
+
const window = countdown_window(max_steps);
|
|
94
|
+
return ({ step_index, messages }) => {
|
|
95
|
+
const steps_left = max_steps - step_index;
|
|
96
|
+
if (steps_left > window) return void 0;
|
|
97
|
+
return { messages: [...messages, {
|
|
98
|
+
role: "user",
|
|
99
|
+
content: countdown_note(steps_left, check)
|
|
100
|
+
}] };
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
//#region src/types.ts
|
|
105
|
+
function config_error(message) {
|
|
106
|
+
return Object.assign(new Error(message), { kind: "config_error" });
|
|
107
|
+
}
|
|
108
|
+
function check_error(message) {
|
|
109
|
+
return Object.assign(new Error(message), { kind: "check_error" });
|
|
110
|
+
}
|
|
111
|
+
/** Wraps a provider/schema failure with the phase and iteration it hit. */
|
|
112
|
+
function phase_error(phase, iteration, cause) {
|
|
113
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
114
|
+
return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
|
|
115
|
+
kind: "phase_error",
|
|
116
|
+
phase,
|
|
117
|
+
iteration,
|
|
118
|
+
cause
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function error_kind(err) {
|
|
122
|
+
if (typeof err === "object" && err !== null && "kind" in err) {
|
|
123
|
+
const kind = err.kind;
|
|
124
|
+
return typeof kind === "string" ? kind : null;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/engine.ts
|
|
130
|
+
/**
|
|
131
|
+
* The ONLY module that calls fascicle's `create_engine`. Every
|
|
132
|
+
* other module receives an `Engine`-shaped value, which keeps the engine
|
|
133
|
+
* mockable in tests and confines provider knowledge to one seam.
|
|
134
|
+
*/
|
|
135
|
+
/** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
|
|
136
|
+
* The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
|
|
137
|
+
* `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
|
|
138
|
+
* and 404s. */
|
|
139
|
+
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
140
|
+
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
141
|
+
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
142
|
+
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
143
|
+
/** The variable an `openrouter` seat's API key is read from. */
|
|
144
|
+
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
145
|
+
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
146
|
+
* CLI also reads on its own. */
|
|
147
|
+
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
148
|
+
/** Every variable a seat reads a credential from. Every process volley starts
|
|
149
|
+
* runs without them (the check, the builder's commands, and volley's own git
|
|
150
|
+
* and checkride calls, which run hooks and filters the builder can write), so
|
|
151
|
+
* nothing the builder writes can read the keys its seats spend. */
|
|
152
|
+
const SEAT_CREDENTIAL_VARS = [OPENROUTER_KEY_VAR, ANTHROPIC_KEY_VAR];
|
|
153
|
+
/** The environment every process volley starts runs with: `env`, `PATH`
|
|
154
|
+
* included, less every variable a seat reads a credential from. */
|
|
155
|
+
function command_env(env) {
|
|
156
|
+
const stripped = new Set(SEAT_CREDENTIAL_VARS);
|
|
157
|
+
return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
|
|
158
|
+
}
|
|
159
|
+
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
160
|
+
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
161
|
+
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
162
|
+
* except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
|
|
163
|
+
* IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
|
|
164
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
165
|
+
"localhost",
|
|
166
|
+
"127.0.0.1",
|
|
167
|
+
"[::1]",
|
|
168
|
+
"0.0.0.0"
|
|
169
|
+
]);
|
|
170
|
+
/** Cross a loopback base URL to the host LLM endpoint. When volley
|
|
171
|
+
* runs inside its sandbox container the local model daemon is on the *host*, so
|
|
172
|
+
* the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
|
|
173
|
+
* (the host-gateway allowlist target; see `sandbox_invocation` in
|
|
174
|
+
* `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
|
|
175
|
+
* loopback host for the gateway, so an unchanged `http://localhost:11434` still
|
|
176
|
+
* reaches the host from in-container. A non-loopback URL (a real remote daemon)
|
|
177
|
+
* is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
|
|
178
|
+
* path) is a no-op — so the default and all-Claude paths are unaffected. */
|
|
179
|
+
function cross_to_host_gateway(base_url, env) {
|
|
180
|
+
const host = env["VOLLEY_MODEL_HOST"];
|
|
181
|
+
if (host === void 0 || host === "") return base_url;
|
|
182
|
+
let parsed;
|
|
183
|
+
try {
|
|
184
|
+
parsed = new URL(base_url);
|
|
185
|
+
} catch {
|
|
186
|
+
return base_url;
|
|
187
|
+
}
|
|
188
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
189
|
+
parsed.hostname = host;
|
|
190
|
+
return parsed.toString().replace(/\/$/, "");
|
|
191
|
+
}
|
|
192
|
+
function strip_trailing_slashes(url) {
|
|
193
|
+
return url.replace(/\/+$/, "");
|
|
194
|
+
}
|
|
195
|
+
const SEAT_URL_SOURCES = {
|
|
196
|
+
ollama: {
|
|
197
|
+
env: "VOLLEY_OLLAMA_URL",
|
|
198
|
+
default_url: DEFAULT_OLLAMA_URL,
|
|
199
|
+
normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
|
|
200
|
+
},
|
|
201
|
+
lmstudio: {
|
|
202
|
+
env: "VOLLEY_LMSTUDIO_URL",
|
|
203
|
+
default_url: DEFAULT_LMSTUDIO_URL,
|
|
204
|
+
normalize: strip_trailing_slashes
|
|
205
|
+
},
|
|
206
|
+
openrouter: {
|
|
207
|
+
env: "VOLLEY_OPENROUTER_URL",
|
|
208
|
+
default_url: DEFAULT_OPENROUTER_URL,
|
|
209
|
+
normalize: strip_trailing_slashes
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
213
|
+
* so get their endpoint probed by `--dry-run`. */
|
|
214
|
+
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
215
|
+
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
216
|
+
* configures, and so takes a URL of its own. */
|
|
217
|
+
function takes_seat_url(provider) {
|
|
218
|
+
return SEAT_URL_PROVIDERS.includes(provider);
|
|
219
|
+
}
|
|
220
|
+
/** An absent, empty, or all-whitespace value counts as unset. */
|
|
221
|
+
function present(value) {
|
|
222
|
+
const trimmed = value?.trim() ?? "";
|
|
223
|
+
return trimmed === "" ? null : trimmed;
|
|
224
|
+
}
|
|
225
|
+
/** The base URL a seat's model calls go to: the seat's own URL, else the
|
|
226
|
+
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
227
|
+
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
228
|
+
* other seat's URL. The result is normalized, then a loopback authority is
|
|
229
|
+
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
230
|
+
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
231
|
+
function seat_base_url(provider, seat_url, env) {
|
|
232
|
+
const source = SEAT_URL_SOURCES[provider];
|
|
233
|
+
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
234
|
+
return cross_to_host_gateway(source.normalize(raw), env);
|
|
235
|
+
}
|
|
236
|
+
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
237
|
+
* or blank key is a config error naming the variable, which `resolve_config`
|
|
238
|
+
* raises before any work, because fascicle's own "requires a non-empty
|
|
239
|
+
* api_key" doesn't say where the key comes from. */
|
|
240
|
+
function openrouter_api_key(env) {
|
|
241
|
+
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
242
|
+
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
243
|
+
return key;
|
|
244
|
+
}
|
|
245
|
+
function load_pricing_overrides(path) {
|
|
246
|
+
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
249
|
+
} catch (err) {
|
|
250
|
+
throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
|
|
254
|
+
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
255
|
+
* fascicle only when the provider actually runs. The entry is complete, because
|
|
256
|
+
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
257
|
+
* returns is all a seat's derived engine knows about the provider, so an
|
|
258
|
+
* `openrouter` entry carries its key and its transport along with its URL.
|
|
259
|
+
*
|
|
260
|
+
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
261
|
+
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
262
|
+
* traffic to volley.
|
|
263
|
+
*
|
|
264
|
+
* For the local providers, native transport is the deferred future bridge, not
|
|
265
|
+
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
266
|
+
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
267
|
+
* so no field is needed today. Flipping later is a one-line change — add
|
|
268
|
+
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
269
|
+
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
270
|
+
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
271
|
+
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
272
|
+
* base-URL fix produces.
|
|
273
|
+
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
274
|
+
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
275
|
+
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
276
|
+
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
277
|
+
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
278
|
+
*
|
|
279
|
+
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
280
|
+
* when volley runs contained, so an in-container model client reaches the
|
|
281
|
+
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
282
|
+
* override is unset and this is a no-op. */
|
|
283
|
+
function seat_provider_config(provider, seat_url, env) {
|
|
284
|
+
const base_url = seat_base_url(provider, seat_url, env);
|
|
285
|
+
switch (provider) {
|
|
286
|
+
case "ollama": return { ollama: { base_url } };
|
|
287
|
+
case "lmstudio": return { lmstudio: { base_url } };
|
|
288
|
+
case "openrouter": return { openrouter: {
|
|
289
|
+
api_key: openrouter_api_key(env),
|
|
290
|
+
base_url,
|
|
291
|
+
transport: "native",
|
|
292
|
+
x_title: "volley"
|
|
293
|
+
} };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
297
|
+
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
298
|
+
* by the builder or critic role is wired in alongside it, at that seat's URL,
|
|
299
|
+
* so one engine drives both roles. An engine holds one URL per provider, so on
|
|
300
|
+
* a provider both seats share it carries the builder's; `create_seat_engines`
|
|
301
|
+
* gives the critic its own engine when its URL differs. Binary, auth mode,
|
|
302
|
+
* fallback provider URLs, and pricing come from the environment. */
|
|
303
|
+
function create_volley_engine(options) {
|
|
304
|
+
const env = options.env ?? process.env;
|
|
305
|
+
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
306
|
+
if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") throw config_error(`VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`);
|
|
307
|
+
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
308
|
+
const pricing_path = env["VOLLEY_PRICING_PATH"];
|
|
309
|
+
const providers = { claude_cli: {
|
|
310
|
+
default_cwd: options.workspace,
|
|
311
|
+
setting_sources: ["project", "local"],
|
|
312
|
+
auth_mode,
|
|
313
|
+
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
314
|
+
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
315
|
+
} };
|
|
316
|
+
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
317
|
+
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
318
|
+
return create_engine({
|
|
319
|
+
providers,
|
|
320
|
+
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/** One engine per seat, shared unless it can't be. fascicle fixes one base URL
|
|
324
|
+
* per provider when it builds an engine, so two seats on the same provider at
|
|
325
|
+
* different URLs need two engines: the critic's is derived from the builder's
|
|
326
|
+
* with `with_providers`, which keeps claude_cli, auth, and pricing and
|
|
327
|
+
* overrides only the shared provider's entry. Every other pairing, including a
|
|
328
|
+
* run that sets no seat URL at all, gets the one engine a run has always had. */
|
|
329
|
+
async function create_seat_engines(options) {
|
|
330
|
+
const env = options.env ?? process.env;
|
|
331
|
+
const builder = create_volley_engine(options);
|
|
332
|
+
const provider = options.critic_provider;
|
|
333
|
+
if (provider !== options.builder_provider || !takes_seat_url(provider) || seat_base_url(provider, options.builder_url ?? null, env) === seat_base_url(provider, options.critic_url ?? null, env)) return {
|
|
334
|
+
builder,
|
|
335
|
+
critic: builder
|
|
336
|
+
};
|
|
337
|
+
try {
|
|
338
|
+
return {
|
|
339
|
+
builder,
|
|
340
|
+
critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
|
|
341
|
+
};
|
|
342
|
+
} catch (err) {
|
|
343
|
+
await builder.dispose();
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/** Dispose each distinct seat engine once. Both disposals start together, so
|
|
348
|
+
* one that fails doesn't keep the other from running. */
|
|
349
|
+
async function dispose_seat_engines(engines) {
|
|
350
|
+
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
351
|
+
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
352
|
+
}
|
|
353
|
+
//#endregion
|
|
76
354
|
//#region src/workspace_tools.ts
|
|
77
355
|
/**
|
|
78
356
|
* Shared workspace tools: `contain()` path confinement plus the read-only
|
|
@@ -183,6 +461,7 @@ function read_only_tools(workspace) {
|
|
|
183
461
|
return [
|
|
184
462
|
{
|
|
185
463
|
name: "read_file",
|
|
464
|
+
read_only: true,
|
|
186
465
|
description: `Read a UTF-8 text file from the workspace. Returns the file content (truncated at ${String(READ_FILE_MAX_BYTES)} bytes).`,
|
|
187
466
|
input_schema: read_file_input,
|
|
188
467
|
execute: (raw) => {
|
|
@@ -193,6 +472,7 @@ function read_only_tools(workspace) {
|
|
|
193
472
|
},
|
|
194
473
|
{
|
|
195
474
|
name: "search_files",
|
|
475
|
+
read_only: true,
|
|
196
476
|
description: `Search workspace files for lines matching a regular expression. Returns up to ${String(200)} "relative/path:line: text" hits.`,
|
|
197
477
|
input_schema: search_files_input,
|
|
198
478
|
execute: (raw) => {
|
|
@@ -222,6 +502,7 @@ function read_only_tools(workspace) {
|
|
|
222
502
|
},
|
|
223
503
|
{
|
|
224
504
|
name: "list_files",
|
|
505
|
+
read_only: true,
|
|
225
506
|
description: "List files under a workspace directory (recursively; skips node_modules, .git, and other build/output directories).",
|
|
226
507
|
input_schema: list_files_input,
|
|
227
508
|
execute: (raw) => {
|
|
@@ -533,14 +814,17 @@ async function run_fetch(raw, ctx, config) {
|
|
|
533
814
|
* containment this runs *inside* volley's hardened container against the
|
|
534
815
|
* bind-mounted worktree — the container is the isolation boundary, no
|
|
535
816
|
* `docker exec` hop — and it is likewise the executor on the
|
|
536
|
-
* `--allow-unsandboxed-builder` host escape hatch.
|
|
537
|
-
*
|
|
817
|
+
* `--allow-unsandboxed-builder` host escape hatch. A command runs with
|
|
818
|
+
* volley's environment less the seats' keys, so nothing the builder runs can
|
|
819
|
+
* read the key its seat spends. `status` is null when a signal (the timeout
|
|
820
|
+
* SIGKILL) killed the process.
|
|
538
821
|
*/
|
|
539
822
|
function host_bash_executor(cwd) {
|
|
540
823
|
return (command, options) => {
|
|
541
824
|
const result = spawnSync(command, {
|
|
542
825
|
shell: true,
|
|
543
826
|
cwd,
|
|
827
|
+
env: command_env(process.env),
|
|
544
828
|
encoding: "utf8",
|
|
545
829
|
timeout: options.timeout_ms,
|
|
546
830
|
killSignal: "SIGKILL",
|
|
@@ -642,320 +926,321 @@ function finish_tool() {
|
|
|
642
926
|
};
|
|
643
927
|
}
|
|
644
928
|
//#endregion
|
|
645
|
-
//#region src/
|
|
929
|
+
//#region src/check/checkride.ts
|
|
646
930
|
/**
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
931
|
+
* checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
|
|
932
|
+
* `.check/summary.json` contract (`schema_version: 1`), and collect raw
|
|
933
|
+
* failing-slot artifacts for the critic — the critic reads what the tool
|
|
934
|
+
* actually said, not a normalized digest. The digest checkride writes on a red
|
|
935
|
+
* run is kept beside them for the next builder, which needs a short list of
|
|
936
|
+
* what to fix more than the whole of every slot's output.
|
|
650
937
|
*/
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}
|
|
668
|
-
return sum;
|
|
938
|
+
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
939
|
+
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
940
|
+
/** How long a check's output may stay open after its shell exits, while
|
|
941
|
+
* something that escaped its process group still holds it, before the reads
|
|
942
|
+
* stop so the check can finish. */
|
|
943
|
+
const OUTPUT_GRACE_MS = 2e3;
|
|
944
|
+
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
945
|
+
* and anything the check started in the background, sit in the shell's group,
|
|
946
|
+
* so killing the shell alone would leave them running. Windows has no process
|
|
947
|
+
* groups to signal, so there only the shell goes. */
|
|
948
|
+
function kill_group(proc) {
|
|
949
|
+
if (proc.pid === void 0) return;
|
|
950
|
+
try {
|
|
951
|
+
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
952
|
+
else process.kill(-proc.pid, "SIGKILL");
|
|
953
|
+
} catch {}
|
|
669
954
|
}
|
|
670
|
-
/**
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
|
|
674
|
-
function
|
|
675
|
-
|
|
676
|
-
return
|
|
955
|
+
/** Run a check to its end in a process group of its own, capturing its
|
|
956
|
+
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
957
|
+
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
958
|
+
* no test workers or servers running. */
|
|
959
|
+
function spawn_capture(cmd, args, options) {
|
|
960
|
+
const { abort } = options;
|
|
961
|
+
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
962
|
+
return new Promise((resolve_promise, reject) => {
|
|
963
|
+
const proc = spawn(cmd, args, {
|
|
964
|
+
cwd: options.cwd,
|
|
965
|
+
env: options.env ?? command_env(process.env),
|
|
966
|
+
detached: process.platform !== "win32",
|
|
967
|
+
stdio: [
|
|
968
|
+
"ignore",
|
|
969
|
+
"pipe",
|
|
970
|
+
"pipe"
|
|
971
|
+
]
|
|
972
|
+
});
|
|
973
|
+
let stdout = "";
|
|
974
|
+
let stderr = "";
|
|
975
|
+
let grace;
|
|
976
|
+
const on_abort = () => {
|
|
977
|
+
kill_group(proc);
|
|
978
|
+
reject(new aborted_error("check aborted"));
|
|
979
|
+
};
|
|
980
|
+
const finish = () => {
|
|
981
|
+
clearTimeout(grace);
|
|
982
|
+
abort?.removeEventListener("abort", on_abort);
|
|
983
|
+
};
|
|
984
|
+
proc.stdout.on("data", (chunk) => {
|
|
985
|
+
stdout += chunk.toString();
|
|
986
|
+
});
|
|
987
|
+
proc.stderr.on("data", (chunk) => {
|
|
988
|
+
stderr += chunk.toString();
|
|
989
|
+
});
|
|
990
|
+
proc.on("error", (err) => {
|
|
991
|
+
finish();
|
|
992
|
+
reject(check_error(`check command failed to start: ${err.message}`));
|
|
993
|
+
});
|
|
994
|
+
proc.on("exit", () => {
|
|
995
|
+
kill_group(proc);
|
|
996
|
+
grace = setTimeout(() => {
|
|
997
|
+
proc.stdout.destroy();
|
|
998
|
+
proc.stderr.destroy();
|
|
999
|
+
}, OUTPUT_GRACE_MS);
|
|
1000
|
+
});
|
|
1001
|
+
proc.on("close", (code) => {
|
|
1002
|
+
finish();
|
|
1003
|
+
resolve_promise({
|
|
1004
|
+
exit_code: code,
|
|
1005
|
+
stdout,
|
|
1006
|
+
stderr
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
abort?.addEventListener("abort", on_abort, { once: true });
|
|
1010
|
+
});
|
|
677
1011
|
}
|
|
678
|
-
/**
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
|
|
683
|
-
|
|
1012
|
+
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
1013
|
+
* command's stdout (pnpm 11's dep-verify prints an "Already up to date"
|
|
1014
|
+
* install line ahead of the JSON), so the object is extracted between the
|
|
1015
|
+
* first `{` and the last `}` rather than parsed from byte 0. An unexpected
|
|
1016
|
+
* schema_version warns and still parses; no JSON object at all is a harness
|
|
1017
|
+
* error (exit 4). */
|
|
1018
|
+
function parse_checkride_summary(stdout) {
|
|
1019
|
+
const start = stdout.indexOf("{");
|
|
1020
|
+
const end = stdout.lastIndexOf("}");
|
|
1021
|
+
let raw;
|
|
1022
|
+
try {
|
|
1023
|
+
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
1024
|
+
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
1025
|
+
} catch {
|
|
1026
|
+
throw check_error("checkride --json produced unparseable stdout");
|
|
1027
|
+
}
|
|
1028
|
+
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
|
|
1029
|
+
const summary = raw;
|
|
684
1030
|
return {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
session_id: claude_cli_reported(result)?.session_id ?? null,
|
|
688
|
-
duration_ms,
|
|
689
|
-
usage: result.usage,
|
|
690
|
-
cost_usd: result.cost?.total_usd ?? null,
|
|
691
|
-
cost_source: cost_source_of(result),
|
|
692
|
-
finish_reason: result.finish_reason,
|
|
693
|
-
tool_calls: result.tool_calls.length,
|
|
694
|
-
salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length,
|
|
695
|
-
...rate !== void 0 ? { throughput: rate } : {}
|
|
1031
|
+
summary,
|
|
1032
|
+
warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
|
|
696
1033
|
};
|
|
697
1034
|
}
|
|
698
|
-
/**
|
|
699
|
-
*
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
builder_cost_usd: state.builder_cost_usd + (role === "builder" ? cost : 0),
|
|
710
|
-
critic_cost_usd: state.critic_cost_usd + (role === "critic" ? cost : 0),
|
|
711
|
-
cost_warned: state.cost_warned || record.cost_usd === null
|
|
712
|
-
};
|
|
1035
|
+
/** Parse the summary from stdout, falling back to the on-disk contract:
|
|
1036
|
+
* `.check/summary.json` is what checkride documents; stdout is its mirror and
|
|
1037
|
+
* wrappers may talk over it beyond what the tolerant slice can recover. */
|
|
1038
|
+
function parse_summary_with_fallback(stdout, workspace) {
|
|
1039
|
+
try {
|
|
1040
|
+
return parse_checkride_summary(stdout);
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
const file = join(workspace, ".check", "summary.json");
|
|
1043
|
+
if (!existsSync(file)) throw err;
|
|
1044
|
+
return parse_checkride_summary(readFileSync(file, "utf8"));
|
|
1045
|
+
}
|
|
713
1046
|
}
|
|
714
|
-
function
|
|
715
|
-
return
|
|
1047
|
+
function failing_checks(summary) {
|
|
1048
|
+
return summary.checks.filter((c) => !c.ok && c.skipped !== true);
|
|
716
1049
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
return
|
|
1050
|
+
/** Whether `path` sits under `dir`. */
|
|
1051
|
+
function within(dir, path) {
|
|
1052
|
+
const from_dir = relative(dir, path);
|
|
1053
|
+
return from_dir !== ".." && !from_dir.startsWith(`..${sep}`) && !isAbsolute(from_dir);
|
|
1054
|
+
}
|
|
1055
|
+
/** Per failing slot: the raw artifact (`<output_file>` when present, else
|
|
1056
|
+
* `<slot>.stdout.txt`) in `check_dir`, a workspace's `.check/` or an archived
|
|
1057
|
+
* iteration's copy of it, truncated with a pointer to the file. The names come
|
|
1058
|
+
* from a summary the builder could have written, so a path that leaves
|
|
1059
|
+
* `check_dir` has no artifact, rather than putting a file from elsewhere in
|
|
1060
|
+
* front of a model. */
|
|
1061
|
+
function read_failing_artifacts(check_dir, failing) {
|
|
1062
|
+
return failing.map((check) => {
|
|
1063
|
+
const file = check.output_file ?? `${check.name}.stdout.txt`;
|
|
1064
|
+
const path = join(check_dir, file);
|
|
1065
|
+
if (!within(check_dir, path) || !existsSync(path) || !statSync(path).isFile()) return {
|
|
1066
|
+
slot: check.name,
|
|
1067
|
+
path: null,
|
|
1068
|
+
content: "(no artifact found)",
|
|
1069
|
+
truncated: false
|
|
1070
|
+
};
|
|
1071
|
+
const raw = readFileSync(path, "utf8");
|
|
1072
|
+
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
1073
|
+
return {
|
|
1074
|
+
slot: check.name,
|
|
1075
|
+
path,
|
|
1076
|
+
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
1077
|
+
truncated
|
|
1078
|
+
};
|
|
1079
|
+
});
|
|
721
1080
|
}
|
|
722
|
-
|
|
723
|
-
|
|
1081
|
+
/** checkride's digest of a red run (`.check/digest.md`, written under
|
|
1082
|
+
* `--digest` and removed on a green one), or undefined when there is none. It
|
|
1083
|
+
* is capped by checkride itself, at 8 kB. */
|
|
1084
|
+
function read_digest(workspace) {
|
|
1085
|
+
const path = join(workspace, ".check", "digest.md");
|
|
1086
|
+
return existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
724
1087
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
1088
|
+
async function run_checkride(opts) {
|
|
1089
|
+
const { workspace, ...spawn_options } = opts;
|
|
1090
|
+
const proc = await spawn_capture("pnpm", [
|
|
1091
|
+
"exec",
|
|
1092
|
+
"checkride",
|
|
1093
|
+
"--json",
|
|
1094
|
+
"--digest"
|
|
1095
|
+
], {
|
|
1096
|
+
...spawn_options,
|
|
1097
|
+
cwd: workspace
|
|
733
1098
|
});
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1099
|
+
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
1100
|
+
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
1101
|
+
const failing = failing_checks(summary);
|
|
1102
|
+
const digest = summary.ok ? void 0 : read_digest(opts.workspace);
|
|
1103
|
+
return {
|
|
1104
|
+
ran: true,
|
|
1105
|
+
runner: "checkride",
|
|
1106
|
+
ok: summary.ok,
|
|
1107
|
+
exit_code: proc.exit_code,
|
|
1108
|
+
duration_ms: summary.total_duration_ms,
|
|
1109
|
+
failing_slots: failing.map((c) => c.name),
|
|
1110
|
+
detail: read_failing_artifacts(join(opts.workspace, ".check"), failing),
|
|
1111
|
+
summary,
|
|
1112
|
+
...digest !== void 0 ? { digest } : {}
|
|
1113
|
+
};
|
|
741
1114
|
}
|
|
742
1115
|
//#endregion
|
|
743
|
-
//#region src/
|
|
744
|
-
/**
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*/
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
769
|
-
* except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
|
|
770
|
-
* IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
|
|
771
|
-
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
772
|
-
"localhost",
|
|
773
|
-
"127.0.0.1",
|
|
774
|
-
"[::1]",
|
|
775
|
-
"0.0.0.0"
|
|
776
|
-
]);
|
|
777
|
-
/** Cross a loopback base URL to the host LLM endpoint. When volley
|
|
778
|
-
* runs inside its sandbox container the local model daemon is on the *host*, so
|
|
779
|
-
* the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
|
|
780
|
-
* (the host-gateway allowlist target; see `sandbox_invocation` in
|
|
781
|
-
* `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
|
|
782
|
-
* loopback host for the gateway, so an unchanged `http://localhost:11434` still
|
|
783
|
-
* reaches the host from in-container. A non-loopback URL (a real remote daemon)
|
|
784
|
-
* is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
|
|
785
|
-
* path) is a no-op — so the default and all-Claude paths are unaffected. */
|
|
786
|
-
function cross_to_host_gateway(base_url, env) {
|
|
787
|
-
const host = env["VOLLEY_MODEL_HOST"];
|
|
788
|
-
if (host === void 0 || host === "") return base_url;
|
|
789
|
-
let parsed;
|
|
790
|
-
try {
|
|
791
|
-
parsed = new URL(base_url);
|
|
792
|
-
} catch {
|
|
793
|
-
return base_url;
|
|
794
|
-
}
|
|
795
|
-
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
796
|
-
parsed.hostname = host;
|
|
797
|
-
return parsed.toString().replace(/\/$/, "");
|
|
798
|
-
}
|
|
799
|
-
function strip_trailing_slashes(url) {
|
|
800
|
-
return url.replace(/\/+$/, "");
|
|
801
|
-
}
|
|
802
|
-
const SEAT_URL_SOURCES = {
|
|
803
|
-
ollama: {
|
|
804
|
-
env: "VOLLEY_OLLAMA_URL",
|
|
805
|
-
default_url: DEFAULT_OLLAMA_URL,
|
|
806
|
-
normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
|
|
807
|
-
},
|
|
808
|
-
lmstudio: {
|
|
809
|
-
env: "VOLLEY_LMSTUDIO_URL",
|
|
810
|
-
default_url: DEFAULT_LMSTUDIO_URL,
|
|
811
|
-
normalize: strip_trailing_slashes
|
|
812
|
-
},
|
|
813
|
-
openrouter: {
|
|
814
|
-
env: "VOLLEY_OPENROUTER_URL",
|
|
815
|
-
default_url: DEFAULT_OPENROUTER_URL,
|
|
816
|
-
normalize: strip_trailing_slashes
|
|
817
|
-
}
|
|
818
|
-
};
|
|
819
|
-
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
820
|
-
* so get their endpoint probed by `--dry-run`. */
|
|
821
|
-
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
822
|
-
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
823
|
-
* configures, and so takes a URL of its own. */
|
|
824
|
-
function takes_seat_url(provider) {
|
|
825
|
-
return SEAT_URL_PROVIDERS.includes(provider);
|
|
826
|
-
}
|
|
827
|
-
/** An absent, empty, or all-whitespace value counts as unset. */
|
|
828
|
-
function present(value) {
|
|
829
|
-
const trimmed = value?.trim() ?? "";
|
|
830
|
-
return trimmed === "" ? null : trimmed;
|
|
1116
|
+
//#region src/check/command.ts
|
|
1117
|
+
/**
|
|
1118
|
+
* Generic command runner: `sh -c <command>`, combined
|
|
1119
|
+
* output captured, exit-0 semantics. Command-not-found is an operator error
|
|
1120
|
+
* (exit 4), not an iterable failure.
|
|
1121
|
+
*/
|
|
1122
|
+
async function run_command_check(opts) {
|
|
1123
|
+
const { command, workspace, ...spawn_options } = opts;
|
|
1124
|
+
const started = Date.now();
|
|
1125
|
+
const proc = await spawn_capture("sh", ["-c", command], {
|
|
1126
|
+
...spawn_options,
|
|
1127
|
+
cwd: workspace
|
|
1128
|
+
});
|
|
1129
|
+
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
1130
|
+
const log = `${proc.stdout}${proc.stderr}`;
|
|
1131
|
+
return {
|
|
1132
|
+
ran: true,
|
|
1133
|
+
runner: "command",
|
|
1134
|
+
ok: proc.exit_code === 0,
|
|
1135
|
+
exit_code: proc.exit_code,
|
|
1136
|
+
duration_ms: Date.now() - started,
|
|
1137
|
+
failing_slots: [],
|
|
1138
|
+
detail: [],
|
|
1139
|
+
log
|
|
1140
|
+
};
|
|
831
1141
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1142
|
+
function skipped_check(reason) {
|
|
1143
|
+
return {
|
|
1144
|
+
ran: false,
|
|
1145
|
+
runner: "none",
|
|
1146
|
+
ok: reason === "none",
|
|
1147
|
+
exit_code: null,
|
|
1148
|
+
duration_ms: 0,
|
|
1149
|
+
failing_slots: [],
|
|
1150
|
+
detail: []
|
|
1151
|
+
};
|
|
842
1152
|
}
|
|
843
|
-
/**
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
*
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1153
|
+
/** What a rejected check says in place of its output. */
|
|
1154
|
+
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
1155
|
+
/** A check the user rejected when it asked to run: recorded as a failed
|
|
1156
|
+
* check, with the refusal as its output, so a round can't converge on work
|
|
1157
|
+
* nothing checked, and the critic hears why there's no result. */
|
|
1158
|
+
function rejected_check(runner) {
|
|
1159
|
+
return {
|
|
1160
|
+
ran: true,
|
|
1161
|
+
runner,
|
|
1162
|
+
ok: false,
|
|
1163
|
+
exit_code: null,
|
|
1164
|
+
duration_ms: 0,
|
|
1165
|
+
failing_slots: [],
|
|
1166
|
+
detail: [],
|
|
1167
|
+
log: CHECK_REJECTED
|
|
1168
|
+
};
|
|
851
1169
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1170
|
+
//#endregion
|
|
1171
|
+
//#region src/cost.ts
|
|
1172
|
+
/**
|
|
1173
|
+
* Cost and usage accumulation. All pricing knowledge lives in
|
|
1174
|
+
* fascicle; volley only folds `GenerateResult.usage` / `.cost` into loop
|
|
1175
|
+
* state and enforces the cap predicate in the guard.
|
|
1176
|
+
*/
|
|
1177
|
+
const EMPTY_USAGE = {
|
|
1178
|
+
input_tokens: 0,
|
|
1179
|
+
output_tokens: 0
|
|
1180
|
+
};
|
|
1181
|
+
function add_usage(a, b) {
|
|
1182
|
+
const sum = {
|
|
1183
|
+
input_tokens: a.input_tokens + b.input_tokens,
|
|
1184
|
+
output_tokens: a.output_tokens + b.output_tokens
|
|
1185
|
+
};
|
|
1186
|
+
for (const key of [
|
|
1187
|
+
"cached_input_tokens",
|
|
1188
|
+
"cache_write_tokens",
|
|
1189
|
+
"reasoning_tokens"
|
|
1190
|
+
]) {
|
|
1191
|
+
const total = (a[key] ?? 0) + (b[key] ?? 0);
|
|
1192
|
+
if (a[key] !== void 0 || b[key] !== void 0) sum[key] = total;
|
|
858
1193
|
}
|
|
1194
|
+
return sum;
|
|
859
1195
|
}
|
|
860
|
-
/**
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
868
|
-
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
869
|
-
* traffic to volley.
|
|
870
|
-
*
|
|
871
|
-
* For the local providers, native transport is the deferred future bridge, not
|
|
872
|
-
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
873
|
-
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
874
|
-
* so no field is needed today. Flipping later is a one-line change — add
|
|
875
|
-
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
876
|
-
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
877
|
-
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
878
|
-
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
879
|
-
* base-URL fix produces.
|
|
880
|
-
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
881
|
-
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
882
|
-
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
883
|
-
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
884
|
-
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
885
|
-
*
|
|
886
|
-
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
887
|
-
* when volley runs contained, so an in-container model client reaches the
|
|
888
|
-
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
889
|
-
* override is unset and this is a no-op. */
|
|
890
|
-
function seat_provider_config(provider, seat_url, env) {
|
|
891
|
-
const base_url = seat_base_url(provider, seat_url, env);
|
|
892
|
-
switch (provider) {
|
|
893
|
-
case "ollama": return { ollama: { base_url } };
|
|
894
|
-
case "lmstudio": return { lmstudio: { base_url } };
|
|
895
|
-
case "openrouter": return { openrouter: {
|
|
896
|
-
api_key: openrouter_api_key(env),
|
|
897
|
-
base_url,
|
|
898
|
-
transport: "native",
|
|
899
|
-
x_title: "volley"
|
|
900
|
-
} };
|
|
901
|
-
}
|
|
1196
|
+
/** The providers whose calls carry the charge they report, which fascicle
|
|
1197
|
+
* records as the call's cost in place of a pricing table's estimate: the
|
|
1198
|
+
* Claude CLI's own total, and OpenRouter's per-response charge. */
|
|
1199
|
+
const REPORTING_PROVIDERS = /* @__PURE__ */ new Set(["claude_cli", "openrouter"]);
|
|
1200
|
+
function cost_source_of(result) {
|
|
1201
|
+
if (result.cost === void 0) return "unknown";
|
|
1202
|
+
return REPORTING_PROVIDERS.has(result.model_resolved.provider) ? "provider_reported" : "engine_derived";
|
|
902
1203
|
}
|
|
903
|
-
/**
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
...
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
925
|
-
return create_engine({
|
|
926
|
-
providers,
|
|
927
|
-
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
928
|
-
});
|
|
1204
|
+
/** One phase's record from its generate result. The duration is fascicle's
|
|
1205
|
+
* call-level wall clock (`GenerateResult.timing`: every turn and the tools
|
|
1206
|
+
* between them) unless the caller measured a wider span, as the critic does for
|
|
1207
|
+
* its whole degradation ladder. */
|
|
1208
|
+
function phase_record(result, model, duration_ms = result.timing?.duration_ms ?? 0) {
|
|
1209
|
+
const rate = throughput(result);
|
|
1210
|
+
const truncated = result.steps.filter((step) => step.truncated === true).length;
|
|
1211
|
+
return {
|
|
1212
|
+
provider: result.model_resolved.provider,
|
|
1213
|
+
model,
|
|
1214
|
+
session_id: claude_cli_reported(result)?.session_id ?? null,
|
|
1215
|
+
duration_ms,
|
|
1216
|
+
usage: result.usage,
|
|
1217
|
+
cost_usd: result.cost?.total_usd ?? null,
|
|
1218
|
+
cost_source: cost_source_of(result),
|
|
1219
|
+
finish_reason: result.finish_reason,
|
|
1220
|
+
tool_calls: result.tool_calls.length,
|
|
1221
|
+
salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length,
|
|
1222
|
+
...truncated > 0 ? { truncated_turns: truncated } : {},
|
|
1223
|
+
...rate !== void 0 ? { throughput: rate } : {}
|
|
1224
|
+
};
|
|
929
1225
|
}
|
|
930
|
-
/**
|
|
931
|
-
*
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
builder,
|
|
942
|
-
critic:
|
|
1226
|
+
/** Fold one phase's result into the loop state. Unknown cost records `null`
|
|
1227
|
+
* for the phase and warns once per run — a run never fails over pricing. */
|
|
1228
|
+
function accumulate(state, role, result, model, duration_ms) {
|
|
1229
|
+
const record = phase_record(result, model, duration_ms);
|
|
1230
|
+
const cost = record.cost_usd ?? 0;
|
|
1231
|
+
return {
|
|
1232
|
+
...state,
|
|
1233
|
+
[role]: record,
|
|
1234
|
+
total_usage: add_usage(state.total_usage, result.usage),
|
|
1235
|
+
total_cost_usd: state.total_cost_usd + cost,
|
|
1236
|
+
iteration_cost_usd: state.iteration_cost_usd + cost,
|
|
1237
|
+
builder_cost_usd: state.builder_cost_usd + (role === "builder" ? cost : 0),
|
|
1238
|
+
critic_cost_usd: state.critic_cost_usd + (role === "critic" ? cost : 0),
|
|
1239
|
+
cost_warned: state.cost_warned || record.cost_usd === null
|
|
943
1240
|
};
|
|
944
|
-
try {
|
|
945
|
-
return {
|
|
946
|
-
builder,
|
|
947
|
-
critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
|
|
948
|
-
};
|
|
949
|
-
} catch (err) {
|
|
950
|
-
await builder.dispose();
|
|
951
|
-
throw err;
|
|
952
|
-
}
|
|
953
1241
|
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
async function dispose_seat_engines(engines) {
|
|
957
|
-
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
958
|
-
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
1242
|
+
function cost_cap_hit(config, state) {
|
|
1243
|
+
return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
|
|
959
1244
|
}
|
|
960
1245
|
//#endregion
|
|
961
1246
|
//#region src/prewarm.ts
|
|
@@ -1192,6 +1477,7 @@ function write_resolved_config(config, env = process.env) {
|
|
|
1192
1477
|
builder_url: config.builder_url,
|
|
1193
1478
|
builder_max_steps: config.builder_max_steps,
|
|
1194
1479
|
builder_max_tool_calls: config.builder_max_tool_calls,
|
|
1480
|
+
builder_max_output_tokens: config.builder_max_output_tokens,
|
|
1195
1481
|
critic_model: config.critic_model,
|
|
1196
1482
|
critic_provider: config.critic_provider,
|
|
1197
1483
|
critic_url: config.critic_url,
|
|
@@ -1219,6 +1505,30 @@ function write_resolved_config(config, env = process.env) {
|
|
|
1219
1505
|
writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
|
|
1220
1506
|
return true;
|
|
1221
1507
|
}
|
|
1508
|
+
/** The identity volley's own commits fall back to. git won't commit without a
|
|
1509
|
+
* name and an email, and the sandbox image carries no git config, so inside
|
|
1510
|
+
* it a checkpoint or a worktree's salvage commit failed outright; a machine
|
|
1511
|
+
* never set up for git can be the same. `.invalid` is reserved, so the email
|
|
1512
|
+
* can't reach anyone. */
|
|
1513
|
+
const FALLBACK_IDENTITY = [["user.name", "volley"], ["user.email", "volley@volley.invalid"]];
|
|
1514
|
+
/** The `-c` flags that give a commit in `cwd` the fallback identity for each
|
|
1515
|
+
* part git has no value for. One the repo or the user configured wins, and so
|
|
1516
|
+
* does a `GIT_AUTHOR_*` or `GIT_COMMITTER_*` variable, which git ranks above
|
|
1517
|
+
* any config. Put them ahead of the subcommand. */
|
|
1518
|
+
function commit_identity(cwd) {
|
|
1519
|
+
return FALLBACK_IDENTITY.flatMap(([key, value]) => {
|
|
1520
|
+
const set = spawnSync("git", [
|
|
1521
|
+
"config",
|
|
1522
|
+
"--get",
|
|
1523
|
+
key
|
|
1524
|
+
], {
|
|
1525
|
+
cwd,
|
|
1526
|
+
env: command_env(process.env),
|
|
1527
|
+
encoding: "utf8"
|
|
1528
|
+
});
|
|
1529
|
+
return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1222
1532
|
/**
|
|
1223
1533
|
* `--git`: commit everything under `root` after a phase (`git -C <root>`, run
|
|
1224
1534
|
* here via the spawn `cwd`). `root` is the build root (`build_root`): the
|
|
@@ -1241,14 +1551,21 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
1241
1551
|
"add",
|
|
1242
1552
|
"-A",
|
|
1243
1553
|
...pathspec
|
|
1244
|
-
], {
|
|
1554
|
+
], {
|
|
1555
|
+
cwd: root,
|
|
1556
|
+
env: command_env(process.env)
|
|
1557
|
+
});
|
|
1245
1558
|
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
1246
1559
|
const commit = spawnSync("git", [
|
|
1560
|
+
...commit_identity(root),
|
|
1247
1561
|
"commit",
|
|
1248
1562
|
"-m",
|
|
1249
1563
|
message,
|
|
1250
1564
|
"--no-verify"
|
|
1251
|
-
], {
|
|
1565
|
+
], {
|
|
1566
|
+
cwd: root,
|
|
1567
|
+
env: command_env(process.env)
|
|
1568
|
+
});
|
|
1252
1569
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
1253
1570
|
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
1254
1571
|
if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
|
|
@@ -1272,17 +1589,20 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
1272
1589
|
branch
|
|
1273
1590
|
], {
|
|
1274
1591
|
cwd: workspace,
|
|
1592
|
+
env: command_env(process.env),
|
|
1275
1593
|
encoding: "utf8"
|
|
1276
1594
|
});
|
|
1277
1595
|
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
1278
1596
|
if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
|
|
1279
1597
|
const commit = spawnSync("git", [
|
|
1598
|
+
...commit_identity(workspace),
|
|
1280
1599
|
"commit",
|
|
1281
1600
|
"-m",
|
|
1282
1601
|
message,
|
|
1283
1602
|
"--no-verify"
|
|
1284
1603
|
], {
|
|
1285
1604
|
cwd: workspace,
|
|
1605
|
+
env: command_env(process.env),
|
|
1286
1606
|
encoding: "utf8"
|
|
1287
1607
|
});
|
|
1288
1608
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
@@ -1392,6 +1712,7 @@ function report_worktree_fate(config, sink) {
|
|
|
1392
1712
|
function git$1(cwd, args) {
|
|
1393
1713
|
const result = spawnSync("git", args, {
|
|
1394
1714
|
cwd,
|
|
1715
|
+
env: command_env(process.env),
|
|
1395
1716
|
encoding: "utf8"
|
|
1396
1717
|
});
|
|
1397
1718
|
if (result.error !== void 0) throw config_error(`git is required for --worktree but could not run: ${String(result.error)}`);
|
|
@@ -1418,6 +1739,7 @@ function git_quiet(cwd, args) {
|
|
|
1418
1739
|
try {
|
|
1419
1740
|
const result = spawnSync("git", args, {
|
|
1420
1741
|
cwd,
|
|
1742
|
+
env: command_env(process.env),
|
|
1421
1743
|
encoding: "utf8"
|
|
1422
1744
|
});
|
|
1423
1745
|
return {
|
|
@@ -1541,6 +1863,7 @@ function salvage_worktree(repo, path, branch) {
|
|
|
1541
1863
|
if (existsSync(path)) {
|
|
1542
1864
|
git_quiet(path, ["add", "-A"]);
|
|
1543
1865
|
git_quiet(path, [
|
|
1866
|
+
...commit_identity(path),
|
|
1544
1867
|
"commit",
|
|
1545
1868
|
"-m",
|
|
1546
1869
|
`volley: salvage unintegrated worktree (${branch})`,
|
|
@@ -1669,14 +1992,9 @@ const HARNESS_APPENDS = {
|
|
|
1669
1992
|
const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
|
|
1670
1993
|
/** Step 2 for a builder that may make several calls a turn: fascicle runs a
|
|
1671
1994
|
* turn's calls in order, so a read can't race an edit, but each change or
|
|
1672
|
-
* command is still worth reading before the next.
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
`2. Batch independent reads into one turn, up to ${String(max_tool_calls)} calls, but`,
|
|
1676
|
-
" make one change or command per turn, and read its result before",
|
|
1677
|
-
" deciding the next call."
|
|
1678
|
-
].join("\n");
|
|
1679
|
-
}
|
|
1995
|
+
* command is still worth reading before the next. Reads don't count toward
|
|
1996
|
+
* the cap, so the step names no number. */
|
|
1997
|
+
const BATCHED_CALLS = ["2. Batch independent reads into one turn, but make one change or command per", " turn, and read its result before deciding the next call."].join("\n");
|
|
1680
1998
|
function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
1681
1999
|
const identity = [
|
|
1682
2000
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
@@ -1684,7 +2002,7 @@ function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
|
1684
2002
|
"criteria."
|
|
1685
2003
|
].join("\n");
|
|
1686
2004
|
const append = readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8");
|
|
1687
|
-
return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN,
|
|
2005
|
+
return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, BATCHED_CALLS) : append).trimEnd()}`;
|
|
1688
2006
|
}
|
|
1689
2007
|
/** The builder system prompt for the active provider: the `claude_cli`
|
|
1690
2008
|
* prompt (implicit built-in tools, "the current working directory") for the
|
|
@@ -1695,15 +2013,25 @@ function resolve_builder_system(config, tool_set) {
|
|
|
1695
2013
|
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
|
|
1696
2014
|
}
|
|
1697
2015
|
const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
|
|
2016
|
+
/** How the step countdown tells the builder to run the check: the command
|
|
2017
|
+
* volley gates on, with the digest and without `--json`, the configured
|
|
2018
|
+
* command, or the project's own tests when no check gates the run. A
|
|
2019
|
+
* workspace can hold checkride with no `check` script, so not `pnpm check`. */
|
|
2020
|
+
function check_hint(config) {
|
|
2021
|
+
if (config.check_resolved === "checkride") return "the check (`pnpm exec checkride --digest`)";
|
|
2022
|
+
if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
|
|
2023
|
+
return "the project's tests";
|
|
2024
|
+
}
|
|
1698
2025
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
1699
2026
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
1700
2027
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
1701
2028
|
* from v2, and takes no injected tools or approval handler. Every other
|
|
1702
2029
|
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
1703
2030
|
* workspace tool surface (or the set the caller injected in its place, with
|
|
1704
|
-
* the handler that answers its approvals)
|
|
1705
|
-
*
|
|
1706
|
-
*
|
|
2031
|
+
* the handler that answers its approvals), the per-call loop knobs, the step
|
|
2032
|
+
* countdown (`./builder/countdown.ts`), the output cap when one is set, and
|
|
2033
|
+
* **no** schema (it produces a workspace, not a verdict); per-call values win
|
|
2034
|
+
* over engine defaults. */
|
|
1707
2035
|
function builder_tool_options(deps) {
|
|
1708
2036
|
const { config } = deps;
|
|
1709
2037
|
const bash_executor = deps.bash_executor ?? null;
|
|
@@ -1717,9 +2045,75 @@ function builder_tool_options(deps) {
|
|
|
1717
2045
|
max_steps: config.builder_max_steps,
|
|
1718
2046
|
tool_error_policy: "feed_back",
|
|
1719
2047
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
1720
|
-
max_tool_calls_per_step: config.builder_max_tool_calls
|
|
2048
|
+
max_tool_calls_per_step: config.builder_max_tool_calls,
|
|
2049
|
+
prepare_step: step_countdown(config.builder_max_steps, check_hint(config)),
|
|
2050
|
+
...config.builder_max_output_tokens !== null ? {
|
|
2051
|
+
max_tokens: config.builder_max_output_tokens,
|
|
2052
|
+
truncated_turn_policy: "continue"
|
|
2053
|
+
} : {}
|
|
1721
2054
|
};
|
|
1722
2055
|
}
|
|
2056
|
+
/** How much of the last check's output the builder's prompt carries: a few KB,
|
|
2057
|
+
* whatever the runner. checkride's digest already fits in 8 kB, and the builder
|
|
2058
|
+
* can run the check again for the rest. */
|
|
2059
|
+
const PREVIOUS_CHECK_MAX_CHARS = 6e3;
|
|
2060
|
+
/** The first `max` characters of `text`, saying where it was cut. */
|
|
2061
|
+
function head(text, max, rest) {
|
|
2062
|
+
return text.length <= max ? text : `${text.slice(0, max)}\n… (cut at ${String(max)} characters; ${rest})`;
|
|
2063
|
+
}
|
|
2064
|
+
/** The last `max` characters of `text`, saying what came before was cut. */
|
|
2065
|
+
function tail(text, max) {
|
|
2066
|
+
return text.length <= max ? text : `… (the last ${String(max)} characters of ${String(text.length)})\n${text.slice(-max)}`;
|
|
2067
|
+
}
|
|
2068
|
+
/** The part of a failed check the builder reads first: checkride's digest,
|
|
2069
|
+
* else the start of each failing slot's raw output, else the end of a
|
|
2070
|
+
* command's output, where test runners and compilers sum up. */
|
|
2071
|
+
function check_excerpt(check) {
|
|
2072
|
+
const max = PREVIOUS_CHECK_MAX_CHARS;
|
|
2073
|
+
if (check.digest !== void 0) return head(check.digest.trimEnd(), max, ".check/digest.md has the rest");
|
|
2074
|
+
if (check.detail.length > 0) return head(check.detail.map((artifact) => {
|
|
2075
|
+
const source = artifact.path === null ? "" : ` (.check/${basename(artifact.path)})`;
|
|
2076
|
+
return `${artifact.slot}${source}:\n${artifact.content.trimEnd()}`;
|
|
2077
|
+
}).join("\n\n"), max, "each slot's raw output under .check/ has the rest");
|
|
2078
|
+
return tail((check.log ?? "").trimEnd(), max);
|
|
2079
|
+
}
|
|
2080
|
+
/** The check section of a later iteration's prompt: what the deterministic
|
|
2081
|
+
* gate itself said about the last session's work, so the builder starts from
|
|
2082
|
+
* its words rather than the critic's account of them, or re-running it to
|
|
2083
|
+
* find out. Empty unless that check ran and failed. A check the user rejected
|
|
2084
|
+
* when it asked to run is recorded as failed but said nothing, and the
|
|
2085
|
+
* section says so rather than call the work failed. */
|
|
2086
|
+
function previous_check_section(check) {
|
|
2087
|
+
if (check === null || !check.ran || check.ok) return [];
|
|
2088
|
+
const heading = [
|
|
2089
|
+
"",
|
|
2090
|
+
"PREVIOUS CHECK RESULT",
|
|
2091
|
+
"---------------------"
|
|
2092
|
+
];
|
|
2093
|
+
if (check.log === "the check was rejected when it asked to run, so it never ran") return [
|
|
2094
|
+
...heading,
|
|
2095
|
+
"The check never ran on the work your last session left: it was rejected when it",
|
|
2096
|
+
"asked to run, so nothing has verified that work yet."
|
|
2097
|
+
];
|
|
2098
|
+
const runner = check.runner === "checkride" ? "checkride, " : "";
|
|
2099
|
+
const exit = check.exit_code === null ? "no exit code" : `exit ${String(check.exit_code)}`;
|
|
2100
|
+
const lines = [...heading, `The check failed on the work your last session left (${runner}${exit}).`];
|
|
2101
|
+
if (check.failing_slots.length > 0) lines.push(`Failing slots: ${check.failing_slots.join(", ")}.`);
|
|
2102
|
+
const excerpt = check_excerpt(check);
|
|
2103
|
+
if (excerpt.length > 0) lines.push("", excerpt);
|
|
2104
|
+
return lines;
|
|
2105
|
+
}
|
|
2106
|
+
/** The line a later iteration's prompt carries when the last session ran out
|
|
2107
|
+
* of steps: it never got to verify, so this one should check first. */
|
|
2108
|
+
function step_limit_lines(step_limit_hit) {
|
|
2109
|
+
if (step_limit_hit === null) return [];
|
|
2110
|
+
return [
|
|
2111
|
+
"",
|
|
2112
|
+
`Your last session hit its ${String(step_limit_hit)}-step limit before it called finish, so`,
|
|
2113
|
+
"it may never have verified its work. Run the check first this time, and keep",
|
|
2114
|
+
"enough steps in hand to verify again before you finish."
|
|
2115
|
+
];
|
|
2116
|
+
}
|
|
1723
2117
|
function compose_builder_prompt(input) {
|
|
1724
2118
|
const parts = [
|
|
1725
2119
|
"TASK",
|
|
@@ -1733,7 +2127,8 @@ function compose_builder_prompt(input) {
|
|
|
1733
2127
|
`ITERATION: ${input.iteration}`
|
|
1734
2128
|
];
|
|
1735
2129
|
if (input.iteration > 1 && input.feedback !== null) parts.push("", "PREVIOUS CRITIC FEEDBACK", "------------------------", input.feedback, "", "Address the feedback above. The workspace already contains your prior work;", "read the current state, then make the necessary changes.");
|
|
1736
|
-
if (input.
|
|
2130
|
+
if (input.iteration > 1 && input.previous !== null) parts.push(...previous_check_section(input.previous.check), ...step_limit_lines(input.previous.step_limit_hit));
|
|
2131
|
+
if (input.checkride) parts.push("", "The definition of done for this workspace includes checkride exiting 0. Run", "`pnpm exec checkride --digest`, the check volley gates on, after each batch of", "edits and again before you finish. When it fails, read .check/digest.md", "first: it lists each failing slot's first findings. Open a slot's raw", "output under .check/ only if the digest isn't enough, then fix and re-run.");
|
|
1737
2132
|
return parts.join("\n");
|
|
1738
2133
|
}
|
|
1739
2134
|
function builder_max_steps_warning(max_steps) {
|
|
@@ -1755,7 +2150,9 @@ function make_builder_step(deps) {
|
|
|
1755
2150
|
...builder_tool_options(deps)
|
|
1756
2151
|
});
|
|
1757
2152
|
}
|
|
1758
|
-
|
|
2153
|
+
/** Run one builder session on the opened iteration `state`, told what the
|
|
2154
|
+
* iteration before it left behind (`previous`, null on the first). */
|
|
2155
|
+
async function run_builder(deps, state, previous, ctx) {
|
|
1759
2156
|
const { config } = deps;
|
|
1760
2157
|
try {
|
|
1761
2158
|
if (config.builder_provider === "ollama") {
|
|
@@ -1768,10 +2165,16 @@ async function run_builder(deps, state, ctx) {
|
|
|
1768
2165
|
criteria: config.criteria,
|
|
1769
2166
|
feedback: state.feedback,
|
|
1770
2167
|
iteration: state.iteration,
|
|
1771
|
-
checkride: config.check_resolved === "checkride"
|
|
2168
|
+
checkride: config.check_resolved === "checkride",
|
|
2169
|
+
previous
|
|
1772
2170
|
}));
|
|
1773
|
-
|
|
1774
|
-
|
|
2171
|
+
const built = accumulate(state, "builder", result, config.builder_model);
|
|
2172
|
+
if (result.finish_reason !== "max_steps") return built;
|
|
2173
|
+
deps.warn(builder_max_steps_warning(config.builder_max_steps));
|
|
2174
|
+
return {
|
|
2175
|
+
...built,
|
|
2176
|
+
builder_max_steps_hits: built.builder_max_steps_hits + 1
|
|
2177
|
+
};
|
|
1775
2178
|
} catch (err) {
|
|
1776
2179
|
throw phase_error("builder", state.iteration, err);
|
|
1777
2180
|
}
|
|
@@ -1849,6 +2252,7 @@ function git(root, args) {
|
|
|
1849
2252
|
try {
|
|
1850
2253
|
const result = spawnSync("git", args, {
|
|
1851
2254
|
cwd: root,
|
|
2255
|
+
env: command_env(process.env),
|
|
1852
2256
|
encoding: "utf8",
|
|
1853
2257
|
maxBuffer: 33554432
|
|
1854
2258
|
});
|
|
@@ -2042,227 +2446,6 @@ function collect_changes(options) {
|
|
|
2042
2446
|
};
|
|
2043
2447
|
}
|
|
2044
2448
|
//#endregion
|
|
2045
|
-
//#region src/check/checkride.ts
|
|
2046
|
-
/**
|
|
2047
|
-
* checkride runner: spawn `pnpm exec checkride --json`, gate on the
|
|
2048
|
-
* `.check/summary.json` contract (`schema_version: 1`), and collect raw
|
|
2049
|
-
* failing-slot artifacts for the critic — the critic reads what the tool
|
|
2050
|
-
* actually said, not a normalized digest.
|
|
2051
|
-
*/
|
|
2052
|
-
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
2053
|
-
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
2054
|
-
/** How long a check's output may stay open after its shell exits, while
|
|
2055
|
-
* something that escaped its process group still holds it, before the reads
|
|
2056
|
-
* stop so the check can finish. */
|
|
2057
|
-
const OUTPUT_GRACE_MS = 2e3;
|
|
2058
|
-
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
2059
|
-
* and anything the check started in the background, sit in the shell's group,
|
|
2060
|
-
* so killing the shell alone would leave them running. Windows has no process
|
|
2061
|
-
* groups to signal, so there only the shell goes. */
|
|
2062
|
-
function kill_group(proc) {
|
|
2063
|
-
if (proc.pid === void 0) return;
|
|
2064
|
-
try {
|
|
2065
|
-
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
2066
|
-
else process.kill(-proc.pid, "SIGKILL");
|
|
2067
|
-
} catch {}
|
|
2068
|
-
}
|
|
2069
|
-
/** Run a check to its end in a process group of its own, capturing its
|
|
2070
|
-
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
2071
|
-
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
2072
|
-
* no test workers or servers running. */
|
|
2073
|
-
function spawn_capture(cmd, args, options) {
|
|
2074
|
-
const { abort } = options;
|
|
2075
|
-
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
2076
|
-
return new Promise((resolve_promise, reject) => {
|
|
2077
|
-
const proc = spawn(cmd, args, {
|
|
2078
|
-
cwd: options.cwd,
|
|
2079
|
-
...options.env !== void 0 ? { env: options.env } : {},
|
|
2080
|
-
detached: process.platform !== "win32",
|
|
2081
|
-
stdio: [
|
|
2082
|
-
"ignore",
|
|
2083
|
-
"pipe",
|
|
2084
|
-
"pipe"
|
|
2085
|
-
]
|
|
2086
|
-
});
|
|
2087
|
-
let stdout = "";
|
|
2088
|
-
let stderr = "";
|
|
2089
|
-
let grace;
|
|
2090
|
-
const on_abort = () => {
|
|
2091
|
-
kill_group(proc);
|
|
2092
|
-
reject(new aborted_error("check aborted"));
|
|
2093
|
-
};
|
|
2094
|
-
const finish = () => {
|
|
2095
|
-
clearTimeout(grace);
|
|
2096
|
-
abort?.removeEventListener("abort", on_abort);
|
|
2097
|
-
};
|
|
2098
|
-
proc.stdout.on("data", (chunk) => {
|
|
2099
|
-
stdout += chunk.toString();
|
|
2100
|
-
});
|
|
2101
|
-
proc.stderr.on("data", (chunk) => {
|
|
2102
|
-
stderr += chunk.toString();
|
|
2103
|
-
});
|
|
2104
|
-
proc.on("error", (err) => {
|
|
2105
|
-
finish();
|
|
2106
|
-
reject(check_error(`check command failed to start: ${err.message}`));
|
|
2107
|
-
});
|
|
2108
|
-
proc.on("exit", () => {
|
|
2109
|
-
kill_group(proc);
|
|
2110
|
-
grace = setTimeout(() => {
|
|
2111
|
-
proc.stdout.destroy();
|
|
2112
|
-
proc.stderr.destroy();
|
|
2113
|
-
}, OUTPUT_GRACE_MS);
|
|
2114
|
-
});
|
|
2115
|
-
proc.on("close", (code) => {
|
|
2116
|
-
finish();
|
|
2117
|
-
resolve_promise({
|
|
2118
|
-
exit_code: code,
|
|
2119
|
-
stdout,
|
|
2120
|
-
stderr
|
|
2121
|
-
});
|
|
2122
|
-
});
|
|
2123
|
-
abort?.addEventListener("abort", on_abort, { once: true });
|
|
2124
|
-
});
|
|
2125
|
-
}
|
|
2126
|
-
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
2127
|
-
* command's stdout (pnpm 11's dep-verify prints an "Already up to date"
|
|
2128
|
-
* install line ahead of the JSON), so the object is extracted between the
|
|
2129
|
-
* first `{` and the last `}` rather than parsed from byte 0. An unexpected
|
|
2130
|
-
* schema_version warns and still parses; no JSON object at all is a harness
|
|
2131
|
-
* error (exit 4). */
|
|
2132
|
-
function parse_checkride_summary(stdout) {
|
|
2133
|
-
const start = stdout.indexOf("{");
|
|
2134
|
-
const end = stdout.lastIndexOf("}");
|
|
2135
|
-
let raw;
|
|
2136
|
-
try {
|
|
2137
|
-
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
2138
|
-
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
2139
|
-
} catch {
|
|
2140
|
-
throw check_error("checkride --json produced unparseable stdout");
|
|
2141
|
-
}
|
|
2142
|
-
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
|
|
2143
|
-
const summary = raw;
|
|
2144
|
-
return {
|
|
2145
|
-
summary,
|
|
2146
|
-
warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
|
|
2147
|
-
};
|
|
2148
|
-
}
|
|
2149
|
-
/** Parse the summary from stdout, falling back to the on-disk contract:
|
|
2150
|
-
* `.check/summary.json` is what checkride documents; stdout is its mirror and
|
|
2151
|
-
* wrappers may talk over it beyond what the tolerant slice can recover. */
|
|
2152
|
-
function parse_summary_with_fallback(stdout, workspace) {
|
|
2153
|
-
try {
|
|
2154
|
-
return parse_checkride_summary(stdout);
|
|
2155
|
-
} catch (err) {
|
|
2156
|
-
const file = join(workspace, ".check", "summary.json");
|
|
2157
|
-
if (!existsSync(file)) throw err;
|
|
2158
|
-
return parse_checkride_summary(readFileSync(file, "utf8"));
|
|
2159
|
-
}
|
|
2160
|
-
}
|
|
2161
|
-
function failing_checks(summary) {
|
|
2162
|
-
return summary.checks.filter((c) => !c.ok && c.skipped !== true);
|
|
2163
|
-
}
|
|
2164
|
-
/** Per failing slot: the raw artifact (`.check/<output_file>` when present,
|
|
2165
|
-
* else `.check/<slot>.stdout.txt`), truncated with a pointer to the file. */
|
|
2166
|
-
function read_failing_artifacts(workspace, failing) {
|
|
2167
|
-
return failing.map((check) => {
|
|
2168
|
-
const file = check.output_file ?? `${check.name}.stdout.txt`;
|
|
2169
|
-
const path = join(workspace, ".check", file);
|
|
2170
|
-
if (!existsSync(path)) return {
|
|
2171
|
-
slot: check.name,
|
|
2172
|
-
path: null,
|
|
2173
|
-
content: "(no artifact found)",
|
|
2174
|
-
truncated: false
|
|
2175
|
-
};
|
|
2176
|
-
const raw = readFileSync(path, "utf8");
|
|
2177
|
-
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
2178
|
-
return {
|
|
2179
|
-
slot: check.name,
|
|
2180
|
-
path,
|
|
2181
|
-
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
2182
|
-
truncated
|
|
2183
|
-
};
|
|
2184
|
-
});
|
|
2185
|
-
}
|
|
2186
|
-
async function run_checkride(opts) {
|
|
2187
|
-
const { workspace, ...spawn_options } = opts;
|
|
2188
|
-
const proc = await spawn_capture("pnpm", [
|
|
2189
|
-
"exec",
|
|
2190
|
-
"checkride",
|
|
2191
|
-
"--json"
|
|
2192
|
-
], {
|
|
2193
|
-
...spawn_options,
|
|
2194
|
-
cwd: workspace
|
|
2195
|
-
});
|
|
2196
|
-
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
2197
|
-
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
2198
|
-
const failing = failing_checks(summary);
|
|
2199
|
-
return {
|
|
2200
|
-
ran: true,
|
|
2201
|
-
runner: "checkride",
|
|
2202
|
-
ok: summary.ok,
|
|
2203
|
-
exit_code: proc.exit_code,
|
|
2204
|
-
duration_ms: summary.total_duration_ms,
|
|
2205
|
-
failing_slots: failing.map((c) => c.name),
|
|
2206
|
-
detail: read_failing_artifacts(opts.workspace, failing),
|
|
2207
|
-
summary
|
|
2208
|
-
};
|
|
2209
|
-
}
|
|
2210
|
-
//#endregion
|
|
2211
|
-
//#region src/check/command.ts
|
|
2212
|
-
/**
|
|
2213
|
-
* Generic command runner: `sh -c <command>`, combined
|
|
2214
|
-
* output captured, exit-0 semantics. Command-not-found is an operator error
|
|
2215
|
-
* (exit 4), not an iterable failure.
|
|
2216
|
-
*/
|
|
2217
|
-
async function run_command_check(opts) {
|
|
2218
|
-
const { command, workspace, ...spawn_options } = opts;
|
|
2219
|
-
const started = Date.now();
|
|
2220
|
-
const proc = await spawn_capture("sh", ["-c", command], {
|
|
2221
|
-
...spawn_options,
|
|
2222
|
-
cwd: workspace
|
|
2223
|
-
});
|
|
2224
|
-
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
2225
|
-
const log = `${proc.stdout}${proc.stderr}`;
|
|
2226
|
-
return {
|
|
2227
|
-
ran: true,
|
|
2228
|
-
runner: "command",
|
|
2229
|
-
ok: proc.exit_code === 0,
|
|
2230
|
-
exit_code: proc.exit_code,
|
|
2231
|
-
duration_ms: Date.now() - started,
|
|
2232
|
-
failing_slots: [],
|
|
2233
|
-
detail: [],
|
|
2234
|
-
log
|
|
2235
|
-
};
|
|
2236
|
-
}
|
|
2237
|
-
function skipped_check(reason) {
|
|
2238
|
-
return {
|
|
2239
|
-
ran: false,
|
|
2240
|
-
runner: "none",
|
|
2241
|
-
ok: reason === "none",
|
|
2242
|
-
exit_code: null,
|
|
2243
|
-
duration_ms: 0,
|
|
2244
|
-
failing_slots: [],
|
|
2245
|
-
detail: []
|
|
2246
|
-
};
|
|
2247
|
-
}
|
|
2248
|
-
/** What a rejected check says in place of its output. */
|
|
2249
|
-
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
2250
|
-
/** A check the user rejected when it asked to run: recorded as a failed
|
|
2251
|
-
* check, with the refusal as its output, so a round can't converge on work
|
|
2252
|
-
* nothing checked, and the critic hears why there's no result. */
|
|
2253
|
-
function rejected_check(runner) {
|
|
2254
|
-
return {
|
|
2255
|
-
ran: true,
|
|
2256
|
-
runner,
|
|
2257
|
-
ok: false,
|
|
2258
|
-
exit_code: null,
|
|
2259
|
-
duration_ms: 0,
|
|
2260
|
-
failing_slots: [],
|
|
2261
|
-
detail: [],
|
|
2262
|
-
log: CHECK_REJECTED
|
|
2263
|
-
};
|
|
2264
|
-
}
|
|
2265
|
-
//#endregion
|
|
2266
2449
|
//#region src/critic/prompt.ts
|
|
2267
2450
|
/**
|
|
2268
2451
|
* Critic prompt resolution and composition. Presets live as
|
|
@@ -2791,6 +2974,7 @@ function phase_summary(record) {
|
|
|
2791
2974
|
tool_calls: record.tool_calls,
|
|
2792
2975
|
salvaged_tool_calls: record.salvaged_tool_calls,
|
|
2793
2976
|
salvage_rate: record.tool_calls === 0 ? 0 : record.salvaged_tool_calls / record.tool_calls,
|
|
2977
|
+
...record.truncated_turns !== void 0 ? { truncated_turns: record.truncated_turns } : {},
|
|
2794
2978
|
...record.retries !== void 0 ? { retries: record.retries } : {},
|
|
2795
2979
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
2796
2980
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
@@ -2813,6 +2997,8 @@ function archive_iteration(config, state) {
|
|
|
2813
2997
|
if (check.runner === "checkride") {
|
|
2814
2998
|
if (check.summary !== void 0) write_json(join(dir, "check", "summary.json"), check.summary);
|
|
2815
2999
|
for (const artifact of check.detail) if (artifact.path !== null && existsSync(artifact.path)) writeFileSync(join(dir, "check", basename(artifact.path)), readFileSync(artifact.path));
|
|
3000
|
+
if (check.digest !== void 0) writeFileSync(join(dir, "check", "digest.md"), check.digest);
|
|
3001
|
+
if (check.log !== void 0) writeFileSync(join(dir, "check", "check.log"), check.log);
|
|
2816
3002
|
} else {
|
|
2817
3003
|
writeFileSync(join(dir, "check", "check.log"), check.log ?? "");
|
|
2818
3004
|
writeFileSync(join(dir, "check", "check.exit"), `${String(check.exit_code)}\n`);
|
|
@@ -2853,6 +3039,7 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
2853
3039
|
builder_cost_usd: state.builder_cost_usd,
|
|
2854
3040
|
critic_cost_usd: state.critic_cost_usd,
|
|
2855
3041
|
check_duration_ms: state.check_duration_ms,
|
|
3042
|
+
builder_max_steps_hits: state.builder_max_steps_hits,
|
|
2856
3043
|
final_verdict: state.verdict,
|
|
2857
3044
|
salvaged_branch: null,
|
|
2858
3045
|
run_state_dir: run_state_dir(config),
|
|
@@ -2911,6 +3098,95 @@ function refuse_symlinks(dir) {
|
|
|
2911
3098
|
if (entry.isDirectory()) refuse_symlinks(path);
|
|
2912
3099
|
}
|
|
2913
3100
|
}
|
|
3101
|
+
/** An archived file's text, or undefined when there's no file to read there. */
|
|
3102
|
+
function read_text(path) {
|
|
3103
|
+
try {
|
|
3104
|
+
return readFileSync(path, "utf8");
|
|
3105
|
+
} catch {
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
function read_json(path) {
|
|
3110
|
+
const text = read_text(path);
|
|
3111
|
+
if (text === void 0) return null;
|
|
3112
|
+
try {
|
|
3113
|
+
return JSON.parse(text);
|
|
3114
|
+
} catch {
|
|
3115
|
+
return null;
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
/** The failing slots' raw artifacts from an archived iteration's `check/`,
|
|
3119
|
+
* which holds each under its base name, found through the archived checkride
|
|
3120
|
+
* summary. */
|
|
3121
|
+
function archived_artifacts(check_dir, failing_slots) {
|
|
3122
|
+
const summary = read_json(join(check_dir, "summary.json"));
|
|
3123
|
+
const checks = Array.isArray(summary?.checks) ? summary.checks : [];
|
|
3124
|
+
return read_failing_artifacts(check_dir, failing_slots.map((name) => {
|
|
3125
|
+
const output_file = checks.find((check) => check.name === name)?.output_file;
|
|
3126
|
+
return {
|
|
3127
|
+
name,
|
|
3128
|
+
adapter: null,
|
|
3129
|
+
ok: false,
|
|
3130
|
+
exit_code: null,
|
|
3131
|
+
duration_ms: 0,
|
|
3132
|
+
output_file: typeof output_file === "string" ? basename(output_file) : null
|
|
3133
|
+
};
|
|
3134
|
+
}));
|
|
3135
|
+
}
|
|
3136
|
+
/** The check an archived iteration ran, rebuilt from its directory with the
|
|
3137
|
+
* output its failure is shown by: the digest and the failing slots' artifacts
|
|
3138
|
+
* for checkride, the log for a command or a rejected check. Null when it ran
|
|
3139
|
+
* none. */
|
|
3140
|
+
function archived_check(dir, recorded) {
|
|
3141
|
+
if (recorded?.ran !== true || typeof recorded.ok !== "boolean") return null;
|
|
3142
|
+
const runner = recorded.runner === "checkride" ? "checkride" : "command";
|
|
3143
|
+
const failing_slots = Array.isArray(recorded.failing_slots) ? recorded.failing_slots.filter((slot) => typeof slot === "string") : [];
|
|
3144
|
+
const check = {
|
|
3145
|
+
ran: true,
|
|
3146
|
+
runner,
|
|
3147
|
+
ok: recorded.ok,
|
|
3148
|
+
exit_code: typeof recorded.exit_code === "number" ? recorded.exit_code : null,
|
|
3149
|
+
duration_ms: typeof recorded.duration_ms === "number" ? recorded.duration_ms : 0,
|
|
3150
|
+
failing_slots,
|
|
3151
|
+
detail: []
|
|
3152
|
+
};
|
|
3153
|
+
if (check.ok) return check;
|
|
3154
|
+
const check_dir = join(dir, "check");
|
|
3155
|
+
const log = read_text(join(check_dir, "check.log"));
|
|
3156
|
+
const logged = log === void 0 ? check : {
|
|
3157
|
+
...check,
|
|
3158
|
+
log
|
|
3159
|
+
};
|
|
3160
|
+
if (runner === "command") return logged;
|
|
3161
|
+
const digest = read_text(join(check_dir, "digest.md"));
|
|
3162
|
+
return {
|
|
3163
|
+
...logged,
|
|
3164
|
+
detail: archived_artifacts(check_dir, failing_slots),
|
|
3165
|
+
...digest !== void 0 ? { digest } : {}
|
|
3166
|
+
};
|
|
3167
|
+
}
|
|
3168
|
+
/** An archived builder record, as `archive_iteration` wrote it from the
|
|
3169
|
+
* phase's own, or null when there is none with a finish reason. */
|
|
3170
|
+
function archived_builder(recorded) {
|
|
3171
|
+
if (typeof recorded !== "object" || recorded === null) return null;
|
|
3172
|
+
return typeof recorded.finish_reason === "string" ? recorded : null;
|
|
3173
|
+
}
|
|
3174
|
+
/** What the last completed iteration left for the next build: its check and
|
|
3175
|
+
* its builder's record, which tell the resumed run's first builder what the
|
|
3176
|
+
* gate said and whether the last session ran out of steps, as a run that never
|
|
3177
|
+
* stopped would have. Null for both before the first iteration. */
|
|
3178
|
+
function archived_round(run, completed) {
|
|
3179
|
+
if (completed === 0) return {
|
|
3180
|
+
check: null,
|
|
3181
|
+
builder: null
|
|
3182
|
+
};
|
|
3183
|
+
const dir = iteration_dir(run, completed);
|
|
3184
|
+
const recorded = read_json(join(dir, "summary.json"));
|
|
3185
|
+
return {
|
|
3186
|
+
check: archived_check(dir, recorded?.check),
|
|
3187
|
+
builder: archived_builder(recorded?.builder)
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
2914
3190
|
/** Recover a resumable run from its state directory (`.volley/`, or
|
|
2915
3191
|
* `<state_dir>/<run_id>/` under a state root): settings from config.json,
|
|
2916
3192
|
* totals from summary.json, feedback from feedback.md. An iteration directory
|
|
@@ -2959,6 +3235,7 @@ function load_resume_state(workspace, run_id, state_dir = null, options = {}) {
|
|
|
2959
3235
|
...typeof recorded["builder_url"] === "string" ? { builder_url: recorded["builder_url"] } : {},
|
|
2960
3236
|
...recorded["builder_max_steps"] === void 0 ? {} : { builder_max_steps: Number(recorded["builder_max_steps"]) },
|
|
2961
3237
|
...recorded["builder_max_tool_calls"] === void 0 ? {} : { builder_max_tool_calls: Number(recorded["builder_max_tool_calls"]) },
|
|
3238
|
+
...typeof recorded["builder_max_output_tokens"] === "number" ? { builder_max_output_tokens: recorded["builder_max_output_tokens"] } : {},
|
|
2962
3239
|
critic_model: String(recorded["critic_model"]),
|
|
2963
3240
|
critic_provider: recorded["critic_provider"] ?? "claude_cli",
|
|
2964
3241
|
...typeof recorded["critic_url"] === "string" ? { critic_url: recorded["critic_url"] } : {},
|
|
@@ -2980,15 +3257,15 @@ function load_resume_state(workspace, run_id, state_dir = null, options = {}) {
|
|
|
2980
3257
|
feedback,
|
|
2981
3258
|
verdict: null,
|
|
2982
3259
|
unmet_criteria: [],
|
|
2983
|
-
|
|
3260
|
+
...archived_round(run, completed),
|
|
2984
3261
|
changes: null,
|
|
2985
|
-
builder: null,
|
|
2986
3262
|
critic: null,
|
|
2987
3263
|
total_usage: summary?.total_usage ?? EMPTY_USAGE,
|
|
2988
3264
|
total_cost_usd: summary?.total_cost_usd ?? 0,
|
|
2989
3265
|
builder_cost_usd: summary?.builder_cost_usd ?? 0,
|
|
2990
3266
|
critic_cost_usd: summary?.critic_cost_usd ?? 0,
|
|
2991
3267
|
check_duration_ms: summary?.check_duration_ms ?? 0,
|
|
3268
|
+
builder_max_steps_hits: summary?.builder_max_steps_hits ?? 0,
|
|
2992
3269
|
iteration_cost_usd: 0,
|
|
2993
3270
|
halt: null,
|
|
2994
3271
|
cost_warned: false
|
|
@@ -3155,7 +3432,7 @@ function record_check_chunk(ctx, chunk) {
|
|
|
3155
3432
|
}
|
|
3156
3433
|
/** The command line the check runs, or null when no check is configured. */
|
|
3157
3434
|
function check_command(config) {
|
|
3158
|
-
if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
|
|
3435
|
+
if (config.check_resolved === "checkride") return "pnpm exec checkride --json --digest";
|
|
3159
3436
|
if (config.check_resolved === "command") return config.check;
|
|
3160
3437
|
return null;
|
|
3161
3438
|
}
|
|
@@ -3186,13 +3463,14 @@ function report_changes(config, renderer, changes) {
|
|
|
3186
3463
|
renderer.warn(`gate edit: ${who} ${String(changes.gate_edits.length)} file(s) that decide ${whose} — ${listed}${hidden > 0 ? ` (+${String(hidden)} more)` : ""}. A green check proves less when ${edits} the gate; the critic is told about this and the run summary records it.` + (config.fail_on_gate_edit ? " This run will end without success (--fail-on-gate-edit, exit 8)." : ""));
|
|
3187
3464
|
}
|
|
3188
3465
|
/** Run the configured check, checkride or a command, with the environment
|
|
3189
|
-
* the caller set for it, else volley's own
|
|
3466
|
+
* the caller set for it, else volley's own less the seats' keys, since the
|
|
3467
|
+
* check runs code the builder wrote. */
|
|
3190
3468
|
async function execute_check(deps, abort) {
|
|
3191
3469
|
const { config } = deps;
|
|
3192
3470
|
const root = build_root(config.workspace, config.worktree);
|
|
3193
3471
|
const options = {
|
|
3194
3472
|
abort,
|
|
3195
|
-
|
|
3473
|
+
env: deps.check?.env ?? command_env(process.env)
|
|
3196
3474
|
};
|
|
3197
3475
|
return config.check_resolved === "checkride" ? run_checkride({
|
|
3198
3476
|
...options,
|
|
@@ -3237,6 +3515,17 @@ function next_iteration(s) {
|
|
|
3237
3515
|
unmet_criteria: []
|
|
3238
3516
|
};
|
|
3239
3517
|
}
|
|
3518
|
+
/** What the builder of the iteration after `s` hears about it beyond the
|
|
3519
|
+
* critic's feedback: its check, and whether its builder ran out of steps. Null
|
|
3520
|
+
* before the first iteration. A resumed run's state carries both, read back
|
|
3521
|
+
* from the last archived iteration. */
|
|
3522
|
+
function previous_round(config, s) {
|
|
3523
|
+
if (s.iteration === 0) return null;
|
|
3524
|
+
return {
|
|
3525
|
+
check: s.check,
|
|
3526
|
+
step_limit_hit: s.builder?.finish_reason === "max_steps" ? config.builder_max_steps : null
|
|
3527
|
+
};
|
|
3528
|
+
}
|
|
3240
3529
|
/** What changed in the build root since the baseline, reported as it's
|
|
3241
3530
|
* measured: the count, and a warning for any gate edit. A run measures the
|
|
3242
3531
|
* builder's work this way after every build, and a critique measures the
|
|
@@ -3267,7 +3556,7 @@ async function build_phase(deps, builder, s, ctx) {
|
|
|
3267
3556
|
builder,
|
|
3268
3557
|
config,
|
|
3269
3558
|
warn: renderer.warn
|
|
3270
|
-
}, next, ctx);
|
|
3559
|
+
}, next, previous_round(config, s), ctx);
|
|
3271
3560
|
renderer.phase_end(built.iteration, "builder", true);
|
|
3272
3561
|
renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
|
|
3273
3562
|
if (built.cost_warned && !s.cost_warned) renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
|
|
@@ -3721,13 +4010,6 @@ const CRITIC_READS = /* @__PURE__ */ new Set([
|
|
|
3721
4010
|
"list_files",
|
|
3722
4011
|
"search"
|
|
3723
4012
|
]);
|
|
3724
|
-
/** The environment the builder's commands and the check run with under acp:
|
|
3725
|
-
* the agent's own, `PATH` included, less every variable a seat reads a
|
|
3726
|
-
* credential from. */
|
|
3727
|
-
function acp_command_env(env) {
|
|
3728
|
-
const stripped = new Set(SEAT_CREDENTIAL_VARS);
|
|
3729
|
-
return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
|
|
3730
|
-
}
|
|
3731
4013
|
/** Whether `tool` asks before a call with `input`. A set without the tool
|
|
3732
4014
|
* asks, so a check never runs unasked by mistake. */
|
|
3733
4015
|
async function asks_before(tool, input) {
|
|
@@ -3742,10 +4024,10 @@ async function asks_before(tool, input) {
|
|
|
3742
4024
|
* the session exactly when the builder's `run_command` would ask before
|
|
3743
4025
|
* running the same command. */
|
|
3744
4026
|
function acp_seat_tools(session, options = {}) {
|
|
3745
|
-
const
|
|
4027
|
+
const env = command_env(options.env ?? process.env);
|
|
3746
4028
|
const tools = workspace_tools(session, {
|
|
3747
4029
|
ignore: [...IGNORED_DIRS],
|
|
3748
|
-
command_env
|
|
4030
|
+
command_env: env
|
|
3749
4031
|
});
|
|
3750
4032
|
const run_command = tools.find((tool) => tool.name === "run_command");
|
|
3751
4033
|
return {
|
|
@@ -3759,7 +4041,7 @@ function acp_seat_tools(session, options = {}) {
|
|
|
3759
4041
|
tool_set: "acp"
|
|
3760
4042
|
},
|
|
3761
4043
|
check: {
|
|
3762
|
-
env
|
|
4044
|
+
env,
|
|
3763
4045
|
on_approval: async (request) => await asks_before(run_command, request.input) ? session.approve(request) : true
|
|
3764
4046
|
}
|
|
3765
4047
|
};
|
|
@@ -3790,6 +4072,7 @@ function resolve_check_runner(check, workspace) {
|
|
|
3790
4072
|
return /(^|\s)checkride(\s|$)/.test(check.trim()) ? "checkride" : "command";
|
|
3791
4073
|
}
|
|
3792
4074
|
const DEFAULT_CRITIC_MODEL = "opus";
|
|
4075
|
+
const DEFAULT_BUILDER_MAX_OUTPUT_TOKENS = 16384;
|
|
3793
4076
|
const DEFAULT_CHECK = "auto";
|
|
3794
4077
|
const DEFAULT_CRITIC = "reviewer";
|
|
3795
4078
|
const DEFAULT_BUILDER_PROVIDER = "claude_cli";
|
|
@@ -3881,6 +4164,7 @@ const CRITIQUE_REFUSED = [
|
|
|
3881
4164
|
["builder_url", "--builder-url"],
|
|
3882
4165
|
["builder_max_steps", "--builder-max-steps"],
|
|
3883
4166
|
["builder_max_tool_calls", "--builder-max-tool-calls"],
|
|
4167
|
+
["builder_max_output_tokens", "--builder-max-output-tokens"],
|
|
3884
4168
|
["builder_permission_mode", "--builder-permission-mode"],
|
|
3885
4169
|
["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
|
|
3886
4170
|
["sandbox_image", "--sandbox-image"],
|
|
@@ -3904,6 +4188,22 @@ const BUILDER_MAX_TOOL_CALLS = {
|
|
|
3904
4188
|
env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
|
|
3905
4189
|
fallback: 1
|
|
3906
4190
|
};
|
|
4191
|
+
/** The builders whose turns are capped when nothing sets a cap: the local
|
|
4192
|
+
* ones, where nothing else bounds a turn. Ollama's MLX runner lets an unset
|
|
4193
|
+
* `num_predict` run to the model's trained context less the prompt, which is
|
|
4194
|
+
* how one qwen3.8 turn spent 30,847 tokens thinking. A hosted model on
|
|
4195
|
+
* OpenRouter has its own output limit, and a request for more than it allows
|
|
4196
|
+
* can be refused, so it's capped only when asked. */
|
|
4197
|
+
const CAPPED_BY_DEFAULT = ["ollama", "lmstudio"];
|
|
4198
|
+
/** The output cap's flag and env var, and its default for `provider`: 16k
|
|
4199
|
+
* tokens on a local builder, else the provider's own limit (null). */
|
|
4200
|
+
function builder_max_output_tokens_count(provider) {
|
|
4201
|
+
return {
|
|
4202
|
+
flag: "--builder-max-output-tokens",
|
|
4203
|
+
env_var: "VOLLEY_BUILDER_MAX_OUTPUT_TOKENS",
|
|
4204
|
+
fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
|
|
4205
|
+
};
|
|
4206
|
+
}
|
|
3907
4207
|
/** A builder count's flag or config value wins over its env var, which wins
|
|
3908
4208
|
* over the default, and either must be a positive integer. Local builder
|
|
3909
4209
|
* only; ignored by the `claude_cli` arm. */
|
|
@@ -4037,6 +4337,7 @@ function resolve_config(raw, options = {}) {
|
|
|
4037
4337
|
if (TOOL_LOOP_BUILDER_PROVIDERS.includes(builder_provider) && options.uncontained_builder !== "ask" && unguarded_builder(raw, builder_env)) throw config_error(`builder-provider '${builder_provider}' runs volley's tool loop with a real bash (write + exec) — refused unless contained. Launch volley inside its sandbox container (docker run <hardened flags> <image> volley run …, which sets VOLLEY_CONTAINED=1), or pass --allow-unsandboxed-builder (VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1) to run uncontained on the host.`);
|
|
4038
4338
|
const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
|
|
4039
4339
|
const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
|
|
4340
|
+
const builder_max_output_tokens = resolve_builder_count(raw.builder_max_output_tokens, builder_env, builder_max_output_tokens_count(builder_provider));
|
|
4040
4341
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
4041
4342
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
4042
4343
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
@@ -4074,6 +4375,7 @@ function resolve_config(raw, options = {}) {
|
|
|
4074
4375
|
builder_url,
|
|
4075
4376
|
builder_max_steps,
|
|
4076
4377
|
builder_max_tool_calls,
|
|
4378
|
+
builder_max_output_tokens,
|
|
4077
4379
|
allow_unsandboxed_builder,
|
|
4078
4380
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
4079
4381
|
critic_provider,
|
|
@@ -4121,6 +4423,7 @@ function initial_state() {
|
|
|
4121
4423
|
builder_cost_usd: 0,
|
|
4122
4424
|
critic_cost_usd: 0,
|
|
4123
4425
|
check_duration_ms: 0,
|
|
4426
|
+
builder_max_steps_hits: 0,
|
|
4124
4427
|
iteration_cost_usd: 0,
|
|
4125
4428
|
halt: null,
|
|
4126
4429
|
cost_warned: false
|
|
@@ -4436,8 +4739,10 @@ function finalize_failed_run(config, err) {
|
|
|
4436
4739
|
const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
|
|
4437
4740
|
try {
|
|
4438
4741
|
const summary_path = state_path(config, "summary.json");
|
|
4742
|
+
const prior = existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null);
|
|
4439
4743
|
write_run_summary(config, {
|
|
4440
|
-
...
|
|
4744
|
+
...prior,
|
|
4745
|
+
builder_max_steps_hits: prior.builder_max_steps_hits ?? 0,
|
|
4441
4746
|
run_state_dir: run_state_dir(config),
|
|
4442
4747
|
status,
|
|
4443
4748
|
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -5233,7 +5538,10 @@ async function probe_seat_endpoints(config, renderer, env, reachable) {
|
|
|
5233
5538
|
/** Which required executables are not runnable on the current PATH. */
|
|
5234
5539
|
function toolchain_missing(tools = REQUIRED_TOOLCHAIN) {
|
|
5235
5540
|
return tools.filter((tool) => {
|
|
5236
|
-
const result = spawnSync(tool, ["--version"], {
|
|
5541
|
+
const result = spawnSync(tool, ["--version"], {
|
|
5542
|
+
env: command_env(process.env),
|
|
5543
|
+
stdio: "ignore"
|
|
5544
|
+
});
|
|
5237
5545
|
return result.error !== void 0 || result.status !== 0;
|
|
5238
5546
|
});
|
|
5239
5547
|
}
|
|
@@ -5242,7 +5550,10 @@ function toolchain_missing(tools = REQUIRED_TOOLCHAIN) {
|
|
|
5242
5550
|
* run's job — worktree.ts). */
|
|
5243
5551
|
function worktree_creatable(workspace) {
|
|
5244
5552
|
const repo = resolve(workspace);
|
|
5245
|
-
const version = spawnSync("git", ["--version"], {
|
|
5553
|
+
const version = spawnSync("git", ["--version"], {
|
|
5554
|
+
env: command_env(process.env),
|
|
5555
|
+
stdio: "ignore"
|
|
5556
|
+
});
|
|
5246
5557
|
if (version.error !== void 0 || version.status !== 0) return {
|
|
5247
5558
|
ok: false,
|
|
5248
5559
|
detail: "git is not available"
|
|
@@ -5256,7 +5567,10 @@ function worktree_creatable(workspace) {
|
|
|
5256
5567
|
repo,
|
|
5257
5568
|
"rev-parse",
|
|
5258
5569
|
"--is-inside-work-tree"
|
|
5259
|
-
], {
|
|
5570
|
+
], {
|
|
5571
|
+
env: command_env(process.env),
|
|
5572
|
+
encoding: "utf8"
|
|
5573
|
+
});
|
|
5260
5574
|
if (inside.status !== 0 || (inside.stdout ?? "").trim() !== "true") return {
|
|
5261
5575
|
ok: false,
|
|
5262
5576
|
detail: `${repo} is not a git work tree`
|
|
@@ -5329,6 +5643,7 @@ function run_checkride_doctor(workspace) {
|
|
|
5329
5643
|
"doctor"
|
|
5330
5644
|
], {
|
|
5331
5645
|
cwd: workspace,
|
|
5646
|
+
env: command_env(process.env),
|
|
5332
5647
|
stdio: [
|
|
5333
5648
|
"ignore",
|
|
5334
5649
|
"ignore",
|
|
@@ -5353,6 +5668,7 @@ async function preflight(config, renderer, env, probes = {}) {
|
|
|
5353
5668
|
renderer.info(seat_line("builder", config.builder_model, config.builder_provider, config.builder_url, env));
|
|
5354
5669
|
renderer.info(`builder max steps: ${config.builder_max_steps}`);
|
|
5355
5670
|
renderer.info(`builder tool calls per turn: ${config.builder_max_tool_calls}`);
|
|
5671
|
+
renderer.info(`builder output tokens per turn: ${config.builder_max_output_tokens ?? "the provider's limit"}`);
|
|
5356
5672
|
}
|
|
5357
5673
|
if (tool_loop_builder) {
|
|
5358
5674
|
const contained = detect_containment(env);
|
|
@@ -5484,7 +5800,7 @@ function with_critic_options(command, required = false) {
|
|
|
5484
5800
|
function with_seat_options(command, required = false) {
|
|
5485
5801
|
const model_note = required ? " (required)" : "";
|
|
5486
5802
|
const provider_note = required ? "required" : "default: claude_cli";
|
|
5487
|
-
return with_critic_options(command.option("--builder-model <model>", `Builder model${model_note}`).option("--builder-provider <name>", `claude_cli | ollama | lmstudio | openrouter (${provider_note})`).option("--builder-url <url>", "Builder's model server base URL, ollama, lmstudio, or openrouter only (default: VOLLEY_OLLAMA_URL / VOLLEY_LMSTUDIO_URL / VOLLEY_OPENROUTER_URL)").option("--builder-max-steps <n>", "Tool-loop builder step cap per iteration, any provider but claude_cli (default: 50)").option("--builder-max-tool-calls <n>", "Tool calls a tool-loop builder may make per turn, any provider but claude_cli (default: 1)").option("--allow-unsandboxed-builder", "Permit a builder other than claude_cli to run without a sandbox (real host bash + write)"), required);
|
|
5803
|
+
return with_critic_options(command.option("--builder-model <model>", `Builder model${model_note}`).option("--builder-provider <name>", `claude_cli | ollama | lmstudio | openrouter (${provider_note})`).option("--builder-url <url>", "Builder's model server base URL, ollama, lmstudio, or openrouter only (default: VOLLEY_OLLAMA_URL / VOLLEY_LMSTUDIO_URL / VOLLEY_OPENROUTER_URL)").option("--builder-max-steps <n>", "Tool-loop builder step cap per iteration, any provider but claude_cli (default: 50)").option("--builder-max-tool-calls <n>", "Tool calls a tool-loop builder may make per turn, reads aside, any provider but claude_cli (default: 1)").option("--builder-max-output-tokens <n>", "Output tokens a tool-loop builder may generate per turn, reasoning included, any provider but claude_cli (default: 16384 on ollama and lmstudio, the provider's on openrouter)").option("--allow-unsandboxed-builder", "Permit a builder other than claude_cli to run without a sandbox (real host bash + write)"), required);
|
|
5488
5804
|
}
|
|
5489
5805
|
/** What a critique declares only so that it can refuse each by name, with
|
|
5490
5806
|
* exit 5: cac would answer an undeclared one with a bare "Unknown option".
|
|
@@ -5495,6 +5811,7 @@ const CRITIQUE_REFUSED_OPTIONS = [
|
|
|
5495
5811
|
"--builder-url <url>",
|
|
5496
5812
|
"--builder-max-steps <n>",
|
|
5497
5813
|
"--builder-max-tool-calls <n>",
|
|
5814
|
+
"--builder-max-output-tokens <n>",
|
|
5498
5815
|
"--builder-permission-mode <mode>",
|
|
5499
5816
|
"--allow-unsandboxed-builder",
|
|
5500
5817
|
"--sandbox-image <tag>",
|
|
@@ -5518,6 +5835,7 @@ function merge_flags(base, flags) {
|
|
|
5518
5835
|
...flags.builderUrl !== void 0 ? { builder_url: flags.builderUrl } : {},
|
|
5519
5836
|
...flags.builderMaxSteps !== void 0 ? { builder_max_steps: Number(flags.builderMaxSteps) } : {},
|
|
5520
5837
|
...flags.builderMaxToolCalls !== void 0 ? { builder_max_tool_calls: Number(flags.builderMaxToolCalls) } : {},
|
|
5838
|
+
...flags.builderMaxOutputTokens !== void 0 ? { builder_max_output_tokens: Number(flags.builderMaxOutputTokens) } : {},
|
|
5521
5839
|
...flags.allowUnsandboxedBuilder === true ? { allow_unsandboxed_builder: true } : {},
|
|
5522
5840
|
...flags.criticModel !== void 0 ? { critic_model: flags.criticModel } : {},
|
|
5523
5841
|
...flags.criticProvider !== void 0 ? { critic_provider: flags.criticProvider } : {},
|