amness 0.1.3 → 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.
- package/dist/cli/amness.js +1 -1
- package/dist/cli/evalcli.d.ts +18 -1
- package/dist/cli/evalcli.js +53 -4
- package/dist/cli/tui/commands.js +5 -2
- package/dist/cli/tui/full.d.ts +4 -0
- package/dist/cli/tui/full.js +24 -4
- package/dist/cli/tui/looppanel.d.ts +71 -0
- package/dist/cli/tui/looppanel.js +218 -0
- package/dist/cli/tui/sidebar.d.ts +4 -18
- package/dist/cli/tui/sidebar.js +8 -10
- package/dist/src/evals.d.ts +23 -2
- package/dist/src/evals.js +19 -5
- package/docs/agent-loop.md +15 -2
- package/docs/evaluation.md +32 -0
- package/docs/usage.md +24 -1
- package/package.json +1 -1
package/dist/cli/amness.js
CHANGED
|
@@ -610,7 +610,7 @@ async function main() {
|
|
|
610
610
|
if (quick === "--help" || quick === "-h" || quick === "help")
|
|
611
611
|
return void console.log(TOP_HELP);
|
|
612
612
|
if (quick === "eval" && argv.length > 1) {
|
|
613
|
-
process.exitCode = await runEval(argv.slice(1), { model: makeModel, modelFor: (id) => createModel({ ...cfg, model: id }), maxTokens: cfg.maxTokens, confirm });
|
|
613
|
+
process.exitCode = await runEval(argv.slice(1), { model: makeModel, modelName: cfg.model, modelFor: (id) => createModel({ ...cfg, model: id }), maxTokens: cfg.maxTokens, confirm });
|
|
614
614
|
return;
|
|
615
615
|
}
|
|
616
616
|
if (quick === "bench") {
|
package/dist/cli/evalcli.d.ts
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
import { type Model } from "../src/index.ts";
|
|
1
|
+
import { type Model, type Row } from "../src/index.ts";
|
|
2
2
|
export declare const EVAL_HELP: string;
|
|
3
|
+
/** Estimated dollars for some rows (prompt, cached and completion tokens priced separately; the rate card is in pricing.ts). Null when the model's price is unknown. */
|
|
4
|
+
export declare function rowsCost(model: string, rows: Row[]): number | null;
|
|
5
|
+
/** What makes a result repeatable: the Amness version and git commit (when run from a clone), and a hash of the tasks (their files, text and checks). */
|
|
6
|
+
export declare function provenance(cases: {
|
|
7
|
+
name: string;
|
|
8
|
+
files: Record<string, string>;
|
|
9
|
+
task: string;
|
|
10
|
+
verify?: string;
|
|
11
|
+
check: unknown;
|
|
12
|
+
solution?: Record<string, string>;
|
|
13
|
+
}[]): {
|
|
14
|
+
version: string | undefined;
|
|
15
|
+
commit: string | undefined;
|
|
16
|
+
suite: string;
|
|
17
|
+
tasks: number;
|
|
18
|
+
};
|
|
3
19
|
export declare function runEval(argv: string[], deps: {
|
|
4
20
|
model: () => Model;
|
|
21
|
+
modelName?: string;
|
|
5
22
|
modelFor?: (id: string) => Model;
|
|
6
23
|
maxTokens: number;
|
|
7
24
|
confirm: (q: string) => Promise<boolean>;
|
package/dist/cli/evalcli.js
CHANGED
|
@@ -7,10 +7,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
// `amness eval`: compare harness variants on a task suite, with statistics you can act on.
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
10
12
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
13
|
import { join, resolve } from "node:path";
|
|
12
|
-
import { BUILTIN_METRICS, gateCompare, importPlugins, tune, writeProfile, adversarialCases, hardCases, analyze, builtinCases, evaluate, recommend, leakAudit, loadSuite, suiteToCases, formatReport, harnessVariant, loadCases, selfTest, variantNames, VARIANT_HELP } from "../src/index.js";
|
|
14
|
+
import { BUILTIN_METRICS, gateCompare, importPlugins, tune, writeProfile, adversarialCases, hardCases, analyze, builtinCases, evaluate, recommend, leakAudit, loadSuite, suiteToCases, formatReport, calibrate, harnessVariant, loadCases, selfTest, variantNames, VARIANT_HELP } from "../src/index.js";
|
|
13
15
|
import { bold, dim, green, red } from "./ui.js";
|
|
16
|
+
import { estimateCost, priceOf } from "./tui/pricing.js";
|
|
14
17
|
import { pathToFileURL } from "node:url";
|
|
15
18
|
async function casesFromModule(file) {
|
|
16
19
|
const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href + `?v=${Date.now()}`)), list = mod.default ?? mod.cases;
|
|
@@ -46,7 +49,32 @@ export const EVAL_HELP = `usage: amness eval [flags]
|
|
|
46
49
|
--out <file> where to save raw results (default .amness/evals/<time>.json)
|
|
47
50
|
--report <file> re-analyse saved results; runs nothing
|
|
48
51
|
--selftest check that every task is solvable and its held-out check is sound; runs no model
|
|
52
|
+
--max-cost <dollars> stop starting new sessions once the estimated spend reaches this amount (sessions already running finish). Off by default. Needs a model with a known price.
|
|
53
|
+
--calibrate how hard is each task? runs only \`baseline\` (5 trials unless you set --trials) and lists each task's pass rate: keep 30% to 70%, drop tasks that are too easy or too hard
|
|
49
54
|
--yes skip the cost confirmation`;
|
|
55
|
+
/** Estimated dollars for some rows (prompt, cached and completion tokens priced separately; the rate card is in pricing.ts). Null when the model's price is unknown. */
|
|
56
|
+
export function rowsCost(model, rows) {
|
|
57
|
+
if (!priceOf(model))
|
|
58
|
+
return null;
|
|
59
|
+
return rows.reduce((n, r) => n + (estimateCost(model, r.prompt ?? r.tokens, Math.max(0, r.tokens - (r.prompt ?? r.tokens)), r.cached ?? 0) ?? 0), 0);
|
|
60
|
+
}
|
|
61
|
+
/** What makes a result repeatable: the Amness version and git commit (when run from a clone), and a hash of the tasks (their files, text and checks). */
|
|
62
|
+
export function provenance(cases) {
|
|
63
|
+
let commit;
|
|
64
|
+
try {
|
|
65
|
+
commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: new URL("..", import.meta.url), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
66
|
+
}
|
|
67
|
+
catch { /* not a git checkout (an installed package) */ }
|
|
68
|
+
let version;
|
|
69
|
+
try {
|
|
70
|
+
version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
71
|
+
}
|
|
72
|
+
catch { /* no package.json next to us */ }
|
|
73
|
+
const h = createHash("sha256");
|
|
74
|
+
for (const x of [...cases].sort((a, b) => a.name.localeCompare(b.name)))
|
|
75
|
+
h.update(JSON.stringify([x.name, x.files, x.task, x.verify ?? "", typeof x.check === "function" ? x.check.toString() : x.check, x.solution ?? {}]));
|
|
76
|
+
return { version, commit, suite: h.digest("hex").slice(0, 16), tasks: cases.length };
|
|
77
|
+
}
|
|
50
78
|
export async function runEval(argv, deps) {
|
|
51
79
|
const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; }, has = (f) => argv.includes(f);
|
|
52
80
|
if (has("--help") || has("-h")) {
|
|
@@ -108,20 +136,41 @@ export async function runEval(argv, deps) {
|
|
|
108
136
|
console.log(`${r.ok ? green("ok ") : red("bad")} ${r.case}${r.why ? dim(" " + r.why) : ""}`);
|
|
109
137
|
return res.every((r) => r.ok) ? 0 : 1;
|
|
110
138
|
}
|
|
111
|
-
const names = variantNames(val("--variants") ?? "ab"), trials = Math.max(1, Number(val("--trials") ?? 3)), jobs = Math.max(1, Number(val("--jobs") ?? 1));
|
|
139
|
+
const calib = has("--calibrate"), names = variantNames(val("--variants") ?? (calib ? "baseline" : "ab")), trials = Math.max(1, Number(val("--trials") ?? (calib ? 5 : 3))), jobs = Math.max(1, Number(val("--jobs") ?? 1));
|
|
112
140
|
const maxTokens = Number(val("--max-tokens") ?? deps.maxTokens), maxSteps = Number(val("--max-steps") ?? 30);
|
|
113
141
|
const model = deps.model(), cheap = val("--cheap-model") && deps.modelFor ? deps.modelFor(val("--cheap-model")) : undefined, variants = names.map((n) => harnessVariant(model, n, { maxTokens, maxSteps, cheap, plugins, modelFor: deps.modelFor }));
|
|
114
142
|
const runs = cases.length * variants.length * trials;
|
|
115
143
|
console.log(`${bold("plan")} ${cases.length} tasks × ${variants.length} variants (${names.join(", ")}) × ${trials} trials = ${bold(String(runs))} agent sessions, up to ${maxTokens.toLocaleString()} tokens each`);
|
|
144
|
+
const mname = deps.modelName ?? "", maxCost = val("--max-cost") === undefined ? undefined : Number(val("--max-cost"));
|
|
145
|
+
if (maxCost !== undefined && !(maxCost > 0))
|
|
146
|
+
throw new Error("--max-cost needs a positive number of dollars, for example --max-cost 0.50");
|
|
147
|
+
if (maxCost !== undefined && !priceOf(mname))
|
|
148
|
+
throw new Error(`--max-cost needs a known price for the model "${mname}" (see cli/tui/pricing.ts); without one Amness cannot estimate the spend`);
|
|
149
|
+
const p = priceOf(mname);
|
|
150
|
+
if (p)
|
|
151
|
+
console.log(dim(`worst case about $${(runs * maxTokens * p[2] / 1e6).toFixed(2)} (every token priced as output; real runs are far cheaper: most of a session is cached input)${maxCost !== undefined ? ` · stops at about $${maxCost.toFixed(2)}` : " · no spending cap (--max-cost sets one)"}`));
|
|
116
152
|
if (!has("--yes") && !(await deps.confirm(`This calls your model up to ${(runs * maxTokens).toLocaleString()} tokens in the worst case. Continue?`)))
|
|
117
153
|
return 1;
|
|
118
|
-
const { rows, analysis } = await evaluate(cases, variants, {
|
|
154
|
+
const { rows, analysis, stopped, skipped } = await evaluate(cases, variants, {
|
|
155
|
+
stop: maxCost === undefined ? undefined : (rs) => { const spent = rowsCost(mname, rs) ?? 0; return spent >= maxCost ? `reached the cost cap: about $${spent.toFixed(3)} spent of $${maxCost.toFixed(2)}` : undefined; },
|
|
119
156
|
metrics: Object.keys(useMetrics).length ? useMetrics : undefined, trials, jobs, baseline: val("--baseline"), k: Math.min(3, trials), seed: val("--seed") ? Number(val("--seed")) : undefined,
|
|
120
157
|
onRow: (r) => console.error(` ${r.pass ? green("PASS") : red("FAIL")} ${r.case.padEnd(22)} ${r.variant.padEnd(18)} ${dim(`${r.steps} steps · ${r.tokens} tok · ${(r.ms / 1000).toFixed(1)}s${r.note ? " · " + r.note : ""}`)}`),
|
|
121
158
|
});
|
|
122
159
|
const out = resolve(val("--out") ?? join(".amness", "evals", new Date().toISOString().replace(/[:.]/g, "-") + ".json"));
|
|
123
160
|
mkdirSync(join(out, ".."), { recursive: true });
|
|
124
|
-
|
|
161
|
+
const spent = rowsCost(mname, rows);
|
|
162
|
+
writeFileSync(out, JSON.stringify({ at: new Date().toISOString(), model: mname || undefined, ...provenance(cases), cliArgs: argv.filter((a, i) => !/key|token/i.test(a) && !/key|token/i.test(argv[i - 1] ?? "")), baseline: analysis.baseline, k: Math.min(3, trials), variants: names, trials, estimatedCost: spent ?? undefined, stopped, rows }, null, 1));
|
|
163
|
+
if (stopped)
|
|
164
|
+
console.log(`\n${red("stopped early")}: ${stopped}. ${skipped} planned session${skipped === 1 ? "" : "s"} did not run, so the results below cover only the runs that finished.`);
|
|
165
|
+
if (spent !== null)
|
|
166
|
+
console.log(dim(`estimated spend: about $${spent.toFixed(3)} for ${rows.length} session${rows.length === 1 ? "" : "s"}${rows.length ? ` ($${(spent / rows.length).toFixed(4)} each)` : ""} (an estimate from token counts, not an invoice)`));
|
|
167
|
+
if (calib) {
|
|
168
|
+
console.log("\n" + bold("how hard is each task?") + dim(` (baseline, ${trials} trials; keep 30% to 70%)`));
|
|
169
|
+
for (const x of calibrate(rows, names[0]))
|
|
170
|
+
console.log(` ${x.verdict === "keep" ? green("keep ") : red(x.verdict.padEnd(10))} ${x.case.padEnd(26)} ${x.passes}/${x.runs} passed ${dim((x.rate * 100).toFixed(0) + "%")}`);
|
|
171
|
+
console.log(dim(`raw results: ${out}`));
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
125
174
|
if (val("--profile-out")) {
|
|
126
175
|
const rec = recommend(analysis, names.includes("full") ? "full" : analysis.baseline), pm = val("--profile-model") ?? "*";
|
|
127
176
|
console.log("\nguard verdicts:");
|
package/dist/cli/tui/commands.js
CHANGED
|
@@ -6,6 +6,7 @@ import { collectFiles, effortsFor, loadMcp, readBundle, writeBundle } from "../.
|
|
|
6
6
|
import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, connections, keyFor, storeKey } from "../config.js";
|
|
7
7
|
import { noStore, where } from "../keystore.js";
|
|
8
8
|
import { fetchModels } from "../models.js";
|
|
9
|
+
import { STAGE_GLYPH, roundOf, stagesOf } from "./looppanel.js";
|
|
9
10
|
import { bold, cyan, dim, green, red, yellow } from "../ui.js";
|
|
10
11
|
import { estimateCost, fmtCost, fmtDuration, fmtTokens } from "./pricing.js";
|
|
11
12
|
import { codeBlocks } from "./clipboard.js";
|
|
@@ -318,10 +319,12 @@ export function buildCommands() {
|
|
|
318
319
|
return a.say(dim("no loop activity yet: send a task first"));
|
|
319
320
|
const tot = T.reduce((n, s) => n + s.completion + s.prompt, 0), ms = T.reduce((n, s) => n + s.ms, 0);
|
|
320
321
|
const waste = cacheWaste(a.tracer?.turns ?? []), wasteCost = waste ? estimateCost(a.cfg.model, waste, 0) : null, wasteNote = waste > 500 ? `${fmtTokens(waste)} tokens re-read at full price after cache breaks${wasteCost !== null ? ` (≈ ${fmtCost(wasteCost - estimateCost(a.cfg.model, waste, 0, waste))} wasted)` : ""} · ` : "";
|
|
321
|
-
const
|
|
322
|
+
const rounds = roundOf(T.map((s) => ({ ...s })));
|
|
323
|
+
const out = ["", bold(`Agent loop · ${T.length} turn${T.length === 1 ? "" : "s"} · ${rounds} round${rounds === 1 ? "" : "s"} · ${fmtDuration(ms)} · ${fmtTokens(tot)} tokens`), dim("◉ gather: read, search · ✎ act: edit, run · ✓ verify: test, build · guessed from the tool calls"), dim(`${wasteNote}${a.trimmed ? `trimmed ~${fmtTokens(Math.round(a.trimmed / 4))} tokens of stale or noisy output · ` : ""}${a.cacheBreaks ? `${a.cacheBreaks} cache break${a.cacheBreaks === 1 ? "" : "s"} · ` : ""}each turn: the model answers, its tool calls run, results go back; guards are shown where they fired`), ""];
|
|
322
324
|
for (const s of T) {
|
|
323
325
|
const pad = String(s.n).padStart(2);
|
|
324
|
-
|
|
326
|
+
const st = stagesOf(s.tools);
|
|
327
|
+
out.push(`${cyan(`${pad} ●`)} ${bold("model")}${st.length ? " " + cyan(st.map((x) => `${STAGE_GLYPH[x]} ${x}`).join(" + ")) : ""} ${dim(`${fmtDuration(s.ms)} · ${fmtTokens(s.prompt)} in · ${fmtTokens(s.completion)} out${s.cached ? ` · ${fmtTokens(s.cached)} cached` : ""}`)}`);
|
|
325
328
|
s.tools.forEach((x, i) => out.push(` ${dim(i === s.tools.length - 1 && !s.notes.length ? "└─" : "├─")} ${x.ok ? green("✓") : red("✗")} ${toolCall(x.name, x.args)} ${dim(fmtDuration(x.ms))}${s.parallel && s.parallel > 1 && i === 0 ? dim(` (${s.parallel} calls this turn)`) : ""}`));
|
|
326
329
|
s.notes.forEach((n, i) => out.push(` ${dim(i === s.notes.length - 1 ? "└─" : "├─")} ${yellow("⚑")} ${dim(n)}`));
|
|
327
330
|
if (!s.tools.length && !s.notes.length)
|
package/dist/cli/tui/full.d.ts
CHANGED
|
@@ -59,6 +59,10 @@ export declare class FullView {
|
|
|
59
59
|
/** Keep a toggled block's header where it is on screen. */
|
|
60
60
|
private pin;
|
|
61
61
|
private drawEmpty;
|
|
62
|
+
/** Steps the user opened in the LOOP section by clicking. */
|
|
63
|
+
private openSteps;
|
|
64
|
+
private openFor;
|
|
65
|
+
private loopData;
|
|
62
66
|
private drawSidebar;
|
|
63
67
|
private drawStatus;
|
|
64
68
|
private editorRows;
|
package/dist/cli/tui/full.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The full-screen UI: header, scrollable transcript, sidebar, input panel, modals, toasts and mouse handling, drawn on a cell canvas.
|
|
2
2
|
import { copyToClipboard } from "./clipboard.js";
|
|
3
3
|
import { menuRows } from "./complete.js";
|
|
4
|
+
import { capNote } from "./looppanel.js";
|
|
4
5
|
import { basename } from "node:path";
|
|
5
6
|
import { A, mix } from "./ansi.js";
|
|
6
7
|
import { brandOfModel } from "./brand.js";
|
|
@@ -332,6 +333,21 @@ export class FullView {
|
|
|
332
333
|
c.ansi(x0 + Math.max(0, Math.floor((w - cellWidth(TIPS[this.tipIdx]) - 5) / 2)), Math.min(y0 + h - 2, ry + 2), tip);
|
|
333
334
|
}
|
|
334
335
|
// ------------------------------------------------------------------ sidebar
|
|
336
|
+
/** Steps the user opened in the LOOP section by clicking. */
|
|
337
|
+
openSteps = new Set();
|
|
338
|
+
openFor = 0;
|
|
339
|
+
loopData(now) {
|
|
340
|
+
const app = this.app, steps = app.trace.filter((s) => s.t0 >= app.runStart - 50); // this run's steps (the trace spans the whole session)
|
|
341
|
+
if (!steps.length)
|
|
342
|
+
return undefined;
|
|
343
|
+
if (this.openFor !== app.runStart) {
|
|
344
|
+
this.openFor = app.runStart;
|
|
345
|
+
this.openSteps.clear();
|
|
346
|
+
} // step numbers start again in a new run
|
|
347
|
+
return { live: app.running, phase: app.phase?.phase ?? "model", cap: app.phase?.max ?? 30, now, frame: app.frame, steps,
|
|
348
|
+
elapsedMs: app.running ? now - app.runStart : Math.max(...steps.map((s) => s.t0 + s.ms)) - app.runStart,
|
|
349
|
+
inflight: [...app.inflight.values()].map((x) => ({ label: toolVerb(x.name, x.args), ms: now - x.t0, agent: x.agent, name: x.name, args: x.args })), open: [...this.openSteps] };
|
|
350
|
+
}
|
|
335
351
|
drawSidebar(c, t, x0, y0, w, h) {
|
|
336
352
|
const app = this.app, u = app.session.usage, win = contextWindow(app.cfg.model), now = Date.now();
|
|
337
353
|
const mkey = `${app.session.messages.length}|${app.lastPrompt}`;
|
|
@@ -355,15 +371,19 @@ export class FullView {
|
|
|
355
371
|
sandbox: app.cfg.sandbox,
|
|
356
372
|
procs: app.agent.procs?.list().map((p) => ({ id: p.id, name: p.name, status: p.status, secs: Math.round((now - p.startedAt) / 1000) })),
|
|
357
373
|
running: app.running, elapsedMs: now - this.startedAt, tip: TIPS[this.tipIdx],
|
|
358
|
-
|
|
359
|
-
live: [...app.inflight.values()].map((x) => ({ label: toolVerb(x.name, x.args), ms: now - x.t0, agent: x.agent })), done: app.activity.map((x) => ({ label: toolVerb(x.name, x.args, true), ok: x.ok, ms: x.ms })) } : undefined,
|
|
374
|
+
loop: this.loopData(now),
|
|
360
375
|
}, t, w, h);
|
|
361
376
|
rows.forEach((r, i) => c.put(x0 + 1, y0 + i, r, w - 1));
|
|
362
377
|
for (let i = 0; i < h; i++)
|
|
363
378
|
c.text(x0, y0 + i, "│", { fg: t.border, bg: t.surface });
|
|
364
379
|
for (const hh of hits)
|
|
365
380
|
if (hh.row < h)
|
|
366
|
-
this.regions.push({ x: x0, y: y0 + hh.row, w, h: 1, kind: "change", click: () => hh.
|
|
381
|
+
this.regions.push({ x: x0, y: y0 + hh.row, w, h: 1, kind: "change", click: () => { if (hh.kind === "step" && hh.step !== undefined) {
|
|
382
|
+
this.openSteps.has(hh.step) ? this.openSteps.delete(hh.step) : this.openSteps.add(hh.step);
|
|
383
|
+
this.invalidate();
|
|
384
|
+
}
|
|
385
|
+
else if (hh.blockId !== undefined)
|
|
386
|
+
this.scrollToBlock(hh.blockId); } });
|
|
367
387
|
}
|
|
368
388
|
// ------------------------------------------------------------------ status line, input, footer
|
|
369
389
|
drawStatus(c, t, x, y, w, now) {
|
|
@@ -374,7 +394,7 @@ export class FullView {
|
|
|
374
394
|
: app.mode === "plan" ? "Planning" : app.thinkBuf.trim() ? "Thinking" : app.streamBuf ? "Writing" : "Working", chars = [...clipWidth(verb, Math.max(8, w - 44)) + "…"];
|
|
375
395
|
const shim = chars.map((ch, i) => F(mix(t.accent, 0xffffff, Math.max(0, 1 - Math.abs(i - ((f * 0.9) % (chars.length + 8) - 4)) / 3)), ch)).join("");
|
|
376
396
|
const spark = app.runSeries.length > 1 ? " " + F(t.accent2, app.runSeries.slice(-10).map((v) => BARS[Math.min(7, Math.round((v / Math.max(1, ...app.runSeries)) * 7))]).join("")) : "";
|
|
377
|
-
c.ansi(x + 1, y, F(mix(t.accent, t.accent2, (f % 20) / 20), glyph) + " " + BOLD(shim) + F(t.dim, ` ${fmtDuration(now - app.runStart)} · ↓ ${fmtTokens(app.runTokens)}`) + (app.phase ? F(t.faint, ` · step ${app.phase.
|
|
397
|
+
c.ansi(x + 1, y, F(mix(t.accent, t.accent2, (f % 20) / 20), glyph) + " " + BOLD(shim) + F(t.dim, ` ${fmtDuration(now - app.runStart)} · ↓ ${fmtTokens(app.runTokens)}`) + (app.phase ? (() => { const n = app.phase.step + 1, cap = capNote(n, app.phase.max); return F(cap.level === "warn" ? t.warn : t.faint, ` · step ${n}${cap.level === "none" ? "" : cap.level === "dim" ? ` of ${app.phase.max}` : ` of ${app.phase.max}, near the limit`}`); })() : "") + spark + F(t.faint, " esc to interrupt"), {}, w - 2);
|
|
378
398
|
if (app.thinkBuf.trim() && app.queued.length === 0) { /* reasoning summary lives in the transcript's thinking block */ }
|
|
379
399
|
}
|
|
380
400
|
app.queued.slice(0, 2).forEach((q, i) => c.ansi(x + 1, y + (app.running ? 1 : 0) + i, F(t.warn, "▸ queued ") + F(t.dim, clipWidth(q.text.replace(/\s+/g, " "), w - 14)), {}, w - 2));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Theme } from "./themes.ts";
|
|
2
|
+
export type LoopStep = {
|
|
3
|
+
n: number;
|
|
4
|
+
t0: number;
|
|
5
|
+
ms: number;
|
|
6
|
+
tools: {
|
|
7
|
+
name: string;
|
|
8
|
+
args: string;
|
|
9
|
+
ms: number;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
}[];
|
|
12
|
+
notes: string[];
|
|
13
|
+
parallel?: number;
|
|
14
|
+
};
|
|
15
|
+
export type LoopData = {
|
|
16
|
+
/** the run is still going */ live: boolean;
|
|
17
|
+
phase: string;
|
|
18
|
+
cap: number;
|
|
19
|
+
now: number;
|
|
20
|
+
frame: number;
|
|
21
|
+
elapsedMs: number;
|
|
22
|
+
/** this run's steps, oldest first; while live, the last one is the current step */ steps: LoopStep[];
|
|
23
|
+
/** tool calls in flight right now (name and args let the panel tell which stage they belong to) */ inflight: {
|
|
24
|
+
label: string;
|
|
25
|
+
ms: number;
|
|
26
|
+
agent?: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
args?: string;
|
|
29
|
+
}[];
|
|
30
|
+
/** steps the user opened by clicking */ open: number[];
|
|
31
|
+
};
|
|
32
|
+
export type LoopHit = {
|
|
33
|
+
line: number;
|
|
34
|
+
step: number;
|
|
35
|
+
};
|
|
36
|
+
/** Notes that mean "the harness stepped in" (shown in the warning colour, and kept visible); everything else (cache, routing, trimming) is background. */
|
|
37
|
+
export declare const isIntervention: (note: string) => boolean;
|
|
38
|
+
/** "Read ×2 → Edit → Run": neighbouring calls of one kind merge, so a burst of reads is one short phrase. */
|
|
39
|
+
export declare function stepSummary(tools: {
|
|
40
|
+
name: string;
|
|
41
|
+
}[]): string;
|
|
42
|
+
/** Wall time of a step: the model call, then its tools (they overlap when run in parallel). */
|
|
43
|
+
export declare const stepMs: (s: LoopStep) => number;
|
|
44
|
+
/** The cap is a safety stop, not a goal, so say nothing about it until it could matter. */
|
|
45
|
+
export declare function capNote(step: number, cap: number): {
|
|
46
|
+
text: string;
|
|
47
|
+
level: "none" | "dim" | "warn";
|
|
48
|
+
};
|
|
49
|
+
export type Stage = "gather" | "act" | "verify";
|
|
50
|
+
export declare const STAGES: Stage[];
|
|
51
|
+
export declare const STAGE_GLYPH: Record<Stage, string>;
|
|
52
|
+
/** 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. */
|
|
53
|
+
export declare function stageOfTool(name: string, args?: string): Stage | undefined;
|
|
54
|
+
/** The stages a step touched, in cycle order. */
|
|
55
|
+
export declare const stagesOf: (tools: {
|
|
56
|
+
name: string;
|
|
57
|
+
args: string;
|
|
58
|
+
}[]) => Stage[];
|
|
59
|
+
/** How many times the agent has come back round: a new round starts when it gathers or acts again after verifying. */
|
|
60
|
+
export declare function roundOf(steps: LoopStep[]): number;
|
|
61
|
+
/** Which stages to light: those running now, and (while the model is thinking and nothing runs) the last one used, dimmed. */
|
|
62
|
+
export declare function litStages(d: LoopData): {
|
|
63
|
+
now: Set<Stage>;
|
|
64
|
+
recent: Set<Stage>;
|
|
65
|
+
};
|
|
66
|
+
export declare function renderLoop(d: LoopData, t: Theme, inner: number, maxRows: number): {
|
|
67
|
+
lines: string[];
|
|
68
|
+
hits: LoopHit[];
|
|
69
|
+
title: string;
|
|
70
|
+
right: string;
|
|
71
|
+
};
|
|
@@ -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[][];
|
package/dist/cli/tui/sidebar.js
CHANGED
|
@@ -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.
|
|
16
|
-
const
|
|
17
|
-
head(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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))
|
package/dist/src/evals.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/docs/agent-loop.md
CHANGED
|
@@ -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):
|
|
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.
|
package/docs/evaluation.md
CHANGED
|
@@ -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/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
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A terminal coding agent for any model: streaming chat TUI, permission modes, plan mode, undo, memory, MCP, with a small reliability harness underneath. OpenAI (Responses API), Claude, and local models.",
|
|
6
6
|
"license": "MIT",
|