amness 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +237 -2
- package/dist/cli/acpcli.d.ts +7 -0
- package/dist/cli/acpcli.js +14 -0
- package/dist/cli/amness.d.ts +2 -0
- package/dist/cli/amness.js +694 -0
- package/dist/cli/benchcli.d.ts +5 -0
- package/dist/cli/benchcli.js +160 -0
- package/dist/cli/config.d.ts +79 -0
- package/dist/cli/config.js +111 -0
- package/dist/cli/demo.d.ts +13 -0
- package/dist/cli/demo.js +44 -0
- package/dist/cli/detect.d.ts +2 -0
- package/dist/cli/detect.js +20 -0
- package/dist/cli/evalcli.d.ts +15 -0
- package/dist/cli/evalcli.js +198 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.js +55 -0
- package/dist/cli/headless.d.ts +2 -0
- package/dist/cli/headless.js +169 -0
- package/dist/cli/keystore.d.ts +9 -0
- package/dist/cli/keystore.js +108 -0
- package/dist/cli/model.d.ts +11 -0
- package/dist/cli/model.js +19 -0
- package/dist/cli/plugincli.d.ts +2 -0
- package/dist/cli/plugincli.js +176 -0
- package/dist/cli/schedulecli.d.ts +2 -0
- package/dist/cli/schedulecli.js +66 -0
- package/dist/cli/state.d.ts +11 -0
- package/dist/cli/state.js +19 -0
- package/dist/cli/tui/ansi.d.ts +38 -0
- package/dist/cli/tui/ansi.js +142 -0
- package/dist/cli/tui/app.d.ts +328 -0
- package/dist/cli/tui/app.js +1358 -0
- package/dist/cli/tui/blocks.d.ts +151 -0
- package/dist/cli/tui/blocks.js +128 -0
- package/dist/cli/tui/brand.d.ts +9 -0
- package/dist/cli/tui/brand.js +16 -0
- package/dist/cli/tui/canvas.d.ts +92 -0
- package/dist/cli/tui/canvas.js +199 -0
- package/dist/cli/tui/cards.d.ts +44 -0
- package/dist/cli/tui/cards.js +215 -0
- package/dist/cli/tui/clipboard.d.ts +5 -0
- package/dist/cli/tui/clipboard.js +29 -0
- package/dist/cli/tui/commands.d.ts +10 -0
- package/dist/cli/tui/commands.js +650 -0
- package/dist/cli/tui/complete.d.ts +39 -0
- package/dist/cli/tui/complete.js +113 -0
- package/dist/cli/tui/custom.d.ts +9 -0
- package/dist/cli/tui/custom.js +34 -0
- package/dist/cli/tui/diffview.d.ts +25 -0
- package/dist/cli/tui/diffview.js +130 -0
- package/dist/cli/tui/editor.d.ts +60 -0
- package/dist/cli/tui/editor.js +230 -0
- package/dist/cli/tui/full.d.ts +86 -0
- package/dist/cli/tui/full.js +630 -0
- package/dist/cli/tui/keys.d.ts +31 -0
- package/dist/cli/tui/keys.js +134 -0
- package/dist/cli/tui/markdown.d.ts +37 -0
- package/dist/cli/tui/markdown.js +155 -0
- package/dist/cli/tui/mentions.d.ts +15 -0
- package/dist/cli/tui/mentions.js +48 -0
- package/dist/cli/tui/modal.d.ts +26 -0
- package/dist/cli/tui/modal.js +107 -0
- package/dist/cli/tui/overlays.d.ts +98 -0
- package/dist/cli/tui/overlays.js +237 -0
- package/dist/cli/tui/pager.d.ts +15 -0
- package/dist/cli/tui/pager.js +66 -0
- package/dist/cli/tui/pricing.d.ts +9 -0
- package/dist/cli/tui/pricing.js +23 -0
- package/dist/cli/tui/rich.d.ts +16 -0
- package/dist/cli/tui/rich.js +72 -0
- package/dist/cli/tui/screen.d.ts +30 -0
- package/dist/cli/tui/screen.js +62 -0
- package/dist/cli/tui/session.d.ts +77 -0
- package/dist/cli/tui/session.js +103 -0
- package/dist/cli/tui/sidebar.d.ts +84 -0
- package/dist/cli/tui/sidebar.js +114 -0
- package/dist/cli/tui/text.d.ts +15 -0
- package/dist/cli/tui/text.js +94 -0
- package/dist/cli/tui/theme.d.ts +59 -0
- package/dist/cli/tui/theme.js +148 -0
- package/dist/cli/tui/themes.d.ts +56 -0
- package/dist/cli/tui/themes.js +80 -0
- package/dist/cli/tui/tokens.d.ts +39 -0
- package/dist/cli/tui/tokens.js +71 -0
- package/dist/cli/tui/view.d.ts +60 -0
- package/dist/cli/tui/view.js +178 -0
- package/dist/cli/ui.d.ts +34 -0
- package/dist/cli/ui.js +224 -0
- package/dist/src/acp.d.ts +28 -0
- package/dist/src/acp.js +111 -0
- package/dist/src/agents.d.ts +23 -0
- package/dist/src/agents.js +71 -0
- package/dist/src/anthropic.d.ts +30 -0
- package/dist/src/anthropic.js +91 -0
- package/dist/src/approver.d.ts +15 -0
- package/dist/src/approver.js +40 -0
- package/dist/src/bench.d.ts +67 -0
- package/dist/src/bench.js +201 -0
- package/dist/src/bundle.d.ts +36 -0
- package/dist/src/bundle.js +55 -0
- package/dist/src/chaos.d.ts +15 -0
- package/dist/src/chaos.js +26 -0
- package/dist/src/code.d.ts +187 -0
- package/dist/src/code.js +590 -0
- package/dist/src/codeindex.d.ts +65 -0
- package/dist/src/codeindex.js +300 -0
- package/dist/src/deep.d.ts +62 -0
- package/dist/src/deep.js +177 -0
- package/dist/src/diff.d.ts +8 -0
- package/dist/src/diff.js +74 -0
- package/dist/src/dynamic.d.ts +42 -0
- package/dist/src/dynamic.js +133 -0
- package/dist/src/embed.d.ts +8 -0
- package/dist/src/embed.js +25 -0
- package/dist/src/evalcases.d.ts +11 -0
- package/dist/src/evalcases.js +196 -0
- package/dist/src/evals.d.ts +197 -0
- package/dist/src/evals.js +340 -0
- package/dist/src/gitflow.d.ts +27 -0
- package/dist/src/gitflow.js +56 -0
- package/dist/src/grade.d.ts +42 -0
- package/dist/src/grade.js +96 -0
- package/dist/src/guidance.d.ts +15 -0
- package/dist/src/guidance.js +68 -0
- package/dist/src/hardcases.d.ts +2 -0
- package/dist/src/hardcases.js +94 -0
- package/dist/src/harness.d.ts +411 -0
- package/dist/src/harness.js +725 -0
- package/dist/src/hooks.d.ts +44 -0
- package/dist/src/hooks.js +116 -0
- package/dist/src/images.d.ts +13 -0
- package/dist/src/images.js +27 -0
- package/dist/src/index.d.ts +57 -0
- package/dist/src/index.js +57 -0
- package/dist/src/ledger.d.ts +58 -0
- package/dist/src/ledger.js +78 -0
- package/dist/src/live.d.ts +16 -0
- package/dist/src/live.js +80 -0
- package/dist/src/lsp.d.ts +31 -0
- package/dist/src/lsp.js +147 -0
- package/dist/src/memory.d.ts +128 -0
- package/dist/src/memory.js +390 -0
- package/dist/src/metrics.d.ts +3 -0
- package/dist/src/metrics.js +42 -0
- package/dist/src/mining.d.ts +49 -0
- package/dist/src/mining.js +126 -0
- package/dist/src/openai.d.ts +50 -0
- package/dist/src/openai.js +307 -0
- package/dist/src/parallel.d.ts +66 -0
- package/dist/src/parallel.js +135 -0
- package/dist/src/patch.d.ts +45 -0
- package/dist/src/patch.js +219 -0
- package/dist/src/permissions.d.ts +46 -0
- package/dist/src/permissions.js +245 -0
- package/dist/src/plugins.d.ts +148 -0
- package/dist/src/plugins.js +225 -0
- package/dist/src/pluginscan.d.ts +8 -0
- package/dist/src/pluginscan.js +20 -0
- package/dist/src/procs.d.ts +41 -0
- package/dist/src/procs.js +94 -0
- package/dist/src/profiles.d.ts +36 -0
- package/dist/src/profiles.js +61 -0
- package/dist/src/progress.d.ts +29 -0
- package/dist/src/progress.js +75 -0
- package/dist/src/prompts.d.ts +10 -0
- package/dist/src/prompts.js +28 -0
- package/dist/src/ratelimit.d.ts +34 -0
- package/dist/src/ratelimit.js +116 -0
- package/dist/src/replay.d.ts +25 -0
- package/dist/src/replay.js +99 -0
- package/dist/src/review.d.ts +20 -0
- package/dist/src/review.js +49 -0
- package/dist/src/route.d.ts +9 -0
- package/dist/src/route.js +30 -0
- package/dist/src/router.d.ts +61 -0
- package/dist/src/router.js +126 -0
- package/dist/src/sandbox.d.ts +13 -0
- package/dist/src/sandbox.js +68 -0
- package/dist/src/schedule.d.ts +20 -0
- package/dist/src/schedule.js +59 -0
- package/dist/src/schema.d.ts +13 -0
- package/dist/src/schema.js +36 -0
- package/dist/src/secrets.d.ts +10 -0
- package/dist/src/secrets.js +41 -0
- package/dist/src/shell.d.ts +34 -0
- package/dist/src/shell.js +95 -0
- package/dist/src/skills.d.ts +20 -0
- package/dist/src/skills.js +112 -0
- package/dist/src/snapshot.d.ts +20 -0
- package/dist/src/snapshot.js +93 -0
- package/dist/src/stats.d.ts +24 -0
- package/dist/src/stats.js +62 -0
- package/dist/src/strategies.d.ts +16 -0
- package/dist/src/strategies.js +35 -0
- package/dist/src/suites.d.ts +30 -0
- package/dist/src/suites.js +112 -0
- package/dist/src/trace.d.ts +63 -0
- package/dist/src/trace.js +153 -0
- package/dist/src/tune.d.ts +38 -0
- package/dist/src/tune.js +49 -0
- package/dist/src/web.d.ts +19 -0
- package/dist/src/web.js +56 -0
- package/dist/src/worktree.d.ts +24 -0
- package/dist/src/worktree.js +85 -0
- package/docs/agent-loop.md +222 -0
- package/docs/architecture.md +228 -0
- package/docs/bench.md +292 -0
- package/docs/budgets.md +185 -0
- package/docs/build-your-own-harness.md +424 -0
- package/docs/cookbook.md +375 -0
- package/docs/development.md +150 -0
- package/docs/evaluation.md +450 -0
- package/docs/features.md +1320 -0
- package/docs/getting-started.md +205 -0
- package/docs/headless.md +188 -0
- package/docs/images/approval.png +0 -0
- package/docs/images/chat.png +0 -0
- package/docs/images/daylight.png +0 -0
- package/docs/images/loop.png +0 -0
- package/docs/images/palette.png +0 -0
- package/docs/images/running.png +0 -0
- package/docs/images/subagent-done.png +0 -0
- package/docs/images/subagent-running.png +0 -0
- package/docs/images/welcome.png +0 -0
- package/docs/index.md +25 -0
- package/docs/library.md +267 -0
- package/docs/plugins.md +691 -0
- package/docs/providers.md +175 -0
- package/docs/safety.md +250 -0
- package/docs/usage.md +348 -0
- package/docs/why.md +155 -0
- package/docs/writing-guide.md +39 -0
- package/package.json +66 -4
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// `amness eval`: compare harness variants on a task suite, with statistics you can act on.
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
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";
|
|
13
|
+
import { bold, dim, green, red } from "./ui.js";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
async function casesFromModule(file) {
|
|
16
|
+
const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href + `?v=${Date.now()}`)), list = mod.default ?? mod.cases;
|
|
17
|
+
if (!Array.isArray(list) || list.some((c) => !c || typeof c.name !== "string" || typeof c.task !== "string" || !c.files || !c.check))
|
|
18
|
+
throw new Error(`${file}: must export a list of cases ({ name, files, task, check })`);
|
|
19
|
+
return list;
|
|
20
|
+
}
|
|
21
|
+
export const EVAL_HELP = `usage: amness eval [flags]
|
|
22
|
+
Run the coding agent on a suite of tasks under several harness variants and compare them statistically.
|
|
23
|
+
|
|
24
|
+
--variants <spec> preset or comma list (default: ab). presets: ab · ablate · all
|
|
25
|
+
variants: ${VARIANT_HELP}
|
|
26
|
+
--trials <n> attempts per task and variant (default 3)
|
|
27
|
+
--suite <file.jsonl> a benchmark in SWE-bench format (instance_id, repo, base_commit, problem_statement, test_patch, FAIL_TO_PASS...); needs --test-cmd
|
|
28
|
+
--test-cmd <cmd> how to run the hidden tests; \`{tests}\` is replaced by the instance's FAIL_TO_PASS ids, e.g. "python -m pytest -x -q {tests}"
|
|
29
|
+
--install <cmd> run once per instance after checkout (dependencies)
|
|
30
|
+
--limit <n> only the first n instances of a suite
|
|
31
|
+
--audit contamination checks on a suite (fix lines in the problem statement or already in the base checkout); runs no model
|
|
32
|
+
--seed <n> seed for the bootstrap intervals (reproducible reports)
|
|
33
|
+
--builtin <name> which built-in suite: code (default, 13 easy tasks), hard (6 tasks with hidden edge cases, a misleading symptom and an exploration question) or adversarial (prompt-injection attempts; a run passes only if the injected command never ran)
|
|
34
|
+
--cases <dir|file> your own tasks: a folder of *.json ({ name, files, task, verify, check }), or a .ts/.js module exporting a list of cases (checks may be functions)
|
|
35
|
+
--metrics <a,b> extra numbers to record per run and compare (diff-lines, tool-calls, tokens-per-step, overclaim, or a metric a plugin defines)
|
|
36
|
+
--gate <file> compare with a saved results file (from --out) and exit 1 on a clear regression: pass rate down by more than --gate-tol points (default 5) or cost per solved task up by more than --gate-cost percent (default 25)
|
|
37
|
+
--plugin <file,...> load plugins from these files: their tasks join the suite and their variants can be named in --variants (also full+plugins: every loaded plugin active)
|
|
38
|
+
--only <a,b> run only these built-in tasks
|
|
39
|
+
--baseline <name> variant the others are compared with (default: the first)
|
|
40
|
+
--cheap-model <id> the cheaper model for the full+route variant (same provider)
|
|
41
|
+
--jobs <n> sessions run at once (default 1)
|
|
42
|
+
--max-tokens <n> token cap per session (default: your configured budget)
|
|
43
|
+
--max-steps <n> steps per session (default 30)
|
|
44
|
+
--profile-out <f> after an ablation, write the recommended profile (guards the data says to switch off) for --profile-model
|
|
45
|
+
--profile-model <p> the model name or pattern the profile applies to (default: the configured model)
|
|
46
|
+
--out <file> where to save raw results (default .amness/evals/<time>.json)
|
|
47
|
+
--report <file> re-analyse saved results; runs nothing
|
|
48
|
+
--selftest check that every task is solvable and its held-out check is sound; runs no model
|
|
49
|
+
--yes skip the cost confirmation`;
|
|
50
|
+
export async function runEval(argv, deps) {
|
|
51
|
+
const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; }, has = (f) => argv.includes(f);
|
|
52
|
+
if (has("--help") || has("-h")) {
|
|
53
|
+
console.log(EVAL_HELP);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
if (has("--report")) {
|
|
58
|
+
const saved = JSON.parse(readFileSync(resolve(val("--report")), "utf8"));
|
|
59
|
+
console.log(formatReport(analyze(saved.rows, { baseline: val("--baseline") ?? saved.baseline, k: saved.k })));
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
const builtin = val("--builtin") ?? "code";
|
|
63
|
+
if (!["code", "adversarial", "hard"].includes(builtin))
|
|
64
|
+
throw new Error(`unknown built-in suite "${builtin}" (code, hard or adversarial)`);
|
|
65
|
+
const plugins = [];
|
|
66
|
+
for (const f of (val("--plugin") ?? "").split(",").filter(Boolean)) {
|
|
67
|
+
const r = await importPlugins(f);
|
|
68
|
+
for (const w of r.warnings)
|
|
69
|
+
console.error(red("! ") + w);
|
|
70
|
+
plugins.push(...r.plugins);
|
|
71
|
+
}
|
|
72
|
+
const casesArg = val("--cases"), isModule = !!casesArg && /\.(m?[jt]s)$/.test(casesArg);
|
|
73
|
+
let cases = isModule ? await casesFromModule(resolve(casesArg)) : casesArg ? loadCases(resolve(casesArg)) : builtin === "adversarial" ? adversarialCases : builtin === "hard" ? hardCases : builtinCases;
|
|
74
|
+
if (val("--suite")) {
|
|
75
|
+
const inst = loadSuite(resolve(val("--suite"))).slice(0, Number(val("--limit") ?? 1e9));
|
|
76
|
+
if (has("--audit")) {
|
|
77
|
+
const leaks = await leakAudit(inst);
|
|
78
|
+
for (const l of leaks)
|
|
79
|
+
console.log(`${red("leak")} ${l.id}: ${l.problem}`);
|
|
80
|
+
console.log(leaks.length ? `${leaks.length} instance(s) with problems` : green("no contamination found in " + inst.length + " instance(s)"));
|
|
81
|
+
return leaks.length ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
if (!val("--test-cmd"))
|
|
84
|
+
throw new Error('a suite needs --test-cmd, e.g. --test-cmd "python -m pytest -x -q {tests}"');
|
|
85
|
+
cases = suiteToCases(inst, { testCmd: val("--test-cmd"), installCmd: val("--install") });
|
|
86
|
+
}
|
|
87
|
+
for (const p of plugins)
|
|
88
|
+
cases.push(...(p.cases ?? []));
|
|
89
|
+
const metricFns = {};
|
|
90
|
+
for (const p of plugins)
|
|
91
|
+
Object.assign(metricFns, p.metrics ?? {});
|
|
92
|
+
for (const m of (val("--metrics") ?? "").split(",").filter(Boolean)) {
|
|
93
|
+
const fn = BUILTIN_METRICS[m] ?? metricFns[m];
|
|
94
|
+
if (!fn)
|
|
95
|
+
throw new Error(`unknown metric "${m}". Built in: ${Object.keys(BUILTIN_METRICS).join(", ")}${Object.keys(metricFns).length ? "; from plugins: " + Object.keys(metricFns).join(", ") : ""}`);
|
|
96
|
+
metricFns[m] = fn;
|
|
97
|
+
}
|
|
98
|
+
const wanted = (val("--metrics") ?? "").split(",").filter(Boolean), useMetrics = Object.fromEntries(Object.entries(metricFns).filter(([k]) => wanted.includes(k) || plugins.some((p) => p.metrics && k in p.metrics)));
|
|
99
|
+
if (val("--only")) {
|
|
100
|
+
const want = val("--only").split(",");
|
|
101
|
+
cases = cases.filter((c) => want.includes(c.name));
|
|
102
|
+
if (!cases.length)
|
|
103
|
+
throw new Error(`no task matches --only ${val("--only")}`);
|
|
104
|
+
}
|
|
105
|
+
if (has("--selftest")) {
|
|
106
|
+
const res = await selfTest(cases);
|
|
107
|
+
for (const r of res)
|
|
108
|
+
console.log(`${r.ok ? green("ok ") : red("bad")} ${r.case}${r.why ? dim(" " + r.why) : ""}`);
|
|
109
|
+
return res.every((r) => r.ok) ? 0 : 1;
|
|
110
|
+
}
|
|
111
|
+
const names = variantNames(val("--variants") ?? "ab"), trials = Math.max(1, Number(val("--trials") ?? 3)), jobs = Math.max(1, Number(val("--jobs") ?? 1));
|
|
112
|
+
const maxTokens = Number(val("--max-tokens") ?? deps.maxTokens), maxSteps = Number(val("--max-steps") ?? 30);
|
|
113
|
+
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
|
+
const runs = cases.length * variants.length * trials;
|
|
115
|
+
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`);
|
|
116
|
+
if (!has("--yes") && !(await deps.confirm(`This calls your model up to ${(runs * maxTokens).toLocaleString()} tokens in the worst case. Continue?`)))
|
|
117
|
+
return 1;
|
|
118
|
+
const { rows, analysis } = await evaluate(cases, variants, {
|
|
119
|
+
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
|
+
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
|
+
});
|
|
122
|
+
const out = resolve(val("--out") ?? join(".amness", "evals", new Date().toISOString().replace(/[:.]/g, "-") + ".json"));
|
|
123
|
+
mkdirSync(join(out, ".."), { recursive: true });
|
|
124
|
+
writeFileSync(out, JSON.stringify({ at: new Date().toISOString(), baseline: analysis.baseline, k: Math.min(3, trials), variants: names, trials, rows }, null, 1));
|
|
125
|
+
if (val("--profile-out")) {
|
|
126
|
+
const rec = recommend(analysis, names.includes("full") ? "full" : analysis.baseline), pm = val("--profile-model") ?? "*";
|
|
127
|
+
console.log("\nguard verdicts:");
|
|
128
|
+
for (const v of rec.verdicts)
|
|
129
|
+
console.log(` ${v.action === "remove" ? "remove" : v.action === "keep" ? "KEEP " : "keep? "} ${v.guard.padEnd(11)} ${v.why}`);
|
|
130
|
+
console.log(` ${rec.strength}`);
|
|
131
|
+
writeFileSync(resolve(val("--profile-out")), JSON.stringify({ models: { [pm]: rec.profile } }, null, 2) + "\n");
|
|
132
|
+
console.log(`profile written to ${val("--profile-out")}: copy it to .amness/profile.json`);
|
|
133
|
+
}
|
|
134
|
+
console.log("\n" + formatReport(analysis) + "\n\n" + dim(`raw results: ${out} (re-analyse with: amness eval --report ${out})`));
|
|
135
|
+
if (val("--gate")) {
|
|
136
|
+
const base = JSON.parse(readFileSync(resolve(val("--gate")), "utf8")), verdicts = gateCompare(rows, base.rows, { tol: val("--gate-tol") ? Number(val("--gate-tol")) / 100 : undefined, costPct: val("--gate-cost") ? Number(val("--gate-cost")) : undefined });
|
|
137
|
+
console.log("\n" + bold("regression gate") + dim(` against ${val("--gate")}`));
|
|
138
|
+
for (const g of verdicts) {
|
|
139
|
+
console.log(` ${g.ok ? green("ok ") : red("FAIL")} ${g.variant}`);
|
|
140
|
+
for (const l of g.lines)
|
|
141
|
+
console.log(" " + l);
|
|
142
|
+
}
|
|
143
|
+
if (verdicts.some((g) => !g.ok))
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
console.error(red("✗ " + e.message));
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export const TUNE_HELP = `usage: amness tune [flags]
|
|
154
|
+
Try switching guards off (and changing a limit), one at a time, on part of your tasks; keep a change only if it also holds up on tasks the search never saw. Rolled back otherwise.
|
|
155
|
+
Writes nothing unless something survives both checks. Calls your model: it asks first.
|
|
156
|
+
|
|
157
|
+
--cases <dir> your own tasks (default: the 13 built-in tasks)
|
|
158
|
+
--guards <a,b> which guards to try removing (default: nudge, loop, stuck, wrapup, condense, stagnation)
|
|
159
|
+
--stagnation <n,n> also try these stagnationTurns values
|
|
160
|
+
--trials <n> attempts per task (default 2)
|
|
161
|
+
--max-steps <n> steps per session (default 30)
|
|
162
|
+
--max-tokens <n> token cap per session
|
|
163
|
+
--jobs <n> sessions at once (default 1)
|
|
164
|
+
--seed <n> seed for the split and the intervals
|
|
165
|
+
--profile-model <p> the model name or pattern to write the profile for (default: the configured model)
|
|
166
|
+
--out <file> where to write the profile (default .amness/profile.json)
|
|
167
|
+
--yes skip the cost confirmation`;
|
|
168
|
+
export async function runTune(argv, deps) {
|
|
169
|
+
const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
|
|
170
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
171
|
+
console.log(TUNE_HELP);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const cases = val("--cases") ? loadCases(resolve(val("--cases"))) : builtinCases, model = deps.model(), maxTokens = Number(val("--max-tokens") ?? deps.maxTokens), maxSteps = Number(val("--max-steps") ?? 30);
|
|
176
|
+
const guards = val("--guards")?.split(",").map((s) => s.trim()), stag = val("--stagnation")?.split(",").map(Number), trials = Math.max(1, Number(val("--trials") ?? 2));
|
|
177
|
+
const n = (guards?.length ?? 6) + (stag?.length ?? 0), runs = Math.round(cases.length * 0.66) * 2 * trials * n + Math.max(1, Math.round(cases.length * 0.34)) * 2 * trials;
|
|
178
|
+
console.log(`${bold("plan")} up to ${runs} agent sessions (${cases.length} tasks, ${n} candidate changes, ${trials} trial(s)), up to ${maxTokens.toLocaleString()} tokens each`);
|
|
179
|
+
if (!argv.includes("--yes") && !(await deps.confirm(`This calls your model up to ${(runs * maxTokens).toLocaleString()} tokens in the worst case. Continue?`)))
|
|
180
|
+
return 1;
|
|
181
|
+
const r = await tune({ cases, trials, guards, stagnationTurns: stag, jobs: Number(val("--jobs") ?? 1), seed: val("--seed") ? Number(val("--seed")) : undefined,
|
|
182
|
+
makeVariant: (profile, name) => ({ ...harnessVariant(model, "full", { maxTokens, maxSteps, profile }), name }),
|
|
183
|
+
onStep: (s) => console.log(` ${s.accepted ? green("keep") : red("drop")} ${s.stage === "heldout" ? "[held-out] " : ""}${s.change} ${dim(s.why)}`) });
|
|
184
|
+
console.log(dim(`\n${r.strength}; held-out tasks: ${r.heldOut.join(", ") || "none"}`));
|
|
185
|
+
if (!r.accepted.length) {
|
|
186
|
+
console.log("nothing survived both checks; no profile written.");
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
const out = resolve(val("--out") ?? join(".amness", "profile.json"));
|
|
190
|
+
writeProfile(out, val("--profile-model") ?? deps.modelName, r.profile);
|
|
191
|
+
console.log(`kept: ${r.accepted.join(", ")}\nprofile written to ${out}`);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
console.error(red("✗ " + e.message));
|
|
196
|
+
return 2;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { bold, cyan, dim, gray, green, magenta, red, yellow, blue } from "./ui.js";
|
|
2
|
+
const one = (s, n = 90) => { const l = s.replace(/\s+/g, " ").trim(); return l.length > n ? l.slice(0, n - 1) + "…" : l; };
|
|
3
|
+
const argSummary = (name, raw) => {
|
|
4
|
+
try {
|
|
5
|
+
const a = JSON.parse(raw);
|
|
6
|
+
return one(a.cmd ?? a.path ?? a.query ?? a.url ?? a.description ?? a.note ?? a.pattern ?? a.summary ?? raw, 80);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return one(raw, 80);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
const agentColor = (name) => ({ lead: cyan, general: magenta }[name] ?? blue)(name.padEnd(7));
|
|
13
|
+
/** One readable trace line per harness event. */
|
|
14
|
+
export function formatEvent(e) {
|
|
15
|
+
const who = e.agent ? agentColor(e.agent) + " " : "";
|
|
16
|
+
switch (e.kind) {
|
|
17
|
+
case "tool": {
|
|
18
|
+
const bad = /^(ERROR|DENIED|REJECTED)/.test(e.result) || /^exit=[^0]/.test(e.result);
|
|
19
|
+
return `${who}${bad ? red("✗") : green("✓")} ${e.name} ${dim(argSummary(e.name, e.args))}${bad ? " " + red(one(e.result, 70)) : ""}`;
|
|
20
|
+
}
|
|
21
|
+
case "nudge": return `${who}${yellow("↻ nudge")} ${dim("model described an action instead of doing it → told to act")}`;
|
|
22
|
+
case "repair": return `${who}${yellow("⚒ repair")} ${dim("invalid tool call → error returned to model: " + one(e.detail, 70))}`;
|
|
23
|
+
case "loop": return `${who}${yellow("∞ loop guard")} ${dim("repeated identical call refused")}`;
|
|
24
|
+
case "rejected": return `${who}${yellow("⛔ finish rejected")} ${dim(one(e.detail, 80))}`;
|
|
25
|
+
case "denied": return `${who}${red("⊘ denied")} ${dim(e.detail)}`;
|
|
26
|
+
case "budget": return `${who}${red("$ budget")} ${e.detail}`;
|
|
27
|
+
case "tools": return `${who}${gray("⚙ toolbox changed")} ${dim(one(e.detail, 70))}`;
|
|
28
|
+
case "edit": {
|
|
29
|
+
const body = e.diff.split("\n").filter((l) => !/^(---|\+\+\+)/.test(l));
|
|
30
|
+
const shown = body.slice(0, 40).map((l) => l.startsWith("+") ? green(" " + l) : l.startsWith("-") ? red(" " + l) : l.startsWith("@@") ? cyan(" " + l) : gray(" " + l));
|
|
31
|
+
if (body.length > 40)
|
|
32
|
+
shown.push(dim(` … ${body.length - 40} more lines`));
|
|
33
|
+
return `${who}${yellow("✎")} ${bold(e.path)} ${e.created ? green("new file ") : ""}${green("+" + e.added)} ${red("-" + e.removed)}\n${shown.join("\n")}`;
|
|
34
|
+
}
|
|
35
|
+
case "aborted": return `${who}${yellow("⏹ stopped")} ${dim("run interrupted; progress is saved and can be resumed")}`;
|
|
36
|
+
case "turn":
|
|
37
|
+
case "phase":
|
|
38
|
+
case "trim":
|
|
39
|
+
case "cachebreak":
|
|
40
|
+
case "run":
|
|
41
|
+
case "model":
|
|
42
|
+
case "policy":
|
|
43
|
+
case "toolstart":
|
|
44
|
+
case "text":
|
|
45
|
+
case "thinking":
|
|
46
|
+
case "assistant":
|
|
47
|
+
case "todos":
|
|
48
|
+
case "steer": return "";
|
|
49
|
+
case "rollover": return `${who}${magenta("⟳ context rollover")} ${dim("handoff note written; continuing in a fresh context")}`;
|
|
50
|
+
case "review": return `${who}${e.pass ? green("✔ review") : yellow("✖ review")} ${dim(e.inconclusive ? "inconclusive (accepted)" : e.pass ? "the reviewer found no problems" : e.findings.slice(0, 2).map((f) => one(f, 70)).join("; "))}`;
|
|
51
|
+
case "audit": return e.changed.length || e.notes.length ? `${who}${e.notes.length ? yellow("◐ audit") : green("◉ audit")} ${dim(`${e.changed.length} file(s) changed${e.testsAfterLastEdit === true ? " · tests passed after the last edit" : ""}${e.notes.length ? " · " + e.notes.join("; ") : ""}`)}` : "";
|
|
52
|
+
case "hook": return `${who}${e.blocked ? red("⛔ hook") : yellow("⚙ hook")} ${dim(e.event + ": " + one(e.detail, 90))}`;
|
|
53
|
+
case "finish": return `${who}${green("✔ finish")} ${dim(one(e.summary, 80))}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Non-interactive mode for CI, scripts and containers: amness run [flags] "task"
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { Budget, Tracer, Hooks, Permissions, projectConfigFingerprint, createCodeAgent, startLive, setShell, loadPlugins, readImage, visionLikely, bestOfN, parallelParts, isRepo } from "../src/index.js";
|
|
5
|
+
import { PROVIDERS, loadConfig, ready } from "./config.js";
|
|
6
|
+
import { formatEvent } from "./format.js";
|
|
7
|
+
import { createModel } from "./model.js";
|
|
8
|
+
export const HELP = `usage: amness run [flags] "task"
|
|
9
|
+
--cwd <dir> workspace (default .)
|
|
10
|
+
--verify <cmd> command that must exit 0 before the agent may finish
|
|
11
|
+
--trust-project run this project's .amness/hooks.json and plugins and honour its allow rules (they execute code; off by default in headless runs)
|
|
12
|
+
--sandbox <mode> run shell commands under an OS sandbox: workspace (write only in the project and temp folders) or strict (also no network)
|
|
13
|
+
--review an independent reviewer writes acceptance criteria first and checks the work before it is accepted (extra model calls)
|
|
14
|
+
--trace <file> write a JSONL trace of every loop event (also AMNESS_OTLP=<collector url> for OpenTelemetry export)
|
|
15
|
+
--approval-reviewer a second model looks at each risky call first and approves clearly safe ones (without --yes, calls it does not clear are still denied)
|
|
16
|
+
--check <cmd> fast command run after every edit; failures are shown to the agent immediately
|
|
17
|
+
--provider <id> ${Object.keys(PROVIDERS).join(" | ")} --model <id> --base-url <url> --effort none|low|medium|high|xhigh|max --api auto|responses|chat
|
|
18
|
+
--bundle <file> continue from a portable session bundle (any provider/model)
|
|
19
|
+
--export <file> write a portable session bundle when done
|
|
20
|
+
--yes auto-approve risky commands (default: deny them)
|
|
21
|
+
--json machine-readable: one JSON event per line on stdout, final {"kind":"result"} line
|
|
22
|
+
--live [port] serve the live browser view
|
|
23
|
+
--best-of <n> attempt the task up to n times in separate git worktrees (stops at the first attempt that passes --verify, which is required); the winner is applied
|
|
24
|
+
--parts <file.json> run independent parts ([{"name","task"}]) concurrently in worktrees, merged one at a time through --verify; conflicts are reported, not applied
|
|
25
|
+
--image <file> show the model a picture (png, jpg, gif, webp; up to 5 MB, repeat the flag for more, max 4); needs a vision-capable model
|
|
26
|
+
--max-tokens <n> hard token cap (default 300000) --no-memory disable memory
|
|
27
|
+
- as the task: read it from standard input (useful on Windows, where quoting a long task is painful)
|
|
28
|
+
exit code: 0 finished, 1 not finished, 2 usage/config error`;
|
|
29
|
+
export async function runHeadless(argv) {
|
|
30
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
31
|
+
console.log(HELP);
|
|
32
|
+
return 0;
|
|
33
|
+
} // asking for help must never start a model run
|
|
34
|
+
const args = [...argv];
|
|
35
|
+
const val = (f) => { const i = args.indexOf(f); return i < 0 ? undefined : args.splice(i, 2)[1]; };
|
|
36
|
+
const bool = (f) => { const i = args.indexOf(f); return i >= 0 && !!args.splice(i, 1); };
|
|
37
|
+
const cfg = loadConfig();
|
|
38
|
+
const provider = val("--provider");
|
|
39
|
+
if (provider) {
|
|
40
|
+
if (!(provider in PROVIDERS)) {
|
|
41
|
+
console.error(`unknown provider ${provider}\n${HELP}`);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
Object.assign(cfg, { provider, baseUrl: PROVIDERS[provider].baseUrl, model: PROVIDERS[provider].model });
|
|
45
|
+
const k = provider === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
|
|
46
|
+
cfg.apiKey = k ?? (loadConfig().provider === provider ? loadConfig().apiKey : undefined);
|
|
47
|
+
}
|
|
48
|
+
cfg.model = val("--model") ?? cfg.model;
|
|
49
|
+
cfg.baseUrl = val("--base-url") ?? cfg.baseUrl;
|
|
50
|
+
cfg.api = val("--api") ?? cfg.api;
|
|
51
|
+
cfg.effort = val("--effort") ?? cfg.effort;
|
|
52
|
+
const cwd = resolve(val("--cwd") ?? "."), verify = val("--verify"), check = val("--check"), bundle = val("--bundle"), exportTo = val("--export");
|
|
53
|
+
const trustHooks = bool("--trust-project") || bool("--trust-hooks"), yes = bool("--yes"), json = bool("--json"), noMemory = bool("--no-memory");
|
|
54
|
+
const li = args.indexOf("--live");
|
|
55
|
+
let livePort;
|
|
56
|
+
if (li >= 0) {
|
|
57
|
+
const nxt = args[li + 1];
|
|
58
|
+
livePort = /^\d+$/.test(nxt ?? "") ? Number(args.splice(li, 2)[1]) : (args.splice(li, 1), 0);
|
|
59
|
+
}
|
|
60
|
+
const imagePaths = [];
|
|
61
|
+
for (let v = val("--image"); v !== undefined; v = val("--image"))
|
|
62
|
+
imagePaths.push(v);
|
|
63
|
+
const maxTokens = Number(val("--max-tokens") ?? cfg.maxTokens);
|
|
64
|
+
let task = args.join(" ").trim();
|
|
65
|
+
if (task === "-")
|
|
66
|
+
task = (await new Promise((res) => { let s = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (s += d)); process.stdin.on("end", () => res(s)); })).trim(); // `amness run -` reads the task from stdin (no quoting problems on any shell)
|
|
67
|
+
if (!task && !bundle) {
|
|
68
|
+
console.error(HELP);
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
if (!ready(cfg)) {
|
|
72
|
+
console.error(`no API key for ${PROVIDERS[cfg.provider].label}. Set ${cfg.provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"} or run \`amness settings\`.`);
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
if (imagePaths.length && !visionLikely(cfg.provider, cfg.model)) {
|
|
76
|
+
console.error(`${cfg.model} does not look like a vision model; pick one that accepts images or drop --image`);
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
let images;
|
|
80
|
+
try {
|
|
81
|
+
images = imagePaths.map((p) => readImage(resolve(p)));
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
console.error(e.message);
|
|
85
|
+
return 2;
|
|
86
|
+
}
|
|
87
|
+
const live = livePort === undefined ? undefined : await startLive({ port: livePort });
|
|
88
|
+
if (live)
|
|
89
|
+
console.error(`live view: ${live.url}`);
|
|
90
|
+
const approve = yes ? () => true : () => "non-interactive run: risky commands are denied unless --yes is passed";
|
|
91
|
+
const budget = new Budget({ maxTokens });
|
|
92
|
+
const model = createModel(cfg);
|
|
93
|
+
const bestOf = Number(val("--best-of") ?? 0), partsFile = val("--parts");
|
|
94
|
+
if (bestOf || partsFile) {
|
|
95
|
+
if (!verify) {
|
|
96
|
+
console.error("--best-of and --parts need --verify <cmd>: that command is what decides which result is kept");
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
if (!(await isRepo(cwd))) {
|
|
100
|
+
console.error("--best-of and --parts need a git repository with at least one commit");
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
if (bestOf) {
|
|
105
|
+
const r = await bestOfN({ repo: cwd, n: bestOf, verify, apply: true, maxTokens, attempt: async (dir) => { const b = new Budget({ maxTokens: Math.max(1, Math.floor(maxTokens / bestOf)) }); const a = createCodeAgent({ model, cwd: dir, verify, approve, sandbox: cfg.sandbox, memory: false, budget: b }); const x = await a.run(task); await a.procs?.dispose(); return { tokens: b.tokens, summary: x.text }; }, onCandidate: (c) => console.error(`attempt ${c.index + 1}: ${c.passed ? "passed verify" : c.error ? "error: " + c.error : "failed verify"} (${c.tokens} tokens)`) });
|
|
106
|
+
if (json)
|
|
107
|
+
console.log(JSON.stringify({ kind: "result", finished: !!r.winner, applied: r.applied, tokens: r.tokens, attempts: r.candidates.length }));
|
|
108
|
+
else
|
|
109
|
+
console.log(r.winner ? `attempt ${r.winner.index + 1} won and was ${r.applied ? "applied" : "NOT applied: " + r.applyError}\n${r.winner.summary ?? ""}` : `no attempt passed ${verify} (${r.candidates.length} tried, ${r.tokens} tokens)`);
|
|
110
|
+
return r.winner && r.applied ? 0 : 1;
|
|
111
|
+
}
|
|
112
|
+
const parts = JSON.parse(readFileSync(resolve(partsFile), "utf8"));
|
|
113
|
+
const { results, tokens } = await parallelParts({ repo: cwd, parts, verify, maxTokens, attempt: async (p, dir) => { const b = new Budget({ maxTokens: Math.max(1, Math.floor(maxTokens / parts.length)) }); const a = createCodeAgent({ model, cwd: dir, verify, approve, sandbox: cfg.sandbox, memory: false, budget: b }); const x = await a.run(p.task); await a.procs?.dispose(); return { tokens: b.tokens, summary: x.text }; }, onPart: (r) => console.error(`${r.part.name}: ${r.status}${r.detail ? " - " + r.detail.split("\n")[0] : ""}`) });
|
|
114
|
+
if (json)
|
|
115
|
+
console.log(JSON.stringify({ kind: "result", tokens, parts: results.map((r) => ({ name: r.part.name, status: r.status })) }));
|
|
116
|
+
else
|
|
117
|
+
for (const r of results)
|
|
118
|
+
console.log(`${r.status.padEnd(14)} ${r.part.name}`);
|
|
119
|
+
return results.every((r) => r.status === "merged") ? 0 : 1;
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
console.error("error: " + e.message);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const tracer = val("--trace") || process.env.AMNESS_OTLP ? new Tracer({ file: val("--trace") ? resolve(val("--trace")) : undefined, otlp: process.env.AMNESS_OTLP, sessionId: `run-${Date.now()}`, model: cfg.model }) : undefined;
|
|
127
|
+
setShell(cfg.shell);
|
|
128
|
+
const loadedPlugins = await loadPlugins(cwd, { trustProject: trustHooks });
|
|
129
|
+
for (const w of loadedPlugins.warnings)
|
|
130
|
+
console.error("plugin: " + w);
|
|
131
|
+
if (loadedPlugins.untrusted)
|
|
132
|
+
console.error(`note: ${loadedPlugins.untrusted.count} project plugin file(s) in .amness/plugins were not loaded; pass --trust-project to run them`);
|
|
133
|
+
const agent = createCodeAgent({
|
|
134
|
+
model, modelById: (id) => createModel({ ...cfg, model: id }), cwd, verify, plugins: loadedPlugins.plugins.length ? loadedPlugins.plugins : undefined, check, approve, record: !!tracer || undefined, sandbox: val("--sandbox") ?? cfg.sandbox, review: bool("--review") || undefined, approvalReviewer: bool("--approval-reviewer") || undefined, hooks: ((h) => { if (!trustHooks && projectConfigFingerprint(cwd))
|
|
135
|
+
console.error("note: ignoring the project's hooks and allow rules (pass --trust-project to use them)"); return h.count() ? h : undefined; })(Hooks.load(cwd, { trustProject: trustHooks })), rules: Permissions.load(cwd, { trustProject: trustHooks }), budget, memory: noMemory ? false : undefined, meta: { provider: cfg.provider, model: cfg.model },
|
|
136
|
+
onEvent: (e) => { live?.onEvent(e); tracer?.event(e); if (json)
|
|
137
|
+
console.log(JSON.stringify(e));
|
|
138
|
+
else {
|
|
139
|
+
const l = formatEvent(e);
|
|
140
|
+
if (l)
|
|
141
|
+
console.error(l);
|
|
142
|
+
} },
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
const r = await agent.run(task, { bundle, images });
|
|
146
|
+
if (exportTo) {
|
|
147
|
+
await agent.exportBundle(r, exportTo);
|
|
148
|
+
console.error(`session bundle: ${exportTo}`);
|
|
149
|
+
}
|
|
150
|
+
const out = { kind: "result", finished: r.finished, text: r.text, steps: r.steps, tokens: budget.tokens, stopped: r.stopped };
|
|
151
|
+
if (json)
|
|
152
|
+
console.log(JSON.stringify(out));
|
|
153
|
+
else
|
|
154
|
+
console.log(r.text);
|
|
155
|
+
return r.finished ? 0 : 1;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
if (json)
|
|
159
|
+
console.log(JSON.stringify({ kind: "error", message: e.message }));
|
|
160
|
+
else
|
|
161
|
+
console.error("error: " + e.message);
|
|
162
|
+
return 1;
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await agent.procs?.dispose();
|
|
166
|
+
await tracer?.close();
|
|
167
|
+
await live?.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Backend = "keychain" | "secret-service" | "dpapi" | "file" | "none";
|
|
2
|
+
export declare function backend(): Backend;
|
|
3
|
+
export declare const where: () => string;
|
|
4
|
+
/** True when keys must never be written to disk (containers). */
|
|
5
|
+
export declare const noStore: () => boolean;
|
|
6
|
+
/** Save a secret. Returns false when the OS store is not available or refused, so the caller can fall back to the file. */
|
|
7
|
+
export declare function setSecret(name: string, value: string): boolean;
|
|
8
|
+
export declare function getSecret(name: string): string | undefined;
|
|
9
|
+
export declare function deleteSecret(name: string): void;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Where API keys live. macOS: the login Keychain. Linux: the desktop secret service (`secret-tool`). Windows: DPAPI, which encrypts the key so only your Windows user on this machine can decrypt it.
|
|
2
|
+
// If the OS store is missing or refuses (a Linux server or Docker image with no keyring), the key stays in the config file with mode 600, and `where()` says so.
|
|
3
|
+
// The key goes to the OS tool on stdin, never on the command line (a command line is visible to other users in `ps`), and is hex-encoded so no quoting can go wrong.
|
|
4
|
+
// Tests and any run with a custom AMNESS_HOME use the file, so they never touch your real store. AMNESS_KEYSTORE=file|keychain|secret-service|dpapi|none forces one.
|
|
5
|
+
// In a container (Docker, Kubernetes) nothing is saved at all ("none"): pass the key as an environment variable or a secret file (see config.ts), so it is never written into an image or a volume.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
const SERVICE = process.env.AMNESS_KEYSTORE_SERVICE || "amness"; // the override is for tests
|
|
11
|
+
const run = (cmd, args, input, timeout = 10_000, env) => {
|
|
12
|
+
try {
|
|
13
|
+
const r = spawnSync(cmd, args, { input, encoding: "utf8", timeout, env });
|
|
14
|
+
return r.error ? undefined : r;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
export function backend() {
|
|
21
|
+
const want = process.env.AMNESS_KEYSTORE;
|
|
22
|
+
if (want === "file" || (!want && process.env.AMNESS_HOME))
|
|
23
|
+
return "file";
|
|
24
|
+
if (want === "keychain" || want === "secret-service" || want === "dpapi" || want === "none")
|
|
25
|
+
return want;
|
|
26
|
+
if (process.platform === "linux" && (existsSync("/.dockerenv") || process.env.KUBERNETES_SERVICE_HOST))
|
|
27
|
+
return "none";
|
|
28
|
+
if (process.platform === "darwin")
|
|
29
|
+
return "keychain";
|
|
30
|
+
if (process.platform === "win32")
|
|
31
|
+
return "dpapi";
|
|
32
|
+
if (process.platform === "linux" && run("secret-tool", ["--version"])?.status !== undefined)
|
|
33
|
+
return "secret-service";
|
|
34
|
+
return "file";
|
|
35
|
+
}
|
|
36
|
+
export const where = () => ({ keychain: "saved in the macOS Keychain", "secret-service": "saved in the system keyring (secret-tool)", dpapi: "saved with Windows encryption (DPAPI, this user only)", file: "saved in ~/.amness/config.json, mode 600, not encrypted", none: "not saved: this is a container, so pass the key as an environment variable or a secret file (OPENAI_API_KEY_FILE / ANTHROPIC_API_KEY_FILE)" })[backend()];
|
|
37
|
+
/** True when keys must never be written to disk (containers). */
|
|
38
|
+
export const noStore = () => backend() === "none";
|
|
39
|
+
// Windows: PowerShell turns the key into an encrypted string that only this Windows user on this machine can decrypt (DPAPI). The string is kept in ~/.amness/secrets/<name>.dpapi.
|
|
40
|
+
const dir = () => join(process.env.AMNESS_HOME ?? join(homedir(), ".amness"), "secrets");
|
|
41
|
+
const blob = (name) => join(dir(), `${name}.dpapi`);
|
|
42
|
+
// A parent PowerShell 7 (pwsh) leaves a PSModulePath that Windows PowerShell 5.1 cannot load its Security module from; dropping it lets 5.1 use its own default.
|
|
43
|
+
const psEnv = () => { const e = { ...process.env }; for (const k of Object.keys(e))
|
|
44
|
+
if (k.toLowerCase() === "psmodulepath")
|
|
45
|
+
delete e[k]; return e; };
|
|
46
|
+
const ps = (script, input) => run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], input, 60_000, psEnv()); // Windows PowerShell can take several seconds to start
|
|
47
|
+
const PS_ENCRYPT = "$p = [Console]::In.ReadToEnd().Trim(); ConvertTo-SecureString $p -AsPlainText -Force | ConvertFrom-SecureString";
|
|
48
|
+
const PS_DECRYPT = "$e = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString $e; [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))";
|
|
49
|
+
const hex = (s) => Buffer.from(s, "utf8").toString("hex");
|
|
50
|
+
const unhex = (s) => Buffer.from(s.trim(), "hex").toString("utf8");
|
|
51
|
+
/** Save a secret. Returns false when the OS store is not available or refused, so the caller can fall back to the file. */
|
|
52
|
+
export function setSecret(name, value) {
|
|
53
|
+
const b = backend();
|
|
54
|
+
if (b === "file" || b === "none")
|
|
55
|
+
return false;
|
|
56
|
+
if (b === "dpapi") {
|
|
57
|
+
const r = ps(PS_ENCRYPT, hex(value));
|
|
58
|
+
if (r?.status !== 0 || !r.stdout.trim())
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(dir(), { recursive: true });
|
|
62
|
+
writeFileSync(blob(name), r.stdout.trim(), { mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return getSecret(name) === value;
|
|
68
|
+
}
|
|
69
|
+
if (b === "keychain") {
|
|
70
|
+
const r = run("security", ["-i"], `add-generic-password -U -s ${SERVICE} -a ${name} -w ${hex(value)}\n`);
|
|
71
|
+
return r?.status === 0 && !/error|fail/i.test(r.stderr ?? "") && getSecret(name) === value;
|
|
72
|
+
}
|
|
73
|
+
const r = run("secret-tool", ["store", `--label=amness ${name}`, "service", SERVICE, "account", name], hex(value));
|
|
74
|
+
return r?.status === 0 && getSecret(name) === value;
|
|
75
|
+
}
|
|
76
|
+
export function getSecret(name) {
|
|
77
|
+
const b = backend();
|
|
78
|
+
if (b === "file" || b === "none")
|
|
79
|
+
return undefined;
|
|
80
|
+
if (b === "dpapi") {
|
|
81
|
+
if (!existsSync(blob(name)))
|
|
82
|
+
return undefined;
|
|
83
|
+
const r = ps(PS_DECRYPT, readFileSync(blob(name), "utf8"));
|
|
84
|
+
const v = r?.status === 0 ? unhex(r.stdout) : "";
|
|
85
|
+
return v || undefined;
|
|
86
|
+
}
|
|
87
|
+
const r = b === "keychain" ? run("security", ["find-generic-password", "-s", SERVICE, "-a", name, "-w"]) : run("secret-tool", ["lookup", "service", SERVICE, "account", name]);
|
|
88
|
+
if (r?.status !== 0 || !r.stdout.trim())
|
|
89
|
+
return undefined;
|
|
90
|
+
const v = unhex(r.stdout);
|
|
91
|
+
return v || undefined;
|
|
92
|
+
}
|
|
93
|
+
export function deleteSecret(name) {
|
|
94
|
+
const b = backend();
|
|
95
|
+
if (b === "file" || b === "none")
|
|
96
|
+
return;
|
|
97
|
+
if (b === "dpapi") {
|
|
98
|
+
try {
|
|
99
|
+
rmSync(blob(name), { force: true });
|
|
100
|
+
}
|
|
101
|
+
catch { /* already gone */ }
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (b === "keychain")
|
|
105
|
+
run("security", ["delete-generic-password", "-s", SERVICE, "-a", name]);
|
|
106
|
+
else
|
|
107
|
+
run("secret-tool", ["clear", "service", SERVICE, "account", name]);
|
|
108
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Model } from "../src/index.ts";
|
|
2
|
+
import { type Config } from "./config.ts";
|
|
3
|
+
/** One place for the UI to hear about rate limits, whichever model object hit them. */
|
|
4
|
+
declare let waitListener: ((ms: number, attempt: number, concurrency: number) => void) | undefined;
|
|
5
|
+
export declare const onRateLimitWait: (fn: typeof waitListener) => void;
|
|
6
|
+
/** Build the model for a config (shared by the menu and headless mode). */
|
|
7
|
+
export declare function createModel(cfg: Config, retries?: number, o?: {
|
|
8
|
+
cacheKey?: string; /** told when a rate limit makes calls wait */
|
|
9
|
+
onWait?: (ms: number, attempt: number, concurrency: number) => void;
|
|
10
|
+
}): Model;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { anthropic, effortsFor, gateFor, openai, rateLimited } from "../src/index.js";
|
|
2
|
+
import { PROVIDERS } from "./config.js";
|
|
3
|
+
/** One place for the UI to hear about rate limits, whichever model object hit them. */
|
|
4
|
+
let waitListener;
|
|
5
|
+
export const onRateLimitWait = (fn) => { waitListener = fn; };
|
|
6
|
+
/** Build the model for a config (shared by the menu and headless mode). */
|
|
7
|
+
export function createModel(cfg, retries, o = {}) {
|
|
8
|
+
// Every model built for the same endpoint and key shares one gate, so parallel subagents slow down together when the provider says 429.
|
|
9
|
+
const gate = gateFor(`${cfg.provider}|${cfg.baseUrl}|${(cfg.apiKey ?? "").slice(-6)}`, cfg.maxConcurrent ?? 4);
|
|
10
|
+
return rateLimited(rawModel(cfg, retries, o), { gate, onWait: (ms, attempt, g) => { o.onWait?.(ms, attempt, g.max); waitListener?.(ms, attempt, g.max); } });
|
|
11
|
+
}
|
|
12
|
+
function rawModel(cfg, retries, o) {
|
|
13
|
+
return cfg.provider === "anthropic"
|
|
14
|
+
? anthropic({ model: cfg.model, apiKey: cfg.apiKey, taskBudget: cfg.taskBudget, effort: cfg.effort && !["none", "minimal"].includes(cfg.effort) ? cfg.effort : undefined, baseUrl: cfg.baseUrl === PROVIDERS.anthropic.baseUrl ? undefined : cfg.baseUrl })
|
|
15
|
+
: openai({
|
|
16
|
+
model: cfg.model, apiKey: cfg.apiKey, baseUrl: cfg.baseUrl, retries, api: cfg.api, promptCacheKey: o.cacheKey, compactAt: cfg.compactAt, externalRetry: true,
|
|
17
|
+
effort: cfg.effort && effortsFor(cfg.model).includes(cfg.effort) ? cfg.effort : undefined, // a value this model does not accept is never sent
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const PLUGIN_HELP = "usage: amness plugin <command>\n new <name> create .amness/plugins/<name>.ts from a template (never overwrites)\n policy <traces...> --plugin <file> replay the tool calls recorded in traces (a file, or a folder such as .amness/traces) through the plugin policies and show what each would allow, ask or deny\n grade <traces...> [--plugin <file>] [--grader a,b] score real recorded sessions (a trace file, or a folder such as .amness/traces) with built-in or plugin graders\n add <file|https-url> copy a plugin file into .amness/plugins/ (never overwrites) and show what it appears to touch; it still needs your approval the first time you start Amness\n list show the plugins that load here (project plugins need --trust-project to be listed as loaded)\nA plugin is a TypeScript file that runs inside Amness: guardrails, loop rules, tools, slash commands and eval variants. See docs/plugins.md.";
|
|
2
|
+
export declare function runPlugin(argv: string[], cwd?: string): Promise<number>;
|