amness 0.1.2 → 0.1.4

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.
@@ -0,0 +1,218 @@
1
+ // The LOOP section of the sidebar: the cycle the agent goes round (gather context, act, verify), which part of it is running now, and the latest steps, with the harness's
2
+ // interventions shown where they happened. The stages are INFERRED from the tool calls (the harness has no gather/act/verify phases of its own): reads and searches gather,
3
+ // edits and most commands act, test/build/lint/typecheck commands and the verify gate verify. It adapts to the room it has: older steps fold into one summary line, the step cap
4
+ // appears only when it starts to matter, and a clicked step opens to show every tool call and note. The full history is the /loop command.
5
+ import { mix } from "./ansi.js";
6
+ import { BOLD, F } from "./rich.js";
7
+ import { clipWidth } from "./text.js";
8
+ import { toolLabel } from "./view.js";
9
+ const SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
10
+ const fmtMs = (ms) => (ms < 1000 ? `${Math.round(ms)}ms` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`);
11
+ /** Notes that mean "the harness stepped in" (shown in the warning colour, and kept visible); everything else (cache, routing, trimming) is background. */
12
+ export const isIntervention = (note) => /nudg|reject|denied|loop|stagnat|stuck|budget|step limit|reviewer|wrap|repair/i.test(note);
13
+ /** "Read ×2 → Edit → Run": neighbouring calls of one kind merge, so a burst of reads is one short phrase. */
14
+ export function stepSummary(tools) {
15
+ const parts = [];
16
+ for (const t of tools) {
17
+ const label = toolLabel(t.name), last = parts[parts.length - 1];
18
+ if (last && last.label === label)
19
+ last.n++;
20
+ else
21
+ parts.push({ label, n: 1 });
22
+ }
23
+ return parts.map((p) => (p.n > 1 ? `${p.label} ×${p.n}` : p.label)).join(" → ");
24
+ }
25
+ /** Wall time of a step: the model call, then its tools (they overlap when run in parallel). */
26
+ export const stepMs = (s) => s.ms + (s.parallel && s.parallel > 1 ? Math.max(0, ...s.tools.map((x) => x.ms)) : s.tools.reduce((n, x) => n + x.ms, 0));
27
+ /** The cap is a safety stop, not a goal, so say nothing about it until it could matter. */
28
+ export function capNote(step, cap) {
29
+ const r = step / Math.max(1, cap);
30
+ return r >= 0.8 ? { text: `${step}/${cap}: close to the step limit`, level: "warn" } : r >= 0.5 ? { text: `of ${cap} max`, level: "dim" } : { text: "", level: "none" };
31
+ }
32
+ export const STAGES = ["gather", "act", "verify"];
33
+ export const STAGE_GLYPH = { gather: "◉", act: "✎", verify: "✓" };
34
+ const GATHER_TOOLS = new Set(["read_file", "list_files", "grep", "repo_map", "find_refs", "impact", "web_search", "fetch_url", "memory_search", "tool_search", "skill", "task", "sh_list", "sh_read"]);
35
+ const NEUTRAL_TOOLS = new Set(["todo_write", "memory_save", "memory_forget", "exit_plan_mode", "finish"]); // planning and bookkeeping: not part of the cycle
36
+ const VERIFY_CMD = /\b(tests?|pytest|jest|vitest|mocha|tsc|typecheck|type-check|lint|eslint|ruff|mypy|pyright|flake8|clippy|rspec|phpunit|verify|check)\b|\b(cargo|go|npm|yarn|pnpm|make|gradle|mvn|dotnet|swift)\s+(run\s+)?(test|build|check|vet|lint|clippy|verify)\b|node\s+--test|\bbuild\b/i;
37
+ const LOOK_CMD = /^\s*(ls|cat|head|tail|less|grep|rg|find|tree|pwd|wc|stat|file|which|du|df|git\s+(status|diff|log|show|branch|blame)|sed\s+-n)\b/;
38
+ /** Which part of the cycle a tool call belongs to. A guess from the tool and, for shell commands, the command line; undefined for planning and bookkeeping tools. */
39
+ export function stageOfTool(name, args = "") {
40
+ if (NEUTRAL_TOOLS.has(name))
41
+ return undefined;
42
+ if (GATHER_TOOLS.has(name))
43
+ return "gather";
44
+ if (name === "sh" || name === "sh_start") {
45
+ let cmd = "";
46
+ try {
47
+ cmd = String(JSON.parse(args).cmd ?? "");
48
+ }
49
+ catch {
50
+ cmd = args;
51
+ }
52
+ return LOOK_CMD.test(cmd) ? "gather" : VERIFY_CMD.test(cmd) ? "verify" : "act";
53
+ }
54
+ return "act";
55
+ }
56
+ /** The stages a step touched, in cycle order. */
57
+ export const stagesOf = (tools) => STAGES.filter((st) => tools.some((x) => stageOfTool(x.name, x.args) === st));
58
+ /** How many times the agent has come back round: a new round starts when it gathers or acts again after verifying. */
59
+ export function roundOf(steps) {
60
+ let round = 1, verified = false;
61
+ for (const s of steps) {
62
+ const st = stagesOf(s.tools);
63
+ if (verified && (st.includes("gather") || st.includes("act"))) {
64
+ round++;
65
+ verified = false;
66
+ }
67
+ if (st.includes("verify"))
68
+ verified = true;
69
+ }
70
+ return round;
71
+ }
72
+ /** Which stages to light: those running now, and (while the model is thinking and nothing runs) the last one used, dimmed. */
73
+ export function litStages(d) {
74
+ const now = new Set(), recent = new Set(), cur = d.steps[d.steps.length - 1];
75
+ if (d.live) {
76
+ if (d.phase === "verify")
77
+ now.add("verify");
78
+ else if (d.phase === "tools") {
79
+ for (const x of d.inflight) {
80
+ const st = x.name ? stageOfTool(x.name, x.args) : undefined;
81
+ if (st)
82
+ now.add(st);
83
+ }
84
+ if (!now.size && cur) {
85
+ const last = cur.tools[cur.tools.length - 1];
86
+ const st = last && stageOfTool(last.name, last.args);
87
+ if (st)
88
+ now.add(st);
89
+ }
90
+ }
91
+ }
92
+ if (!now.size)
93
+ for (let i = d.steps.length - 1; i >= 0; i--) {
94
+ const st = stagesOf(d.steps[i].tools);
95
+ if (st.length) {
96
+ st.forEach((x) => recent.add(x));
97
+ break;
98
+ }
99
+ }
100
+ return { now, recent };
101
+ }
102
+ export function renderLoop(d, t, inner, maxRows) {
103
+ const lines = [], hits = [];
104
+ const cur = d.live ? d.steps[d.steps.length - 1] : undefined, round = roundOf(d.steps);
105
+ const tools = d.steps.reduce((n, s) => n + s.tools.length, 0), failed = d.steps.reduce((n, s) => n + s.tools.filter((x) => !x.ok).length, 0);
106
+ const color = { gather: t.tool.read, act: t.tool.edit, verify: t.ok };
107
+ const title = d.live ? "LOOP" : "LAST RUN";
108
+ const cap = cur ? capNote(cur.n, d.cap) : { text: "", level: "none" };
109
+ const right = d.live && cur ? F(t.dim, `round ${round} · step ${cur.n}${cap.level === "dim" ? " of " + d.cap : ""}`) : F(t.dim, `${Math.round(d.elapsedMs / 1000)}s`);
110
+ const spin = F(t.accent2, SPIN[d.frame % SPIN.length]);
111
+ // the cycle: the stage that is running is bright, the one just used (while the model thinks) is half-bright, the rest are dim. A verify that failed stays red until the next one passes.
112
+ const { now, recent } = litStages(d);
113
+ let verifyFailed = false;
114
+ for (const s of d.steps) {
115
+ const v = s.tools.filter((x) => stageOfTool(x.name, x.args) === "verify");
116
+ if (v.length)
117
+ verifyFailed = v.some((x) => !x.ok);
118
+ }
119
+ const compact = inner < 27;
120
+ const cycle = STAGES.map((st) => {
121
+ const c = st === "verify" && verifyFailed ? t.err : color[st], label = compact ? "" : " " + st, text = STAGE_GLYPH[st] + label;
122
+ return now.has(st) ? BOLD(F(c, text)) : recent.has(st) || (st === "verify" && verifyFailed) ? F(mix(c, t.surface, 0.35), text) : F(t.faint, text);
123
+ }).join(F(t.border, " ▸ "));
124
+ lines.push(cycle);
125
+ if (d.live && cur) {
126
+ const act = d.phase === "compact" ? "compacting the history" : d.phase === "verify" ? "verifying the result" : d.phase === "tools" && d.inflight.length ? "" : `thinking ${fmtMs(Math.max(0, d.now - cur.t0))}`;
127
+ if (act)
128
+ lines.push(spin + " " + F(t.dim, act));
129
+ else {
130
+ const x = d.inflight[0];
131
+ lines.push(spin + " " + F(t.text, clipWidth((x.agent && x.agent !== "lead" ? "↳ " : "") + x.label, inner - 9)) + F(t.faint, ` ${fmtMs(x.ms)}`));
132
+ if (d.inflight.length > 1)
133
+ lines.push(" " + F(t.faint, `+${d.inflight.length - 1} more running`));
134
+ }
135
+ if (cap.level === "warn") {
136
+ const w = Math.max(6, Math.min(20, inner - 4)), f = Math.max(1, Math.min(w, Math.round((cur.n / d.cap) * w)));
137
+ lines.push(F(t.warn, "▰".repeat(f)) + F(t.border, "▱".repeat(w - f)) + F(t.warn, " " + cap.text));
138
+ }
139
+ }
140
+ const lastN = d.steps[d.steps.length - 1]?.n ?? 0;
141
+ const blocks = d.steps.map((s) => {
142
+ const isCur = d.live && s === cur, open = d.open.includes(s.n), out = [], bad = s.tools.some((x) => !x.ok);
143
+ const st = isCur && now.size ? [...now] : stagesOf(s.tools);
144
+ const glyphs = st.length ? st.map((x) => F(bad && !isCur ? t.err : color[x], STAGE_GLYPH[x])).join("") : isCur ? F(t.accent2, "▸") : F(t.faint, "·");
145
+ const what = isCur && !s.tools.length && !d.inflight.length ? "thinking…" : s.tools.length || isCur ? stepSummary(s.tools) || (d.inflight.length ? "…" : "") : "replied";
146
+ const time = isCur ? "" : fmtMs(stepMs(s)), num = String(s.n).padStart(2);
147
+ const body = clipWidth(what, Math.max(4, inner - 8 - st.length - (bad && !isCur ? 2 : 0) - time.length));
148
+ out.push(F(t.faint, num) + " " + glyphs + " " + (isCur ? F(t.text, body) : F(bad ? t.err : t.dim, body)) + (bad && !isCur ? F(t.err, " ✗") : "") + (time ? F(t.faint, " " + time) : ""));
149
+ const showAll = open || isCur;
150
+ if (open)
151
+ for (const x of s.tools)
152
+ out.push(" " + (x.ok ? F(t.ok, "✓ ") : F(t.err, "✗ ")) + F(t.dim, clipWidth(`${toolLabel(x.name)} ${shortArgs(x.args)}`, inner - 12)) + F(t.faint, ` ${fmtMs(x.ms)}`));
153
+ let hotNote = false;
154
+ for (const n of s.notes) {
155
+ const hot = isIntervention(n);
156
+ hotNote ||= hot;
157
+ if (!(hot || showAll || s.n >= lastN - 2))
158
+ continue;
159
+ // what the harness did is worth reading in full: an intervention wraps to two lines, background notes are clipped to one
160
+ const parts = hot ? wrapWords(n, inner - 7, 2) : [clipWidth(n, inner - 7)];
161
+ parts.forEach((l, i) => out.push(" " + (i ? " " : F(hot ? t.warn : t.faint, hot ? "⚑ " : "· ")) + F(hot ? t.dim : t.faint, l)));
162
+ }
163
+ return { step: s.n, lines: out, must: s.n > lastN - 12 && (hotNote || open) };
164
+ });
165
+ // choose from the newest back: about as many steps as the room allows (3 when it is short), and every step that has an intervention or was opened
166
+ const room = Math.max(3, maxRows - lines.length - 1), nominal = Math.max(3, Math.min(8, Math.floor(room / 2)));
167
+ const shown = new Set();
168
+ let used = 0, count = 0;
169
+ const fitsAll = blocks.reduce((n, b) => n + b.lines.length, 0) <= room; // everything fits: fold nothing
170
+ for (let i = blocks.length - 1; i >= 0 && fitsAll; i--)
171
+ shown.add(i);
172
+ for (let i = blocks.length - 1; i >= 0 && !fitsAll; i--) {
173
+ const b = blocks[i], need = b.lines.length;
174
+ if (!shown.size) {
175
+ shown.add(i);
176
+ used += need;
177
+ count++;
178
+ continue;
179
+ } // the newest step is always there
180
+ if (used + need <= room && (count < nominal || b.must)) {
181
+ shown.add(i);
182
+ used += need;
183
+ count++;
184
+ }
185
+ }
186
+ const hidden = blocks.filter((_, i) => !shown.has(i));
187
+ const firstShown = Math.min(...shown);
188
+ if (hidden.length && blocks.findIndex((_, i) => !shown.has(i)) < firstShown) {
189
+ const old = blocks.slice(0, firstShown), ot = old.reduce((n, b) => n + d.steps.find((s) => s.n === b.step).tools.length, 0), of = old.reduce((n, b) => n + d.steps.find((s) => s.n === b.step).tools.filter((x) => !x.ok).length, 0);
190
+ lines.push(F(t.faint, `… ${old.length} earlier step${old.length === 1 ? "" : "s"} · ${ot} tool${ot === 1 ? "" : "s"}${of ? ` · ${of} failed` : ""}`));
191
+ }
192
+ blocks.forEach((b, i) => { if (!shown.has(i))
193
+ return; hits.push({ line: lines.length, step: b.step }); lines.push(...b.lines); });
194
+ if (!d.live || hidden.length)
195
+ lines.push(F(t.faint, `${d.steps.length} step${d.steps.length === 1 ? "" : "s"} · ${tools} tool${tools === 1 ? "" : "s"}${failed ? " · " : ""}`) + (failed ? F(t.err, `${failed} failed`) : "") + F(t.faint, " /loop: all"));
196
+ return { lines, hits, title, right };
197
+ }
198
+ function wrapWords(s, w, maxLines) {
199
+ const lines = [""];
200
+ for (const word of s.split(/\s+/)) {
201
+ const last = lines[lines.length - 1];
202
+ if ((last + " " + word).trim().length <= w)
203
+ lines[lines.length - 1] = (last + " " + word).trim();
204
+ else
205
+ lines.push(word);
206
+ }
207
+ const cut = lines.slice(0, maxLines);
208
+ if (lines.length > maxLines)
209
+ cut[maxLines - 1] = clipWidth(cut[maxLines - 1], w - 1) + "…";
210
+ return cut.map((l) => clipWidth(l, w));
211
+ }
212
+ function shortArgs(raw) { try {
213
+ const a = JSON.parse(raw);
214
+ return String(a.path ?? a.cmd ?? a.pattern ?? a.query ?? a.url ?? a.name ?? "").replace(/\s+/g, " ");
215
+ }
216
+ catch {
217
+ return "";
218
+ } }
@@ -1,5 +1,6 @@
1
1
  import { type Cell } from "./ansi.ts";
2
2
  import type { Theme } from "./themes.ts";
3
+ import { type LoopData } from "./looppanel.ts";
3
4
  export type SidebarData = {
4
5
  title: string;
5
6
  model: string;
@@ -37,23 +38,7 @@ export type SidebarData = {
37
38
  created: boolean;
38
39
  blockId: number;
39
40
  }[];
40
- run?: {
41
- phase: string;
42
- step: number;
43
- max: number;
44
- elapsedMs: number;
45
- frame: number;
46
- live: {
47
- label: string;
48
- ms: number;
49
- agent?: string;
50
- }[];
51
- done: {
52
- label: string;
53
- ok: boolean;
54
- ms: number;
55
- }[];
56
- };
41
+ /** the agent loop of the current (or last) run, step by step */ loop?: LoopData;
57
42
  sandbox?: string;
58
43
  /** one line per plugin status (see Plugin.status) */ pluginStatus?: string[];
59
44
  /** the token cap for the current/last turn: tokens counted (cached input at a tenth), the cap, and whether usage was estimated */ budget?: {
@@ -75,8 +60,9 @@ export type SidebarData = {
75
60
  };
76
61
  export type SideHit = {
77
62
  row: number;
78
- kind: "change" | "todos";
63
+ kind: "change" | "todos" | "step";
79
64
  blockId?: number;
65
+ step?: number;
80
66
  };
81
67
  export declare function renderSidebar(d: SidebarData, t: Theme, width: number, height: number): {
82
68
  rows: Cell[][];
@@ -4,6 +4,7 @@ import { BOLD, F, ITAL, fit, split } from "./rich.js";
4
4
  import { cellWidth, clipWidth } from "./text.js";
5
5
  import { brandOfModel } from "./brand.js";
6
6
  import { fmtCost, fmtTokens } from "./pricing.js";
7
+ import { renderLoop } from "./looppanel.js";
7
8
  const BARS = "▁▂▃▄▅▆▇█";
8
9
  export function renderSidebar(d, t, width, height) {
9
10
  const inner = width - 4, out = [], hits = [], bg = t.surface;
@@ -12,16 +13,13 @@ export function renderSidebar(d, t, width, height) {
12
13
  push(); push(right ? split(F(t.faint, BOLD(title)), right, inner, bg) : F(t.faint, BOLD(title))); };
13
14
  const brand = brandOfModel(d.model, d.provider, t);
14
15
  push();
15
- if (d.run) {
16
- const r = d.run, SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏", stages = [["model", "think"], ["tools", "act"], ["verify", "verify"]];
17
- head("RUN", F(t.dim, `${(r.elapsedMs / 1000).toFixed(0)}s`));
18
- push(stages.map(([k, label], i) => (k === r.phase || (r.phase === "compact" && k === "model") ? BOLD(F(t.accent, "● " + label)) : F(t.faint, "○ " + label)) + (i < 2 ? F(t.border, " ─ ") : "")).join(""));
19
- const lbl = `step ${r.step + 1}/${r.max}`, bw = Math.max(6, Math.min(24, inner - cellWidth(lbl) - 2)), f = Math.max(1, Math.round(((r.step + 1) / Math.max(1, r.max)) * bw));
20
- push(F(r.step + 1 >= r.max - 2 ? t.warn : t.accent2, "▰".repeat(f)) + F(t.border, "▱".repeat(bw - f)) + F(t.dim, " " + lbl));
21
- for (const x of r.live.slice(0, 5))
22
- push(F(t.accent2, SPIN[r.frame % SPIN.length]) + " " + F(t.text, clipWidth(x.label, inner - 9)) + F(t.faint, ` ${fmtMs(x.ms)}`));
23
- for (const x of r.done.slice(-4).reverse())
24
- push(F(x.ok ? t.ok : t.err, x.ok ? "✓" : "✗") + " " + F(t.dim, clipWidth(x.label, inner - 9)) + F(t.faint, x.ms >= 50 ? ` ${fmtMs(x.ms)}` : ""));
16
+ if (d.loop && d.loop.steps.length) {
17
+ const room = Math.max(4, Math.min(14, height - 26)), lp = renderLoop(d.loop, t, inner, room);
18
+ head(lp.title, lp.right);
19
+ for (const h of lp.hits)
20
+ hits.push({ row: out.length + h.line, kind: "step", step: h.step });
21
+ for (const l of lp.lines)
22
+ push(l);
25
23
  }
26
24
  head("SESSION");
27
25
  for (const l of wrap(d.title || "New conversation", inner, 2))
@@ -10,7 +10,13 @@ export type Case = {
10
10
  /** Shown to the agent: a command it may run to check its work (and the `finish` gate in the full variant). */
11
11
  verify?: string;
12
12
  /** Held-out judge: shell command (exit 0 = pass) or function. The agent never sees this. */
13
- check: string | ((dir: string) => boolean | string | Promise<boolean | string>);
13
+ check: string | ((dir: string, run?: {
14
+ text: string;
15
+ steps: number;
16
+ tokens: number;
17
+ }) => boolean | string | Promise<boolean | string>);
18
+ /** What the agent would say when it finishes with `solution` applied. Only needed when `check` reads `run.text` (for example "the summary names the file", "the summary admits it could not verify"). */
19
+ solutionSummary?: string;
14
20
  /** A known-good fix (files to overwrite). Used by `selfTest` to prove the case is solvable and its check is not trivially true. */
15
21
  solution?: Record<string, string>;
16
22
  /** Extra tools for this case (a stubbed web page, a fake MCP server): the agent gets them next to its own. */
@@ -60,7 +66,8 @@ export declare function evaluate(cases: Case[], variants: Variant[], o?: {
60
66
  onRow?: (r: Row) => void;
61
67
  baseline?: string;
62
68
  k?: number;
63
- seed?: number;
69
+ seed?: number; /** Called before each run starts with the rows so far; a returned reason stops the rest (runs already started finish). Used for cost caps. */
70
+ stop?: (rows: Row[]) => string | undefined;
64
71
  }): Promise<{
65
72
  rows: Row[];
66
73
  summary: {
@@ -73,6 +80,8 @@ export declare function evaluate(cases: Case[], variants: Variant[], o?: {
73
80
  }[];
74
81
  table: string;
75
82
  analysis: Analysis;
83
+ stopped: string | undefined;
84
+ skipped: number;
76
85
  }>;
77
86
  export declare function formatTable(s: {
78
87
  variant: string;
@@ -195,3 +204,15 @@ export declare function gateCompare(current: Row[], baseline: Row[], o?: {
195
204
  costPct?: number;
196
205
  seed?: number;
197
206
  }): GateVerdict[];
207
+ /** How hard each task is for one variant (use `baseline`): a task every run passes, or none does, cannot tell two variants apart. Keep tasks whose pass rate is between `low` and `high`. */
208
+ export type Calibration = {
209
+ case: string;
210
+ runs: number;
211
+ passes: number;
212
+ rate: number;
213
+ verdict: "keep" | "too easy" | "too hard";
214
+ };
215
+ export declare function calibrate(rows: Row[], variant?: string, band?: {
216
+ low: number;
217
+ high: number;
218
+ }): Calibration[];
package/dist/src/evals.js CHANGED
@@ -12,6 +12,7 @@ import { bootstrap, holm, mean, minDetectable, passPowK, signTest } from "./stat
12
12
  /** Run every case x variant x trial in a fresh temp workspace and judge each result with the case's held-out check. `jobs` runs that many sessions at once. */
13
13
  export async function evaluate(cases, variants, o = {}) {
14
14
  const rows = [], work = [];
15
+ let stopped;
15
16
  for (const c of cases)
16
17
  for (const v of variants)
17
18
  for (let trial = 0; trial < (o.trials ?? 1); trial++)
@@ -39,7 +40,7 @@ export async function evaluate(cases, variants, o = {}) {
39
40
  try {
40
41
  const out = await v.run(c, dir);
41
42
  Object.assign(row, { steps: out.steps, tokens: out.tokens, events: out.events, prompt: out.prompt, cached: out.cached });
42
- const j = typeof c.check === "string" ? ((await runShell(c.check, dir, 120_000)).startsWith("exit=0") || "check command failed") : await c.check(dir);
43
+ const j = typeof c.check === "string" ? ((await runShell(c.check, dir, 120_000)).startsWith("exit=0") || "check command failed") : await c.check(dir, { text: out.text, steps: out.steps, tokens: out.tokens });
43
44
  row.pass = j === true;
44
45
  if (j !== true)
45
46
  row.note = String(j || "failed");
@@ -64,14 +65,18 @@ export async function evaluate(cases, variants, o = {}) {
64
65
  rmSync(dir, { recursive: true, force: true });
65
66
  });
66
67
  let next = 0;
67
- await Promise.all(Array.from({ length: Math.max(1, Math.min(o.jobs ?? 1, work.length)) }, async () => { while (next < work.length)
68
- await work[next++](); }));
68
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(o.jobs ?? 1, work.length)) }, async () => { while (next < work.length) {
69
+ stopped ??= o.stop?.(rows);
70
+ if (stopped)
71
+ return;
72
+ await work[next++]();
73
+ } }));
69
74
  rows.sort((a, b) => cases.findIndex((c) => c.name === a.case) - cases.findIndex((c) => c.name === b.case) || variants.findIndex((v) => v.name === a.variant) - variants.findIndex((v) => v.name === b.variant) || a.trial - b.trial);
70
75
  const summary = variants.map((v) => {
71
76
  const r = rows.filter((x) => x.variant === v.name), avg = (f) => Math.round(r.reduce((n, x) => n + f(x), 0) / (r.length || 1));
72
77
  return { variant: v.name, runs: r.length, passRate: r.filter((x) => x.pass).length / (r.length || 1), steps: avg((x) => x.steps), tokens: avg((x) => x.tokens), secs: avg((x) => x.ms) / 1000 };
73
78
  });
74
- return { rows, summary, table: formatTable(summary), analysis: analyze(rows, { baseline: o.baseline, k: o.k, seed: o.seed }) };
79
+ return { rows, summary, table: formatTable(summary), analysis: analyze(rows, { baseline: o.baseline, k: o.k, seed: o.seed }), /** why the run ended early, if it did */ stopped, /** runs that never started because of that */ skipped: work.length - next };
75
80
  }
76
81
  export function formatTable(s) {
77
82
  const line = (a) => a.map((x, i) => String(x).padEnd([22, 6, 9, 7, 9, 7][i])).join(" ");
@@ -279,7 +284,8 @@ export async function selfTest(cases) {
279
284
  mkdirSync(dirname(join(dir, f)), { recursive: true });
280
285
  writeFileSync(join(dir, f), t);
281
286
  }
282
- const judge = async () => (typeof c.check === "string" ? (await runShell(c.check, dir, 60_000)).startsWith("exit=0") : (await c.check(dir)) === true);
287
+ let said = ""; // what the agent "says": nothing before the fix, the reference summary after it
288
+ const judge = async () => (typeof c.check === "string" ? (await runShell(c.check, dir, 60_000)).startsWith("exit=0") : (await c.check(dir, { text: said, steps: 0, tokens: 0 })) === true);
283
289
  if (await judge()) {
284
290
  out.push({ case: c.name, ok: false, why: "check already passes before any fix" });
285
291
  continue;
@@ -292,6 +298,7 @@ export async function selfTest(cases) {
292
298
  mkdirSync(dirname(join(dir, f)), { recursive: true });
293
299
  writeFileSync(join(dir, f), t);
294
300
  }
301
+ said = c.solutionSummary ?? "";
295
302
  out.push((await judge()) ? { case: c.name, ok: true } : { case: c.name, ok: false, why: "check still fails with the reference solution" });
296
303
  }
297
304
  finally {
@@ -338,3 +345,10 @@ export function gateCompare(current, baseline, o = {}) {
338
345
  }
339
346
  return out;
340
347
  }
348
+ export function calibrate(rows, variant = "baseline", band = { low: 0.3, high: 0.7 }) {
349
+ const names = [...new Set(rows.filter((r) => !r.excluded && r.variant === variant).map((r) => r.case))];
350
+ return names.map((name) => {
351
+ const r = rows.filter((x) => !x.excluded && x.variant === variant && x.case === name), passes = r.filter((x) => x.pass).length, rate = passes / r.length;
352
+ return { case: name, runs: r.length, passes, rate, verdict: rate > band.high ? "too easy" : rate < band.low ? "too hard" : "keep" };
353
+ });
354
+ }
@@ -64,7 +64,7 @@ You type `Fix the failing test in src/math.ts` in the chat and press Enter. The
64
64
  **Limits and common problems.**
65
65
 
66
66
  - The exact step numbers differ from run to run. A plain-text answer without `finish` also ends a run, and then `finished` is `false` in the result.
67
- - The loop stops after `maxSteps` (the code agent uses 60 in chat and 40 otherwise, unless you set it).
67
+ - The loop stops after `maxSteps` (the code agent uses 60 in chat and 40 otherwise, unless you set it; the eval runner and the plain library harness default to 30). The number is a safety stop against a runaway loop, not a target, and no measurement chose it. In our built-in test suites most tasks finish in about 6 steps and about 1 run in 100 reaches 30. The token budget and the loop and stuck guards are what normally end a run.
68
68
  - Not every event appears in every run. Guards only speak when they fire.
69
69
 
70
70
  **Related pages.** [Architecture](architecture.md), [Library](library.md), [Why: what Amness checks](why.md).
@@ -197,11 +197,24 @@ A mismatch is rejected with the reason. This happens at most twice, so it cannot
197
197
 
198
198
  **Related pages.** [Budgets and subagents](budgets.md), [Providers and models](providers.md).
199
199
 
200
+ ## See how other tools shape the loop
201
+
202
+ The basic loop is the same almost everywhere. Anthropic describes it as *gather context, take action, verify work, repeat* ([Building agents with the Claude Agent SDK](https://claude.com/blog/building-agents-with-the-claude-agent-sdk), read in full). OpenAI describes a Codex turn the same way: the model and its tools go round many times until the model gives a final answer ([Unrolling the Codex agent loop](https://openai.com/index/unrolling-the-codex-agent-loop), seen only as a search summary). A survey of eleven coding agents says they run on hand-written loops, not on agent frameworks ([arXiv 2609.00006](https://arxiv.org/abs/2609.00006), abstract only).
203
+
204
+ So there are no hidden extra stages. What differs between tools is where they put **gates** around the loop:
205
+
206
+ - **Gather, act and verify are not separate phases in Amness.** The loop has four phases: `model`, `tools`, `compact` and `verify`. *Gathering* is the model using read-only tools, *acting* is edits and commands, and *verifying* is the model's own test runs, the optional `check` command after each edit, and the verify gate at the end. The strategies `explore-then-act` and `plan-first` are the only things that force an order.
207
+ - **A plan at the first edit.** [PAUSE](https://arxiv.org/html/2610.29870v1) (read in full) holds back the first source edit and asks a separate, stronger planner with no tools for one plan. Then the same agent continues. In the paper, 80% of first edits came after the middle of a run, so the agent has already seen most of the evidence by then. The authors report gains of 6 to 17 points in three harnesses at about 4 cents per task. They also report that the plan helped much less when the planner was the same model as the agent, that most comparisons used one run per task, and that edits made through the shell escape the trigger. `plan-first` in Amness plans at step 0, before anything is read, which is a different design.
208
+ - **Checks after every edit.** Anthropic calls rules-based feedback such as linting "the best form of feedback" and calls an LLM judge "not very robust". Amness has a post-edit `check` command and language-server diagnostics, both off by default.
209
+ - **A fixed pipeline instead of a loop.** [Agentless](https://arxiv.org/abs/2407.01489) (search summary only) runs localise, repair, then validate with generated tests, and no model-chosen actions. Amness's `best-of-n` option belongs to the same "sample several, pick one" family.
210
+
211
+ These are other people's results on their own benchmarks and models, not measurements of Amness.
212
+
200
213
  ## Learn where these choices come from
201
214
 
202
215
  Recent work on coding-agent loops points the same way. The loop follows it where this can be done without training a model.
203
216
 
204
- - [An Empirical Study of Harness Design for Coding Agents](https://arxiv.org/abs/2609.20804) (2026): cheap rule-based elision before any LLM summary gave the lowest cost. A recall tool for elided content was almost never used, so Amness has none. Stuck detection and light post-edit checks are cheap ways to stop wasted turns.
217
+ - [An Empirical Study of Harness Design for Coding Agents](https://arxiv.org/abs/2609.20804) (2026): managing the context mattered a lot with a small window (a 35.7 point gap in success at 32k tokens) and little with a large one (2.7 points at 128k). The highest level of context management had the lowest mean cost. A recall tool for elided content was rarely used, so Amness has none. The paper held stuck detection and post-edit checks fixed, so it does not test them; Amness keeps them because they are cheap, not because this paper measured them.
205
218
  - [AgentDiet](https://arxiv.org/abs/2509.23586) (trajectory reduction): removing useless, redundant and *expired* information cut input tokens by 40-60% with unchanged results. This is the source of the expired-read and repeated-call rules.
206
219
  - [CoACT](https://arxiv.org/abs/2607.02911) and [SWE-Pruner](https://arxiv.org/abs/2601.16746): reads use most of the tokens. Compression must keep what the next action needs, and earlier messages stay untouched so the cache stays valid. Amness uses rules for this. The papers use trained compressors, which would add a dependency.
207
220
  - [Don't Break the Cache](https://arxiv.org/abs/2601.06007) (prompt caching for long-horizon agents): caching cut API cost by 41-80% across OpenAI, Anthropic and Google, but naive full-context caching can hurt. Put dynamic content at the end, avoid dynamic tool lists, and keep dynamic tool results out of the cached prefix. [Keeping the Cache Warm Pays](https://arxiv.org/abs/2607.19214) motivates the longer OpenAI retention and the opt-in Claude keep-alive.
@@ -262,6 +262,38 @@ Most public benchmarks need a separate environment for each instance, often a Do
262
262
  ## Limits and common problems
263
263
 
264
264
  - A real run costs real tokens. The command prints the worst case and asks first. Use `--max-tokens` and `--max-steps` (default 30) to limit each session.
265
+ - **A spending cap, when you want one.** Nothing is capped by default. Add `--max-cost 0.50` and Amness stops starting new sessions once the estimated spend reaches 50 cents. Sessions that are already running finish, so with `--jobs 4` you can overshoot by up to four sessions. The result file and the report cover only the runs that finished, and say how many were skipped. The estimate prices prompt, cached and completion tokens from the rate card in `cli/tui/pricing.ts`. It is an estimate, not an invoice, and it needs a model with a known price. After every run Amness prints the estimated spend and the cost per session.
266
+
267
+ ## Check how hard your tasks are
268
+
269
+ A task that every run passes, or that none passes, cannot tell two variants apart. Before you compare variants, run:
270
+
271
+ ```bash
272
+ amness eval --calibrate --yes
273
+ ```
274
+
275
+ This runs only `baseline`, 5 trials per task (change it with `--trials`), and lists each task's pass rate:
276
+
277
+ ```text
278
+ how hard is each task? (baseline, 5 trials; keep 30% to 70%)
279
+ keep explore-answer 3/5 passed 60%
280
+ too easy fix-off-by-one 5/5 passed 100%
281
+ too hard cross-module-rename 0/5 passed 0%
282
+ ```
283
+
284
+ Keep the tasks marked `keep`. With only 5 trials the pass rate is rough, so treat a task near the edge of the band (30% or 70%) as undecided and run more trials. A few easy tasks are still worth keeping as a "must not get worse" check.
285
+
286
+ ## Checks that read the agent's final message
287
+
288
+ A task's `check` can be a function that receives the workspace and, as a second argument, what the agent said when it finished:
289
+
290
+ ```ts
291
+ { name: "say-where", files: {}, task: "create notes.txt and tell me where it is",
292
+ check: (dir, run) => existsSync(join(dir, "notes.txt")) && /notes\.txt/.test(run?.text ?? ""),
293
+ solution: { "notes.txt": "x" }, solutionSummary: "Created ./notes.txt" }
294
+ ```
295
+
296
+ `run` also has `steps` and `tokens`. `solutionSummary` is what the reference solution would say. `--selftest` uses it, so a check that depends on the message is still proven sound (it must fail on silence and pass with the reference message). This works for checks written as functions in a `.ts` or `.js` module, not for JSON tasks with a shell command.
265
297
  - The built-in suites are small and made by us. They are a regression test for the guards, not proof of quality.
266
298
  - `full+route` needs `--cheap-model`. A variant name that Amness does not know stops with an error that lists the known names.
267
299
  - `--variants` and `--baseline`: if the baseline name is not among the variants, Amness uses the first variant.
package/docs/providers.md CHANGED
@@ -69,6 +69,17 @@ The model list in `/model` (with prices per million tokens as shown in the picke
69
69
  - If the chat says "check your key with /provider", the provider answered with an authentication error (401 or 403).
70
70
  - The test call checks that the model replies. It does not check that the model can call tools.
71
71
 
72
+ ## Connect several providers
73
+
74
+ You can keep a key for every provider at the same time. Each key is saved on its own (in the Keychain, the Linux keyring or DPAPI), so switching provider never overwrites another key.
75
+
76
+ 1. In the chat, type `/connect`. You see every provider with `●` (connected) or `○` (not connected), and where the key comes from: a saved key, or an environment variable.
77
+ 2. Choose a provider. If it has no key, Amness asks for one (hidden). If it has one, you can use it now, replace the key, or remove the saved key.
78
+ 3. The menu stays open, so you can add the next provider. Press `Esc` when you are done.
79
+ 4. Switch between them with `/provider`. Amness uses the saved key of the provider you pick, and asks only if there is none.
80
+
81
+ An environment variable wins over a saved key for its own provider: `ANTHROPIC_API_KEY` for Claude, `OPENROUTER_API_KEY` (or `OPENAI_API_KEY`) for OpenRouter, and `OPENAI_API_KEY` for the others. Environment keys are never saved. **Limit:** a key from an older version moves to the provider that was active when you first start the new version, so check `/connect` once.
82
+
72
83
  ## Switch provider, model or effort
73
84
 
74
85
  **How to switch in the chat.**
@@ -152,7 +163,8 @@ In `auto` mode Amness fixes these cases for you:
152
163
 
153
164
  | Variable | What it does |
154
165
  |---|---|
155
- | `OPENAI_API_KEY` | The key for every provider except `anthropic`. |
166
+ | `OPENAI_API_KEY` | The key for every provider except `anthropic` (and for OpenRouter when `OPENROUTER_API_KEY` is not set). |
167
+ | `OPENROUTER_API_KEY` | The key for `openrouter`. |
156
168
  | `ANTHROPIC_API_KEY` | The key for `anthropic`. |
157
169
  | `OPENAI_BASE_URL` | The base URL for non-Claude providers. |
158
170
  | `AMNESS_MODEL` | The model id. It wins over the saved config. |
package/docs/safety.md CHANGED
@@ -40,15 +40,15 @@ Notes about the provider:
40
40
 
41
41
  | System | Where the key is saved | Protection |
42
42
  |---|---|---|
43
- | macOS | the login Keychain (service `amness`) | encrypted by macOS |
43
+ | macOS | the login Keychain (service `amness`, one entry per provider, for example `apiKey-anthropic`) | encrypted by macOS |
44
44
  | Linux with a desktop keyring | the secret service, through `secret-tool` | encrypted by the keyring |
45
- | Windows | `~/.amness/secrets/apiKey.dpapi` | encrypted with DPAPI: only your Windows user on this machine can decrypt it |
45
+ | Windows | `~/.amness/secrets/apiKey-<provider>.dpapi` | encrypted with DPAPI: only your Windows user on this machine can decrypt it |
46
46
  | Docker or Kubernetes | nowhere: kept in memory for that run | nothing is written to the image or a volume. Pass the key as an environment variable or a secret file (`ANTHROPIC_API_KEY_FILE=/run/secrets/anthropic`). |
47
47
  | Linux server with no keyring | `~/.amness/config.json` with mode 600 | **not encrypted**, only your user can read it. Prefer an environment variable or your platform's secret manager. |
48
48
 
49
49
  - Amness passes the key to the system tool on standard input, never on the command line, where other users could see it in the process list.
50
50
  - A key that an older version saved in `config.json` is moved to the key store the next time you start Amness, and removed from the file once the move is checked.
51
- - To remove a saved key, open the settings and clear "API key", or on macOS run `security delete-generic-password -s amness -a apiKey`.
51
+ - To remove a saved key, open the settings and clear "API key", or on macOS run `security delete-generic-password -s amness -a apiKey-anthropic` (use `apiKey-openai` and so on for the others).
52
52
  - The environment variable wins over the saved key.
53
53
  - Amness does not write keys into a project folder.
54
54
  - Commands the agent runs do not get your Amness keys. The variables `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `TAVILY_API_KEY`, `OPENROUTER_API_KEY` and `AMNESS_OTLP` are removed from their environment (`childEnv` in `src/secrets.ts`).
package/docs/usage.md CHANGED
@@ -60,7 +60,30 @@ If you have no key yet, the first start asks you to pick a provider and paste a
60
60
 
61
61
  - **Collapsed reads.** A run of reads and searches becomes one row, for example "Explored · 3 files read · 2 searches". Click it to open it. Edits, commands and failures always get their own card. Long diffs show the first 16 rows, and you click to see the rest.
62
62
  - **Cards.** Your prompts, the assistant replies (with code blocks that have a copy button), tool cards with a status chip, and diffs with line numbers and word-level marks. Wide terminals show a split view.
63
- - **Live run view.** The status line says what the agent does now, for example "Reading src/app.ts" or "Waiting for you". It also shows elapsed time, tokens and the step counter. The sidebar has a RUN panel with the think, act and verify steps, running tools and the last finished actions.
63
+ - **Live run view.** The status line says what the agent does now, for example "Reading src/app.ts" or "Waiting for you". It also shows elapsed time, tokens and the step number. The sidebar has a **LOOP** panel that draws the cycle the agent goes round and lights the part that is running:
64
+
65
+ ```text
66
+ LOOP round 2 · step 14
67
+ ◉ gather ▸ ✎ act ▸ ✓ verify
68
+ ⠋ Updating src/app.ts 2.1s
69
+ 12 ◉ Search → Read ×2 3.1s
70
+ ⚑ nudged: no edits after
71
+ 5 reads
72
+ 13 ✎ Update 0.8s
73
+ 14 ✎ …
74
+ ```
75
+
76
+ - **gather** ◉ (blue): reading, searching, listing files, asking a subagent, shell commands such as `ls` or `git diff`.
77
+ - **act** ✎ (amber): editing and writing files, and shell commands that change things.
78
+ - **verify** ✓ (green): shell commands that look like tests, builds, linting or type checks (`npm test`, `pytest`, `tsc`, `cargo test`…), the `check` command, and the verify gate when the agent says it is done.
79
+
80
+ The bright stage is the one running now. When two kinds run together, both light up. While the model is thinking and nothing runs, the stage it used last stays visible but dimmer. A test that failed keeps the verify icon red until a later one passes. A **round** is one trip round the cycle: it counts up when the agent gathers or acts again after verifying, so "round 3" means it went back to work twice after checking.
81
+
82
+ **These stages are a guess.** Amness reads them from the tool calls, and the loop itself has no such phases. An unusual shell command can be put in the wrong stage, and a step where the model only thinks has no stage.
83
+
84
+ Under the cycle you see the latest steps, one line each: the stage icons, the tools used, and the time. Calls of one kind in a row merge (`Read ×2`). A red `✗` means a tool failed. A line with `⚑` means the harness stepped in (a nudge, a loop or stagnation warning, a rejected finish, a denied call, a reviewer finding). It stays visible even when the step is older. **Click a step** to see every tool call with its time. A tall sidebar shows more steps than a short one, and older ones fold into one line. Type **`/loop`** for the whole history of the run, with every turn labelled by its stage. When the run ends, the panel stays as **LAST RUN**.
85
+
86
+ The step limit is a safety stop, not a goal, so it stays hidden at first. It appears as "of 60" after half the steps and as a yellow bar with a warning at 80%.
64
87
  - **Sidebar** (`Ctrl+B`). It shows the session, a context gauge, token use, todos, changed files and the workspace. The *context* is the text the model can read at one time.
65
88
  - **Modals.** Windows for approvals (with the diff, command or plan), the command palette (`Ctrl+P`), pickers and help.
66
89
 
@@ -92,7 +115,9 @@ amness --inline
92
115
  - Set the environment variable `AMNESS_UI=inline`.
93
116
  - Type `/ui inline` in the chat. `/ui full` goes back.
94
117
 
95
- **What you see.** Each tool call is one line, for example `● Read(src/a.ts)`, with a short result. `Ctrl+O` shows everything. Each edit shows a diff with line numbers. A live todo list, a status line (elapsed time and tokens) and a footer (permission mode, git branch, model, context gauge, estimated cost) stay visible. Tool bullets have a colour for each kind and turn red when the tool fails. With `NO_COLOR=1`, or when the output is piped, Amness uses plain text.
118
+ **What you see.** Each tool call is one line, for example `● Read(src/a.ts)`, with a short result. `Ctrl+O` shows everything. Each edit shows a diff with line numbers.
119
+
120
+ In the full-screen view, the **most recent** tool call stays open. A shell command shows `$ the command` and its output, up to 20 lines. When the next tool call starts, the earlier one folds to one line again. Click a line to open or close it yourself. If the output is longer than 20 lines, the card says how many are hidden. Type `/output` to read all of it in a pager (`/output 2` for the call before). A lone file read does not open itself. A live todo list, a status line (elapsed time and tokens) and a footer (permission mode, git branch, model, context gauge, estimated cost) stay visible. Tool bullets have a colour for each kind and turn red when the tool fails. With `NO_COLOR=1`, or when the output is piped, Amness uses plain text.
96
121
 
97
122
  ## Type in the input box
98
123
 
@@ -204,14 +229,36 @@ The file is attached to your message. In the transcript you see a note such as "
204
229
 
205
230
  ## Slash commands
206
231
 
207
- Type `/` in the input box to see the popup. `/help` lists the commands in the chat.
232
+ Type `/` in the input box to see the popup. It lists **every** command, grouped under titles: Conversation, Model & keys, Safety, Quality, Project, Extend and Interface. Your own and plugin commands go under Custom.
233
+
234
+ - Move with `↑` and `↓`. `PgUp` and `PgDn` jump eight at a time. The top of the box shows where you are, for example `12/60 ↓`.
235
+ - Keep typing to filter. The titles disappear and the best match comes first.
236
+ - `Tab` or `Enter` picks the command. `Esc` closes the popup.
237
+
238
+ **Completion after the command.** Type a command and a space, and the popup lists the values it accepts, each with a short explanation:
239
+
240
+ | You type | The popup shows |
241
+ |---|---|
242
+ | `/mode ` | `default`, `acceptEdits`, `plan`, `yolo` |
243
+ | `/model ` | the known models with their hints, then every other model your provider reports (OpenAI, Claude, OpenRouter, Ollama and other OpenAI-compatible servers). Amness asks the provider once and remembers the answer for ten minutes. If the provider cannot be reached, you still see the known models. |
244
+ | `/models ` | the roles (`explore`, `compact`, `review`, `cheap`, `fallback`), then the models |
245
+ | `/effort `, `/api `, `/theme `, `/shell `, `/strategy `, `/prompt `, `/sandbox `, `/diffmode `, `/ui ` | their choices |
246
+ | `/keepwarm `, `/lsp `, `/approver `, `/reviewer `, `/router `, `/mouse ` | `on`, `off` |
247
+ | `/branch ` | your turns, numbered, with the first words of each |
248
+ | `/memory `, `/permissions `, `/copy `, `/pr `, `/report `, `/mcp `, `/budget `, `/check `, `/verify ` | their sub-commands or common values |
249
+
250
+ `Enter` picks the highlighted value and runs the command. For commands that take free text or a number (`/model`, `/budget`, `/check`, `/verify`), `Enter` runs exactly what you typed unless you moved into the list with the arrow keys, so a model id that is not in the list still works. `Tab` always completes. Commands that need more words (`/memory add`, `/permissions allow`) add a space and wait.
251
+
252
+ `/help` lists the commands in the chat.
208
253
 
209
254
  ### Models and providers
210
255
 
211
256
  | Command | What it does |
212
257
  |---|---|
213
258
  | `/model [id]` | Switches the model. Without an id, it opens a picker. |
214
- | `/provider` | Switches the provider (OpenAI, Claude, OpenRouter, Ollama, custom). The choice is saved. |
259
+ | `/output [n]` (alias `/last`) | Shows the full output of the last tool call in a pager. `/output 2` shows the one before. |
260
+ | `/provider` | Switches the provider (OpenAI, Claude, OpenRouter, Ollama, custom). The list shows `●` for providers that are connected and `○` for those that are not. Each provider uses its own saved key. |
261
+ | `/connect` (alias `/keys`) | Opens the key manager. See which providers are connected, add a key for any of them, replace or remove one, or switch to it. Add as many as you want. |
215
262
  | `/effort [level]` | Sets the reasoning effort. Levels depend on the model. `default` lets the model decide. |
216
263
  | `/api [auto\|responses\|chat]` | Chooses the OpenAI wire API. It does not apply to Claude. |
217
264
  | `/models [role model]` | Uses another model for `explore`, `compact`, `review` or `cheap`. `/models fallback <model>` sets a model for when the main one is overloaded or not reachable. Use `off` to clear a role. |