vigiles 2.4.0 → 2.5.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/README.md +54 -3
- package/dist/agent-result.d.ts +40 -0
- package/dist/agent-result.js +97 -0
- package/dist/agent-runtime.d.ts +64 -0
- package/dist/agent-runtime.js +147 -0
- package/dist/cli.js +106 -0
- package/dist/compile.d.ts +32 -3
- package/dist/compile.js +268 -0
- package/dist/eval-cache.d.ts +33 -0
- package/dist/eval-cache.js +94 -0
- package/dist/eval.d.ts +172 -9
- package/dist/eval.js +319 -58
- package/dist/harness-assert.d.ts +140 -12
- package/dist/harness-assert.js +283 -25
- package/dist/harness-test.d.ts +97 -11
- package/dist/harness-test.js +147 -37
- package/dist/judge.js +2 -0
- package/dist/linters.d.ts +6 -0
- package/dist/linters.js +1 -0
- package/dist/mock-entry.d.ts +2 -0
- package/dist/mock-entry.js +36 -0
- package/dist/mock-model.d.ts +29 -0
- package/dist/mock-model.js +40 -0
- package/dist/plugin-loader.js +51 -17
- package/dist/sandbox.d.ts +76 -0
- package/dist/sandbox.js +241 -0
- package/dist/spec.d.ts +130 -0
- package/dist/spec.js +55 -0
- package/dist/stats.d.ts +49 -0
- package/dist/stats.js +109 -0
- package/package.json +5 -3
package/dist/compile.js
CHANGED
|
@@ -16,6 +16,9 @@ exports.validateCommandRef = validateCommandRef;
|
|
|
16
16
|
exports.validateSymbolRef = validateSymbolRef;
|
|
17
17
|
exports.compileClaude = compileClaude;
|
|
18
18
|
exports.compileSkill = compileSkill;
|
|
19
|
+
exports.compileAgent = compileAgent;
|
|
20
|
+
exports.validateRailway = validateRailway;
|
|
21
|
+
exports.compileRailway = compileRailway;
|
|
19
22
|
exports.checkFileHash = checkFileHash;
|
|
20
23
|
exports.adoptDiff = adoptDiff;
|
|
21
24
|
const node_fs_1 = require("node:fs");
|
|
@@ -641,6 +644,267 @@ function compileSkill(spec, options = {}) {
|
|
|
641
644
|
const content = renderSkillFrontmatter(spec) + "\n\n" + sections.trim() + "\n";
|
|
642
645
|
return { markdown: addHash(content, specFile), errors };
|
|
643
646
|
}
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
// Compile a subagent spec → agents/<name>.md
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// The tool contract a subagent may declare — the rails it runs on. Anything
|
|
651
|
+
// else must be an MCP tool (mcp__server__tool), else it's a typo / nonexistent
|
|
652
|
+
// tool the dispatched worker could never call.
|
|
653
|
+
const KNOWN_AGENT_TOOLS = [
|
|
654
|
+
"Read",
|
|
655
|
+
"Write",
|
|
656
|
+
"Edit",
|
|
657
|
+
"Bash",
|
|
658
|
+
"Grep",
|
|
659
|
+
"Glob",
|
|
660
|
+
"WebSearch",
|
|
661
|
+
"WebFetch",
|
|
662
|
+
"NotebookEdit",
|
|
663
|
+
"TodoWrite",
|
|
664
|
+
"Task",
|
|
665
|
+
"Skill",
|
|
666
|
+
];
|
|
667
|
+
const MCP_TOOL_RE = /^mcp__[a-z0-9_-]+__[a-z0-9_-]+$/i;
|
|
668
|
+
// Tools the platform never exposes to a subagent, whatever the list says — so a
|
|
669
|
+
// subagent listing one is a guaranteed-dead reference only a compiler catches.
|
|
670
|
+
const NEVER_AVAILABLE_TOOLS = new Set([
|
|
671
|
+
"Agent",
|
|
672
|
+
"AskUserQuestion",
|
|
673
|
+
"EnterPlanMode",
|
|
674
|
+
"ExitPlanMode",
|
|
675
|
+
"ScheduleWakeup",
|
|
676
|
+
"WaitForMcpServers",
|
|
677
|
+
]);
|
|
678
|
+
/** Closest known tool by edit distance (≤ 3), for a "did you mean" hint. */
|
|
679
|
+
function closestTool(tool) {
|
|
680
|
+
let best = null;
|
|
681
|
+
let bestDistance = Infinity;
|
|
682
|
+
for (const known of KNOWN_AGENT_TOOLS) {
|
|
683
|
+
const d = (0, linters_js_1.editDistance)(tool.toLowerCase(), known.toLowerCase());
|
|
684
|
+
if (d < bestDistance) {
|
|
685
|
+
bestDistance = d;
|
|
686
|
+
best = known;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return bestDistance <= 3 ? best : null;
|
|
690
|
+
}
|
|
691
|
+
/** Verify a subagent's allowed-tools contract — the rails are real tools. */
|
|
692
|
+
function validateAgentTools(tools) {
|
|
693
|
+
const errors = [];
|
|
694
|
+
for (const tool of tools) {
|
|
695
|
+
if (NEVER_AVAILABLE_TOOLS.has(tool)) {
|
|
696
|
+
errors.push({
|
|
697
|
+
type: "unknown-tool",
|
|
698
|
+
message: `Tool "${tool}" is never available to a subagent — remove it from the tools list.`,
|
|
699
|
+
});
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
if (KNOWN_AGENT_TOOLS.includes(tool))
|
|
703
|
+
continue;
|
|
704
|
+
if (MCP_TOOL_RE.test(tool))
|
|
705
|
+
continue;
|
|
706
|
+
const near = closestTool(tool);
|
|
707
|
+
const hint = near ? ` Did you mean "${near}"?` : "";
|
|
708
|
+
errors.push({
|
|
709
|
+
type: "unknown-tool",
|
|
710
|
+
message: `Unknown tool "${tool}" in agent tools — use a built-in tool (${KNOWN_AGENT_TOOLS.join(", ")}) or an MCP tool (mcp__server__tool).${hint}`,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
return errors;
|
|
714
|
+
}
|
|
715
|
+
/** Build the subagent YAML frontmatter (name / description / model / tools). */
|
|
716
|
+
function renderAgentFrontmatter(spec) {
|
|
717
|
+
const fm = [
|
|
718
|
+
"---",
|
|
719
|
+
"",
|
|
720
|
+
`name: ${spec.name}`,
|
|
721
|
+
`description: ${spec.description}`,
|
|
722
|
+
];
|
|
723
|
+
if (spec.model !== undefined)
|
|
724
|
+
fm.push(`model: ${spec.model}`);
|
|
725
|
+
if (spec.tools && spec.tools.length > 0) {
|
|
726
|
+
fm.push(`tools: ${spec.tools.join(", ")}`);
|
|
727
|
+
}
|
|
728
|
+
fm.push("", "---");
|
|
729
|
+
return fm.join("\n");
|
|
730
|
+
}
|
|
731
|
+
/** Render the subagent's named `##` system-prompt sections (verified like CLAUDE.md). */
|
|
732
|
+
function renderAgentSections(sections, basePath) {
|
|
733
|
+
const lines = [];
|
|
734
|
+
const errors = [];
|
|
735
|
+
for (const [name, content] of Object.entries(sections)) {
|
|
736
|
+
if (name.toLowerCase() === "rules") {
|
|
737
|
+
errors.push({
|
|
738
|
+
type: "reserved-section-key",
|
|
739
|
+
message: `Section key "${name}" is reserved — use the \`rules\` field instead.`,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
const heading = name.charAt(0).toUpperCase() + name.slice(1);
|
|
743
|
+
if (typeof content === "string") {
|
|
744
|
+
errors.push(...validateSectionContent(name, content));
|
|
745
|
+
lines.push(`## ${heading}\n\n${content.trim()}`);
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
errors.push(...validateRefs(content, basePath));
|
|
749
|
+
const rendered = content.map(renderFragment).join("");
|
|
750
|
+
errors.push(...validateSectionContent(name, rendered));
|
|
751
|
+
lines.push(`## ${heading}\n\n${rendered.trim()}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return { lines, errors };
|
|
755
|
+
}
|
|
756
|
+
/** Render a result-contract track shape as a compact `{ "f": type, … }` line. */
|
|
757
|
+
function renderShape(shape) {
|
|
758
|
+
const fields = Object.entries(shape)
|
|
759
|
+
.map(([k, t]) => `"${k}": ${t}`)
|
|
760
|
+
.join(", ");
|
|
761
|
+
return fields ? `{ ${fields} }` : "{}";
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Render the subagent's typed result contract — the `## Output contract` section
|
|
765
|
+
* that turns a flat worker into a railway step: it must end its turn with a
|
|
766
|
+
* `vigiles:ok` / `vigiles:err` block matching one of these shapes, so its
|
|
767
|
+
* outcome is parseable (`parseAgentResult`) and testable (`assertAgentOk`).
|
|
768
|
+
*/
|
|
769
|
+
function renderOutputContract(contract) {
|
|
770
|
+
return [
|
|
771
|
+
"## Output contract",
|
|
772
|
+
"",
|
|
773
|
+
"Finish your turn with exactly one fenced block — success or error — matching one of these shapes.",
|
|
774
|
+
"",
|
|
775
|
+
"On success:",
|
|
776
|
+
"",
|
|
777
|
+
"```vigiles:ok",
|
|
778
|
+
renderShape(contract.ok),
|
|
779
|
+
"```",
|
|
780
|
+
"",
|
|
781
|
+
"On error:",
|
|
782
|
+
"",
|
|
783
|
+
"```vigiles:err",
|
|
784
|
+
renderShape(contract.err),
|
|
785
|
+
"```",
|
|
786
|
+
].join("\n");
|
|
787
|
+
}
|
|
788
|
+
/** Render the rules a subagent must follow as a `## Rules` section. */
|
|
789
|
+
function renderAgentRules(rules) {
|
|
790
|
+
const parts = ["## Rules", ""];
|
|
791
|
+
for (const [id, rule] of Object.entries(rules)) {
|
|
792
|
+
parts.push(compileRule(id, rule), "");
|
|
793
|
+
}
|
|
794
|
+
return parts.join("\n").trim();
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Compile an AgentSpec into a subagent markdown file with YAML frontmatter.
|
|
798
|
+
* Verifies the tool contract and the body's references; the marks the body
|
|
799
|
+
* carries (`vigiles:symbol`, file/cmd refs) are the same ones `audit` re-checks.
|
|
800
|
+
*/
|
|
801
|
+
function compileAgent(spec, options = {}) {
|
|
802
|
+
const basePath = options.basePath ?? process.cwd();
|
|
803
|
+
const specFile = options.specFile ?? "agent.md.spec.ts";
|
|
804
|
+
const errors = [];
|
|
805
|
+
if (!specFile.endsWith(".spec.ts")) {
|
|
806
|
+
errors.push({
|
|
807
|
+
type: "spec-name-mismatch",
|
|
808
|
+
message: `Spec file "${specFile}" must end with .spec.ts`,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
else if (!/\.md$/i.test((0, node_path_1.basename)(specFile, ".spec.ts"))) {
|
|
812
|
+
errors.push({
|
|
813
|
+
type: "spec-name-mismatch",
|
|
814
|
+
message: `Spec file "${specFile}" should be named <output>.spec.ts (e.g., agents/reviewer.md.spec.ts)`,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
if (spec.tools)
|
|
818
|
+
errors.push(...validateAgentTools(spec.tools));
|
|
819
|
+
if (Array.isArray(spec.body)) {
|
|
820
|
+
errors.push(...validateRefs(spec.body, basePath));
|
|
821
|
+
}
|
|
822
|
+
const sections = [];
|
|
823
|
+
if (spec.body !== undefined)
|
|
824
|
+
sections.push(renderBody(spec.body).trim());
|
|
825
|
+
if (spec.sections) {
|
|
826
|
+
const result = renderAgentSections(spec.sections, basePath);
|
|
827
|
+
sections.push(...result.lines);
|
|
828
|
+
errors.push(...result.errors);
|
|
829
|
+
}
|
|
830
|
+
if (spec.rules && Object.keys(spec.rules).length > 0) {
|
|
831
|
+
sections.push(renderAgentRules(spec.rules));
|
|
832
|
+
}
|
|
833
|
+
if (spec.output)
|
|
834
|
+
sections.push(renderOutputContract(spec.output));
|
|
835
|
+
const body = sections.join("\n\n");
|
|
836
|
+
errors.push(...checkInlineCode(body, DEFAULT_MAX_INLINE_CODE_LINES));
|
|
837
|
+
const content = renderAgentFrontmatter(spec) + "\n\n" + body.trim() + "\n";
|
|
838
|
+
return { markdown: addHash(content, specFile), errors };
|
|
839
|
+
}
|
|
840
|
+
/** Verify a railway: non-empty, bounded recovery, every delegate target real. */
|
|
841
|
+
function validateRailway(rw, knownAgents) {
|
|
842
|
+
const errors = [];
|
|
843
|
+
if (rw.steps.length === 0) {
|
|
844
|
+
errors.push({
|
|
845
|
+
type: "invalid-railway",
|
|
846
|
+
message: `Railway "${rw.name}" has no steps.`,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
if (rw.recover && rw.recover.max < 1) {
|
|
850
|
+
errors.push({
|
|
851
|
+
type: "invalid-railway",
|
|
852
|
+
message: `Railway "${rw.name}" recover.max must be ≥ 1 (got ${String(rw.recover.max)}).`,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
if (knownAgents) {
|
|
856
|
+
const known = new Set(knownAgents);
|
|
857
|
+
const refs = [...rw.steps];
|
|
858
|
+
if (rw.onError)
|
|
859
|
+
refs.push(rw.onError);
|
|
860
|
+
if (rw.recover)
|
|
861
|
+
refs.push(rw.recover.step);
|
|
862
|
+
for (const s of refs) {
|
|
863
|
+
if (!known.has(s.agent)) {
|
|
864
|
+
errors.push({
|
|
865
|
+
type: "stale-ref",
|
|
866
|
+
message: `Railway "${rw.name}" delegates to unknown agent "${s.agent}".`,
|
|
867
|
+
path: s.agent,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return errors;
|
|
873
|
+
}
|
|
874
|
+
/** Render the orchestrator command markdown for a railway. */
|
|
875
|
+
function renderRailwayMarkdown(rw) {
|
|
876
|
+
const lines = [
|
|
877
|
+
`# Railway: ${rw.name}`,
|
|
878
|
+
"",
|
|
879
|
+
"Dispatch these subagents on the **success track**, in order. Each returns a " +
|
|
880
|
+
"result block (`vigiles:ok` / `vigiles:err`). If a step returns an error, " +
|
|
881
|
+
"stop the success track and run the error handler with that error payload.",
|
|
882
|
+
"",
|
|
883
|
+
"## Success track",
|
|
884
|
+
"",
|
|
885
|
+
];
|
|
886
|
+
rw.steps.forEach((s, i) => {
|
|
887
|
+
const task = s.task ? ` — ${s.task}` : "";
|
|
888
|
+
lines.push(`${String(i + 1)}. **${s.agent}**${task}`);
|
|
889
|
+
});
|
|
890
|
+
if (rw.recover) {
|
|
891
|
+
lines.push("", "## Recovery", "", `If a step errors, retry it via **${rw.recover.step.agent}** up to ${String(rw.recover.max)}× before falling to the error track.`);
|
|
892
|
+
}
|
|
893
|
+
if (rw.onError) {
|
|
894
|
+
lines.push("", "## On error", "", `Run **${rw.onError.agent}** with the failing step's error payload.`);
|
|
895
|
+
}
|
|
896
|
+
return lines.join("\n");
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Compile a railway into an orchestrator command markdown (with integrity hash),
|
|
900
|
+
* resolving every delegate target against `knownAgents` when provided.
|
|
901
|
+
*/
|
|
902
|
+
function compileRailway(rw, options = {}) {
|
|
903
|
+
const errors = validateRailway(rw, options.knownAgents);
|
|
904
|
+
const specFile = options.specFile ?? `${rw.name}.railway.spec.ts`;
|
|
905
|
+
const content = renderRailwayMarkdown(rw) + "\n";
|
|
906
|
+
return { markdown: addHash(content, specFile), errors };
|
|
907
|
+
}
|
|
644
908
|
/** Check if a generated file's hash is intact. */
|
|
645
909
|
function checkFileHash(filePath) {
|
|
646
910
|
if (!(0, node_fs_1.existsSync)(filePath)) {
|
|
@@ -673,6 +937,10 @@ function adoptDiff(filePath, spec, basePath) {
|
|
|
673
937
|
const { markdown } = compileSkill(spec, { basePath, specFile: filePath });
|
|
674
938
|
compiledContent = markdown;
|
|
675
939
|
}
|
|
940
|
+
else if (spec._specType === "agent") {
|
|
941
|
+
const { markdown } = compileAgent(spec, { basePath, specFile: filePath });
|
|
942
|
+
compiledContent = markdown;
|
|
943
|
+
}
|
|
676
944
|
// Simple line-based diff
|
|
677
945
|
const currentLines = currentContent.replace(HASH_RE, "").split("\n");
|
|
678
946
|
const compiledLines = (compiledContent ?? "")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type SHA256Hash } from "./hash.js";
|
|
2
|
+
import type { RunOut } from "./eval.js";
|
|
3
|
+
/** Cache behaviour: never touch the cache / read-only / read-and-write. */
|
|
4
|
+
export type CacheMode = "off" | "read" | "readwrite";
|
|
5
|
+
/** Everything that determines a trial's model output (the cache key inputs). */
|
|
6
|
+
export interface CacheKeyInput {
|
|
7
|
+
readonly task: string;
|
|
8
|
+
readonly model: string;
|
|
9
|
+
readonly tools: readonly string[];
|
|
10
|
+
/** The resolved fixture + arm + plugin files written before the run. */
|
|
11
|
+
readonly files: Record<string, string>;
|
|
12
|
+
/** The resolved `.claude/settings.json` for the arm (or undefined). */
|
|
13
|
+
readonly settings: unknown;
|
|
14
|
+
/** Which trial this is — distinct trials are distinct samples, cached apart. */
|
|
15
|
+
readonly trialIndex: number;
|
|
16
|
+
}
|
|
17
|
+
/** A recorded trial: its raw output plus the post-run cwd snapshot. */
|
|
18
|
+
export interface CacheRecord {
|
|
19
|
+
readonly out: RunOut;
|
|
20
|
+
/** Text files present in the cwd after the run (relative path → contents). */
|
|
21
|
+
readonly files: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
/** Deterministic content hash of the key inputs (order-independent). */
|
|
24
|
+
export declare function cacheKey(input: CacheKeyInput): SHA256Hash;
|
|
25
|
+
/** Read a cached record by key, or null on miss / unreadable / malformed. */
|
|
26
|
+
export declare function readCache(dir: string, key: SHA256Hash): CacheRecord | null;
|
|
27
|
+
/** Write a cached record by key (creating the cache dir as needed). */
|
|
28
|
+
export declare function writeCache(dir: string, key: SHA256Hash, record: CacheRecord): void;
|
|
29
|
+
/** Snapshot the text files under `cwd` as `relativePath → contents` (bounded). */
|
|
30
|
+
export declare function snapshotDir(cwd: string): Record<string, string>;
|
|
31
|
+
/** Restore a snapshot into `cwd`, recreating directories as needed. */
|
|
32
|
+
export declare function restoreDir(cwd: string, files: Record<string, string>): void;
|
|
33
|
+
//# sourceMappingURL=eval-cache.d.ts.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.cacheKey = cacheKey;
|
|
4
|
+
exports.readCache = readCache;
|
|
5
|
+
exports.writeCache = writeCache;
|
|
6
|
+
exports.snapshotDir = snapshotDir;
|
|
7
|
+
exports.restoreDir = restoreDir;
|
|
8
|
+
/**
|
|
9
|
+
* vigiles — record/replay cache for the eval tier.
|
|
10
|
+
*
|
|
11
|
+
* A real-model eval is slow and costs money, yet most iteration is on the
|
|
12
|
+
* `measure` function, not the model call. This cache records each trial's raw
|
|
13
|
+
* output AND its post-run filesystem, keyed on everything that determines the
|
|
14
|
+
* model's behaviour — `task`, the resolved fixture files + settings, model,
|
|
15
|
+
* tools, and the trial index — but DELIBERATELY NOT the `measure` function. So
|
|
16
|
+
* editing your metric and re-running re-scores the captured runs for free; the
|
|
17
|
+
* model is only re-called when a model-affecting input changes (or `cache:"off"`,
|
|
18
|
+
* which always re-samples for a fresh statistic).
|
|
19
|
+
*
|
|
20
|
+
* Restoring the post-run filesystem is what makes replay *sound*: `measure`
|
|
21
|
+
* routinely reads agent-produced files via `ctx.file()` / `ctx.sh("grep …")`, so
|
|
22
|
+
* a stdout-only cache would silently mis-score on replay. We snapshot the cwd's
|
|
23
|
+
* text files after the run and restore them into a fresh dir before re-scoring.
|
|
24
|
+
*/
|
|
25
|
+
const node_fs_1 = require("node:fs");
|
|
26
|
+
const node_path_1 = require("node:path");
|
|
27
|
+
const hash_js_1 = require("./hash.js");
|
|
28
|
+
const MAX_SNAPSHOT_FILE_BYTES = 1024 * 1024;
|
|
29
|
+
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
30
|
+
/**
|
|
31
|
+
* Canonicalize a value so the key is stable regardless of object key order —
|
|
32
|
+
* recursively sorts object keys. Arrays keep order (it's significant for tools).
|
|
33
|
+
*/
|
|
34
|
+
function canonical(value) {
|
|
35
|
+
if (Array.isArray(value))
|
|
36
|
+
return value.map(canonical);
|
|
37
|
+
if (value !== null && typeof value === "object") {
|
|
38
|
+
const obj = value;
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const k of Object.keys(obj).sort())
|
|
41
|
+
out[k] = canonical(obj[k]);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
/** Deterministic content hash of the key inputs (order-independent). */
|
|
47
|
+
function cacheKey(input) {
|
|
48
|
+
return (0, hash_js_1.sha256short)(JSON.stringify(canonical(input)));
|
|
49
|
+
}
|
|
50
|
+
/** Read a cached record by key, or null on miss / unreadable / malformed. */
|
|
51
|
+
function readCache(dir, key) {
|
|
52
|
+
const path = (0, node_path_1.join)(dir, `${key}.json`);
|
|
53
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Write a cached record by key (creating the cache dir as needed). */
|
|
63
|
+
function writeCache(dir, key, record) {
|
|
64
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
65
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${key}.json`), JSON.stringify(record));
|
|
66
|
+
}
|
|
67
|
+
/** Snapshot the text files under `cwd` as `relativePath → contents` (bounded). */
|
|
68
|
+
function snapshotDir(cwd) {
|
|
69
|
+
const out = {};
|
|
70
|
+
const walk = (dir) => {
|
|
71
|
+
for (const entry of (0, node_fs_1.readdirSync)(dir)) {
|
|
72
|
+
if (SKIP_DIRS.has(entry))
|
|
73
|
+
continue;
|
|
74
|
+
const full = (0, node_path_1.join)(dir, entry);
|
|
75
|
+
const st = (0, node_fs_1.statSync)(full);
|
|
76
|
+
if (st.isDirectory())
|
|
77
|
+
walk(full);
|
|
78
|
+
else if (st.isFile() && st.size <= MAX_SNAPSHOT_FILE_BYTES) {
|
|
79
|
+
out[(0, node_path_1.relative)(cwd, full)] = (0, node_fs_1.readFileSync)(full, "utf-8");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
walk((0, node_path_1.resolve)(cwd));
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** Restore a snapshot into `cwd`, recreating directories as needed. */
|
|
87
|
+
function restoreDir(cwd, files) {
|
|
88
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
89
|
+
const full = (0, node_path_1.resolve)(cwd, rel);
|
|
90
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(full), { recursive: true });
|
|
91
|
+
(0, node_fs_1.writeFileSync)(full, content);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=eval-cache.js.map
|
package/dist/eval.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ToolCall, type Trace } from "./harness-test.js";
|
|
2
|
+
import { type CacheMode } from "./eval-cache.js";
|
|
1
3
|
/** One arm of the comparison: fixture overrides + settings (hooks) for this arm. */
|
|
2
4
|
export interface EvalArm {
|
|
3
5
|
/** Files written on top of the base fixture for this arm. */
|
|
@@ -19,15 +21,31 @@ export interface EvalArm {
|
|
|
19
21
|
*/
|
|
20
22
|
readonly pluginDir?: string;
|
|
21
23
|
}
|
|
22
|
-
/**
|
|
23
|
-
export interface
|
|
24
|
+
/** Per-run resource use, parsed from the terminal `result` event (0 when absent). */
|
|
25
|
+
export interface EvalUsage {
|
|
26
|
+
/** `total_cost_usd` reported by claude. */
|
|
27
|
+
readonly costUsd: number;
|
|
28
|
+
/** Wall-clock `duration_ms` of the run. */
|
|
29
|
+
readonly durationMs: number;
|
|
30
|
+
readonly inputTokens: number;
|
|
31
|
+
readonly outputTokens: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Context handed to `measure` after a run, to compute that run's metrics. It is
|
|
35
|
+
* a `Trace` (so the bare predicates `usedTool` / `skillResolved` / `toolCount` /
|
|
36
|
+
* `toolUsedWith` from `harness-assert.ts` run over it, the same as over a
|
|
37
|
+
* `runHarnessTest` result) plus the eval-only `sh` end-state probe and `usage`.
|
|
38
|
+
*/
|
|
39
|
+
export interface RunContext extends Trace {
|
|
24
40
|
readonly cwd: string;
|
|
25
41
|
readonly exitCode: number;
|
|
26
42
|
readonly stdout: string;
|
|
27
43
|
/** `num_turns` reported by claude, or 0. */
|
|
28
44
|
readonly turns: number;
|
|
29
|
-
/**
|
|
30
|
-
|
|
45
|
+
/** The tools the agent invoked, each paired with its result (parsed from the stream). */
|
|
46
|
+
readonly toolCalls: readonly ToolCall[];
|
|
47
|
+
/** Cost / latency / tokens for this run (use as metrics, e.g. `{ cost: ctx.usage.costUsd }`). */
|
|
48
|
+
readonly usage: EvalUsage;
|
|
31
49
|
/** Run a shell command in the working dir; returns trimmed stdout ("" on error). */
|
|
32
50
|
sh(command: string): string;
|
|
33
51
|
}
|
|
@@ -52,6 +70,32 @@ export interface EvalSpec<M extends Metrics> {
|
|
|
52
70
|
readonly timeoutMs?: number;
|
|
53
71
|
/** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
|
|
54
72
|
readonly spacingSec?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Record/replay cache mode. Default `"off"` (always re-sample). `"readwrite"`
|
|
75
|
+
* records each trial (output + post-run files) and replays it on a matching
|
|
76
|
+
* re-run — so editing `measure` re-scores for free; the model is re-called only
|
|
77
|
+
* when a model-affecting input changes. `"read"` replays but never records.
|
|
78
|
+
* The cache key excludes `measure`, so changing your metric still hits.
|
|
79
|
+
*/
|
|
80
|
+
readonly cache?: CacheMode;
|
|
81
|
+
/** Where cache records live. Default `.vigiles/eval-cache` under cwd. */
|
|
82
|
+
readonly cacheDir?: string;
|
|
83
|
+
/**
|
|
84
|
+
* How many trials to run at once (across all arms × trials). Default 1 (fully
|
|
85
|
+
* sequential — the safe, no-surprise default). Raise it to cut wall-clock time;
|
|
86
|
+
* rate-limit bursts are absorbed by the retry/backoff below.
|
|
87
|
+
*/
|
|
88
|
+
readonly concurrency?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Abort the run once measured cost reaches this many USD. In-flight trials
|
|
91
|
+
* finish; remaining ones are skipped and `report.aborted` is set. Needs the
|
|
92
|
+
* model to report `total_cost_usd` (the eval tier does).
|
|
93
|
+
*/
|
|
94
|
+
readonly maxCostUsd?: number;
|
|
95
|
+
/** Retries on a detected rate-limit/overload before giving up. Default 3. */
|
|
96
|
+
readonly rateLimitRetries?: number;
|
|
97
|
+
/** Base backoff ms (doubled each retry). Default 1000. */
|
|
98
|
+
readonly retryBackoffMs?: number;
|
|
55
99
|
}
|
|
56
100
|
/** Per-metric summary statistics across an arm's runs. */
|
|
57
101
|
export interface MetricStat {
|
|
@@ -63,6 +107,21 @@ export interface MetricStat {
|
|
|
63
107
|
readonly se: number;
|
|
64
108
|
/** Number of runs the metric was observed in. */
|
|
65
109
|
readonly n: number;
|
|
110
|
+
/**
|
|
111
|
+
* pass^k (τ-bench): 1 if the metric succeeded on EVERY trial, else 0. The
|
|
112
|
+
* reliability question a non-deterministic harness needs — "worked every time"
|
|
113
|
+
* is not "worked on average". A trial counts as a success when its value is
|
|
114
|
+
* truthy (booleans true, counts > 0), so model your metric as success/fail.
|
|
115
|
+
*/
|
|
116
|
+
readonly passK: number;
|
|
117
|
+
}
|
|
118
|
+
/** Aggregated cost / latency / tokens across an arm's runs. */
|
|
119
|
+
export interface ArmUsage {
|
|
120
|
+
readonly totalCostUsd: number;
|
|
121
|
+
readonly meanCostUsd: number;
|
|
122
|
+
readonly meanDurationMs: number;
|
|
123
|
+
readonly totalInputTokens: number;
|
|
124
|
+
readonly totalOutputTokens: number;
|
|
66
125
|
}
|
|
67
126
|
export interface ArmReport {
|
|
68
127
|
readonly runs: number;
|
|
@@ -70,12 +129,51 @@ export interface ArmReport {
|
|
|
70
129
|
readonly metrics: Record<string, number>;
|
|
71
130
|
/** Per-metric mean / std / se / n, so an A/B gap can be read for significance. */
|
|
72
131
|
readonly stats: Record<string, MetricStat>;
|
|
132
|
+
/** Cost / latency / token totals + means for this arm. */
|
|
133
|
+
readonly usage: ArmUsage;
|
|
73
134
|
}
|
|
74
135
|
export interface EvalReport {
|
|
75
136
|
readonly name: string;
|
|
76
137
|
readonly trials: number;
|
|
77
138
|
readonly arms: Record<string, ArmReport>;
|
|
139
|
+
/** Total measured cost across every arm × trial (0 when usage wasn't reported). */
|
|
140
|
+
readonly totalCostUsd: number;
|
|
141
|
+
/** True if a `maxCostUsd` budget cap stopped the run before all trials ran. */
|
|
142
|
+
readonly aborted: boolean;
|
|
78
143
|
}
|
|
144
|
+
/** The raw output of one trial: the agent's exit code + captured streams. */
|
|
145
|
+
export interface RunOut {
|
|
146
|
+
code: number;
|
|
147
|
+
stdout: string;
|
|
148
|
+
/** Captured stderr, when the runner provides it (used for rate-limit detection). */
|
|
149
|
+
stderr?: string;
|
|
150
|
+
}
|
|
151
|
+
/** The per-trial arguments handed to an {@link AgentRunner}. */
|
|
152
|
+
export interface AgentRunArgs {
|
|
153
|
+
readonly task: string;
|
|
154
|
+
readonly cwd: string;
|
|
155
|
+
readonly model: string;
|
|
156
|
+
readonly tools: readonly string[];
|
|
157
|
+
readonly hasSettings: boolean;
|
|
158
|
+
readonly pluginDir: string | undefined;
|
|
159
|
+
readonly timeoutMs: number;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Runs one trial and returns its raw output. The default ({@link spawnAgent})
|
|
163
|
+
* drives the real `claude` CLI; `runEvalWith` takes one explicitly, so the eval
|
|
164
|
+
* orchestration is testable without a model (pass a fake returning canned
|
|
165
|
+
* stream-json) and a custom runtime can be plugged in.
|
|
166
|
+
*/
|
|
167
|
+
export type AgentRunner = (args: AgentRunArgs) => Promise<RunOut>;
|
|
168
|
+
/**
|
|
169
|
+
* Run the eval: every arm × every trial against the real `claude` CLI, with the
|
|
170
|
+
* metric computed per run and aggregated per arm. Requires `claude` on PATH and
|
|
171
|
+
* working model auth (e.g. `ANTHROPIC_API_KEY`). Thin wrapper over
|
|
172
|
+
* {@link runEvalWith} with the real agent runner.
|
|
173
|
+
*/
|
|
174
|
+
export declare function runEval<M extends Metrics>(spec: EvalSpec<M>): Promise<EvalReport>;
|
|
175
|
+
/** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
|
|
176
|
+
export declare function parseUsage(stdout: string): EvalUsage;
|
|
79
177
|
/** Aggregate per-run metrics: mean for numbers, fraction-true (0..1) for booleans. */
|
|
80
178
|
export declare function aggregate(rows: readonly Metrics[]): Record<string, number>;
|
|
81
179
|
/**
|
|
@@ -84,12 +182,77 @@ export declare function aggregate(rows: readonly Metrics[]): Record<string, numb
|
|
|
84
182
|
* a difference smaller than the combined se is not yet significant.
|
|
85
183
|
*/
|
|
86
184
|
export declare function aggregateStats(rows: readonly Metrics[]): Record<string, MetricStat>;
|
|
185
|
+
/** Aggregate per-run usage into an arm's cost / latency / token totals + means. */
|
|
186
|
+
export declare function aggregateUsage(usages: readonly EvalUsage[]): ArmUsage;
|
|
187
|
+
/** Whether a run's captured output looks like a rate-limit / overload. Pure. */
|
|
188
|
+
export declare function isRateLimited(out: RunOut): boolean;
|
|
189
|
+
/** Map `worker` over `items` with at most `concurrency` in flight, order preserved. */
|
|
190
|
+
export declare function runPool<T, R>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<R>): Promise<R[]>;
|
|
87
191
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
192
|
+
* The eval orchestration — every arm × trial via `runner`, run through the cache
|
|
193
|
+
* and a rate-limit retry, with at most `concurrency` in flight and an optional
|
|
194
|
+
* `maxCostUsd` budget cap; metric + usage computed per run and aggregated per
|
|
195
|
+
* arm. Exported with an injectable `runner` so the loop, `measure` context,
|
|
196
|
+
* caching, pooling, and aggregation are unit-testable without spawning a model
|
|
197
|
+
* (pass a fake returning canned stream-json). `runEval` is this with the real
|
|
198
|
+
* agent runner.
|
|
91
199
|
*/
|
|
92
|
-
export declare function
|
|
93
|
-
/** Format an eval report as a compact table for the console (mean ± se). */
|
|
200
|
+
export declare function runEvalWith<M extends Metrics>(spec: EvalSpec<M>, runner: AgentRunner): Promise<EvalReport>;
|
|
201
|
+
/** Format an eval report as a compact table for the console (mean ± se, pass^k). */
|
|
94
202
|
export declare function formatEvalReport(report: EvalReport): string;
|
|
203
|
+
/**
|
|
204
|
+
* Measure how reliably a skill/behaviour *triggers*. A skill's value is its
|
|
205
|
+
* description firing on the right task — the #1 documented skill-authoring pain —
|
|
206
|
+
* and that's a property of the real model, not the wiring (which the
|
|
207
|
+
* deterministic tier already proves). Install the plugin natively (`pluginDir`),
|
|
208
|
+
* give a set of varied `prompts`, and a `fired` predicate over the run's `Trace`
|
|
209
|
+
* (reuse the bare predicates, e.g. `(t) => skillResolved(t, "x:y")`).
|
|
210
|
+
*/
|
|
211
|
+
export interface TriggerRateSpec {
|
|
212
|
+
/** Plugin dir installed natively (`--plugin-dir`) so its skills/commands activate. */
|
|
213
|
+
readonly pluginDir: string;
|
|
214
|
+
/** The varied prompts to test the trigger against. */
|
|
215
|
+
readonly prompts: readonly string[];
|
|
216
|
+
/** Did the behaviour fire on this run? e.g. `(t) => skillResolved(t, "x:y")`. */
|
|
217
|
+
readonly fired: (trace: Trace) => boolean;
|
|
218
|
+
/** Trials per prompt. Default 1. */
|
|
219
|
+
readonly trials?: number;
|
|
220
|
+
/** Model alias. Default "haiku". */
|
|
221
|
+
readonly model?: string;
|
|
222
|
+
/** Tools the agent may use. Default: Read Edit Write Bash Skill. */
|
|
223
|
+
readonly allowedTools?: readonly string[];
|
|
224
|
+
/** Per-run timeout ms. Default 240000. */
|
|
225
|
+
readonly timeoutMs?: number;
|
|
226
|
+
/** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
|
|
227
|
+
readonly spacingSec?: number;
|
|
228
|
+
}
|
|
229
|
+
/** Per-prompt trigger result: how many of its trials fired. */
|
|
230
|
+
export interface PromptTriggerStat {
|
|
231
|
+
readonly prompt: string;
|
|
232
|
+
readonly fired: number;
|
|
233
|
+
readonly trials: number;
|
|
234
|
+
/** `fired / trials` (0 when no trials). */
|
|
235
|
+
readonly rate: number;
|
|
236
|
+
}
|
|
237
|
+
export interface TriggerRateReport {
|
|
238
|
+
/** Overall fraction of runs in which the behaviour fired (0..1). */
|
|
239
|
+
readonly rate: number;
|
|
240
|
+
/** Total runs (prompts × trials). */
|
|
241
|
+
readonly n: number;
|
|
242
|
+
readonly perPrompt: readonly PromptTriggerStat[];
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Trigger-rate orchestration — every prompt × trial via `runner`, the `fired`
|
|
246
|
+
* predicate evaluated per run and aggregated into an overall + per-prompt rate.
|
|
247
|
+
* Exported with an injectable `runner` so the loop is unit-testable without a
|
|
248
|
+
* model; `measureTriggerRate` is this with the real agent runner.
|
|
249
|
+
*/
|
|
250
|
+
export declare function measureTriggerRateWith(spec: TriggerRateSpec, runner: AgentRunner): Promise<TriggerRateReport>;
|
|
251
|
+
/**
|
|
252
|
+
* Measure a skill/behaviour's real trigger rate across prompts × trials against
|
|
253
|
+
* the real `claude` CLI. Requires `claude` + model auth.
|
|
254
|
+
*/
|
|
255
|
+
export declare function measureTriggerRate(spec: TriggerRateSpec): Promise<TriggerRateReport>;
|
|
256
|
+
/** Format a trigger-rate report: overall %, then each prompt's rate. */
|
|
257
|
+
export declare function formatTriggerRateReport(report: TriggerRateReport): string;
|
|
95
258
|
//# sourceMappingURL=eval.d.ts.map
|