@magland/gentl 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +157 -2
- package/dist/claude.js +120 -0
- package/dist/cli.js +316 -0
- package/dist/docker.js +76 -0
- package/dist/evaluate.js +126 -0
- package/dist/finalstate.js +64 -0
- package/dist/format.js +15 -0
- package/dist/host.js +64 -0
- package/dist/hub.js +147 -0
- package/dist/localcopy.js +116 -0
- package/dist/model.js +112 -0
- package/dist/protocol.js +188 -0
- package/dist/run.js +423 -0
- package/dist/sandbox.js +129 -0
- package/dist/suite.js +114 -0
- package/dist/tokens.js +7 -0
- package/package.json +43 -4
- package/sandbox/Dockerfile +9 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as readline from "node:readline";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { checkDocker, ensureDefaultImage } from "./docker.js";
|
|
7
|
+
import { fetchHubTask, hubCredentials, parseHubTaskUrl, runDirsIn, runHub, uploadRun } from "./hub.js";
|
|
8
|
+
import { findRepo, prepareLocalCopy, removeLocalCopy } from "./localcopy.js";
|
|
9
|
+
import { evaluate, runTask } from "./run.js";
|
|
10
|
+
import { runBatch, suiteTasks } from "./suite.js";
|
|
11
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
12
|
+
const DEFAULT_MODEL = "anthropic/claude-haiku-5.5";
|
|
13
|
+
const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
|
|
14
|
+
const USAGE = `gentl ${VERSION}: a readability test for codebases
|
|
15
|
+
|
|
16
|
+
usage:
|
|
17
|
+
gentl run (TASK.md | DIR | URL | -t TEXT) [options]
|
|
18
|
+
run the agent on a task, then evaluate the run; DIR runs each
|
|
19
|
+
*.md in it as a suite; URL is a task on a hub (https://<hub>/tasks/<id>)
|
|
20
|
+
gentl evaluate RUN_DIR [options] (re-)evaluate a finished run
|
|
21
|
+
gentl upload RUN_DIR... upload runs (or batch directories of runs) to their task's hub
|
|
22
|
+
|
|
23
|
+
options:
|
|
24
|
+
-t, --task TEXT task text given directly
|
|
25
|
+
--image IMG Docker image for the sandbox (default: built from gentl's sandbox/Dockerfile)
|
|
26
|
+
--host run in a fresh temp directory on this machine instead of Docker
|
|
27
|
+
--confirm ask before each action (the default with --host)
|
|
28
|
+
--yes don't ask: run a hub task without confirming it, and with --host, run actions
|
|
29
|
+
without asking
|
|
30
|
+
--upload upload each finished run to the hub its task came from
|
|
31
|
+
--model M model for the agent (default ${DEFAULT_MODEL})
|
|
32
|
+
--evaluator-model M model for the evaluator (default ${DEFAULT_MODEL})
|
|
33
|
+
--backend B openrouter (any OpenAI-compatible endpoint; key in OPENROUTER_API_KEY) or
|
|
34
|
+
claude-code (your Claude Code login, via claude -p); default from
|
|
35
|
+
GENTL_BACKEND, else openrouter
|
|
36
|
+
--base-url URL OpenAI-compatible endpoint (default ${DEFAULT_BASE_URL})
|
|
37
|
+
--max-turns N stop after N turns (default 50)
|
|
38
|
+
--repeat N run each task N times and report the success rate and median read tokens
|
|
39
|
+
--local serve clones of the task's repo from your checkout, uncommitted changes included
|
|
40
|
+
(the default when the task file, suite or current directory is in a git repo)
|
|
41
|
+
--remote clone the published repo instead
|
|
42
|
+
--keep keep the container or temp workspace instead of deleting it
|
|
43
|
+
|
|
44
|
+
The API key is read from OPENROUTER_API_KEY, in the environment or in ./.env.
|
|
45
|
+
Runs are written to ./runs/<id>/.`;
|
|
46
|
+
/** A variable from the environment, or from ./.env. */
|
|
47
|
+
function envValue(name) {
|
|
48
|
+
if (process.env[name])
|
|
49
|
+
return process.env[name];
|
|
50
|
+
if (!fs.existsSync(".env"))
|
|
51
|
+
return undefined;
|
|
52
|
+
for (const line of fs.readFileSync(".env", "utf8").split("\n")) {
|
|
53
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
54
|
+
if (m && m[1] === name)
|
|
55
|
+
return m[2].replace(/^(['"])(.*)\1$/, "$2");
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
function fail(msg) {
|
|
60
|
+
console.error(`gentl: ${msg}`);
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
async function run(argv) {
|
|
64
|
+
const { values, positionals } = parseArgs({
|
|
65
|
+
args: argv,
|
|
66
|
+
allowPositionals: true,
|
|
67
|
+
options: {
|
|
68
|
+
task: { type: "string", short: "t" },
|
|
69
|
+
host: { type: "boolean" },
|
|
70
|
+
image: { type: "string" },
|
|
71
|
+
confirm: { type: "boolean" },
|
|
72
|
+
yes: { type: "boolean" },
|
|
73
|
+
local: { type: "boolean" },
|
|
74
|
+
upload: { type: "boolean" },
|
|
75
|
+
remote: { type: "boolean" },
|
|
76
|
+
model: { type: "string", default: DEFAULT_MODEL },
|
|
77
|
+
"evaluator-model": { type: "string", default: DEFAULT_MODEL },
|
|
78
|
+
backend: { type: "string" },
|
|
79
|
+
"base-url": { type: "string", default: DEFAULT_BASE_URL },
|
|
80
|
+
"max-turns": { type: "string", default: "50" },
|
|
81
|
+
repeat: { type: "string", default: "1" },
|
|
82
|
+
keep: { type: "boolean" },
|
|
83
|
+
help: { type: "boolean", short: "h" },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
if (values.help) {
|
|
87
|
+
console.log(USAGE);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if ((values.task === undefined) === (positionals.length !== 1))
|
|
91
|
+
fail("give one task: a task file or -t TEXT");
|
|
92
|
+
if (values.host && values.image)
|
|
93
|
+
fail("--image applies to the Docker sandbox, not --host");
|
|
94
|
+
if (values.confirm && values.yes)
|
|
95
|
+
fail("--confirm and --yes contradict each other");
|
|
96
|
+
if (values.local && values.remote)
|
|
97
|
+
fail("--local and --remote contradict each other");
|
|
98
|
+
const confirm = values.host ? !values.yes : !!values.confirm;
|
|
99
|
+
if (confirm && !process.stdin.isTTY)
|
|
100
|
+
fail("confirming each action needs a terminal (use --yes with --host to skip it)");
|
|
101
|
+
const maxTurns = Number(values["max-turns"]);
|
|
102
|
+
if (!Number.isInteger(maxTurns) || maxTurns < 1)
|
|
103
|
+
fail("--max-turns must be a positive integer");
|
|
104
|
+
const repeat = Number(values.repeat);
|
|
105
|
+
if (!Number.isInteger(repeat) || repeat < 1)
|
|
106
|
+
fail("--repeat must be a positive integer");
|
|
107
|
+
const modelFor = modelConfig(values.backend, values["base-url"]);
|
|
108
|
+
let sandbox;
|
|
109
|
+
if (values.host) {
|
|
110
|
+
sandbox = { kind: "host" };
|
|
111
|
+
if (!confirm)
|
|
112
|
+
console.log("warning: host mode without confirmation; the agent's commands run on this machine unchecked");
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
try {
|
|
116
|
+
checkDocker();
|
|
117
|
+
sandbox = { kind: "docker", image: values.image ?? ensureDefaultImage() };
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
fail(e.message);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// What to run: a suite directory, a task file, or task text.
|
|
124
|
+
let target;
|
|
125
|
+
if (values.task !== undefined) {
|
|
126
|
+
const task = values.task.trim();
|
|
127
|
+
target = { kind: "task", task, taskName: (task.toLowerCase().match(/[a-z0-9]+/g) ?? ["task"]).slice(0, 5).join("-"),
|
|
128
|
+
taskSource: { kind: "text" } };
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const p = positionals[0];
|
|
132
|
+
if (/^https?:\/\//.test(p)) {
|
|
133
|
+
if (!parseHubTaskUrl(p))
|
|
134
|
+
fail(`not a hub task URL: ${p} (expected https://<hub>/tasks/<id>)`);
|
|
135
|
+
if (values.local)
|
|
136
|
+
fail("--local does not apply to hub tasks; they always clone the published repo");
|
|
137
|
+
let t;
|
|
138
|
+
try {
|
|
139
|
+
t = await fetchHubTask(p);
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
fail(e.message);
|
|
143
|
+
}
|
|
144
|
+
console.log(`Task ${t.id} from ${t.hub}: ${t.title}\n\n${t.text.replace(/^/gm, " ")}\n`);
|
|
145
|
+
if (!values.yes) {
|
|
146
|
+
if (!process.stdin.isTTY)
|
|
147
|
+
fail("a hub task needs confirming in a terminal (or pass --yes)");
|
|
148
|
+
if (!(await yesNo("Run this task? [y/n] "))) {
|
|
149
|
+
console.log("Not running it.");
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const slug = (t.title.toLowerCase().match(/[a-z0-9]+/g) ?? []).slice(0, 5).join("-");
|
|
154
|
+
target = { kind: "task", task: t.text, taskName: `hub${t.id}${slug ? `-${slug}` : ""}`,
|
|
155
|
+
taskSource: { kind: "hub", url: t.url, hub: t.hub, id: t.id, title: t.title } };
|
|
156
|
+
}
|
|
157
|
+
else if (!fs.existsSync(p))
|
|
158
|
+
fail(`no such file: ${p}`);
|
|
159
|
+
else
|
|
160
|
+
target = fs.statSync(p).isDirectory() ? { kind: "suite", dir: p }
|
|
161
|
+
: { kind: "task", task: fs.readFileSync(p, "utf8").trim(), taskName: path.basename(p).replace(/\.[^.]*$/, ""),
|
|
162
|
+
taskSource: { kind: "file", path: p } };
|
|
163
|
+
}
|
|
164
|
+
if (target.kind === "task" && !target.task)
|
|
165
|
+
fail("the task is empty");
|
|
166
|
+
// Uploads go to the hub the task came from; credentials are settled before any run starts.
|
|
167
|
+
let upload = null;
|
|
168
|
+
if (values.upload) {
|
|
169
|
+
if (target.kind !== "task" || target.taskSource.kind !== "hub")
|
|
170
|
+
fail("--upload works only for tasks from a hub");
|
|
171
|
+
try {
|
|
172
|
+
upload = await hubCredentials(target.taskSource.hub);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
fail(e.message);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// The local copy: the repo containing the task file or suite (or the current directory for -t).
|
|
179
|
+
// Hub tasks always clone the published repo, so their results match public code.
|
|
180
|
+
const isHub = target.kind === "task" && target.taskSource.kind === "hub";
|
|
181
|
+
if (isHub && values.local)
|
|
182
|
+
fail("--local does not apply to hub tasks; they always clone the published repo");
|
|
183
|
+
let localCopy = null;
|
|
184
|
+
if (!values.remote && !isHub) {
|
|
185
|
+
const dir = target.kind === "suite" ? target.dir : target.taskSource.kind === "file" ? path.dirname(target.taskSource.path) : ".";
|
|
186
|
+
const found = findRepo(path.resolve(dir));
|
|
187
|
+
if ("reason" in found) {
|
|
188
|
+
if (values.local)
|
|
189
|
+
fail(`--local: ${found.reason}`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
try {
|
|
193
|
+
localCopy = prepareLocalCopy(found.repo, found.origin);
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
fail(e.message);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const common = { sandbox, confirm, localCopy, upload, maxTurns, keep: !!values.keep, version: VERSION,
|
|
201
|
+
model: modelFor(values.model), evaluatorModel: modelFor(values["evaluator-model"]) };
|
|
202
|
+
let code = 0;
|
|
203
|
+
try {
|
|
204
|
+
if (target.kind === "suite") {
|
|
205
|
+
code = (await runBatch(path.basename(path.resolve(target.dir)), suiteTasks(target.dir), repeat, common)) ? 0 : 1;
|
|
206
|
+
}
|
|
207
|
+
else if (repeat > 1) {
|
|
208
|
+
code = (await runBatch(`${target.taskName}-x${repeat}`, [target], repeat, common)) ? 0 : 1;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const out = await runTask({ ...common, task: target.task, taskName: target.taskName, taskSource: target.taskSource, runsDir: "runs" });
|
|
212
|
+
code = out.summary.stop_reason === "error" ? 1 : 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
console.error(`gentl: ${e.message}`);
|
|
217
|
+
code = 2;
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
if (localCopy)
|
|
221
|
+
removeLocalCopy(localCopy);
|
|
222
|
+
}
|
|
223
|
+
process.exit(code);
|
|
224
|
+
}
|
|
225
|
+
/** Ask a yes/no question; Ctrl-C or Ctrl-D counts as no. */
|
|
226
|
+
function yesNo(question) {
|
|
227
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
// Resolve before closing: close() fires the "close" handler synchronously.
|
|
230
|
+
rl.question(question, (a) => {
|
|
231
|
+
resolve(/^y(es)?$/i.test(a.trim()));
|
|
232
|
+
rl.close();
|
|
233
|
+
});
|
|
234
|
+
rl.on("close", () => resolve(false));
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
async function uploadCommand(argv) {
|
|
238
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { help: { type: "boolean", short: "h" } } });
|
|
239
|
+
if (values.help || !positionals.length) {
|
|
240
|
+
console.log(USAGE);
|
|
241
|
+
process.exit(values.help ? 0 : 2);
|
|
242
|
+
}
|
|
243
|
+
const dirs = positionals.flatMap((p) => (fs.existsSync(p) ? runDirsIn(p) : fail(`no such directory: ${p}`)));
|
|
244
|
+
if (!dirs.length)
|
|
245
|
+
fail("no run directories found");
|
|
246
|
+
const creds = new Map();
|
|
247
|
+
let failed = 0;
|
|
248
|
+
for (const dir of dirs) {
|
|
249
|
+
try {
|
|
250
|
+
const hub = runHub(dir);
|
|
251
|
+
if (!hub)
|
|
252
|
+
throw new Error("its task did not come from a hub, so there is nowhere to upload it");
|
|
253
|
+
if (!creds.has(hub))
|
|
254
|
+
creds.set(hub, await hubCredentials(hub));
|
|
255
|
+
console.log(`${dir}: uploaded ${await uploadRun(dir, creds.get(hub))}`);
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
failed++;
|
|
259
|
+
console.log(`${dir}: ${e.message}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
process.exit(failed ? 1 : 0);
|
|
263
|
+
}
|
|
264
|
+
/** Checks the backend settings once and returns a function giving the config for a model. */
|
|
265
|
+
function modelConfig(backendOpt, baseUrl) {
|
|
266
|
+
const backend = backendOpt ?? envValue("GENTL_BACKEND") ?? "openrouter";
|
|
267
|
+
if (backend === "claude-code")
|
|
268
|
+
return (model) => ({ backend, model, baseUrl: "", apiKey: "" });
|
|
269
|
+
if (backend !== "openrouter")
|
|
270
|
+
fail(`unknown backend ${JSON.stringify(backend)}; use openrouter or claude-code`);
|
|
271
|
+
const apiKey = envValue("OPENROUTER_API_KEY");
|
|
272
|
+
if (!apiKey)
|
|
273
|
+
fail("OPENROUTER_API_KEY is not set (environment or ./.env); or use --backend claude-code");
|
|
274
|
+
return (model) => ({ backend: "openrouter", model, baseUrl, apiKey });
|
|
275
|
+
}
|
|
276
|
+
async function evaluateCommand(argv) {
|
|
277
|
+
const { values, positionals } = parseArgs({
|
|
278
|
+
args: argv,
|
|
279
|
+
allowPositionals: true,
|
|
280
|
+
options: {
|
|
281
|
+
"evaluator-model": { type: "string", default: DEFAULT_MODEL },
|
|
282
|
+
backend: { type: "string" },
|
|
283
|
+
"base-url": { type: "string", default: DEFAULT_BASE_URL },
|
|
284
|
+
help: { type: "boolean", short: "h" },
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
if (values.help) {
|
|
288
|
+
console.log(USAGE);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (positionals.length !== 1)
|
|
292
|
+
fail("give one run directory");
|
|
293
|
+
const runDir = path.resolve(positionals[0]);
|
|
294
|
+
for (const f of ["summary.json", "events.jsonl"]) {
|
|
295
|
+
if (!fs.existsSync(path.join(runDir, f)))
|
|
296
|
+
fail(`${f} not found in ${runDir}`);
|
|
297
|
+
}
|
|
298
|
+
const ok = await evaluate(runDir, modelConfig(values.backend, values["base-url"])(values["evaluator-model"]));
|
|
299
|
+
process.exit(ok ? 0 : 1);
|
|
300
|
+
}
|
|
301
|
+
async function main() {
|
|
302
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
303
|
+
if (cmd === "run")
|
|
304
|
+
await run(rest);
|
|
305
|
+
else if (cmd === "evaluate")
|
|
306
|
+
await evaluateCommand(rest);
|
|
307
|
+
else if (cmd === "upload")
|
|
308
|
+
await uploadCommand(rest);
|
|
309
|
+
else if (cmd === "--version" || cmd === "-V")
|
|
310
|
+
console.log(VERSION);
|
|
311
|
+
else {
|
|
312
|
+
console.log(USAGE);
|
|
313
|
+
process.exit(cmd === "-h" || cmd === "--help" ? 0 : 2);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
main().catch((e) => fail(e instanceof Error ? e.message : String(e)));
|
package/dist/docker.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Docker mode: a fresh container per run, from the bundled default image or one given with --image.
|
|
2
|
+
// The image needs bash and setsid (util-linux); git is needed by most tasks and by the final-state record.
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import { execLocal, Sandbox } from "./sandbox.js";
|
|
7
|
+
const WORKDIR = "/work";
|
|
8
|
+
function docker(args, input) {
|
|
9
|
+
return execLocal("docker", args, { input });
|
|
10
|
+
}
|
|
11
|
+
/** The default image, built from sandbox/Dockerfile on first use; its tag follows the Dockerfile's content. */
|
|
12
|
+
export function ensureDefaultImage() {
|
|
13
|
+
const dockerfile = fs.readFileSync(new URL("../sandbox/Dockerfile", import.meta.url));
|
|
14
|
+
const tag = "gentl-sandbox:" + createHash("sha256").update(dockerfile).digest("hex").slice(0, 12);
|
|
15
|
+
if (docker(["image", "inspect", tag]).ok)
|
|
16
|
+
return tag;
|
|
17
|
+
console.log(`Building the sandbox image ${tag} (first run only) ...`);
|
|
18
|
+
const p = spawnSync("docker", ["build", "-t", tag, "-"], { input: dockerfile, stdio: ["pipe", "inherit", "inherit"] });
|
|
19
|
+
if (p.status !== 0)
|
|
20
|
+
throw new Error(`docker build failed for ${tag}`);
|
|
21
|
+
return tag;
|
|
22
|
+
}
|
|
23
|
+
export function checkDocker() {
|
|
24
|
+
const r = docker(["version", "--format", "{{.Server.Version}}"]);
|
|
25
|
+
if (!r.ok)
|
|
26
|
+
throw new Error(`Docker is not available (${r.out.trim().split("\n")[0]}). Install or start Docker, or use --host.`);
|
|
27
|
+
}
|
|
28
|
+
/** Messages from the docker client and setsid (the command's own stderr is merged into stdout in the container).
|
|
29
|
+
* setsid's note about a child killed by a signal is dropped; a missing or stopped container is fatal. */
|
|
30
|
+
function classifyDockerStderr(text) {
|
|
31
|
+
const lines = text.split("\n").filter((l) => l.trim() && !/^setsid: child \d+ did not exit normally/.test(l));
|
|
32
|
+
const fatal = lines.find((l) => /No such container|is not running|Cannot connect to the Docker daemon/i.test(l)) ?? null;
|
|
33
|
+
return { keep: lines.length ? lines.join("\n") + "\n" : "", fatal };
|
|
34
|
+
}
|
|
35
|
+
export class DockerSandbox extends Sandbox {
|
|
36
|
+
name;
|
|
37
|
+
image;
|
|
38
|
+
kind = "docker";
|
|
39
|
+
workdir = WORKDIR;
|
|
40
|
+
imageId;
|
|
41
|
+
count = 0;
|
|
42
|
+
/** `mounts` are read-only bind mounts (host directory to container directory). */
|
|
43
|
+
constructor(name, image, mounts = []) {
|
|
44
|
+
super();
|
|
45
|
+
this.name = name;
|
|
46
|
+
this.image = image;
|
|
47
|
+
const volumes = mounts.flatMap((m) => ["-v", `${m.host}:${m.container}:ro`]);
|
|
48
|
+
const r = docker(["run", "-d", "--init", "--name", name, "-w", WORKDIR, ...volumes, "--entrypoint", "sleep", image, "infinity"]);
|
|
49
|
+
if (!r.ok)
|
|
50
|
+
throw new Error(`could not start container from ${image}: ${r.out.trim()}`);
|
|
51
|
+
this.imageId = docker(["image", "inspect", "-f", "{{.Id}}", image]).out.trim();
|
|
52
|
+
}
|
|
53
|
+
describe() {
|
|
54
|
+
return `docker, image ${this.image}, container ${this.name}`;
|
|
55
|
+
}
|
|
56
|
+
start(cmd) {
|
|
57
|
+
// setsid gives the command its own process group inside the container; its id goes to a pid file so the group
|
|
58
|
+
// can be killed (killing the local docker client would leave the command running). The command's stderr is
|
|
59
|
+
// merged into stdout inside the container, so the client's stderr holds only messages from setsid and docker.
|
|
60
|
+
const pidFile = `/tmp/.gentl-cmd-${++this.count}.pid`;
|
|
61
|
+
const child = spawn("docker", ["exec", "-w", WORKDIR, this.name, "setsid", "-w", "bash", "-c",
|
|
62
|
+
`echo $$ > ${pidFile}; exec bash -c "$1" 2>&1`, "gentl", cmd], { detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
63
|
+
const kill = () => {
|
|
64
|
+
docker(["exec", this.name, "bash", "-c", `kill -9 -- -$(cat ${pidFile}) 2>/dev/null`]);
|
|
65
|
+
setTimeout(() => child.kill("SIGKILL"), 2000).unref();
|
|
66
|
+
};
|
|
67
|
+
return { child, kill, sandboxStderr: classifyDockerStderr };
|
|
68
|
+
}
|
|
69
|
+
exec(args, cwd, input) {
|
|
70
|
+
const w = cwd ? (cwd.startsWith("/") ? cwd : `${WORKDIR}/${cwd}`) : WORKDIR;
|
|
71
|
+
return docker(["exec", ...(input !== undefined ? ["-i"] : []), "-w", w, this.name, ...args], input);
|
|
72
|
+
}
|
|
73
|
+
remove() {
|
|
74
|
+
docker(["rm", "-f", this.name]);
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/evaluate.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The evaluator: one model call that reads a run directory and writes a verdict and a friction report.
|
|
2
|
+
// It depends only on the run directory, so a run can be re-evaluated later (`gentl evaluate`).
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { clip, fence } from "./format.js";
|
|
6
|
+
import { createModelClient } from "./model.js";
|
|
7
|
+
import { parseFirstJson } from "./protocol.js";
|
|
8
|
+
const RESULT_HIDDEN_CAP = 6000; // chars of the part of a result the agent did not see
|
|
9
|
+
const RESULT_CAP = 12000; // chars of any single result
|
|
10
|
+
const LOG_CAP = 500000; // chars of the whole log; beyond this, results are clipped harder
|
|
11
|
+
export const EVALUATOR_PROMPT = `You evaluate one run of gentl, a readability test for codebases. An agent was given a task as a newcomer to some software (typically: clone a repo, set it up from its docs, make a change, verify it). The agent had no memory between turns except short notes it kept, and it saw only its last action's result each turn, often truncated. Its success is self-reported, so you judge it.
|
|
12
|
+
|
|
13
|
+
You receive the task, the log of every turn (notes, action, result; parts of results the agent did not see are marked), the questions the agent asked (no one answered them), the agent's finish report, and the final state of the workspace (git status and diff of each repo, and other files).
|
|
14
|
+
|
|
15
|
+
Write two things.
|
|
16
|
+
|
|
17
|
+
1. A verdict: did the agent actually accomplish the task? Judge from the evidence in the log and the final state, not from the agent's claims. Every part of the task must be done, and verified where the task implies it (for example, tests actually ran and passed). Give short reasons that cite turn numbers.
|
|
18
|
+
|
|
19
|
+
2. A friction report for the maintainers of the software, in markdown, with these sections:
|
|
20
|
+
- Docs used: which docs, files or pages the agent relied on, and for what (cite turns).
|
|
21
|
+
- Missing or wrong: what the agent needed but could not find in the docs, or found wrong or outdated. Be specific: which file or section, and what it should say.
|
|
22
|
+
- Guesses: where the agent went ahead without support from the docs (inferred from config, CI files or code, or trial and error).
|
|
23
|
+
- Prior knowledge: commands, APIs, flags or facts the agent used that never appeared in any result it saw before that point, so they came from its training and the docs were not tested there.
|
|
24
|
+
- Questions: each question the agent asked, and whether the docs answer it.
|
|
25
|
+
Keep it short and concrete, as bullets; write "none" for an empty section. Separate problems in the software's docs from problems caused by the agent's own mistakes or by the test environment (for example a missing system package), and say which is which.
|
|
26
|
+
|
|
27
|
+
Respond with one JSON object: {"success": true|false, "reasons": "...", "friction_report": "..."}`;
|
|
28
|
+
const EVAL_SCHEMA = {
|
|
29
|
+
name: "evaluation",
|
|
30
|
+
schema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: { success: { type: "boolean" }, reasons: { type: "string" }, friction_report: { type: "string" } },
|
|
33
|
+
required: ["success", "reasons", "friction_report"],
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
/** A result as the evaluator sees it: the full result, with the part the agent did not see marked. */
|
|
38
|
+
function resultForEvaluator(ev, cap) {
|
|
39
|
+
const full = ev.result_full ?? "";
|
|
40
|
+
const notices = (ev.notices ?? []).map((n) => `${n}\n`).join("");
|
|
41
|
+
const t = ev.truncation;
|
|
42
|
+
if (!t)
|
|
43
|
+
return notices + clip(full, cap);
|
|
44
|
+
const hidden = full.slice(t.head, full.length - t.tail);
|
|
45
|
+
return notices + full.slice(0, t.head) +
|
|
46
|
+
`\n[--- the agent did not see the next ${hidden.length} chars ---]\n${clip(hidden, Math.min(RESULT_HIDDEN_CAP, cap))}\n[--- end of the part the agent did not see ---]\n` +
|
|
47
|
+
full.slice(full.length - t.tail);
|
|
48
|
+
}
|
|
49
|
+
function formatActionForEvaluator(a) {
|
|
50
|
+
if (!a || typeof a !== "object")
|
|
51
|
+
return "(none)";
|
|
52
|
+
switch (a.type) {
|
|
53
|
+
case "run":
|
|
54
|
+
return `run:\n${fence(String(a.cmd), "bash")}`;
|
|
55
|
+
case "write_file":
|
|
56
|
+
return `write_file ${a.path}:\n${fence(clip(String(a.content), RESULT_CAP))}`;
|
|
57
|
+
case "edit_file":
|
|
58
|
+
return `edit_file ${a.path}\nold:\n${fence(String(a.old))}new:\n${fence(String(a.new))}`;
|
|
59
|
+
case "ask":
|
|
60
|
+
return `ask: ${a.question}`;
|
|
61
|
+
default:
|
|
62
|
+
return fence(JSON.stringify(a, null, 2), "json");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function buildEvaluatorInput(runDir) {
|
|
66
|
+
const summary = JSON.parse(fs.readFileSync(path.join(runDir, "summary.json"), "utf8"));
|
|
67
|
+
const events = fs.readFileSync(path.join(runDir, "events.jsonl"), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
68
|
+
const finalPath = path.join(runDir, "final-state.md");
|
|
69
|
+
const finalState = fs.existsSync(finalPath) ? fs.readFileSync(finalPath, "utf8") : "(not recorded)";
|
|
70
|
+
const turns = events.filter((e) => e.type === "turn");
|
|
71
|
+
const build = (cap) => {
|
|
72
|
+
let log = "";
|
|
73
|
+
for (const ev of turns) {
|
|
74
|
+
log += `## Turn ${ev.turn}\n\n`;
|
|
75
|
+
if (ev.parse_error) {
|
|
76
|
+
log += `The response could not be parsed (${ev.parse_error}).\n\n`;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
log += `Notes after this turn:\n${fence(ev.notes || "(empty)")}\nAction: ${formatActionForEvaluator(ev.parsed?.action)}\n`;
|
|
80
|
+
if (ev.declined !== undefined)
|
|
81
|
+
log += `\nThe person running gentl declined this action${ev.declined ? `: ${ev.declined}` : ""}.\n`;
|
|
82
|
+
if (ev.result_full !== undefined)
|
|
83
|
+
log += `\nResult:\n${fence(resultForEvaluator(ev, cap))}`;
|
|
84
|
+
log += "\n";
|
|
85
|
+
}
|
|
86
|
+
return log;
|
|
87
|
+
};
|
|
88
|
+
let log = build(RESULT_CAP);
|
|
89
|
+
if (log.length > LOG_CAP)
|
|
90
|
+
log = build(2000);
|
|
91
|
+
const asks = (summary.asks ?? []);
|
|
92
|
+
const finish = summary.finished
|
|
93
|
+
? `success: ${summary.self_reported_success}\nsummary: ${summary.self_report_summary}\nfriction: ${summary.self_report_friction ?? "(none given)"}`
|
|
94
|
+
: `The agent did not finish (stop reason: ${summary.stop_reason}).`;
|
|
95
|
+
const lc = summary.local_copy
|
|
96
|
+
? ` Clones of ${summary.local_copy.origin} were served from the maintainer's local working copy (with any uncommitted changes), so the agent's clone may differ from the published repo and its origin URL is a local file:// path; this is intended, not a problem with the run.`
|
|
97
|
+
: "";
|
|
98
|
+
return `# Task\n\n${summary.task}\n\n# Run\n\nModel ${summary.model}, sandbox ${summary.sandbox}, ${summary.turns} turns, stop reason ${summary.stop_reason}.${lc}\n\n` +
|
|
99
|
+
`# Log\n\n${log}\n# Questions the agent asked\n\n${asks.length ? asks.map((a) => `- turn ${a.turn}: ${a.question}`).join("\n") : "none"}\n\n` +
|
|
100
|
+
`# The agent's finish report\n\n${finish}\n\n# Final state of the workspace\n\n${finalState}\n`;
|
|
101
|
+
}
|
|
102
|
+
/** Evaluate a run directory and write evaluation.json and friction.md; also records the verdict in summary.json. */
|
|
103
|
+
export async function evaluateRun(runDir, cfg, signal, log) {
|
|
104
|
+
const input = buildEvaluatorInput(runDir);
|
|
105
|
+
const client = createModelClient(cfg, EVAL_SCHEMA, log);
|
|
106
|
+
const reply = await client.call(EVALUATOR_PROMPT, input, signal);
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = parseFirstJson(reply.text, (v) => typeof v.success === "boolean");
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new Error(`could not parse the evaluator's response: ${reply.text.slice(0, 300)}`);
|
|
113
|
+
}
|
|
114
|
+
const ev = {
|
|
115
|
+
model: cfg.model, success: parsed.success, reasons: String(parsed.reasons ?? ""),
|
|
116
|
+
friction_report: String(parsed.friction_report ?? ""), structured_output: client.structured,
|
|
117
|
+
usage: reply.usage, cost_usd: Number(reply.usage.cost ?? 0), input_chars: input.length, created: new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
fs.writeFileSync(path.join(runDir, "evaluation.json"), JSON.stringify(ev, null, 2) + "\n");
|
|
120
|
+
fs.writeFileSync(path.join(runDir, "friction.md"), `# Evaluation\n\nVerdict: ${ev.success ? "success" : "failure"} (evaluator ${ev.model})\n\n${ev.reasons}\n\n# Friction report\n\n${ev.friction_report}\n`);
|
|
121
|
+
const summaryPath = path.join(runDir, "summary.json");
|
|
122
|
+
const summary = JSON.parse(fs.readFileSync(summaryPath, "utf8"));
|
|
123
|
+
summary.evaluation = { model: ev.model, success: ev.success, reasons: ev.reasons, cost_usd: ev.cost_usd };
|
|
124
|
+
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2) + "\n");
|
|
125
|
+
return ev;
|
|
126
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The final state of the workspace, for the evaluator: git status and diff of each repo, plus loose files.
|
|
2
|
+
// Everything runs inside the sandbox through `exec`, so it works the same for host and Docker.
|
|
3
|
+
import { clip, fence } from "./format.js";
|
|
4
|
+
const SKIP_DIRS = ["node_modules", ".venv", "venv", "env", "site-packages", "__pycache__", "build", "dist", "target"];
|
|
5
|
+
const MAX_DEPTH = 4; // how deep to look for .git below the working directory
|
|
6
|
+
const DIFF_CAP = 40000; // chars per repo diff
|
|
7
|
+
const FILE_CAP = 10000; // bytes per untracked or loose file
|
|
8
|
+
const FILES_CAP = 40000; // chars of file contents per section
|
|
9
|
+
const MAX_FILES = 40; // files listed per section
|
|
10
|
+
const PRUNE = SKIP_DIRS.map((d) => `-name ${d}`).join(" -o ");
|
|
11
|
+
function lines(s) {
|
|
12
|
+
return s.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
function filesSection(sb, cwd, rels) {
|
|
15
|
+
let out = "";
|
|
16
|
+
let budget = FILES_CAP;
|
|
17
|
+
for (const rel of rels.slice(0, MAX_FILES)) {
|
|
18
|
+
const r = sb.exec(["bash", "-c", `[ -f "$1" ] && [ "$(wc -c < "$1")" -le ${FILE_CAP} ] && cat "$1"`, "_", rel], cwd);
|
|
19
|
+
if (!r.ok || r.out.includes("\0") || r.out.length > budget) {
|
|
20
|
+
out += `\n${rel}: (not shown: binary, large, or over the size budget)\n`;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
budget -= r.out.length;
|
|
24
|
+
out += `\n${rel}:\n${fence(r.out)}`;
|
|
25
|
+
}
|
|
26
|
+
if (rels.length > MAX_FILES)
|
|
27
|
+
out += `\n(${rels.length - MAX_FILES} more files not listed)\n`;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
export function describeFinalState(sb) {
|
|
31
|
+
const sh = (script, cwd) => sb.exec(["bash", "-c", script], cwd);
|
|
32
|
+
const git = (cwd, ...args) => sb.exec(["git", "-c", "core.quotepath=off", ...args], cwd);
|
|
33
|
+
const top = sh("ls -1Ap");
|
|
34
|
+
const repoDirs = lines(sh(`find . -maxdepth ${MAX_DEPTH} \\( ${PRUNE} \\) -prune -o -name .git -prune -print`).out)
|
|
35
|
+
.map((p) => p.replace(/\/\.git$/, "").replace(/^\.\//, "") || ".")
|
|
36
|
+
.sort();
|
|
37
|
+
const repos = [];
|
|
38
|
+
let text = `Top level of the working directory: ${lines(top.out).join(", ") || "(empty)"}\n\n`;
|
|
39
|
+
for (const rel of repoDirs) {
|
|
40
|
+
const upstream = git(rel, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}");
|
|
41
|
+
const base = upstream.ok ? upstream.out.trim() : "HEAD";
|
|
42
|
+
const status = git(rel, "status", "--short", "--branch").out;
|
|
43
|
+
const commits = base !== "HEAD" ? git(rel, "log", "--oneline", `${base}..HEAD`).out : "";
|
|
44
|
+
const diff = git(rel, "diff", base).out;
|
|
45
|
+
const untracked = lines(git(rel, "ls-files", "--others", "--exclude-standard").out);
|
|
46
|
+
repos.push({ path: rel, changed: !!(commits.trim() || diff.trim() || untracked.length) });
|
|
47
|
+
text += `## Repo ${rel}\n\ngit status:\n${fence(status || "(clean)")}`;
|
|
48
|
+
if (commits.trim())
|
|
49
|
+
text += `\nCommits made since ${base}:\n${fence(commits)}`;
|
|
50
|
+
text += `\ngit diff ${base} (committed and uncommitted changes to tracked files):\n${fence(clip(diff, DIFF_CAP) || "(none)", "diff")}`;
|
|
51
|
+
if (untracked.length)
|
|
52
|
+
text += `\nUntracked files:\n${filesSection(sb, rel, untracked)}`;
|
|
53
|
+
text += "\n";
|
|
54
|
+
}
|
|
55
|
+
// Files in the working directory outside any repo (e.g. an example script the agent wrote), two levels deep.
|
|
56
|
+
const loose = lines(sh(`find . -maxdepth 2 \\( -name '.?*' -o ${PRUNE} \\) -prune -o -type f -print`).out)
|
|
57
|
+
.map((p) => p.replace(/^\.\//, ""))
|
|
58
|
+
.filter((p) => !repoDirs.some((r) => r === "." || p.startsWith(r + "/")));
|
|
59
|
+
if (loose.length)
|
|
60
|
+
text += `## Files outside any repo\n${filesSection(sb, ".", loose)}`;
|
|
61
|
+
if (!repoDirs.length)
|
|
62
|
+
text += "No git repos found in the working directory.\n";
|
|
63
|
+
return { text, repos };
|
|
64
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Code block whose fence is longer than any backtick run in the text. */
|
|
2
|
+
export function fence(text, lang = "") {
|
|
3
|
+
const runs = text.match(/`{3,}/g) ?? [];
|
|
4
|
+
const n = Math.max(3, ...runs.map((r) => r.length + 1));
|
|
5
|
+
const f = "`".repeat(n);
|
|
6
|
+
return `${f}${lang}\n${text.replace(/\n+$/, "")}\n${f}\n`;
|
|
7
|
+
}
|
|
8
|
+
/** Keep the start and end of a long text, noting how much was cut. */
|
|
9
|
+
export function clip(text, maxChars) {
|
|
10
|
+
if (text.length <= maxChars)
|
|
11
|
+
return text;
|
|
12
|
+
const head = text.slice(0, Math.floor(maxChars / 2));
|
|
13
|
+
const tail = text.slice(text.length - Math.floor(maxChars / 2));
|
|
14
|
+
return `${head}\n[... ${text.length - head.length - tail.length} chars cut ...]\n${tail}`;
|
|
15
|
+
}
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Host mode: a fresh temp directory on this machine, with work/ (where the agent's commands run) and home/ (HOME).
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { execLocal, Sandbox } from "./sandbox.js";
|
|
7
|
+
// Environment variables passed through from the user's environment; everything else (API keys, tokens, XDG dirs) is dropped.
|
|
8
|
+
const PASSTHROUGH_ENV = ["PATH", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "USER", "LOGNAME", "SHELL",
|
|
9
|
+
"http_proxy", "https_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"];
|
|
10
|
+
function killGroup(pid) {
|
|
11
|
+
if (!pid)
|
|
12
|
+
return;
|
|
13
|
+
try {
|
|
14
|
+
process.kill(-pid, "SIGKILL");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// already gone
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class HostSandbox extends Sandbox {
|
|
21
|
+
kind = "host";
|
|
22
|
+
root;
|
|
23
|
+
workdir;
|
|
24
|
+
env = {};
|
|
25
|
+
groups = new Set(); // process groups of all commands, killed on remove()
|
|
26
|
+
constructor() {
|
|
27
|
+
super();
|
|
28
|
+
this.root = fs.mkdtempSync(path.join(os.tmpdir(), "gentl-"));
|
|
29
|
+
this.workdir = path.join(this.root, "work");
|
|
30
|
+
const home = path.join(this.root, "home");
|
|
31
|
+
fs.mkdirSync(this.workdir);
|
|
32
|
+
fs.mkdirSync(home);
|
|
33
|
+
for (const k of PASSTHROUGH_ENV)
|
|
34
|
+
if (process.env[k] !== undefined)
|
|
35
|
+
this.env[k] = process.env[k];
|
|
36
|
+
Object.assign(this.env, { HOME: home, TERM: "dumb", NO_COLOR: "1", PAGER: "cat", GIT_PAGER: "cat",
|
|
37
|
+
GIT_TERMINAL_PROMPT: "0" });
|
|
38
|
+
}
|
|
39
|
+
describe() {
|
|
40
|
+
return `host, workspace ${this.root}`;
|
|
41
|
+
}
|
|
42
|
+
start(cmd) {
|
|
43
|
+
const child = spawn("bash", ["-c", cmd], { cwd: this.workdir, env: this.env, detached: true,
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"] });
|
|
45
|
+
if (child.pid)
|
|
46
|
+
this.groups.add(child.pid);
|
|
47
|
+
return { child, kill: () => killGroup(child.pid) };
|
|
48
|
+
}
|
|
49
|
+
exec(args, cwd, input) {
|
|
50
|
+
return execLocal(args[0], args.slice(1), { cwd: path.resolve(this.workdir, cwd ?? "."), env: this.env, input });
|
|
51
|
+
}
|
|
52
|
+
remove() {
|
|
53
|
+
for (const pid of this.groups)
|
|
54
|
+
killGroup(pid);
|
|
55
|
+
try {
|
|
56
|
+
fs.rmSync(this.root, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Some caches (e.g. Go modules) are read-only.
|
|
60
|
+
spawnSync("chmod", ["-R", "u+w", this.root]);
|
|
61
|
+
fs.rmSync(this.root, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|