@robmclarty/volley 0.10.0 → 0.11.0
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 +27 -0
- package/README.md +69 -8
- package/dist/cli.js +684 -296
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +44 -3
- package/dist/index.js +549 -246
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -39,6 +39,232 @@ function error_kind(err) {
|
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
41
41
|
//#endregion
|
|
42
|
+
//#region src/engine.ts
|
|
43
|
+
/**
|
|
44
|
+
* The ONLY module that calls fascicle's `create_engine`. Every
|
|
45
|
+
* other module receives an `Engine`-shaped value, which keeps the engine
|
|
46
|
+
* mockable in tests and confines provider knowledge to one seam.
|
|
47
|
+
*/
|
|
48
|
+
/** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
|
|
49
|
+
* The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
|
|
50
|
+
* `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
|
|
51
|
+
* and 404s. */
|
|
52
|
+
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
53
|
+
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
54
|
+
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
55
|
+
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
56
|
+
/** The variable an `openrouter` seat's API key is read from. */
|
|
57
|
+
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
58
|
+
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
59
|
+
* CLI also reads on its own. */
|
|
60
|
+
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
61
|
+
/** Every variable a seat reads a credential from. Every process volley starts
|
|
62
|
+
* runs without them (the check, the builder's commands, and volley's own git
|
|
63
|
+
* and checkride calls, which run hooks and filters the builder can write), so
|
|
64
|
+
* nothing the builder writes can read the keys its seats spend. */
|
|
65
|
+
const SEAT_CREDENTIAL_VARS = [OPENROUTER_KEY_VAR, ANTHROPIC_KEY_VAR];
|
|
66
|
+
/** The environment every process volley starts runs with: `env`, `PATH`
|
|
67
|
+
* included, less every variable a seat reads a credential from. */
|
|
68
|
+
function command_env(env) {
|
|
69
|
+
const stripped = new Set(SEAT_CREDENTIAL_VARS);
|
|
70
|
+
return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
|
|
71
|
+
}
|
|
72
|
+
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
73
|
+
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
74
|
+
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
75
|
+
* except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
|
|
76
|
+
* IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
|
|
77
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
78
|
+
"localhost",
|
|
79
|
+
"127.0.0.1",
|
|
80
|
+
"[::1]",
|
|
81
|
+
"0.0.0.0"
|
|
82
|
+
]);
|
|
83
|
+
/** Cross a loopback base URL to the host LLM endpoint. When volley
|
|
84
|
+
* runs inside its sandbox container the local model daemon is on the *host*, so
|
|
85
|
+
* the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
|
|
86
|
+
* (the host-gateway allowlist target; see `sandbox_invocation` in
|
|
87
|
+
* `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
|
|
88
|
+
* loopback host for the gateway, so an unchanged `http://localhost:11434` still
|
|
89
|
+
* reaches the host from in-container. A non-loopback URL (a real remote daemon)
|
|
90
|
+
* is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
|
|
91
|
+
* path) is a no-op — so the default and all-Claude paths are unaffected. */
|
|
92
|
+
function cross_to_host_gateway(base_url, env) {
|
|
93
|
+
const host = env["VOLLEY_MODEL_HOST"];
|
|
94
|
+
if (host === void 0 || host === "") return base_url;
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = new URL(base_url);
|
|
98
|
+
} catch {
|
|
99
|
+
return base_url;
|
|
100
|
+
}
|
|
101
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
102
|
+
parsed.hostname = host;
|
|
103
|
+
return parsed.toString().replace(/\/$/, "");
|
|
104
|
+
}
|
|
105
|
+
function strip_trailing_slashes(url) {
|
|
106
|
+
return url.replace(/\/+$/, "");
|
|
107
|
+
}
|
|
108
|
+
const SEAT_URL_SOURCES = {
|
|
109
|
+
ollama: {
|
|
110
|
+
env: "VOLLEY_OLLAMA_URL",
|
|
111
|
+
default_url: DEFAULT_OLLAMA_URL,
|
|
112
|
+
normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
|
|
113
|
+
},
|
|
114
|
+
lmstudio: {
|
|
115
|
+
env: "VOLLEY_LMSTUDIO_URL",
|
|
116
|
+
default_url: DEFAULT_LMSTUDIO_URL,
|
|
117
|
+
normalize: strip_trailing_slashes
|
|
118
|
+
},
|
|
119
|
+
openrouter: {
|
|
120
|
+
env: "VOLLEY_OPENROUTER_URL",
|
|
121
|
+
default_url: DEFAULT_OPENROUTER_URL,
|
|
122
|
+
normalize: strip_trailing_slashes
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
126
|
+
* so get their endpoint probed by `--dry-run`. */
|
|
127
|
+
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
128
|
+
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
129
|
+
* configures, and so takes a URL of its own. */
|
|
130
|
+
function takes_seat_url(provider) {
|
|
131
|
+
return SEAT_URL_PROVIDERS.includes(provider);
|
|
132
|
+
}
|
|
133
|
+
/** An absent, empty, or all-whitespace value counts as unset. */
|
|
134
|
+
function present(value) {
|
|
135
|
+
const trimmed = value?.trim() ?? "";
|
|
136
|
+
return trimmed === "" ? null : trimmed;
|
|
137
|
+
}
|
|
138
|
+
/** The base URL a seat's model calls go to: the seat's own URL, else the
|
|
139
|
+
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
140
|
+
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
141
|
+
* other seat's URL. The result is normalized, then a loopback authority is
|
|
142
|
+
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
143
|
+
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
144
|
+
function seat_base_url(provider, seat_url, env) {
|
|
145
|
+
const source = SEAT_URL_SOURCES[provider];
|
|
146
|
+
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
147
|
+
return cross_to_host_gateway(source.normalize(raw), env);
|
|
148
|
+
}
|
|
149
|
+
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
150
|
+
* or blank key is a config error naming the variable, which `resolve_config`
|
|
151
|
+
* raises before any work, because fascicle's own "requires a non-empty
|
|
152
|
+
* api_key" doesn't say where the key comes from. */
|
|
153
|
+
function openrouter_api_key(env) {
|
|
154
|
+
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
155
|
+
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
156
|
+
return key;
|
|
157
|
+
}
|
|
158
|
+
function load_pricing_overrides(path) {
|
|
159
|
+
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
162
|
+
} catch (err) {
|
|
163
|
+
throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
|
|
167
|
+
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
168
|
+
* fascicle only when the provider actually runs. The entry is complete, because
|
|
169
|
+
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
170
|
+
* returns is all a seat's derived engine knows about the provider, so an
|
|
171
|
+
* `openrouter` entry carries its key and its transport along with its URL.
|
|
172
|
+
*
|
|
173
|
+
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
174
|
+
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
175
|
+
* traffic to volley.
|
|
176
|
+
*
|
|
177
|
+
* For the local providers, native transport is the deferred future bridge, not
|
|
178
|
+
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
179
|
+
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
180
|
+
* so no field is needed today. Flipping later is a one-line change — add
|
|
181
|
+
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
182
|
+
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
183
|
+
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
184
|
+
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
185
|
+
* base-URL fix produces.
|
|
186
|
+
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
187
|
+
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
188
|
+
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
189
|
+
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
190
|
+
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
191
|
+
*
|
|
192
|
+
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
193
|
+
* when volley runs contained, so an in-container model client reaches the
|
|
194
|
+
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
195
|
+
* override is unset and this is a no-op. */
|
|
196
|
+
function seat_provider_config(provider, seat_url, env) {
|
|
197
|
+
const base_url = seat_base_url(provider, seat_url, env);
|
|
198
|
+
switch (provider) {
|
|
199
|
+
case "ollama": return { ollama: { base_url } };
|
|
200
|
+
case "lmstudio": return { lmstudio: { base_url } };
|
|
201
|
+
case "openrouter": return { openrouter: {
|
|
202
|
+
api_key: openrouter_api_key(env),
|
|
203
|
+
base_url,
|
|
204
|
+
transport: "native",
|
|
205
|
+
x_title: "volley"
|
|
206
|
+
} };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
210
|
+
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
211
|
+
* by the builder or critic role is wired in alongside it, at that seat's URL,
|
|
212
|
+
* so one engine drives both roles. An engine holds one URL per provider, so on
|
|
213
|
+
* a provider both seats share it carries the builder's; `create_seat_engines`
|
|
214
|
+
* gives the critic its own engine when its URL differs. Binary, auth mode,
|
|
215
|
+
* fallback provider URLs, and pricing come from the environment. */
|
|
216
|
+
function create_volley_engine(options) {
|
|
217
|
+
const env = options.env ?? process.env;
|
|
218
|
+
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
219
|
+
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}`);
|
|
220
|
+
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
221
|
+
const pricing_path = env["VOLLEY_PRICING_PATH"];
|
|
222
|
+
const providers = { claude_cli: {
|
|
223
|
+
default_cwd: options.workspace,
|
|
224
|
+
setting_sources: ["project", "local"],
|
|
225
|
+
auth_mode,
|
|
226
|
+
omit_env: SEAT_CREDENTIAL_VARS,
|
|
227
|
+
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
228
|
+
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
229
|
+
} };
|
|
230
|
+
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
231
|
+
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
232
|
+
return create_engine({
|
|
233
|
+
providers,
|
|
234
|
+
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** One engine per seat, shared unless it can't be. fascicle fixes one base URL
|
|
238
|
+
* per provider when it builds an engine, so two seats on the same provider at
|
|
239
|
+
* different URLs need two engines: the critic's is derived from the builder's
|
|
240
|
+
* with `with_providers`, which keeps claude_cli, auth, and pricing and
|
|
241
|
+
* overrides only the shared provider's entry. Every other pairing, including a
|
|
242
|
+
* run that sets no seat URL at all, gets the one engine a run has always had. */
|
|
243
|
+
async function create_seat_engines(options) {
|
|
244
|
+
const env = options.env ?? process.env;
|
|
245
|
+
const builder = create_volley_engine(options);
|
|
246
|
+
const provider = options.critic_provider;
|
|
247
|
+
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 {
|
|
248
|
+
builder,
|
|
249
|
+
critic: builder
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
return {
|
|
253
|
+
builder,
|
|
254
|
+
critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
|
|
255
|
+
};
|
|
256
|
+
} catch (err) {
|
|
257
|
+
await builder.dispose();
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** Dispose each distinct seat engine once. Both disposals start together, so
|
|
262
|
+
* one that fails doesn't keep the other from running. */
|
|
263
|
+
async function dispose_seat_engines(engines) {
|
|
264
|
+
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
265
|
+
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
266
|
+
}
|
|
267
|
+
//#endregion
|
|
42
268
|
//#region src/changes.ts
|
|
43
269
|
/**
|
|
44
270
|
* What the builder changed, and whether it touched the gate.
|
|
@@ -111,6 +337,7 @@ function git$1(root, args) {
|
|
|
111
337
|
try {
|
|
112
338
|
const result = spawnSync("git", args, {
|
|
113
339
|
cwd: root,
|
|
340
|
+
env: command_env(process.env),
|
|
114
341
|
encoding: "utf8",
|
|
115
342
|
maxBuffer: 33554432
|
|
116
343
|
});
|
|
@@ -348,220 +575,6 @@ function detect_containment(env) {
|
|
|
348
575
|
return value === "1" || value === "true";
|
|
349
576
|
}
|
|
350
577
|
//#endregion
|
|
351
|
-
//#region src/engine.ts
|
|
352
|
-
/**
|
|
353
|
-
* The ONLY module that calls fascicle's `create_engine`. Every
|
|
354
|
-
* other module receives an `Engine`-shaped value, which keeps the engine
|
|
355
|
-
* mockable in tests and confines provider knowledge to one seam.
|
|
356
|
-
*/
|
|
357
|
-
/** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
|
|
358
|
-
* The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
|
|
359
|
-
* `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
|
|
360
|
-
* and 404s. */
|
|
361
|
-
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
362
|
-
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
363
|
-
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
364
|
-
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
365
|
-
/** The variable an `openrouter` seat's API key is read from. */
|
|
366
|
-
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
367
|
-
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
368
|
-
* CLI also reads on its own. */
|
|
369
|
-
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
370
|
-
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
371
|
-
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
372
|
-
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
373
|
-
* except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
|
|
374
|
-
* IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
|
|
375
|
-
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
376
|
-
"localhost",
|
|
377
|
-
"127.0.0.1",
|
|
378
|
-
"[::1]",
|
|
379
|
-
"0.0.0.0"
|
|
380
|
-
]);
|
|
381
|
-
/** Cross a loopback base URL to the host LLM endpoint. When volley
|
|
382
|
-
* runs inside its sandbox container the local model daemon is on the *host*, so
|
|
383
|
-
* the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
|
|
384
|
-
* (the host-gateway allowlist target; see `sandbox_invocation` in
|
|
385
|
-
* `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
|
|
386
|
-
* loopback host for the gateway, so an unchanged `http://localhost:11434` still
|
|
387
|
-
* reaches the host from in-container. A non-loopback URL (a real remote daemon)
|
|
388
|
-
* is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
|
|
389
|
-
* path) is a no-op — so the default and all-Claude paths are unaffected. */
|
|
390
|
-
function cross_to_host_gateway(base_url, env) {
|
|
391
|
-
const host = env["VOLLEY_MODEL_HOST"];
|
|
392
|
-
if (host === void 0 || host === "") return base_url;
|
|
393
|
-
let parsed;
|
|
394
|
-
try {
|
|
395
|
-
parsed = new URL(base_url);
|
|
396
|
-
} catch {
|
|
397
|
-
return base_url;
|
|
398
|
-
}
|
|
399
|
-
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
400
|
-
parsed.hostname = host;
|
|
401
|
-
return parsed.toString().replace(/\/$/, "");
|
|
402
|
-
}
|
|
403
|
-
function strip_trailing_slashes(url) {
|
|
404
|
-
return url.replace(/\/+$/, "");
|
|
405
|
-
}
|
|
406
|
-
const SEAT_URL_SOURCES = {
|
|
407
|
-
ollama: {
|
|
408
|
-
env: "VOLLEY_OLLAMA_URL",
|
|
409
|
-
default_url: DEFAULT_OLLAMA_URL,
|
|
410
|
-
normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
|
|
411
|
-
},
|
|
412
|
-
lmstudio: {
|
|
413
|
-
env: "VOLLEY_LMSTUDIO_URL",
|
|
414
|
-
default_url: DEFAULT_LMSTUDIO_URL,
|
|
415
|
-
normalize: strip_trailing_slashes
|
|
416
|
-
},
|
|
417
|
-
openrouter: {
|
|
418
|
-
env: "VOLLEY_OPENROUTER_URL",
|
|
419
|
-
default_url: DEFAULT_OPENROUTER_URL,
|
|
420
|
-
normalize: strip_trailing_slashes
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
424
|
-
* so get their endpoint probed by `--dry-run`. */
|
|
425
|
-
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
426
|
-
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
427
|
-
* configures, and so takes a URL of its own. */
|
|
428
|
-
function takes_seat_url(provider) {
|
|
429
|
-
return SEAT_URL_PROVIDERS.includes(provider);
|
|
430
|
-
}
|
|
431
|
-
/** An absent, empty, or all-whitespace value counts as unset. */
|
|
432
|
-
function present(value) {
|
|
433
|
-
const trimmed = value?.trim() ?? "";
|
|
434
|
-
return trimmed === "" ? null : trimmed;
|
|
435
|
-
}
|
|
436
|
-
/** The base URL a seat's model calls go to: the seat's own URL, else the
|
|
437
|
-
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
438
|
-
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
439
|
-
* other seat's URL. The result is normalized, then a loopback authority is
|
|
440
|
-
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
441
|
-
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
442
|
-
function seat_base_url(provider, seat_url, env) {
|
|
443
|
-
const source = SEAT_URL_SOURCES[provider];
|
|
444
|
-
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
445
|
-
return cross_to_host_gateway(source.normalize(raw), env);
|
|
446
|
-
}
|
|
447
|
-
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
448
|
-
* or blank key is a config error naming the variable, which `resolve_config`
|
|
449
|
-
* raises before any work, because fascicle's own "requires a non-empty
|
|
450
|
-
* api_key" doesn't say where the key comes from. */
|
|
451
|
-
function openrouter_api_key(env) {
|
|
452
|
-
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
453
|
-
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
454
|
-
return key;
|
|
455
|
-
}
|
|
456
|
-
function load_pricing_overrides(path) {
|
|
457
|
-
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
458
|
-
try {
|
|
459
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
460
|
-
} catch (err) {
|
|
461
|
-
throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
/** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
|
|
465
|
-
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
466
|
-
* fascicle only when the provider actually runs. The entry is complete, because
|
|
467
|
-
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
468
|
-
* returns is all a seat's derived engine knows about the provider, so an
|
|
469
|
-
* `openrouter` entry carries its key and its transport along with its URL.
|
|
470
|
-
*
|
|
471
|
-
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
472
|
-
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
473
|
-
* traffic to volley.
|
|
474
|
-
*
|
|
475
|
-
* For the local providers, native transport is the deferred future bridge, not
|
|
476
|
-
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
477
|
-
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
478
|
-
* so no field is needed today. Flipping later is a one-line change — add
|
|
479
|
-
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
480
|
-
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
481
|
-
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
482
|
-
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
483
|
-
* base-URL fix produces.
|
|
484
|
-
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
485
|
-
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
486
|
-
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
487
|
-
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
488
|
-
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
489
|
-
*
|
|
490
|
-
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
491
|
-
* when volley runs contained, so an in-container model client reaches the
|
|
492
|
-
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
493
|
-
* override is unset and this is a no-op. */
|
|
494
|
-
function seat_provider_config(provider, seat_url, env) {
|
|
495
|
-
const base_url = seat_base_url(provider, seat_url, env);
|
|
496
|
-
switch (provider) {
|
|
497
|
-
case "ollama": return { ollama: { base_url } };
|
|
498
|
-
case "lmstudio": return { lmstudio: { base_url } };
|
|
499
|
-
case "openrouter": return { openrouter: {
|
|
500
|
-
api_key: openrouter_api_key(env),
|
|
501
|
-
base_url,
|
|
502
|
-
transport: "native",
|
|
503
|
-
x_title: "volley"
|
|
504
|
-
} };
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
508
|
-
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
509
|
-
* by the builder or critic role is wired in alongside it, at that seat's URL,
|
|
510
|
-
* so one engine drives both roles. An engine holds one URL per provider, so on
|
|
511
|
-
* a provider both seats share it carries the builder's; `create_seat_engines`
|
|
512
|
-
* gives the critic its own engine when its URL differs. Binary, auth mode,
|
|
513
|
-
* fallback provider URLs, and pricing come from the environment. */
|
|
514
|
-
function create_volley_engine(options) {
|
|
515
|
-
const env = options.env ?? process.env;
|
|
516
|
-
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
517
|
-
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}`);
|
|
518
|
-
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
519
|
-
const pricing_path = env["VOLLEY_PRICING_PATH"];
|
|
520
|
-
const providers = { claude_cli: {
|
|
521
|
-
default_cwd: options.workspace,
|
|
522
|
-
setting_sources: ["project", "local"],
|
|
523
|
-
auth_mode,
|
|
524
|
-
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
525
|
-
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
526
|
-
} };
|
|
527
|
-
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
528
|
-
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
529
|
-
return create_engine({
|
|
530
|
-
providers,
|
|
531
|
-
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
|
-
/** One engine per seat, shared unless it can't be. fascicle fixes one base URL
|
|
535
|
-
* per provider when it builds an engine, so two seats on the same provider at
|
|
536
|
-
* different URLs need two engines: the critic's is derived from the builder's
|
|
537
|
-
* with `with_providers`, which keeps claude_cli, auth, and pricing and
|
|
538
|
-
* overrides only the shared provider's entry. Every other pairing, including a
|
|
539
|
-
* run that sets no seat URL at all, gets the one engine a run has always had. */
|
|
540
|
-
async function create_seat_engines(options) {
|
|
541
|
-
const env = options.env ?? process.env;
|
|
542
|
-
const builder = create_volley_engine(options);
|
|
543
|
-
const provider = options.critic_provider;
|
|
544
|
-
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 {
|
|
545
|
-
builder,
|
|
546
|
-
critic: builder
|
|
547
|
-
};
|
|
548
|
-
try {
|
|
549
|
-
return {
|
|
550
|
-
builder,
|
|
551
|
-
critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
|
|
552
|
-
};
|
|
553
|
-
} catch (err) {
|
|
554
|
-
await builder.dispose();
|
|
555
|
-
throw err;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
/** Dispose each distinct seat engine once. Both disposals start together, so
|
|
559
|
-
* one that fails doesn't keep the other from running. */
|
|
560
|
-
async function dispose_seat_engines(engines) {
|
|
561
|
-
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
562
|
-
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
563
|
-
}
|
|
564
|
-
//#endregion
|
|
565
578
|
//#region src/trust.ts
|
|
566
579
|
/**
|
|
567
580
|
* Which run configs a host-side resume may trust. A resume runs what a run's
|
|
@@ -723,6 +736,7 @@ function write_resolved_config(config, env = process.env) {
|
|
|
723
736
|
builder_max_steps: config.builder_max_steps,
|
|
724
737
|
builder_max_tool_calls: config.builder_max_tool_calls,
|
|
725
738
|
builder_max_output_tokens: config.builder_max_output_tokens,
|
|
739
|
+
builder_trim_history: config.builder_trim_history,
|
|
726
740
|
critic_model: config.critic_model,
|
|
727
741
|
critic_provider: config.critic_provider,
|
|
728
742
|
critic_url: config.critic_url,
|
|
@@ -768,6 +782,7 @@ function commit_identity(cwd) {
|
|
|
768
782
|
key
|
|
769
783
|
], {
|
|
770
784
|
cwd,
|
|
785
|
+
env: command_env(process.env),
|
|
771
786
|
encoding: "utf8"
|
|
772
787
|
});
|
|
773
788
|
return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
|
|
@@ -795,7 +810,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
795
810
|
"add",
|
|
796
811
|
"-A",
|
|
797
812
|
...pathspec
|
|
798
|
-
], {
|
|
813
|
+
], {
|
|
814
|
+
cwd: root,
|
|
815
|
+
env: command_env(process.env)
|
|
816
|
+
});
|
|
799
817
|
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
800
818
|
const commit = spawnSync("git", [
|
|
801
819
|
...commit_identity(root),
|
|
@@ -803,7 +821,10 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
803
821
|
"-m",
|
|
804
822
|
message,
|
|
805
823
|
"--no-verify"
|
|
806
|
-
], {
|
|
824
|
+
], {
|
|
825
|
+
cwd: root,
|
|
826
|
+
env: command_env(process.env)
|
|
827
|
+
});
|
|
807
828
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
808
829
|
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
809
830
|
if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
|
|
@@ -827,6 +848,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
827
848
|
branch
|
|
828
849
|
], {
|
|
829
850
|
cwd: workspace,
|
|
851
|
+
env: command_env(process.env),
|
|
830
852
|
encoding: "utf8"
|
|
831
853
|
});
|
|
832
854
|
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
@@ -839,6 +861,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
839
861
|
"--no-verify"
|
|
840
862
|
], {
|
|
841
863
|
cwd: workspace,
|
|
864
|
+
env: command_env(process.env),
|
|
842
865
|
encoding: "utf8"
|
|
843
866
|
});
|
|
844
867
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
@@ -938,6 +961,7 @@ const CRITIQUE_REFUSED = [
|
|
|
938
961
|
["builder_max_steps", "--builder-max-steps"],
|
|
939
962
|
["builder_max_tool_calls", "--builder-max-tool-calls"],
|
|
940
963
|
["builder_max_output_tokens", "--builder-max-output-tokens"],
|
|
964
|
+
["builder_trim_history", "--builder-trim-history"],
|
|
941
965
|
["builder_permission_mode", "--builder-permission-mode"],
|
|
942
966
|
["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
|
|
943
967
|
["sandbox_image", "--sandbox-image"],
|
|
@@ -977,6 +1001,12 @@ function builder_max_output_tokens_count(provider) {
|
|
|
977
1001
|
fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
|
|
978
1002
|
};
|
|
979
1003
|
}
|
|
1004
|
+
/** The history trim's size: off unless something sets it. */
|
|
1005
|
+
const BUILDER_TRIM_HISTORY = {
|
|
1006
|
+
flag: "--builder-trim-history",
|
|
1007
|
+
env_var: "VOLLEY_BUILDER_TRIM_HISTORY",
|
|
1008
|
+
fallback: null
|
|
1009
|
+
};
|
|
980
1010
|
/** A builder count's flag or config value wins over its env var, which wins
|
|
981
1011
|
* over the default, and either must be a positive integer. Local builder
|
|
982
1012
|
* only; ignored by the `claude_cli` arm. */
|
|
@@ -1111,6 +1141,7 @@ function resolve_config(raw, options = {}) {
|
|
|
1111
1141
|
const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
|
|
1112
1142
|
const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
|
|
1113
1143
|
const builder_max_output_tokens = resolve_builder_count(raw.builder_max_output_tokens, builder_env, builder_max_output_tokens_count(builder_provider));
|
|
1144
|
+
const builder_trim_history = resolve_builder_count(raw.builder_trim_history, builder_env, BUILDER_TRIM_HISTORY);
|
|
1114
1145
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
1115
1146
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
1116
1147
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
@@ -1149,6 +1180,7 @@ function resolve_config(raw, options = {}) {
|
|
|
1149
1180
|
builder_max_steps,
|
|
1150
1181
|
builder_max_tool_calls,
|
|
1151
1182
|
builder_max_output_tokens,
|
|
1183
|
+
builder_trim_history,
|
|
1152
1184
|
allow_unsandboxed_builder,
|
|
1153
1185
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
1154
1186
|
critic_provider,
|
|
@@ -1256,6 +1288,187 @@ function step_countdown(max_steps, check) {
|
|
|
1256
1288
|
};
|
|
1257
1289
|
}
|
|
1258
1290
|
//#endregion
|
|
1291
|
+
//#region src/builder/prepare_step.ts
|
|
1292
|
+
/** One hook that runs `hooks` in order, each on the request the one before it
|
|
1293
|
+
* made, and reports no change when none of them made one. */
|
|
1294
|
+
function compose_prepare_steps(hooks) {
|
|
1295
|
+
return async ({ step_index, messages }) => {
|
|
1296
|
+
let request = messages;
|
|
1297
|
+
let changed = false;
|
|
1298
|
+
for (const hook of hooks) {
|
|
1299
|
+
const prepared = await hook({
|
|
1300
|
+
step_index,
|
|
1301
|
+
messages: request
|
|
1302
|
+
});
|
|
1303
|
+
if (prepared?.messages !== void 0) {
|
|
1304
|
+
request = prepared.messages;
|
|
1305
|
+
changed = true;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
return changed ? { messages: request } : void 0;
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
//#endregion
|
|
1312
|
+
//#region src/builder/trim.ts
|
|
1313
|
+
/**
|
|
1314
|
+
* Trimming a long tool-loop session's history (#7). Every turn resends the
|
|
1315
|
+
* whole session, every earlier `read_file` result included, even for files the
|
|
1316
|
+
* builder has rewritten since, so a long local session's requests grow
|
|
1317
|
+
* linearly and each turn's latency grows with them: volley-41806476's input
|
|
1318
|
+
* went from 2.6k to 57k tokens over 50 steps, and its turns from 3 s to as
|
|
1319
|
+
* much as 120 s.
|
|
1320
|
+
*
|
|
1321
|
+
* Above a size the operator sets (`--builder-trim-history`), each turn's
|
|
1322
|
+
* request replaces the older tool results with one-line stubs: a read of a
|
|
1323
|
+
* file a later call wrote, edited, or read whole says so, and any other result
|
|
1324
|
+
* names its call and size. The last `TRIM_KEEP_TURNS` turns, every assistant
|
|
1325
|
+
* and user message, every failed call's result, and anything short stay as
|
|
1326
|
+
* they are. It goes in through fascicle's `prepare_step`, which reshapes only
|
|
1327
|
+
* what one turn sends, so the transcript stays as the model wrote it.
|
|
1328
|
+
*
|
|
1329
|
+
* A local runtime reuses the KV cache for the prefix a request shares with
|
|
1330
|
+
* the last one, and rewriting an earlier message breaks that prefix, so the
|
|
1331
|
+
* window is prefilled again. So the trim moves in coarse steps: it stubs
|
|
1332
|
+
* everything before a turn boundary, boundaries fall every
|
|
1333
|
+
* `TRIM_STRIDE_TURNS` turns, and the boundary moves on only when the trimmed
|
|
1334
|
+
* request has grown past the size again. Between moves each request starts
|
|
1335
|
+
* with the same bytes as the last, since a stub depends only on what came
|
|
1336
|
+
* before its boundary and never on the step. The trim keeps no state:
|
|
1337
|
+
* `trim_cutoff` works the boundary out again from the transcript each turn.
|
|
1338
|
+
*/
|
|
1339
|
+
/** A rough token count per character: the hook sees no token counts, so a
|
|
1340
|
+
* request's size is estimated from its text. */
|
|
1341
|
+
const CHARS_PER_TOKEN = 4;
|
|
1342
|
+
/** What a message costs beyond its text: its role and framing. */
|
|
1343
|
+
const MESSAGE_OVERHEAD_TOKENS = 8;
|
|
1344
|
+
/** The calls that change a file, whose success makes an earlier read of it
|
|
1345
|
+
* stale. Both volley's tools and fascicle's ACP tools use these names and a
|
|
1346
|
+
* `path` input. */
|
|
1347
|
+
const FILE_WRITERS = /* @__PURE__ */ new Set(["write_file", "edit_file"]);
|
|
1348
|
+
const COMMAND_TOOLS = /* @__PURE__ */ new Set(["bash", "run_command"]);
|
|
1349
|
+
function message_chars(message) {
|
|
1350
|
+
if (typeof message.content === "string") return message.content.length;
|
|
1351
|
+
let chars = 0;
|
|
1352
|
+
for (const part of message.content) if (part.type === "text" || part.type === "reasoning") chars += part.text.length;
|
|
1353
|
+
else if (part.type === "tool_call") chars += part.name.length + (JSON.stringify(part.input) ?? "").length;
|
|
1354
|
+
return chars;
|
|
1355
|
+
}
|
|
1356
|
+
/** A request's size in tokens, estimated from its characters. */
|
|
1357
|
+
function estimate_tokens(messages) {
|
|
1358
|
+
let tokens = 0;
|
|
1359
|
+
for (const message of messages) tokens += Math.ceil(message_chars(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD_TOKENS;
|
|
1360
|
+
return tokens;
|
|
1361
|
+
}
|
|
1362
|
+
function call_input(input) {
|
|
1363
|
+
if (typeof input !== "object" || input === null) return {
|
|
1364
|
+
path: null,
|
|
1365
|
+
whole: true
|
|
1366
|
+
};
|
|
1367
|
+
const fields = input;
|
|
1368
|
+
return {
|
|
1369
|
+
path: typeof fields["path"] === "string" ? posix.normalize(fields["path"]) : null,
|
|
1370
|
+
whole: fields["offset"] === void 0 && fields["limit"] === void 0
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
/** Each tool call's input, by call id, from the assistant messages in
|
|
1374
|
+
* `messages`. */
|
|
1375
|
+
function call_inputs(messages) {
|
|
1376
|
+
const inputs = /* @__PURE__ */ new Map();
|
|
1377
|
+
for (const message of messages) {
|
|
1378
|
+
if (message.role !== "assistant" || typeof message.content === "string") continue;
|
|
1379
|
+
for (const part of message.content) if (part.type === "tool_call") inputs.set(part.id, call_input(part.input));
|
|
1380
|
+
}
|
|
1381
|
+
return inputs;
|
|
1382
|
+
}
|
|
1383
|
+
/** Whether this result is one whose call changed its file or read it whole. */
|
|
1384
|
+
function supersession_of(message, input) {
|
|
1385
|
+
if (input === void 0 || input.path === null || message.is_error === true) return null;
|
|
1386
|
+
if (FILE_WRITERS.has(message.name)) return message.content.startsWith("error:") ? null : {
|
|
1387
|
+
tool: message.name,
|
|
1388
|
+
changed: true
|
|
1389
|
+
};
|
|
1390
|
+
if (message.name === "read_file" && input.whole) return {
|
|
1391
|
+
tool: "read_file",
|
|
1392
|
+
changed: false
|
|
1393
|
+
};
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
function exit_code_of(content) {
|
|
1397
|
+
try {
|
|
1398
|
+
const parsed = JSON.parse(content);
|
|
1399
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
1400
|
+
const code = parsed["exit_code"];
|
|
1401
|
+
if (typeof code === "number") return code;
|
|
1402
|
+
}
|
|
1403
|
+
} catch {}
|
|
1404
|
+
return null;
|
|
1405
|
+
}
|
|
1406
|
+
function stub(message, input, later) {
|
|
1407
|
+
const size = `${String(message.content.length)} chars`;
|
|
1408
|
+
if (later !== void 0 && input !== void 0 && input.path !== null) return later.changed ? `[volley] read_file ${input.path} (${size}) elided: a later ${later.tool} changed this file. Read it again for its current content.` : `[volley] read_file ${input.path} (${size}) elided: it was read again later in this session.`;
|
|
1409
|
+
const exit_code = COMMAND_TOOLS.has(message.name) ? exit_code_of(message.content) : null;
|
|
1410
|
+
const detail = exit_code === null ? size : `${size}, exit ${String(exit_code)}`;
|
|
1411
|
+
return `[volley] ${message.name} output (${detail}) elided from an earlier turn. Run it again if you need it.`;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* The request with every tool result before `cutoff` that is long enough to
|
|
1415
|
+
* matter and didn't fail replaced by a stub. Every message keeps its place,
|
|
1416
|
+
* role, id, and name. A stub reads only what comes before `cutoff`, so the
|
|
1417
|
+
* same transcript prefix always gives the same bytes.
|
|
1418
|
+
*/
|
|
1419
|
+
function trim_view(messages, cutoff) {
|
|
1420
|
+
const window = messages.slice(0, cutoff);
|
|
1421
|
+
const inputs = call_inputs(window);
|
|
1422
|
+
const next_on_path = /* @__PURE__ */ new Map();
|
|
1423
|
+
const later_for = /* @__PURE__ */ new Map();
|
|
1424
|
+
for (let i = window.length - 1; i >= 0; i -= 1) {
|
|
1425
|
+
const message = window[i];
|
|
1426
|
+
if (message?.role !== "tool") continue;
|
|
1427
|
+
const input = inputs.get(message.tool_call_id);
|
|
1428
|
+
const path = input?.path ?? null;
|
|
1429
|
+
if (path === null) continue;
|
|
1430
|
+
if (message.name === "read_file") {
|
|
1431
|
+
const later = next_on_path.get(path);
|
|
1432
|
+
if (later !== void 0) later_for.set(i, later);
|
|
1433
|
+
}
|
|
1434
|
+
const supersedes = supersession_of(message, input);
|
|
1435
|
+
if (supersedes !== null) next_on_path.set(path, supersedes);
|
|
1436
|
+
}
|
|
1437
|
+
return messages.map((message, i) => {
|
|
1438
|
+
if (i >= cutoff || message.role !== "tool" || message.is_error === true) return message;
|
|
1439
|
+
if (message.content.length <= 200) return message;
|
|
1440
|
+
return {
|
|
1441
|
+
...message,
|
|
1442
|
+
content: stub(message, inputs.get(message.tool_call_id), later_for.get(i))
|
|
1443
|
+
};
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Where to cut this turn's request, as a message index, or null to send it
|
|
1448
|
+
* whole. Boundaries are the starts of turns `TRIM_STRIDE_TURNS`,
|
|
1449
|
+
* `2 * TRIM_STRIDE_TURNS`, and so on, short of the last `TRIM_KEEP_TURNS`.
|
|
1450
|
+
* The cut moves to the next boundary only while the request, trimmed at the
|
|
1451
|
+
* last one, is still over `threshold`. A trimmed request only grows as the
|
|
1452
|
+
* session goes on, so a boundary once taken is kept.
|
|
1453
|
+
*/
|
|
1454
|
+
function trim_cutoff(messages, threshold) {
|
|
1455
|
+
const turn_starts = messages.flatMap((message, i) => message.role === "assistant" ? [i] : []);
|
|
1456
|
+
let cutoff = null;
|
|
1457
|
+
for (let turn = 8; turn <= turn_starts.length - 6; turn += 8) {
|
|
1458
|
+
if (estimate_tokens(cutoff === null ? messages : trim_view(messages, cutoff)) <= threshold) break;
|
|
1459
|
+
cutoff = turn_starts[turn] ?? null;
|
|
1460
|
+
}
|
|
1461
|
+
return cutoff;
|
|
1462
|
+
}
|
|
1463
|
+
/** The `prepare_step` hook that trims a request estimated above `threshold`
|
|
1464
|
+
* tokens. */
|
|
1465
|
+
function step_trim(threshold) {
|
|
1466
|
+
return ({ messages }) => {
|
|
1467
|
+
const cutoff = trim_cutoff(messages, threshold);
|
|
1468
|
+
return cutoff === null ? void 0 : { messages: trim_view(messages, cutoff) };
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
//#endregion
|
|
1259
1472
|
//#region src/workspace_tools.ts
|
|
1260
1473
|
/**
|
|
1261
1474
|
* Shared workspace tools: `contain()` path confinement plus the read-only
|
|
@@ -1719,14 +1932,17 @@ async function run_fetch(raw, ctx, config) {
|
|
|
1719
1932
|
* containment this runs *inside* volley's hardened container against the
|
|
1720
1933
|
* bind-mounted worktree — the container is the isolation boundary, no
|
|
1721
1934
|
* `docker exec` hop — and it is likewise the executor on the
|
|
1722
|
-
* `--allow-unsandboxed-builder` host escape hatch.
|
|
1723
|
-
*
|
|
1935
|
+
* `--allow-unsandboxed-builder` host escape hatch. A command runs with
|
|
1936
|
+
* volley's environment less the seats' keys, so nothing the builder runs can
|
|
1937
|
+
* read the key its seat spends. `status` is null when a signal (the timeout
|
|
1938
|
+
* SIGKILL) killed the process.
|
|
1724
1939
|
*/
|
|
1725
1940
|
function host_bash_executor(cwd) {
|
|
1726
1941
|
return (command, options) => {
|
|
1727
1942
|
const result = spawnSync(command, {
|
|
1728
1943
|
shell: true,
|
|
1729
1944
|
cwd,
|
|
1945
|
+
env: command_env(process.env),
|
|
1730
1946
|
encoding: "utf8",
|
|
1731
1947
|
timeout: options.timeout_ms,
|
|
1732
1948
|
killSignal: "SIGKILL",
|
|
@@ -1864,7 +2080,7 @@ function spawn_capture(cmd, args, options) {
|
|
|
1864
2080
|
return new Promise((resolve_promise, reject) => {
|
|
1865
2081
|
const proc = spawn(cmd, args, {
|
|
1866
2082
|
cwd: options.cwd,
|
|
1867
|
-
|
|
2083
|
+
env: options.env ?? command_env(process.env),
|
|
1868
2084
|
detached: process.platform !== "win32",
|
|
1869
2085
|
stdio: [
|
|
1870
2086
|
"ignore",
|
|
@@ -2070,6 +2286,24 @@ function rejected_check(runner) {
|
|
|
2070
2286
|
};
|
|
2071
2287
|
}
|
|
2072
2288
|
//#endregion
|
|
2289
|
+
//#region src/claude_cli_permissions.ts
|
|
2290
|
+
const BUILDER_ALLOWED_TOOLS = [
|
|
2291
|
+
"Read(/**)",
|
|
2292
|
+
"Edit(/**)",
|
|
2293
|
+
"Bash",
|
|
2294
|
+
"WebFetch"
|
|
2295
|
+
];
|
|
2296
|
+
const CRITIC_ALLOWED_TOOLS = ["Read(/**)"];
|
|
2297
|
+
const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
|
|
2298
|
+
const PERMISSION_BLOCKS = { permissions: {
|
|
2299
|
+
blockWritesOutsideWorkingDirectories: true,
|
|
2300
|
+
blockReadsOutsideWorkingDirectories: true
|
|
2301
|
+
} };
|
|
2302
|
+
/** The CLI flags that turn on `PERMISSION_BLOCKS`. */
|
|
2303
|
+
function settings_args() {
|
|
2304
|
+
return ["--settings", JSON.stringify(PERMISSION_BLOCKS)];
|
|
2305
|
+
}
|
|
2306
|
+
//#endregion
|
|
2073
2307
|
//#region src/cost.ts
|
|
2074
2308
|
/**
|
|
2075
2309
|
* Cost and usage accumulation. All pricing knowledge lives in
|
|
@@ -2125,14 +2359,77 @@ function phase_record(result, model, duration_ms = result.timing?.duration_ms ??
|
|
|
2125
2359
|
...rate !== void 0 ? { throughput: rate } : {}
|
|
2126
2360
|
};
|
|
2127
2361
|
}
|
|
2362
|
+
const NS_PER_MS = 1e6;
|
|
2363
|
+
function ns_field(payload, name) {
|
|
2364
|
+
const value = payload[name];
|
|
2365
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
2366
|
+
}
|
|
2367
|
+
/** A turn's prefill and decode times: Ollama's own durations when it reported
|
|
2368
|
+
* them (in ns), else the time to the first streamed chunk, else none. */
|
|
2369
|
+
function step_split(step) {
|
|
2370
|
+
const reported = step.provider_reported?.["ollama"];
|
|
2371
|
+
if (typeof reported === "object" && reported !== null) {
|
|
2372
|
+
const ollama = reported;
|
|
2373
|
+
const evaluated = ns_field(ollama, "eval_duration");
|
|
2374
|
+
const prompt = ns_field(ollama, "prompt_eval_duration");
|
|
2375
|
+
if (evaluated !== void 0 && prompt !== void 0) return {
|
|
2376
|
+
prefill_ms: Math.round(prompt / NS_PER_MS),
|
|
2377
|
+
decode_ms: Math.round(evaluated / NS_PER_MS),
|
|
2378
|
+
timing_basis: "ollama_reported"
|
|
2379
|
+
};
|
|
2380
|
+
const total = ns_field(ollama, "total_duration");
|
|
2381
|
+
const load = ns_field(ollama, "load_duration");
|
|
2382
|
+
if (evaluated !== void 0 && total !== void 0 && load !== void 0 && total >= load + evaluated) return {
|
|
2383
|
+
prefill_ms: Math.round((total - load - evaluated) / NS_PER_MS),
|
|
2384
|
+
decode_ms: Math.round(evaluated / NS_PER_MS),
|
|
2385
|
+
timing_basis: "ollama_derived"
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
const timing = step.timing;
|
|
2389
|
+
if (timing?.first_chunk_ms !== void 0 && timing.whole_tool_call !== true) return {
|
|
2390
|
+
prefill_ms: timing.first_chunk_ms,
|
|
2391
|
+
decode_ms: Math.max(0, timing.duration_ms - timing.first_chunk_ms),
|
|
2392
|
+
timing_basis: "first_chunk"
|
|
2393
|
+
};
|
|
2394
|
+
return { timing_basis: "none" };
|
|
2395
|
+
}
|
|
2396
|
+
/** How much a tool call's result put into the transcript: its output as the
|
|
2397
|
+
* loop serializes it, or the error text it fed back. */
|
|
2398
|
+
function output_chars(call) {
|
|
2399
|
+
if (call.output === void 0) return call.error?.message.length ?? 0;
|
|
2400
|
+
return typeof call.output === "string" ? call.output.length : (JSON.stringify(call.output) ?? "").length;
|
|
2401
|
+
}
|
|
2402
|
+
/** Each turn of a session as the archive records it, or undefined when the
|
|
2403
|
+
* result has none. */
|
|
2404
|
+
function step_records(result) {
|
|
2405
|
+
if (result.steps.length === 0) return void 0;
|
|
2406
|
+
return result.steps.map((step) => ({
|
|
2407
|
+
index: step.index,
|
|
2408
|
+
input_tokens: step.usage.input_tokens,
|
|
2409
|
+
output_tokens: step.usage.output_tokens,
|
|
2410
|
+
duration_ms: step.timing?.duration_ms ?? 0,
|
|
2411
|
+
...step_split(step),
|
|
2412
|
+
finish_reason: step.finish_reason,
|
|
2413
|
+
...step.truncated === true ? { truncated: true } : {},
|
|
2414
|
+
tool_calls: step.tool_calls.map((call) => ({
|
|
2415
|
+
name: call.name,
|
|
2416
|
+
output_chars: output_chars(call)
|
|
2417
|
+
}))
|
|
2418
|
+
}));
|
|
2419
|
+
}
|
|
2128
2420
|
/** Fold one phase's result into the loop state. Unknown cost records `null`
|
|
2129
|
-
* for the phase and warns once per run — a run never fails over pricing.
|
|
2421
|
+
* for the phase and warns once per run — a run never fails over pricing. The
|
|
2422
|
+
* builder's record also carries its session's turns. */
|
|
2130
2423
|
function accumulate(state, role, result, model, duration_ms) {
|
|
2131
2424
|
const record = phase_record(result, model, duration_ms);
|
|
2425
|
+
const steps = role === "builder" ? step_records(result) : void 0;
|
|
2132
2426
|
const cost = record.cost_usd ?? 0;
|
|
2133
2427
|
return {
|
|
2134
2428
|
...state,
|
|
2135
|
-
[role]:
|
|
2429
|
+
[role]: steps !== void 0 ? {
|
|
2430
|
+
...record,
|
|
2431
|
+
steps
|
|
2432
|
+
} : record,
|
|
2136
2433
|
total_usage: add_usage(state.total_usage, result.usage),
|
|
2137
2434
|
total_cost_usd: state.total_cost_usd + cost,
|
|
2138
2435
|
iteration_cost_usd: state.iteration_cost_usd + cost,
|
|
@@ -2292,6 +2589,7 @@ function report_worktree_fate(config, sink) {
|
|
|
2292
2589
|
function git(cwd, args) {
|
|
2293
2590
|
const result = spawnSync("git", args, {
|
|
2294
2591
|
cwd,
|
|
2592
|
+
env: command_env(process.env),
|
|
2295
2593
|
encoding: "utf8"
|
|
2296
2594
|
});
|
|
2297
2595
|
if (result.error !== void 0) throw config_error(`git is required for --worktree but could not run: ${String(result.error)}`);
|
|
@@ -2318,6 +2616,7 @@ function git_quiet(cwd, args) {
|
|
|
2318
2616
|
try {
|
|
2319
2617
|
const result = spawnSync("git", args, {
|
|
2320
2618
|
cwd,
|
|
2619
|
+
env: command_env(process.env),
|
|
2321
2620
|
encoding: "utf8"
|
|
2322
2621
|
});
|
|
2323
2622
|
return {
|
|
@@ -2529,16 +2828,6 @@ async function with_worktree(options, body) {
|
|
|
2529
2828
|
* complete agentic session in the workspace — Claude Code under `claude_cli`,
|
|
2530
2829
|
* or a model driving volley's tools on any other provider, local or hosted.
|
|
2531
2830
|
*/
|
|
2532
|
-
const BUILDER_ALLOWED_TOOLS = [
|
|
2533
|
-
"Read",
|
|
2534
|
-
"Write",
|
|
2535
|
-
"Edit",
|
|
2536
|
-
"MultiEdit",
|
|
2537
|
-
"Bash",
|
|
2538
|
-
"Grep",
|
|
2539
|
-
"Glob",
|
|
2540
|
-
"WebFetch"
|
|
2541
|
-
];
|
|
2542
2831
|
function compose_builder_system() {
|
|
2543
2832
|
return [
|
|
2544
2833
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
@@ -2600,6 +2889,14 @@ function check_hint(config) {
|
|
|
2600
2889
|
if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
|
|
2601
2890
|
return "the project's tests";
|
|
2602
2891
|
}
|
|
2892
|
+
/** The builder's `prepare_step`: the countdown, after the history trim when
|
|
2893
|
+
* `--builder-trim-history` turns it on, so the countdown's note still goes
|
|
2894
|
+
* last. */
|
|
2895
|
+
function builder_prepare_step(config) {
|
|
2896
|
+
const countdown = step_countdown(config.builder_max_steps, check_hint(config));
|
|
2897
|
+
if (config.builder_trim_history === null) return countdown;
|
|
2898
|
+
return compose_prepare_steps([step_trim(config.builder_trim_history), countdown]);
|
|
2899
|
+
}
|
|
2603
2900
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
2604
2901
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
2605
2902
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
@@ -2607,7 +2904,8 @@ function check_hint(config) {
|
|
|
2607
2904
|
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
2608
2905
|
* workspace tool surface (or the set the caller injected in its place, with
|
|
2609
2906
|
* the handler that answers its approvals), the per-call loop knobs, the step
|
|
2610
|
-
* countdown (`./builder/countdown.ts`)
|
|
2907
|
+
* countdown (`./builder/countdown.ts`) and the history trim when it's on
|
|
2908
|
+
* (`./builder/trim.ts`), the output cap when one is set, and
|
|
2611
2909
|
* **no** schema (it produces a workspace, not a verdict); per-call values win
|
|
2612
2910
|
* over engine defaults. */
|
|
2613
2911
|
function builder_tool_options(deps) {
|
|
@@ -2615,7 +2913,11 @@ function builder_tool_options(deps) {
|
|
|
2615
2913
|
const bash_executor = deps.bash_executor ?? null;
|
|
2616
2914
|
if (config.builder_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
2617
2915
|
allowed_tools: [...BUILDER_ALLOWED_TOOLS],
|
|
2618
|
-
extra_args: [
|
|
2916
|
+
extra_args: [
|
|
2917
|
+
"--permission-mode",
|
|
2918
|
+
config.builder_permission_mode,
|
|
2919
|
+
...settings_args()
|
|
2920
|
+
]
|
|
2619
2921
|
} } };
|
|
2620
2922
|
return {
|
|
2621
2923
|
tools: deps.tools === void 0 ? builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}) : [...deps.tools],
|
|
@@ -2624,7 +2926,7 @@ function builder_tool_options(deps) {
|
|
|
2624
2926
|
tool_error_policy: "feed_back",
|
|
2625
2927
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
2626
2928
|
max_tool_calls_per_step: config.builder_max_tool_calls,
|
|
2627
|
-
prepare_step:
|
|
2929
|
+
prepare_step: builder_prepare_step(config),
|
|
2628
2930
|
...config.builder_max_output_tokens !== null ? {
|
|
2629
2931
|
max_tokens: config.builder_max_output_tokens,
|
|
2630
2932
|
truncated_turn_policy: "continue"
|
|
@@ -2885,12 +3187,6 @@ function compose_critic_prompt(input) {
|
|
|
2885
3187
|
* structured verdict. The
|
|
2886
3188
|
* harness — not the critic — writes the run's `feedback.md` and `verdict`.
|
|
2887
3189
|
*/
|
|
2888
|
-
const CRITIC_ALLOWED_TOOLS = [
|
|
2889
|
-
"Read",
|
|
2890
|
-
"Grep",
|
|
2891
|
-
"Glob"
|
|
2892
|
-
];
|
|
2893
|
-
const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
|
|
2894
3190
|
/** A critic that holds tools isn't constrain-decoded, so nothing stops it
|
|
2895
3191
|
* answering null for the feedback or the unmet list, as GLM did on approving.
|
|
2896
3192
|
* Both fields allow null and read it as empty, so that answer is a verdict
|
|
@@ -2903,7 +3199,8 @@ const verdict_schema = z.object({
|
|
|
2903
3199
|
unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
|
|
2904
3200
|
});
|
|
2905
3201
|
/** Read-only tool wiring per provider. The claude_cli critic is confined at
|
|
2906
|
-
* the CLI permission layer (allowlist + explicit disallow
|
|
3202
|
+
* the CLI permission layer (allowlist + explicit disallow, and the settings
|
|
3203
|
+
* blocks that keep its reads in the build root); every other critic,
|
|
2907
3204
|
* local or `openrouter`, gets volley's own workspace-scoped read-only tools (or
|
|
2908
3205
|
* the set the caller injected in their place) and no write path at all.
|
|
2909
3206
|
*
|
|
@@ -2925,7 +3222,11 @@ const verdict_schema = z.object({
|
|
|
2925
3222
|
function critic_tool_options(config, tools) {
|
|
2926
3223
|
if (config.critic_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
2927
3224
|
allowed_tools: [...CRITIC_ALLOWED_TOOLS],
|
|
2928
|
-
extra_args: [
|
|
3225
|
+
extra_args: [
|
|
3226
|
+
"--disallowedTools",
|
|
3227
|
+
CRITIC_DISALLOWED_TOOLS,
|
|
3228
|
+
...settings_args()
|
|
3229
|
+
]
|
|
2929
3230
|
} } };
|
|
2930
3231
|
return {
|
|
2931
3232
|
tools: tools === void 0 ? read_only_tools(build_root(config.workspace, config.worktree)) : [...tools],
|
|
@@ -3277,7 +3578,8 @@ function phase_summary(record) {
|
|
|
3277
3578
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
3278
3579
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
3279
3580
|
...record.degrade_cause !== void 0 ? { degrade_cause: record.degrade_cause } : {},
|
|
3280
|
-
...record.throughput !== void 0 ? { throughput: record.throughput } : {}
|
|
3581
|
+
...record.throughput !== void 0 ? { throughput: record.throughput } : {},
|
|
3582
|
+
...record.steps !== void 0 ? { steps: record.steps } : {}
|
|
3281
3583
|
};
|
|
3282
3584
|
}
|
|
3283
3585
|
/** Archive one completed iteration: verdict files, check artifacts, and the
|
|
@@ -3528,13 +3830,14 @@ function report_changes(config, renderer, changes) {
|
|
|
3528
3830
|
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)." : ""));
|
|
3529
3831
|
}
|
|
3530
3832
|
/** Run the configured check, checkride or a command, with the environment
|
|
3531
|
-
* the caller set for it, else volley's own
|
|
3833
|
+
* the caller set for it, else volley's own less the seats' keys, since the
|
|
3834
|
+
* check runs code the builder wrote. */
|
|
3532
3835
|
async function execute_check(deps, abort) {
|
|
3533
3836
|
const { config } = deps;
|
|
3534
3837
|
const root = build_root(config.workspace, config.worktree);
|
|
3535
3838
|
const options = {
|
|
3536
3839
|
abort,
|
|
3537
|
-
|
|
3840
|
+
env: deps.check?.env ?? command_env(process.env)
|
|
3538
3841
|
};
|
|
3539
3842
|
return config.check_resolved === "checkride" ? run_checkride({
|
|
3540
3843
|
...options,
|