@robmclarty/volley 0.9.0 → 0.10.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 +22 -0
- package/README.md +57 -16
- package/dist/cli.js +562 -270
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +31 -4
- package/dist/index.js +441 -242
- 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,34 @@ 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
|
|
76
104
|
//#region src/workspace_tools.ts
|
|
77
105
|
/**
|
|
78
106
|
* Shared workspace tools: `contain()` path confinement plus the read-only
|
|
@@ -183,6 +211,7 @@ function read_only_tools(workspace) {
|
|
|
183
211
|
return [
|
|
184
212
|
{
|
|
185
213
|
name: "read_file",
|
|
214
|
+
read_only: true,
|
|
186
215
|
description: `Read a UTF-8 text file from the workspace. Returns the file content (truncated at ${String(READ_FILE_MAX_BYTES)} bytes).`,
|
|
187
216
|
input_schema: read_file_input,
|
|
188
217
|
execute: (raw) => {
|
|
@@ -193,6 +222,7 @@ function read_only_tools(workspace) {
|
|
|
193
222
|
},
|
|
194
223
|
{
|
|
195
224
|
name: "search_files",
|
|
225
|
+
read_only: true,
|
|
196
226
|
description: `Search workspace files for lines matching a regular expression. Returns up to ${String(200)} "relative/path:line: text" hits.`,
|
|
197
227
|
input_schema: search_files_input,
|
|
198
228
|
execute: (raw) => {
|
|
@@ -222,6 +252,7 @@ function read_only_tools(workspace) {
|
|
|
222
252
|
},
|
|
223
253
|
{
|
|
224
254
|
name: "list_files",
|
|
255
|
+
read_only: true,
|
|
225
256
|
description: "List files under a workspace directory (recursively; skips node_modules, .git, and other build/output directories).",
|
|
226
257
|
input_schema: list_files_input,
|
|
227
258
|
execute: (raw) => {
|
|
@@ -642,6 +673,273 @@ function finish_tool() {
|
|
|
642
673
|
};
|
|
643
674
|
}
|
|
644
675
|
//#endregion
|
|
676
|
+
//#region src/types.ts
|
|
677
|
+
function config_error(message) {
|
|
678
|
+
return Object.assign(new Error(message), { kind: "config_error" });
|
|
679
|
+
}
|
|
680
|
+
function check_error(message) {
|
|
681
|
+
return Object.assign(new Error(message), { kind: "check_error" });
|
|
682
|
+
}
|
|
683
|
+
/** Wraps a provider/schema failure with the phase and iteration it hit. */
|
|
684
|
+
function phase_error(phase, iteration, cause) {
|
|
685
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
686
|
+
return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
|
|
687
|
+
kind: "phase_error",
|
|
688
|
+
phase,
|
|
689
|
+
iteration,
|
|
690
|
+
cause
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
function error_kind(err) {
|
|
694
|
+
if (typeof err === "object" && err !== null && "kind" in err) {
|
|
695
|
+
const kind = err.kind;
|
|
696
|
+
return typeof kind === "string" ? kind : null;
|
|
697
|
+
}
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/check/checkride.ts
|
|
702
|
+
/**
|
|
703
|
+
* checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
|
|
704
|
+
* `.check/summary.json` contract (`schema_version: 1`), and collect raw
|
|
705
|
+
* failing-slot artifacts for the critic — the critic reads what the tool
|
|
706
|
+
* actually said, not a normalized digest. The digest checkride writes on a red
|
|
707
|
+
* run is kept beside them for the next builder, which needs a short list of
|
|
708
|
+
* what to fix more than the whole of every slot's output.
|
|
709
|
+
*/
|
|
710
|
+
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
711
|
+
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
712
|
+
/** How long a check's output may stay open after its shell exits, while
|
|
713
|
+
* something that escaped its process group still holds it, before the reads
|
|
714
|
+
* stop so the check can finish. */
|
|
715
|
+
const OUTPUT_GRACE_MS = 2e3;
|
|
716
|
+
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
717
|
+
* and anything the check started in the background, sit in the shell's group,
|
|
718
|
+
* so killing the shell alone would leave them running. Windows has no process
|
|
719
|
+
* groups to signal, so there only the shell goes. */
|
|
720
|
+
function kill_group(proc) {
|
|
721
|
+
if (proc.pid === void 0) return;
|
|
722
|
+
try {
|
|
723
|
+
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
724
|
+
else process.kill(-proc.pid, "SIGKILL");
|
|
725
|
+
} catch {}
|
|
726
|
+
}
|
|
727
|
+
/** Run a check to its end in a process group of its own, capturing its
|
|
728
|
+
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
729
|
+
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
730
|
+
* no test workers or servers running. */
|
|
731
|
+
function spawn_capture(cmd, args, options) {
|
|
732
|
+
const { abort } = options;
|
|
733
|
+
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
734
|
+
return new Promise((resolve_promise, reject) => {
|
|
735
|
+
const proc = spawn(cmd, args, {
|
|
736
|
+
cwd: options.cwd,
|
|
737
|
+
...options.env !== void 0 ? { env: options.env } : {},
|
|
738
|
+
detached: process.platform !== "win32",
|
|
739
|
+
stdio: [
|
|
740
|
+
"ignore",
|
|
741
|
+
"pipe",
|
|
742
|
+
"pipe"
|
|
743
|
+
]
|
|
744
|
+
});
|
|
745
|
+
let stdout = "";
|
|
746
|
+
let stderr = "";
|
|
747
|
+
let grace;
|
|
748
|
+
const on_abort = () => {
|
|
749
|
+
kill_group(proc);
|
|
750
|
+
reject(new aborted_error("check aborted"));
|
|
751
|
+
};
|
|
752
|
+
const finish = () => {
|
|
753
|
+
clearTimeout(grace);
|
|
754
|
+
abort?.removeEventListener("abort", on_abort);
|
|
755
|
+
};
|
|
756
|
+
proc.stdout.on("data", (chunk) => {
|
|
757
|
+
stdout += chunk.toString();
|
|
758
|
+
});
|
|
759
|
+
proc.stderr.on("data", (chunk) => {
|
|
760
|
+
stderr += chunk.toString();
|
|
761
|
+
});
|
|
762
|
+
proc.on("error", (err) => {
|
|
763
|
+
finish();
|
|
764
|
+
reject(check_error(`check command failed to start: ${err.message}`));
|
|
765
|
+
});
|
|
766
|
+
proc.on("exit", () => {
|
|
767
|
+
kill_group(proc);
|
|
768
|
+
grace = setTimeout(() => {
|
|
769
|
+
proc.stdout.destroy();
|
|
770
|
+
proc.stderr.destroy();
|
|
771
|
+
}, OUTPUT_GRACE_MS);
|
|
772
|
+
});
|
|
773
|
+
proc.on("close", (code) => {
|
|
774
|
+
finish();
|
|
775
|
+
resolve_promise({
|
|
776
|
+
exit_code: code,
|
|
777
|
+
stdout,
|
|
778
|
+
stderr
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
abort?.addEventListener("abort", on_abort, { once: true });
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
785
|
+
* command's stdout (pnpm 11's dep-verify prints an "Already up to date"
|
|
786
|
+
* install line ahead of the JSON), so the object is extracted between the
|
|
787
|
+
* first `{` and the last `}` rather than parsed from byte 0. An unexpected
|
|
788
|
+
* schema_version warns and still parses; no JSON object at all is a harness
|
|
789
|
+
* error (exit 4). */
|
|
790
|
+
function parse_checkride_summary(stdout) {
|
|
791
|
+
const start = stdout.indexOf("{");
|
|
792
|
+
const end = stdout.lastIndexOf("}");
|
|
793
|
+
let raw;
|
|
794
|
+
try {
|
|
795
|
+
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
796
|
+
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
797
|
+
} catch {
|
|
798
|
+
throw check_error("checkride --json produced unparseable stdout");
|
|
799
|
+
}
|
|
800
|
+
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
|
|
801
|
+
const summary = raw;
|
|
802
|
+
return {
|
|
803
|
+
summary,
|
|
804
|
+
warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
/** Parse the summary from stdout, falling back to the on-disk contract:
|
|
808
|
+
* `.check/summary.json` is what checkride documents; stdout is its mirror and
|
|
809
|
+
* wrappers may talk over it beyond what the tolerant slice can recover. */
|
|
810
|
+
function parse_summary_with_fallback(stdout, workspace) {
|
|
811
|
+
try {
|
|
812
|
+
return parse_checkride_summary(stdout);
|
|
813
|
+
} catch (err) {
|
|
814
|
+
const file = join(workspace, ".check", "summary.json");
|
|
815
|
+
if (!existsSync(file)) throw err;
|
|
816
|
+
return parse_checkride_summary(readFileSync(file, "utf8"));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
function failing_checks(summary) {
|
|
820
|
+
return summary.checks.filter((c) => !c.ok && c.skipped !== true);
|
|
821
|
+
}
|
|
822
|
+
/** Whether `path` sits under `dir`. */
|
|
823
|
+
function within(dir, path) {
|
|
824
|
+
const from_dir = relative(dir, path);
|
|
825
|
+
return from_dir !== ".." && !from_dir.startsWith(`..${sep}`) && !isAbsolute(from_dir);
|
|
826
|
+
}
|
|
827
|
+
/** Per failing slot: the raw artifact (`<output_file>` when present, else
|
|
828
|
+
* `<slot>.stdout.txt`) in `check_dir`, a workspace's `.check/` or an archived
|
|
829
|
+
* iteration's copy of it, truncated with a pointer to the file. The names come
|
|
830
|
+
* from a summary the builder could have written, so a path that leaves
|
|
831
|
+
* `check_dir` has no artifact, rather than putting a file from elsewhere in
|
|
832
|
+
* front of a model. */
|
|
833
|
+
function read_failing_artifacts(check_dir, failing) {
|
|
834
|
+
return failing.map((check) => {
|
|
835
|
+
const file = check.output_file ?? `${check.name}.stdout.txt`;
|
|
836
|
+
const path = join(check_dir, file);
|
|
837
|
+
if (!within(check_dir, path) || !existsSync(path) || !statSync(path).isFile()) return {
|
|
838
|
+
slot: check.name,
|
|
839
|
+
path: null,
|
|
840
|
+
content: "(no artifact found)",
|
|
841
|
+
truncated: false
|
|
842
|
+
};
|
|
843
|
+
const raw = readFileSync(path, "utf8");
|
|
844
|
+
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
845
|
+
return {
|
|
846
|
+
slot: check.name,
|
|
847
|
+
path,
|
|
848
|
+
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
849
|
+
truncated
|
|
850
|
+
};
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
/** checkride's digest of a red run (`.check/digest.md`, written under
|
|
854
|
+
* `--digest` and removed on a green one), or undefined when there is none. It
|
|
855
|
+
* is capped by checkride itself, at 8 kB. */
|
|
856
|
+
function read_digest(workspace) {
|
|
857
|
+
const path = join(workspace, ".check", "digest.md");
|
|
858
|
+
return existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
859
|
+
}
|
|
860
|
+
async function run_checkride(opts) {
|
|
861
|
+
const { workspace, ...spawn_options } = opts;
|
|
862
|
+
const proc = await spawn_capture("pnpm", [
|
|
863
|
+
"exec",
|
|
864
|
+
"checkride",
|
|
865
|
+
"--json",
|
|
866
|
+
"--digest"
|
|
867
|
+
], {
|
|
868
|
+
...spawn_options,
|
|
869
|
+
cwd: workspace
|
|
870
|
+
});
|
|
871
|
+
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
872
|
+
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
873
|
+
const failing = failing_checks(summary);
|
|
874
|
+
const digest = summary.ok ? void 0 : read_digest(opts.workspace);
|
|
875
|
+
return {
|
|
876
|
+
ran: true,
|
|
877
|
+
runner: "checkride",
|
|
878
|
+
ok: summary.ok,
|
|
879
|
+
exit_code: proc.exit_code,
|
|
880
|
+
duration_ms: summary.total_duration_ms,
|
|
881
|
+
failing_slots: failing.map((c) => c.name),
|
|
882
|
+
detail: read_failing_artifacts(join(opts.workspace, ".check"), failing),
|
|
883
|
+
summary,
|
|
884
|
+
...digest !== void 0 ? { digest } : {}
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
//#endregion
|
|
888
|
+
//#region src/check/command.ts
|
|
889
|
+
/**
|
|
890
|
+
* Generic command runner: `sh -c <command>`, combined
|
|
891
|
+
* output captured, exit-0 semantics. Command-not-found is an operator error
|
|
892
|
+
* (exit 4), not an iterable failure.
|
|
893
|
+
*/
|
|
894
|
+
async function run_command_check(opts) {
|
|
895
|
+
const { command, workspace, ...spawn_options } = opts;
|
|
896
|
+
const started = Date.now();
|
|
897
|
+
const proc = await spawn_capture("sh", ["-c", command], {
|
|
898
|
+
...spawn_options,
|
|
899
|
+
cwd: workspace
|
|
900
|
+
});
|
|
901
|
+
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
902
|
+
const log = `${proc.stdout}${proc.stderr}`;
|
|
903
|
+
return {
|
|
904
|
+
ran: true,
|
|
905
|
+
runner: "command",
|
|
906
|
+
ok: proc.exit_code === 0,
|
|
907
|
+
exit_code: proc.exit_code,
|
|
908
|
+
duration_ms: Date.now() - started,
|
|
909
|
+
failing_slots: [],
|
|
910
|
+
detail: [],
|
|
911
|
+
log
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
function skipped_check(reason) {
|
|
915
|
+
return {
|
|
916
|
+
ran: false,
|
|
917
|
+
runner: "none",
|
|
918
|
+
ok: reason === "none",
|
|
919
|
+
exit_code: null,
|
|
920
|
+
duration_ms: 0,
|
|
921
|
+
failing_slots: [],
|
|
922
|
+
detail: []
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
/** What a rejected check says in place of its output. */
|
|
926
|
+
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
927
|
+
/** A check the user rejected when it asked to run: recorded as a failed
|
|
928
|
+
* check, with the refusal as its output, so a round can't converge on work
|
|
929
|
+
* nothing checked, and the critic hears why there's no result. */
|
|
930
|
+
function rejected_check(runner) {
|
|
931
|
+
return {
|
|
932
|
+
ran: true,
|
|
933
|
+
runner,
|
|
934
|
+
ok: false,
|
|
935
|
+
exit_code: null,
|
|
936
|
+
duration_ms: 0,
|
|
937
|
+
failing_slots: [],
|
|
938
|
+
detail: [],
|
|
939
|
+
log: CHECK_REJECTED
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
//#endregion
|
|
645
943
|
//#region src/cost.ts
|
|
646
944
|
/**
|
|
647
945
|
* Cost and usage accumulation. All pricing knowledge lives in
|
|
@@ -681,6 +979,7 @@ function cost_source_of(result) {
|
|
|
681
979
|
* its whole degradation ladder. */
|
|
682
980
|
function phase_record(result, model, duration_ms = result.timing?.duration_ms ?? 0) {
|
|
683
981
|
const rate = throughput(result);
|
|
982
|
+
const truncated = result.steps.filter((step) => step.truncated === true).length;
|
|
684
983
|
return {
|
|
685
984
|
provider: result.model_resolved.provider,
|
|
686
985
|
model,
|
|
@@ -692,6 +991,7 @@ function phase_record(result, model, duration_ms = result.timing?.duration_ms ??
|
|
|
692
991
|
finish_reason: result.finish_reason,
|
|
693
992
|
tool_calls: result.tool_calls.length,
|
|
694
993
|
salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length,
|
|
994
|
+
...truncated > 0 ? { truncated_turns: truncated } : {},
|
|
695
995
|
...rate !== void 0 ? { throughput: rate } : {}
|
|
696
996
|
};
|
|
697
997
|
}
|
|
@@ -715,31 +1015,6 @@ function cost_cap_hit(config, state) {
|
|
|
715
1015
|
return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
|
|
716
1016
|
}
|
|
717
1017
|
//#endregion
|
|
718
|
-
//#region src/types.ts
|
|
719
|
-
function config_error(message) {
|
|
720
|
-
return Object.assign(new Error(message), { kind: "config_error" });
|
|
721
|
-
}
|
|
722
|
-
function check_error(message) {
|
|
723
|
-
return Object.assign(new Error(message), { kind: "check_error" });
|
|
724
|
-
}
|
|
725
|
-
/** Wraps a provider/schema failure with the phase and iteration it hit. */
|
|
726
|
-
function phase_error(phase, iteration, cause) {
|
|
727
|
-
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
728
|
-
return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
|
|
729
|
-
kind: "phase_error",
|
|
730
|
-
phase,
|
|
731
|
-
iteration,
|
|
732
|
-
cause
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
function error_kind(err) {
|
|
736
|
-
if (typeof err === "object" && err !== null && "kind" in err) {
|
|
737
|
-
const kind = err.kind;
|
|
738
|
-
return typeof kind === "string" ? kind : null;
|
|
739
|
-
}
|
|
740
|
-
return null;
|
|
741
|
-
}
|
|
742
|
-
//#endregion
|
|
743
1018
|
//#region src/engine.ts
|
|
744
1019
|
/**
|
|
745
1020
|
* The ONLY module that calls fascicle's `create_engine`. Every
|
|
@@ -1192,6 +1467,7 @@ function write_resolved_config(config, env = process.env) {
|
|
|
1192
1467
|
builder_url: config.builder_url,
|
|
1193
1468
|
builder_max_steps: config.builder_max_steps,
|
|
1194
1469
|
builder_max_tool_calls: config.builder_max_tool_calls,
|
|
1470
|
+
builder_max_output_tokens: config.builder_max_output_tokens,
|
|
1195
1471
|
critic_model: config.critic_model,
|
|
1196
1472
|
critic_provider: config.critic_provider,
|
|
1197
1473
|
critic_url: config.critic_url,
|
|
@@ -1219,6 +1495,29 @@ function write_resolved_config(config, env = process.env) {
|
|
|
1219
1495
|
writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
|
|
1220
1496
|
return true;
|
|
1221
1497
|
}
|
|
1498
|
+
/** The identity volley's own commits fall back to. git won't commit without a
|
|
1499
|
+
* name and an email, and the sandbox image carries no git config, so inside
|
|
1500
|
+
* it a checkpoint or a worktree's salvage commit failed outright; a machine
|
|
1501
|
+
* never set up for git can be the same. `.invalid` is reserved, so the email
|
|
1502
|
+
* can't reach anyone. */
|
|
1503
|
+
const FALLBACK_IDENTITY = [["user.name", "volley"], ["user.email", "volley@volley.invalid"]];
|
|
1504
|
+
/** The `-c` flags that give a commit in `cwd` the fallback identity for each
|
|
1505
|
+
* part git has no value for. One the repo or the user configured wins, and so
|
|
1506
|
+
* does a `GIT_AUTHOR_*` or `GIT_COMMITTER_*` variable, which git ranks above
|
|
1507
|
+
* any config. Put them ahead of the subcommand. */
|
|
1508
|
+
function commit_identity(cwd) {
|
|
1509
|
+
return FALLBACK_IDENTITY.flatMap(([key, value]) => {
|
|
1510
|
+
const set = spawnSync("git", [
|
|
1511
|
+
"config",
|
|
1512
|
+
"--get",
|
|
1513
|
+
key
|
|
1514
|
+
], {
|
|
1515
|
+
cwd,
|
|
1516
|
+
encoding: "utf8"
|
|
1517
|
+
});
|
|
1518
|
+
return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1222
1521
|
/**
|
|
1223
1522
|
* `--git`: commit everything under `root` after a phase (`git -C <root>`, run
|
|
1224
1523
|
* here via the spawn `cwd`). `root` is the build root (`build_root`): the
|
|
@@ -1244,6 +1543,7 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
1244
1543
|
], { cwd: root });
|
|
1245
1544
|
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
1246
1545
|
const commit = spawnSync("git", [
|
|
1546
|
+
...commit_identity(root),
|
|
1247
1547
|
"commit",
|
|
1248
1548
|
"-m",
|
|
1249
1549
|
message,
|
|
@@ -1277,6 +1577,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
1277
1577
|
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
1278
1578
|
if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
|
|
1279
1579
|
const commit = spawnSync("git", [
|
|
1580
|
+
...commit_identity(workspace),
|
|
1280
1581
|
"commit",
|
|
1281
1582
|
"-m",
|
|
1282
1583
|
message,
|
|
@@ -1541,6 +1842,7 @@ function salvage_worktree(repo, path, branch) {
|
|
|
1541
1842
|
if (existsSync(path)) {
|
|
1542
1843
|
git_quiet(path, ["add", "-A"]);
|
|
1543
1844
|
git_quiet(path, [
|
|
1845
|
+
...commit_identity(path),
|
|
1544
1846
|
"commit",
|
|
1545
1847
|
"-m",
|
|
1546
1848
|
`volley: salvage unintegrated worktree (${branch})`,
|
|
@@ -1669,14 +1971,9 @@ const HARNESS_APPENDS = {
|
|
|
1669
1971
|
const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
|
|
1670
1972
|
/** Step 2 for a builder that may make several calls a turn: fascicle runs a
|
|
1671
1973
|
* 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
|
-
}
|
|
1974
|
+
* command is still worth reading before the next. Reads don't count toward
|
|
1975
|
+
* the cap, so the step names no number. */
|
|
1976
|
+
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
1977
|
function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
1681
1978
|
const identity = [
|
|
1682
1979
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
@@ -1684,7 +1981,7 @@ function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
|
1684
1981
|
"criteria."
|
|
1685
1982
|
].join("\n");
|
|
1686
1983
|
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,
|
|
1984
|
+
return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, BATCHED_CALLS) : append).trimEnd()}`;
|
|
1688
1985
|
}
|
|
1689
1986
|
/** The builder system prompt for the active provider: the `claude_cli`
|
|
1690
1987
|
* prompt (implicit built-in tools, "the current working directory") for the
|
|
@@ -1695,15 +1992,25 @@ function resolve_builder_system(config, tool_set) {
|
|
|
1695
1992
|
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
|
|
1696
1993
|
}
|
|
1697
1994
|
const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
|
|
1995
|
+
/** How the step countdown tells the builder to run the check: the command
|
|
1996
|
+
* volley gates on, with the digest and without `--json`, the configured
|
|
1997
|
+
* command, or the project's own tests when no check gates the run. A
|
|
1998
|
+
* workspace can hold checkride with no `check` script, so not `pnpm check`. */
|
|
1999
|
+
function check_hint(config) {
|
|
2000
|
+
if (config.check_resolved === "checkride") return "the check (`pnpm exec checkride --digest`)";
|
|
2001
|
+
if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
|
|
2002
|
+
return "the project's tests";
|
|
2003
|
+
}
|
|
1698
2004
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
1699
2005
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
1700
2006
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
1701
2007
|
* from v2, and takes no injected tools or approval handler. Every other
|
|
1702
2008
|
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
1703
2009
|
* workspace tool surface (or the set the caller injected in its place, with
|
|
1704
|
-
* the handler that answers its approvals)
|
|
1705
|
-
*
|
|
1706
|
-
*
|
|
2010
|
+
* the handler that answers its approvals), the per-call loop knobs, the step
|
|
2011
|
+
* countdown (`./builder/countdown.ts`), the output cap when one is set, and
|
|
2012
|
+
* **no** schema (it produces a workspace, not a verdict); per-call values win
|
|
2013
|
+
* over engine defaults. */
|
|
1707
2014
|
function builder_tool_options(deps) {
|
|
1708
2015
|
const { config } = deps;
|
|
1709
2016
|
const bash_executor = deps.bash_executor ?? null;
|
|
@@ -1717,9 +2024,75 @@ function builder_tool_options(deps) {
|
|
|
1717
2024
|
max_steps: config.builder_max_steps,
|
|
1718
2025
|
tool_error_policy: "feed_back",
|
|
1719
2026
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
1720
|
-
max_tool_calls_per_step: config.builder_max_tool_calls
|
|
2027
|
+
max_tool_calls_per_step: config.builder_max_tool_calls,
|
|
2028
|
+
prepare_step: step_countdown(config.builder_max_steps, check_hint(config)),
|
|
2029
|
+
...config.builder_max_output_tokens !== null ? {
|
|
2030
|
+
max_tokens: config.builder_max_output_tokens,
|
|
2031
|
+
truncated_turn_policy: "continue"
|
|
2032
|
+
} : {}
|
|
1721
2033
|
};
|
|
1722
2034
|
}
|
|
2035
|
+
/** How much of the last check's output the builder's prompt carries: a few KB,
|
|
2036
|
+
* whatever the runner. checkride's digest already fits in 8 kB, and the builder
|
|
2037
|
+
* can run the check again for the rest. */
|
|
2038
|
+
const PREVIOUS_CHECK_MAX_CHARS = 6e3;
|
|
2039
|
+
/** The first `max` characters of `text`, saying where it was cut. */
|
|
2040
|
+
function head(text, max, rest) {
|
|
2041
|
+
return text.length <= max ? text : `${text.slice(0, max)}\n… (cut at ${String(max)} characters; ${rest})`;
|
|
2042
|
+
}
|
|
2043
|
+
/** The last `max` characters of `text`, saying what came before was cut. */
|
|
2044
|
+
function tail(text, max) {
|
|
2045
|
+
return text.length <= max ? text : `… (the last ${String(max)} characters of ${String(text.length)})\n${text.slice(-max)}`;
|
|
2046
|
+
}
|
|
2047
|
+
/** The part of a failed check the builder reads first: checkride's digest,
|
|
2048
|
+
* else the start of each failing slot's raw output, else the end of a
|
|
2049
|
+
* command's output, where test runners and compilers sum up. */
|
|
2050
|
+
function check_excerpt(check) {
|
|
2051
|
+
const max = PREVIOUS_CHECK_MAX_CHARS;
|
|
2052
|
+
if (check.digest !== void 0) return head(check.digest.trimEnd(), max, ".check/digest.md has the rest");
|
|
2053
|
+
if (check.detail.length > 0) return head(check.detail.map((artifact) => {
|
|
2054
|
+
const source = artifact.path === null ? "" : ` (.check/${basename(artifact.path)})`;
|
|
2055
|
+
return `${artifact.slot}${source}:\n${artifact.content.trimEnd()}`;
|
|
2056
|
+
}).join("\n\n"), max, "each slot's raw output under .check/ has the rest");
|
|
2057
|
+
return tail((check.log ?? "").trimEnd(), max);
|
|
2058
|
+
}
|
|
2059
|
+
/** The check section of a later iteration's prompt: what the deterministic
|
|
2060
|
+
* gate itself said about the last session's work, so the builder starts from
|
|
2061
|
+
* its words rather than the critic's account of them, or re-running it to
|
|
2062
|
+
* find out. Empty unless that check ran and failed. A check the user rejected
|
|
2063
|
+
* when it asked to run is recorded as failed but said nothing, and the
|
|
2064
|
+
* section says so rather than call the work failed. */
|
|
2065
|
+
function previous_check_section(check) {
|
|
2066
|
+
if (check === null || !check.ran || check.ok) return [];
|
|
2067
|
+
const heading = [
|
|
2068
|
+
"",
|
|
2069
|
+
"PREVIOUS CHECK RESULT",
|
|
2070
|
+
"---------------------"
|
|
2071
|
+
];
|
|
2072
|
+
if (check.log === "the check was rejected when it asked to run, so it never ran") return [
|
|
2073
|
+
...heading,
|
|
2074
|
+
"The check never ran on the work your last session left: it was rejected when it",
|
|
2075
|
+
"asked to run, so nothing has verified that work yet."
|
|
2076
|
+
];
|
|
2077
|
+
const runner = check.runner === "checkride" ? "checkride, " : "";
|
|
2078
|
+
const exit = check.exit_code === null ? "no exit code" : `exit ${String(check.exit_code)}`;
|
|
2079
|
+
const lines = [...heading, `The check failed on the work your last session left (${runner}${exit}).`];
|
|
2080
|
+
if (check.failing_slots.length > 0) lines.push(`Failing slots: ${check.failing_slots.join(", ")}.`);
|
|
2081
|
+
const excerpt = check_excerpt(check);
|
|
2082
|
+
if (excerpt.length > 0) lines.push("", excerpt);
|
|
2083
|
+
return lines;
|
|
2084
|
+
}
|
|
2085
|
+
/** The line a later iteration's prompt carries when the last session ran out
|
|
2086
|
+
* of steps: it never got to verify, so this one should check first. */
|
|
2087
|
+
function step_limit_lines(step_limit_hit) {
|
|
2088
|
+
if (step_limit_hit === null) return [];
|
|
2089
|
+
return [
|
|
2090
|
+
"",
|
|
2091
|
+
`Your last session hit its ${String(step_limit_hit)}-step limit before it called finish, so`,
|
|
2092
|
+
"it may never have verified its work. Run the check first this time, and keep",
|
|
2093
|
+
"enough steps in hand to verify again before you finish."
|
|
2094
|
+
];
|
|
2095
|
+
}
|
|
1723
2096
|
function compose_builder_prompt(input) {
|
|
1724
2097
|
const parts = [
|
|
1725
2098
|
"TASK",
|
|
@@ -1733,7 +2106,8 @@ function compose_builder_prompt(input) {
|
|
|
1733
2106
|
`ITERATION: ${input.iteration}`
|
|
1734
2107
|
];
|
|
1735
2108
|
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.
|
|
2109
|
+
if (input.iteration > 1 && input.previous !== null) parts.push(...previous_check_section(input.previous.check), ...step_limit_lines(input.previous.step_limit_hit));
|
|
2110
|
+
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
2111
|
return parts.join("\n");
|
|
1738
2112
|
}
|
|
1739
2113
|
function builder_max_steps_warning(max_steps) {
|
|
@@ -1755,7 +2129,9 @@ function make_builder_step(deps) {
|
|
|
1755
2129
|
...builder_tool_options(deps)
|
|
1756
2130
|
});
|
|
1757
2131
|
}
|
|
1758
|
-
|
|
2132
|
+
/** Run one builder session on the opened iteration `state`, told what the
|
|
2133
|
+
* iteration before it left behind (`previous`, null on the first). */
|
|
2134
|
+
async function run_builder(deps, state, previous, ctx) {
|
|
1759
2135
|
const { config } = deps;
|
|
1760
2136
|
try {
|
|
1761
2137
|
if (config.builder_provider === "ollama") {
|
|
@@ -1768,10 +2144,16 @@ async function run_builder(deps, state, ctx) {
|
|
|
1768
2144
|
criteria: config.criteria,
|
|
1769
2145
|
feedback: state.feedback,
|
|
1770
2146
|
iteration: state.iteration,
|
|
1771
|
-
checkride: config.check_resolved === "checkride"
|
|
2147
|
+
checkride: config.check_resolved === "checkride",
|
|
2148
|
+
previous
|
|
1772
2149
|
}));
|
|
1773
|
-
|
|
1774
|
-
|
|
2150
|
+
const built = accumulate(state, "builder", result, config.builder_model);
|
|
2151
|
+
if (result.finish_reason !== "max_steps") return built;
|
|
2152
|
+
deps.warn(builder_max_steps_warning(config.builder_max_steps));
|
|
2153
|
+
return {
|
|
2154
|
+
...built,
|
|
2155
|
+
builder_max_steps_hits: built.builder_max_steps_hits + 1
|
|
2156
|
+
};
|
|
1775
2157
|
} catch (err) {
|
|
1776
2158
|
throw phase_error("builder", state.iteration, err);
|
|
1777
2159
|
}
|
|
@@ -2042,227 +2424,6 @@ function collect_changes(options) {
|
|
|
2042
2424
|
};
|
|
2043
2425
|
}
|
|
2044
2426
|
//#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
2427
|
//#region src/critic/prompt.ts
|
|
2267
2428
|
/**
|
|
2268
2429
|
* Critic prompt resolution and composition. Presets live as
|
|
@@ -2791,6 +2952,7 @@ function phase_summary(record) {
|
|
|
2791
2952
|
tool_calls: record.tool_calls,
|
|
2792
2953
|
salvaged_tool_calls: record.salvaged_tool_calls,
|
|
2793
2954
|
salvage_rate: record.tool_calls === 0 ? 0 : record.salvaged_tool_calls / record.tool_calls,
|
|
2955
|
+
...record.truncated_turns !== void 0 ? { truncated_turns: record.truncated_turns } : {},
|
|
2794
2956
|
...record.retries !== void 0 ? { retries: record.retries } : {},
|
|
2795
2957
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
2796
2958
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
@@ -2813,6 +2975,8 @@ function archive_iteration(config, state) {
|
|
|
2813
2975
|
if (check.runner === "checkride") {
|
|
2814
2976
|
if (check.summary !== void 0) write_json(join(dir, "check", "summary.json"), check.summary);
|
|
2815
2977
|
for (const artifact of check.detail) if (artifact.path !== null && existsSync(artifact.path)) writeFileSync(join(dir, "check", basename(artifact.path)), readFileSync(artifact.path));
|
|
2978
|
+
if (check.digest !== void 0) writeFileSync(join(dir, "check", "digest.md"), check.digest);
|
|
2979
|
+
if (check.log !== void 0) writeFileSync(join(dir, "check", "check.log"), check.log);
|
|
2816
2980
|
} else {
|
|
2817
2981
|
writeFileSync(join(dir, "check", "check.log"), check.log ?? "");
|
|
2818
2982
|
writeFileSync(join(dir, "check", "check.exit"), `${String(check.exit_code)}\n`);
|
|
@@ -2853,6 +3017,7 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
2853
3017
|
builder_cost_usd: state.builder_cost_usd,
|
|
2854
3018
|
critic_cost_usd: state.critic_cost_usd,
|
|
2855
3019
|
check_duration_ms: state.check_duration_ms,
|
|
3020
|
+
builder_max_steps_hits: state.builder_max_steps_hits,
|
|
2856
3021
|
final_verdict: state.verdict,
|
|
2857
3022
|
salvaged_branch: null,
|
|
2858
3023
|
run_state_dir: run_state_dir(config),
|
|
@@ -2911,6 +3076,95 @@ function refuse_symlinks(dir) {
|
|
|
2911
3076
|
if (entry.isDirectory()) refuse_symlinks(path);
|
|
2912
3077
|
}
|
|
2913
3078
|
}
|
|
3079
|
+
/** An archived file's text, or undefined when there's no file to read there. */
|
|
3080
|
+
function read_text(path) {
|
|
3081
|
+
try {
|
|
3082
|
+
return readFileSync(path, "utf8");
|
|
3083
|
+
} catch {
|
|
3084
|
+
return;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
function read_json(path) {
|
|
3088
|
+
const text = read_text(path);
|
|
3089
|
+
if (text === void 0) return null;
|
|
3090
|
+
try {
|
|
3091
|
+
return JSON.parse(text);
|
|
3092
|
+
} catch {
|
|
3093
|
+
return null;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
/** The failing slots' raw artifacts from an archived iteration's `check/`,
|
|
3097
|
+
* which holds each under its base name, found through the archived checkride
|
|
3098
|
+
* summary. */
|
|
3099
|
+
function archived_artifacts(check_dir, failing_slots) {
|
|
3100
|
+
const summary = read_json(join(check_dir, "summary.json"));
|
|
3101
|
+
const checks = Array.isArray(summary?.checks) ? summary.checks : [];
|
|
3102
|
+
return read_failing_artifacts(check_dir, failing_slots.map((name) => {
|
|
3103
|
+
const output_file = checks.find((check) => check.name === name)?.output_file;
|
|
3104
|
+
return {
|
|
3105
|
+
name,
|
|
3106
|
+
adapter: null,
|
|
3107
|
+
ok: false,
|
|
3108
|
+
exit_code: null,
|
|
3109
|
+
duration_ms: 0,
|
|
3110
|
+
output_file: typeof output_file === "string" ? basename(output_file) : null
|
|
3111
|
+
};
|
|
3112
|
+
}));
|
|
3113
|
+
}
|
|
3114
|
+
/** The check an archived iteration ran, rebuilt from its directory with the
|
|
3115
|
+
* output its failure is shown by: the digest and the failing slots' artifacts
|
|
3116
|
+
* for checkride, the log for a command or a rejected check. Null when it ran
|
|
3117
|
+
* none. */
|
|
3118
|
+
function archived_check(dir, recorded) {
|
|
3119
|
+
if (recorded?.ran !== true || typeof recorded.ok !== "boolean") return null;
|
|
3120
|
+
const runner = recorded.runner === "checkride" ? "checkride" : "command";
|
|
3121
|
+
const failing_slots = Array.isArray(recorded.failing_slots) ? recorded.failing_slots.filter((slot) => typeof slot === "string") : [];
|
|
3122
|
+
const check = {
|
|
3123
|
+
ran: true,
|
|
3124
|
+
runner,
|
|
3125
|
+
ok: recorded.ok,
|
|
3126
|
+
exit_code: typeof recorded.exit_code === "number" ? recorded.exit_code : null,
|
|
3127
|
+
duration_ms: typeof recorded.duration_ms === "number" ? recorded.duration_ms : 0,
|
|
3128
|
+
failing_slots,
|
|
3129
|
+
detail: []
|
|
3130
|
+
};
|
|
3131
|
+
if (check.ok) return check;
|
|
3132
|
+
const check_dir = join(dir, "check");
|
|
3133
|
+
const log = read_text(join(check_dir, "check.log"));
|
|
3134
|
+
const logged = log === void 0 ? check : {
|
|
3135
|
+
...check,
|
|
3136
|
+
log
|
|
3137
|
+
};
|
|
3138
|
+
if (runner === "command") return logged;
|
|
3139
|
+
const digest = read_text(join(check_dir, "digest.md"));
|
|
3140
|
+
return {
|
|
3141
|
+
...logged,
|
|
3142
|
+
detail: archived_artifacts(check_dir, failing_slots),
|
|
3143
|
+
...digest !== void 0 ? { digest } : {}
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
3146
|
+
/** An archived builder record, as `archive_iteration` wrote it from the
|
|
3147
|
+
* phase's own, or null when there is none with a finish reason. */
|
|
3148
|
+
function archived_builder(recorded) {
|
|
3149
|
+
if (typeof recorded !== "object" || recorded === null) return null;
|
|
3150
|
+
return typeof recorded.finish_reason === "string" ? recorded : null;
|
|
3151
|
+
}
|
|
3152
|
+
/** What the last completed iteration left for the next build: its check and
|
|
3153
|
+
* its builder's record, which tell the resumed run's first builder what the
|
|
3154
|
+
* gate said and whether the last session ran out of steps, as a run that never
|
|
3155
|
+
* stopped would have. Null for both before the first iteration. */
|
|
3156
|
+
function archived_round(run, completed) {
|
|
3157
|
+
if (completed === 0) return {
|
|
3158
|
+
check: null,
|
|
3159
|
+
builder: null
|
|
3160
|
+
};
|
|
3161
|
+
const dir = iteration_dir(run, completed);
|
|
3162
|
+
const recorded = read_json(join(dir, "summary.json"));
|
|
3163
|
+
return {
|
|
3164
|
+
check: archived_check(dir, recorded?.check),
|
|
3165
|
+
builder: archived_builder(recorded?.builder)
|
|
3166
|
+
};
|
|
3167
|
+
}
|
|
2914
3168
|
/** Recover a resumable run from its state directory (`.volley/`, or
|
|
2915
3169
|
* `<state_dir>/<run_id>/` under a state root): settings from config.json,
|
|
2916
3170
|
* totals from summary.json, feedback from feedback.md. An iteration directory
|
|
@@ -2959,6 +3213,7 @@ function load_resume_state(workspace, run_id, state_dir = null, options = {}) {
|
|
|
2959
3213
|
...typeof recorded["builder_url"] === "string" ? { builder_url: recorded["builder_url"] } : {},
|
|
2960
3214
|
...recorded["builder_max_steps"] === void 0 ? {} : { builder_max_steps: Number(recorded["builder_max_steps"]) },
|
|
2961
3215
|
...recorded["builder_max_tool_calls"] === void 0 ? {} : { builder_max_tool_calls: Number(recorded["builder_max_tool_calls"]) },
|
|
3216
|
+
...typeof recorded["builder_max_output_tokens"] === "number" ? { builder_max_output_tokens: recorded["builder_max_output_tokens"] } : {},
|
|
2962
3217
|
critic_model: String(recorded["critic_model"]),
|
|
2963
3218
|
critic_provider: recorded["critic_provider"] ?? "claude_cli",
|
|
2964
3219
|
...typeof recorded["critic_url"] === "string" ? { critic_url: recorded["critic_url"] } : {},
|
|
@@ -2980,15 +3235,15 @@ function load_resume_state(workspace, run_id, state_dir = null, options = {}) {
|
|
|
2980
3235
|
feedback,
|
|
2981
3236
|
verdict: null,
|
|
2982
3237
|
unmet_criteria: [],
|
|
2983
|
-
|
|
3238
|
+
...archived_round(run, completed),
|
|
2984
3239
|
changes: null,
|
|
2985
|
-
builder: null,
|
|
2986
3240
|
critic: null,
|
|
2987
3241
|
total_usage: summary?.total_usage ?? EMPTY_USAGE,
|
|
2988
3242
|
total_cost_usd: summary?.total_cost_usd ?? 0,
|
|
2989
3243
|
builder_cost_usd: summary?.builder_cost_usd ?? 0,
|
|
2990
3244
|
critic_cost_usd: summary?.critic_cost_usd ?? 0,
|
|
2991
3245
|
check_duration_ms: summary?.check_duration_ms ?? 0,
|
|
3246
|
+
builder_max_steps_hits: summary?.builder_max_steps_hits ?? 0,
|
|
2992
3247
|
iteration_cost_usd: 0,
|
|
2993
3248
|
halt: null,
|
|
2994
3249
|
cost_warned: false
|
|
@@ -3155,7 +3410,7 @@ function record_check_chunk(ctx, chunk) {
|
|
|
3155
3410
|
}
|
|
3156
3411
|
/** The command line the check runs, or null when no check is configured. */
|
|
3157
3412
|
function check_command(config) {
|
|
3158
|
-
if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
|
|
3413
|
+
if (config.check_resolved === "checkride") return "pnpm exec checkride --json --digest";
|
|
3159
3414
|
if (config.check_resolved === "command") return config.check;
|
|
3160
3415
|
return null;
|
|
3161
3416
|
}
|
|
@@ -3237,6 +3492,17 @@ function next_iteration(s) {
|
|
|
3237
3492
|
unmet_criteria: []
|
|
3238
3493
|
};
|
|
3239
3494
|
}
|
|
3495
|
+
/** What the builder of the iteration after `s` hears about it beyond the
|
|
3496
|
+
* critic's feedback: its check, and whether its builder ran out of steps. Null
|
|
3497
|
+
* before the first iteration. A resumed run's state carries both, read back
|
|
3498
|
+
* from the last archived iteration. */
|
|
3499
|
+
function previous_round(config, s) {
|
|
3500
|
+
if (s.iteration === 0) return null;
|
|
3501
|
+
return {
|
|
3502
|
+
check: s.check,
|
|
3503
|
+
step_limit_hit: s.builder?.finish_reason === "max_steps" ? config.builder_max_steps : null
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3240
3506
|
/** What changed in the build root since the baseline, reported as it's
|
|
3241
3507
|
* measured: the count, and a warning for any gate edit. A run measures the
|
|
3242
3508
|
* builder's work this way after every build, and a critique measures the
|
|
@@ -3267,7 +3533,7 @@ async function build_phase(deps, builder, s, ctx) {
|
|
|
3267
3533
|
builder,
|
|
3268
3534
|
config,
|
|
3269
3535
|
warn: renderer.warn
|
|
3270
|
-
}, next, ctx);
|
|
3536
|
+
}, next, previous_round(config, s), ctx);
|
|
3271
3537
|
renderer.phase_end(built.iteration, "builder", true);
|
|
3272
3538
|
renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
|
|
3273
3539
|
if (built.cost_warned && !s.cost_warned) renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
|
|
@@ -3790,6 +4056,7 @@ function resolve_check_runner(check, workspace) {
|
|
|
3790
4056
|
return /(^|\s)checkride(\s|$)/.test(check.trim()) ? "checkride" : "command";
|
|
3791
4057
|
}
|
|
3792
4058
|
const DEFAULT_CRITIC_MODEL = "opus";
|
|
4059
|
+
const DEFAULT_BUILDER_MAX_OUTPUT_TOKENS = 16384;
|
|
3793
4060
|
const DEFAULT_CHECK = "auto";
|
|
3794
4061
|
const DEFAULT_CRITIC = "reviewer";
|
|
3795
4062
|
const DEFAULT_BUILDER_PROVIDER = "claude_cli";
|
|
@@ -3881,6 +4148,7 @@ const CRITIQUE_REFUSED = [
|
|
|
3881
4148
|
["builder_url", "--builder-url"],
|
|
3882
4149
|
["builder_max_steps", "--builder-max-steps"],
|
|
3883
4150
|
["builder_max_tool_calls", "--builder-max-tool-calls"],
|
|
4151
|
+
["builder_max_output_tokens", "--builder-max-output-tokens"],
|
|
3884
4152
|
["builder_permission_mode", "--builder-permission-mode"],
|
|
3885
4153
|
["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
|
|
3886
4154
|
["sandbox_image", "--sandbox-image"],
|
|
@@ -3904,6 +4172,22 @@ const BUILDER_MAX_TOOL_CALLS = {
|
|
|
3904
4172
|
env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
|
|
3905
4173
|
fallback: 1
|
|
3906
4174
|
};
|
|
4175
|
+
/** The builders whose turns are capped when nothing sets a cap: the local
|
|
4176
|
+
* ones, where nothing else bounds a turn. Ollama's MLX runner lets an unset
|
|
4177
|
+
* `num_predict` run to the model's trained context less the prompt, which is
|
|
4178
|
+
* how one qwen3.8 turn spent 30,847 tokens thinking. A hosted model on
|
|
4179
|
+
* OpenRouter has its own output limit, and a request for more than it allows
|
|
4180
|
+
* can be refused, so it's capped only when asked. */
|
|
4181
|
+
const CAPPED_BY_DEFAULT = ["ollama", "lmstudio"];
|
|
4182
|
+
/** The output cap's flag and env var, and its default for `provider`: 16k
|
|
4183
|
+
* tokens on a local builder, else the provider's own limit (null). */
|
|
4184
|
+
function builder_max_output_tokens_count(provider) {
|
|
4185
|
+
return {
|
|
4186
|
+
flag: "--builder-max-output-tokens",
|
|
4187
|
+
env_var: "VOLLEY_BUILDER_MAX_OUTPUT_TOKENS",
|
|
4188
|
+
fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
|
|
4189
|
+
};
|
|
4190
|
+
}
|
|
3907
4191
|
/** A builder count's flag or config value wins over its env var, which wins
|
|
3908
4192
|
* over the default, and either must be a positive integer. Local builder
|
|
3909
4193
|
* only; ignored by the `claude_cli` arm. */
|
|
@@ -4037,6 +4321,7 @@ function resolve_config(raw, options = {}) {
|
|
|
4037
4321
|
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
4322
|
const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
|
|
4039
4323
|
const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
|
|
4324
|
+
const builder_max_output_tokens = resolve_builder_count(raw.builder_max_output_tokens, builder_env, builder_max_output_tokens_count(builder_provider));
|
|
4040
4325
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
4041
4326
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
4042
4327
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
@@ -4074,6 +4359,7 @@ function resolve_config(raw, options = {}) {
|
|
|
4074
4359
|
builder_url,
|
|
4075
4360
|
builder_max_steps,
|
|
4076
4361
|
builder_max_tool_calls,
|
|
4362
|
+
builder_max_output_tokens,
|
|
4077
4363
|
allow_unsandboxed_builder,
|
|
4078
4364
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
4079
4365
|
critic_provider,
|
|
@@ -4121,6 +4407,7 @@ function initial_state() {
|
|
|
4121
4407
|
builder_cost_usd: 0,
|
|
4122
4408
|
critic_cost_usd: 0,
|
|
4123
4409
|
check_duration_ms: 0,
|
|
4410
|
+
builder_max_steps_hits: 0,
|
|
4124
4411
|
iteration_cost_usd: 0,
|
|
4125
4412
|
halt: null,
|
|
4126
4413
|
cost_warned: false
|
|
@@ -4436,8 +4723,10 @@ function finalize_failed_run(config, err) {
|
|
|
4436
4723
|
const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
|
|
4437
4724
|
try {
|
|
4438
4725
|
const summary_path = state_path(config, "summary.json");
|
|
4726
|
+
const prior = existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null);
|
|
4439
4727
|
write_run_summary(config, {
|
|
4440
|
-
...
|
|
4728
|
+
...prior,
|
|
4729
|
+
builder_max_steps_hits: prior.builder_max_steps_hits ?? 0,
|
|
4441
4730
|
run_state_dir: run_state_dir(config),
|
|
4442
4731
|
status,
|
|
4443
4732
|
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -5353,6 +5642,7 @@ async function preflight(config, renderer, env, probes = {}) {
|
|
|
5353
5642
|
renderer.info(seat_line("builder", config.builder_model, config.builder_provider, config.builder_url, env));
|
|
5354
5643
|
renderer.info(`builder max steps: ${config.builder_max_steps}`);
|
|
5355
5644
|
renderer.info(`builder tool calls per turn: ${config.builder_max_tool_calls}`);
|
|
5645
|
+
renderer.info(`builder output tokens per turn: ${config.builder_max_output_tokens ?? "the provider's limit"}`);
|
|
5356
5646
|
}
|
|
5357
5647
|
if (tool_loop_builder) {
|
|
5358
5648
|
const contained = detect_containment(env);
|
|
@@ -5484,7 +5774,7 @@ function with_critic_options(command, required = false) {
|
|
|
5484
5774
|
function with_seat_options(command, required = false) {
|
|
5485
5775
|
const model_note = required ? " (required)" : "";
|
|
5486
5776
|
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);
|
|
5777
|
+
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
5778
|
}
|
|
5489
5779
|
/** What a critique declares only so that it can refuse each by name, with
|
|
5490
5780
|
* exit 5: cac would answer an undeclared one with a bare "Unknown option".
|
|
@@ -5495,6 +5785,7 @@ const CRITIQUE_REFUSED_OPTIONS = [
|
|
|
5495
5785
|
"--builder-url <url>",
|
|
5496
5786
|
"--builder-max-steps <n>",
|
|
5497
5787
|
"--builder-max-tool-calls <n>",
|
|
5788
|
+
"--builder-max-output-tokens <n>",
|
|
5498
5789
|
"--builder-permission-mode <mode>",
|
|
5499
5790
|
"--allow-unsandboxed-builder",
|
|
5500
5791
|
"--sandbox-image <tag>",
|
|
@@ -5518,6 +5809,7 @@ function merge_flags(base, flags) {
|
|
|
5518
5809
|
...flags.builderUrl !== void 0 ? { builder_url: flags.builderUrl } : {},
|
|
5519
5810
|
...flags.builderMaxSteps !== void 0 ? { builder_max_steps: Number(flags.builderMaxSteps) } : {},
|
|
5520
5811
|
...flags.builderMaxToolCalls !== void 0 ? { builder_max_tool_calls: Number(flags.builderMaxToolCalls) } : {},
|
|
5812
|
+
...flags.builderMaxOutputTokens !== void 0 ? { builder_max_output_tokens: Number(flags.builderMaxOutputTokens) } : {},
|
|
5521
5813
|
...flags.allowUnsandboxedBuilder === true ? { allow_unsandboxed_builder: true } : {},
|
|
5522
5814
|
...flags.criticModel !== void 0 ? { critic_model: flags.criticModel } : {},
|
|
5523
5815
|
...flags.criticProvider !== void 0 ? { critic_provider: flags.criticProvider } : {},
|