@robmclarty/volley 0.10.1 → 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 +19 -0
- package/README.md +65 -9
- package/dist/cli.js +395 -33
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +40 -0
- package/dist/index.js +301 -25
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ type VolleyConfig = {
|
|
|
51
51
|
builder_max_steps?: number;
|
|
52
52
|
builder_max_tool_calls?: number;
|
|
53
53
|
builder_max_output_tokens?: number;
|
|
54
|
+
builder_trim_history?: number;
|
|
54
55
|
allow_unsandboxed_builder?: boolean;
|
|
55
56
|
critic_model?: string;
|
|
56
57
|
critic_provider?: CriticProvider;
|
|
@@ -114,6 +115,11 @@ type ResolvedConfig = {
|
|
|
114
115
|
* one step, not the session: fascicle feeds back that it hit the limit and
|
|
115
116
|
* the loop goes on. Ignored by the `claude_cli` builder. */
|
|
116
117
|
builder_max_output_tokens: number | null;
|
|
118
|
+
/** The estimated request size, in tokens, above which a tool-loop builder's
|
|
119
|
+
* turns send older tool results as one-line stubs (#7), or null to send
|
|
120
|
+
* every turn whole. Null by default, until a long local session is shown
|
|
121
|
+
* to run faster with it on. Ignored by the `claude_cli` builder. */
|
|
122
|
+
builder_trim_history: number | null;
|
|
117
123
|
/** Operator opt-out permitting a non-`claude_cli` builder to run without a
|
|
118
124
|
* sandbox — it gets a real host bash. Refused by default. */
|
|
119
125
|
allow_unsandboxed_builder: boolean;
|
|
@@ -285,6 +291,40 @@ type PhaseRecord = {
|
|
|
285
291
|
* another way. A thinking seat needs ai-sdk-ollama 4.3.0 or later, which
|
|
286
292
|
* streams the reasoning even though volley never sets `think`. */
|
|
287
293
|
throughput?: Throughput;
|
|
294
|
+
/** Each turn of a builder session, in order, so a long session's growth
|
|
295
|
+
* and where its time went can be read off the archive (#7). Set on a builder
|
|
296
|
+
* record whose provider reported steps (every tool-loop builder); absent on
|
|
297
|
+
* the critic and on a session with none. Additive. */
|
|
298
|
+
steps?: BuilderStepRecord[];
|
|
299
|
+
};
|
|
300
|
+
/** Where a builder turn's prefill and decode times came from.
|
|
301
|
+
* `ollama_reported`: Ollama's own `prompt_eval_duration` and `eval_duration`
|
|
302
|
+
* (native transport). `ollama_derived`: prefill as `total_duration` less
|
|
303
|
+
* `load_duration` and `eval_duration`, the ai_sdk transport's metadata, which
|
|
304
|
+
* leaves `prompt_eval_duration` out; slightly high, since the total includes
|
|
305
|
+
* Ollama's own overhead. `first_chunk`: prefill as the time to the first
|
|
306
|
+
* streamed chunk, decode as the rest of the round trip. `none`: no split. */
|
|
307
|
+
type StepTimingBasis = 'ollama_reported' | 'ollama_derived' | 'first_chunk' | 'none';
|
|
308
|
+
/** One turn of a builder session. */
|
|
309
|
+
type BuilderStepRecord = {
|
|
310
|
+
index: number;
|
|
311
|
+
/** The whole request this turn sent, so the session's growth reads as the
|
|
312
|
+
* sequence of these. */
|
|
313
|
+
input_tokens: number;
|
|
314
|
+
output_tokens: number;
|
|
315
|
+
/** The turn's provider round trip, tool execution excluded. */
|
|
316
|
+
duration_ms: number;
|
|
317
|
+
prefill_ms?: number;
|
|
318
|
+
decode_ms?: number;
|
|
319
|
+
timing_basis: StepTimingBasis;
|
|
320
|
+
finish_reason: FinishReason;
|
|
321
|
+
truncated?: true;
|
|
322
|
+
/** Each tool call the turn made, and how many characters its result put
|
|
323
|
+
* into the transcript every later turn resends. */
|
|
324
|
+
tool_calls: Array<{
|
|
325
|
+
name: string;
|
|
326
|
+
output_chars: number;
|
|
327
|
+
}>;
|
|
288
328
|
};
|
|
289
329
|
type HaltReason = 'cost_cap' | 'gate_edit' | null;
|
|
290
330
|
/** Carry-state threaded through the fascicle loop. */
|
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";
|
|
@@ -223,6 +223,7 @@ function create_volley_engine(options) {
|
|
|
223
223
|
default_cwd: options.workspace,
|
|
224
224
|
setting_sources: ["project", "local"],
|
|
225
225
|
auth_mode,
|
|
226
|
+
omit_env: SEAT_CREDENTIAL_VARS,
|
|
226
227
|
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
227
228
|
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
228
229
|
} };
|
|
@@ -735,6 +736,7 @@ function write_resolved_config(config, env = process.env) {
|
|
|
735
736
|
builder_max_steps: config.builder_max_steps,
|
|
736
737
|
builder_max_tool_calls: config.builder_max_tool_calls,
|
|
737
738
|
builder_max_output_tokens: config.builder_max_output_tokens,
|
|
739
|
+
builder_trim_history: config.builder_trim_history,
|
|
738
740
|
critic_model: config.critic_model,
|
|
739
741
|
critic_provider: config.critic_provider,
|
|
740
742
|
critic_url: config.critic_url,
|
|
@@ -959,6 +961,7 @@ const CRITIQUE_REFUSED = [
|
|
|
959
961
|
["builder_max_steps", "--builder-max-steps"],
|
|
960
962
|
["builder_max_tool_calls", "--builder-max-tool-calls"],
|
|
961
963
|
["builder_max_output_tokens", "--builder-max-output-tokens"],
|
|
964
|
+
["builder_trim_history", "--builder-trim-history"],
|
|
962
965
|
["builder_permission_mode", "--builder-permission-mode"],
|
|
963
966
|
["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
|
|
964
967
|
["sandbox_image", "--sandbox-image"],
|
|
@@ -998,6 +1001,12 @@ function builder_max_output_tokens_count(provider) {
|
|
|
998
1001
|
fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
|
|
999
1002
|
};
|
|
1000
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
|
+
};
|
|
1001
1010
|
/** A builder count's flag or config value wins over its env var, which wins
|
|
1002
1011
|
* over the default, and either must be a positive integer. Local builder
|
|
1003
1012
|
* only; ignored by the `claude_cli` arm. */
|
|
@@ -1132,6 +1141,7 @@ function resolve_config(raw, options = {}) {
|
|
|
1132
1141
|
const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
|
|
1133
1142
|
const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
|
|
1134
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);
|
|
1135
1145
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
1136
1146
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
1137
1147
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
@@ -1170,6 +1180,7 @@ function resolve_config(raw, options = {}) {
|
|
|
1170
1180
|
builder_max_steps,
|
|
1171
1181
|
builder_max_tool_calls,
|
|
1172
1182
|
builder_max_output_tokens,
|
|
1183
|
+
builder_trim_history,
|
|
1173
1184
|
allow_unsandboxed_builder,
|
|
1174
1185
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
1175
1186
|
critic_provider,
|
|
@@ -1277,6 +1288,187 @@ function step_countdown(max_steps, check) {
|
|
|
1277
1288
|
};
|
|
1278
1289
|
}
|
|
1279
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
|
|
1280
1472
|
//#region src/workspace_tools.ts
|
|
1281
1473
|
/**
|
|
1282
1474
|
* Shared workspace tools: `contain()` path confinement plus the read-only
|
|
@@ -2094,6 +2286,24 @@ function rejected_check(runner) {
|
|
|
2094
2286
|
};
|
|
2095
2287
|
}
|
|
2096
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
|
|
2097
2307
|
//#region src/cost.ts
|
|
2098
2308
|
/**
|
|
2099
2309
|
* Cost and usage accumulation. All pricing knowledge lives in
|
|
@@ -2149,14 +2359,77 @@ function phase_record(result, model, duration_ms = result.timing?.duration_ms ??
|
|
|
2149
2359
|
...rate !== void 0 ? { throughput: rate } : {}
|
|
2150
2360
|
};
|
|
2151
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
|
+
}
|
|
2152
2420
|
/** Fold one phase's result into the loop state. Unknown cost records `null`
|
|
2153
|
-
* 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. */
|
|
2154
2423
|
function accumulate(state, role, result, model, duration_ms) {
|
|
2155
2424
|
const record = phase_record(result, model, duration_ms);
|
|
2425
|
+
const steps = role === "builder" ? step_records(result) : void 0;
|
|
2156
2426
|
const cost = record.cost_usd ?? 0;
|
|
2157
2427
|
return {
|
|
2158
2428
|
...state,
|
|
2159
|
-
[role]:
|
|
2429
|
+
[role]: steps !== void 0 ? {
|
|
2430
|
+
...record,
|
|
2431
|
+
steps
|
|
2432
|
+
} : record,
|
|
2160
2433
|
total_usage: add_usage(state.total_usage, result.usage),
|
|
2161
2434
|
total_cost_usd: state.total_cost_usd + cost,
|
|
2162
2435
|
iteration_cost_usd: state.iteration_cost_usd + cost,
|
|
@@ -2555,16 +2828,6 @@ async function with_worktree(options, body) {
|
|
|
2555
2828
|
* complete agentic session in the workspace — Claude Code under `claude_cli`,
|
|
2556
2829
|
* or a model driving volley's tools on any other provider, local or hosted.
|
|
2557
2830
|
*/
|
|
2558
|
-
const BUILDER_ALLOWED_TOOLS = [
|
|
2559
|
-
"Read",
|
|
2560
|
-
"Write",
|
|
2561
|
-
"Edit",
|
|
2562
|
-
"MultiEdit",
|
|
2563
|
-
"Bash",
|
|
2564
|
-
"Grep",
|
|
2565
|
-
"Glob",
|
|
2566
|
-
"WebFetch"
|
|
2567
|
-
];
|
|
2568
2831
|
function compose_builder_system() {
|
|
2569
2832
|
return [
|
|
2570
2833
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
@@ -2626,6 +2889,14 @@ function check_hint(config) {
|
|
|
2626
2889
|
if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
|
|
2627
2890
|
return "the project's tests";
|
|
2628
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
|
+
}
|
|
2629
2900
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
2630
2901
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
2631
2902
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
@@ -2633,7 +2904,8 @@ function check_hint(config) {
|
|
|
2633
2904
|
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
2634
2905
|
* workspace tool surface (or the set the caller injected in its place, with
|
|
2635
2906
|
* the handler that answers its approvals), the per-call loop knobs, the step
|
|
2636
|
-
* 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
|
|
2637
2909
|
* **no** schema (it produces a workspace, not a verdict); per-call values win
|
|
2638
2910
|
* over engine defaults. */
|
|
2639
2911
|
function builder_tool_options(deps) {
|
|
@@ -2641,7 +2913,11 @@ function builder_tool_options(deps) {
|
|
|
2641
2913
|
const bash_executor = deps.bash_executor ?? null;
|
|
2642
2914
|
if (config.builder_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
2643
2915
|
allowed_tools: [...BUILDER_ALLOWED_TOOLS],
|
|
2644
|
-
extra_args: [
|
|
2916
|
+
extra_args: [
|
|
2917
|
+
"--permission-mode",
|
|
2918
|
+
config.builder_permission_mode,
|
|
2919
|
+
...settings_args()
|
|
2920
|
+
]
|
|
2645
2921
|
} } };
|
|
2646
2922
|
return {
|
|
2647
2923
|
tools: deps.tools === void 0 ? builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}) : [...deps.tools],
|
|
@@ -2650,7 +2926,7 @@ function builder_tool_options(deps) {
|
|
|
2650
2926
|
tool_error_policy: "feed_back",
|
|
2651
2927
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
2652
2928
|
max_tool_calls_per_step: config.builder_max_tool_calls,
|
|
2653
|
-
prepare_step:
|
|
2929
|
+
prepare_step: builder_prepare_step(config),
|
|
2654
2930
|
...config.builder_max_output_tokens !== null ? {
|
|
2655
2931
|
max_tokens: config.builder_max_output_tokens,
|
|
2656
2932
|
truncated_turn_policy: "continue"
|
|
@@ -2911,12 +3187,6 @@ function compose_critic_prompt(input) {
|
|
|
2911
3187
|
* structured verdict. The
|
|
2912
3188
|
* harness — not the critic — writes the run's `feedback.md` and `verdict`.
|
|
2913
3189
|
*/
|
|
2914
|
-
const CRITIC_ALLOWED_TOOLS = [
|
|
2915
|
-
"Read",
|
|
2916
|
-
"Grep",
|
|
2917
|
-
"Glob"
|
|
2918
|
-
];
|
|
2919
|
-
const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
|
|
2920
3190
|
/** A critic that holds tools isn't constrain-decoded, so nothing stops it
|
|
2921
3191
|
* answering null for the feedback or the unmet list, as GLM did on approving.
|
|
2922
3192
|
* Both fields allow null and read it as empty, so that answer is a verdict
|
|
@@ -2929,7 +3199,8 @@ const verdict_schema = z.object({
|
|
|
2929
3199
|
unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
|
|
2930
3200
|
});
|
|
2931
3201
|
/** Read-only tool wiring per provider. The claude_cli critic is confined at
|
|
2932
|
-
* 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,
|
|
2933
3204
|
* local or `openrouter`, gets volley's own workspace-scoped read-only tools (or
|
|
2934
3205
|
* the set the caller injected in their place) and no write path at all.
|
|
2935
3206
|
*
|
|
@@ -2951,7 +3222,11 @@ const verdict_schema = z.object({
|
|
|
2951
3222
|
function critic_tool_options(config, tools) {
|
|
2952
3223
|
if (config.critic_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
2953
3224
|
allowed_tools: [...CRITIC_ALLOWED_TOOLS],
|
|
2954
|
-
extra_args: [
|
|
3225
|
+
extra_args: [
|
|
3226
|
+
"--disallowedTools",
|
|
3227
|
+
CRITIC_DISALLOWED_TOOLS,
|
|
3228
|
+
...settings_args()
|
|
3229
|
+
]
|
|
2955
3230
|
} } };
|
|
2956
3231
|
return {
|
|
2957
3232
|
tools: tools === void 0 ? read_only_tools(build_root(config.workspace, config.worktree)) : [...tools],
|
|
@@ -3303,7 +3578,8 @@ function phase_summary(record) {
|
|
|
3303
3578
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
3304
3579
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
3305
3580
|
...record.degrade_cause !== void 0 ? { degrade_cause: record.degrade_cause } : {},
|
|
3306
|
-
...record.throughput !== void 0 ? { throughput: record.throughput } : {}
|
|
3581
|
+
...record.throughput !== void 0 ? { throughput: record.throughput } : {},
|
|
3582
|
+
...record.steps !== void 0 ? { steps: record.steps } : {}
|
|
3307
3583
|
};
|
|
3308
3584
|
}
|
|
3309
3585
|
/** Archive one completed iteration: verdict files, check artifacts, and the
|