@magland/gentl 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -9
- package/dist/cli.js +40 -3
- package/dist/evaluate.js +10 -2
- package/dist/host.js +30 -3
- package/dist/protocol.js +34 -6
- package/dist/run.js +45 -18
- package/dist/sandbox.js +65 -4
- package/dist/suite.js +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -33,16 +33,17 @@ Options:
|
|
|
33
33
|
|
|
34
34
|
- `--image IMG`: the Docker image for the sandbox. The default is built on first use from [sandbox/Dockerfile](sandbox/Dockerfile) and tagged by its content (`gentl-sandbox:<hash>`).
|
|
35
35
|
- `--host`: run on this machine in a fresh temp directory instead of Docker, for software that can't run in a container (for example a licensed MATLAB).
|
|
36
|
+
- `--matlab-on-host`: `--host` set up for the MATLAB installed on this machine; see Host mode.
|
|
36
37
|
- `--confirm`: ask before each action. This is the default with `--host`; Docker runs are unattended unless you pass it.
|
|
37
38
|
- `--yes`: with `--host`, run actions without asking.
|
|
38
39
|
- `--model M`: the agent's model, default `anthropic/claude-haiku-5.5`.
|
|
39
40
|
- `--evaluator-model M`: the evaluator's model, same default.
|
|
40
|
-
- `--upload`: upload each
|
|
41
|
+
- `--upload`: upload each run that finishes, reaches the turn limit or is aborted to the hub its task came from.
|
|
41
42
|
- `--yes`: don't ask before running a hub task (and, with `--host`, before each action).
|
|
42
43
|
- `--backend B`: `openrouter` (default) or `claude-code`; see Backends.
|
|
43
44
|
- `--local` / `--remote`: serve clones of the task's repo from your checkout (the default inside a git repo), or clone the published repo; see Local copy.
|
|
44
45
|
- `--base-url URL`: any OpenAI-compatible endpoint (default `https://openrouter.ai/api/v1`). The key in `OPENROUTER_API_KEY` is sent to it.
|
|
45
|
-
- `--max-turns N`: default
|
|
46
|
+
- `--max-turns N`: default 250.
|
|
46
47
|
- `--repeat N`: run each task N times; see Repeated runs.
|
|
47
48
|
- `--keep`: keep the container or temp workspace instead of deleting it.
|
|
48
49
|
|
|
@@ -68,13 +69,19 @@ The image and its id are recorded in `summary.json`.
|
|
|
68
69
|
|
|
69
70
|
The agent works in `work/` inside a fresh temp directory, with `HOME` set to a sibling `home/` so it does not see your git config, caches or credentials. Only a few environment variables are passed through (`PATH`, locale, proxies); API keys and tokens are not. Each command runs in a fresh bash shell in its own process group, with no input and a 300 s timeout. The workspace is deleted at the end unless `--keep` is given.
|
|
70
71
|
|
|
71
|
-
The agent can still reach anything on your machine that you can, so by default gentl shows each action and asks `ok? [y/n]` before running it. On `n` you can give a reason, which is passed back to the agent.
|
|
72
|
+
The agent can still reach anything on your machine that you can, so by default gentl shows each action and asks `ok? [y/n]` before running it. On `n` you can give a reason, which is passed back to the agent.
|
|
73
|
+
|
|
74
|
+
Some licensed software keeps its licensing state in your home directory, which the agent's empty HOME does not have. `--share-home PATH` (repeatable) links that part of your real home directory into the agent's HOME, read and write, leaving the rest hidden; `--pass-env NAME` (repeatable) passes one more environment variable through. Both are printed at the start and recorded in `summary.json`. For MATLAB there is a shortcut, `--matlab-on-host`: it implies `--host` and shares `.matlab`, `.MathWorks` and `.MATLABConnector` (those that exist), which MATLAB's licensing needs (tested with R2025b and online licensing; without them MATLAB fails with licensing error 5201). It warns if there is no `matlab` command on your PATH. As with `--host`, add `--yes` to run without confirming each action:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
gentl run TASK --matlab-on-host --yes
|
|
78
|
+
```
|
|
72
79
|
|
|
73
80
|
Host mode also inherits whatever is installed on your machine (compilers, Python packages, uv), so setup steps can succeed without the docs being followed, and reading effort is not comparable with Docker runs.
|
|
74
81
|
|
|
75
82
|
### Suites
|
|
76
83
|
|
|
77
|
-
`gentl run DIR` runs every `*.md` file in the directory, in name order, skipping `README.md` and files starting with `_`. The task name is the file name. Runs go to `runs/<timestamp>-<suite>/<run>/`, and at the end gentl prints a results table (verdict, self-report, turns, read tokens, notes max, questions, cost) and writes it to `results.md` and `results.json` in the suite directory.
|
|
84
|
+
`gentl run DIR` runs every `*.md` file in the directory, in name order, skipping `README.md` and files starting with `_`. The task name is the file name. Runs go to `runs/<timestamp>-<suite>/<run>/`, and at the end gentl prints a results table (verdict, self-report, turns, read tokens, notes max, questions, cost) and writes it to `results.md` and `results.json` in the suite directory. Aborting a run with Ctrl-C skips the remaining runs.
|
|
78
85
|
|
|
79
86
|
### Repeated runs
|
|
80
87
|
|
|
@@ -90,12 +97,15 @@ Each turn the model sees only the system prompt, the task, its notes, and its la
|
|
|
90
97
|
{"notes_keep": false, "notes": "...", "notes_edit": {"old": "...", "new": "..."}, "notes_append": "...", "action": {...}}
|
|
91
98
|
```
|
|
92
99
|
|
|
93
|
-
The notes are the agent's working memory,
|
|
100
|
+
The notes are the agent's working memory and its record of what it has done. The prompt asks for generous, specific notes (each step and its outcome, what was read and what it said, exact commands that worked, what failed, the plan), grown with append and edit rather than rewritten, since agents with thin notes reread files and rerun commands in circles. The prompt gives a stable layout (Goal, Done, Learned, Commands, Plan) and a sense of size (several hundred to a couple of thousand words on a long task), and no longer tells the agent that notes are counted. When a rewrite shrinks notes of 100 tokens or more by over half, the next result says so (`notes_shrink_warnings`); `notes_tokens_max` shows how much the agent carried. (An earlier version asked for small notes, to mimic how much a person holds in their head; that may come back once the agent stays on course with less.) `notes_keep` and `action` are required. Each turn the agent either updates its notes (`notes_keep: false` with one or more of the operations, applied in order: rewrite, edit, append) or keeps them as they are (`notes_keep: true`). Making the choice explicit matters: without it, models often wrote the action first and skipped the notes, then forgot what they had learned. If a response makes no notes decision anyway, gentl asks again within the same turn, once, with a short reminder (counted as reading and recorded as `notes_reminders`).
|
|
101
|
+
|
|
102
|
+
A memoryless agent can also go in circles, rereading the same sections because nothing tells it it has read them. Two things work against that. The prompt asks the agent to keep a short log in its notes of what it has read and what it gave, to check it before reading, and to act on a result in the turn it sees it (for code: read the whole region to change at once, then edit) rather than collecting a file piece by piece. And when the agent repeats an action it already took at any earlier turn, the result says so, naming those turns (counted as reading; `repeated_actions` in the summary).
|
|
94
103
|
|
|
95
104
|
Actions:
|
|
96
105
|
|
|
97
|
-
- `run {cmd, max_chars?}`: bash in the working directory. stdout and stderr are combined. Output longer than `max_chars` (default
|
|
98
|
-
- `
|
|
106
|
+
- `run {cmd, max_chars?}`: bash in the working directory. stdout and stderr are combined. Output longer than `max_chars` (default 4000, at most 20000) is cut in the middle, keeping the head and tail, and the result's first line says so.
|
|
107
|
+
- `read_file {path, start_line?, end_line?}`: exactly the requested lines of a file, numbered, never cut in the middle; a range over 20000 characters stops at a line boundary and says where to go on. The prompt asks the agent to read files this way rather than with `cat` or `sed`, whose output, like any command's, is cut in the middle when long. Without it, an agent reading part of a file would get both ends of the range and not the middle, ask for the middle, lose the rest, and go round in circles.
|
|
108
|
+
- `write_file {path, content}` and `edit_file {path, old, new}` (`old` must occur exactly once). When `old` does not match, the error says why if it can: a backslash sequence in the code (`\n` in a string) written in the JSON as a real line break, or else the file's lines closest to it.
|
|
99
109
|
- `ask {question}`: no one answers. The question is recorded as a gap in the docs, and the agent is told to decide from the docs and code.
|
|
100
110
|
- `finish {success, summary, friction?}`: the summary cites the evidence of success; `friction` is the agent's own account of what was missing or unclear in the docs.
|
|
101
111
|
|
|
@@ -108,9 +118,13 @@ gentl asks for structured output (a JSON schema) so responses parse reliably; if
|
|
|
108
118
|
|
|
109
119
|
The backend is recorded in `summary.json`. Runs with different backends are not directly comparable.
|
|
110
120
|
|
|
121
|
+
### Stopping a run
|
|
122
|
+
|
|
123
|
+
Ctrl-C aborts the run wherever it is: a running command is killed and a model call cancelled. The run is recorded with stop reason `aborted`, evaluated (its verdict is always a failure, and the friction report covers what happened up to the abort), and, with `--upload`, reported to the hub. A second Ctrl-C during the evaluation skips it. The sandbox is removed in every case. A command that hangs is also stopped by the 300 s timeout, and the agent is told. SIGTERM and SIGHUP (the process killed, the terminal closed) end the run with stop reason `interrupted`, without an evaluation or upload; the run directory is still written.
|
|
124
|
+
|
|
111
125
|
## Evaluation
|
|
112
126
|
|
|
113
|
-
After a run that finishes
|
|
127
|
+
After a run that finishes, reaches the turn limit or is aborted, gentl records the final state of the workspace: for each git repo, `git status`, the commits and `git diff` against its upstream, and untracked files; also any loose files in the working directory. Then an evaluator (one model call) reads the full log, with the parts of each result that the agent did not see marked as such, the questions asked, the agent's finish report and the final state. It writes:
|
|
114
128
|
|
|
115
129
|
- a verdict: did the agent actually accomplish the task, judged from the evidence rather than the agent's claim;
|
|
116
130
|
- a friction report: docs used, what was missing or wrong, where the agent guessed, signs of prior knowledge (commands or APIs it used that never appeared in anything it read), and the questions it asked.
|
|
@@ -128,7 +142,7 @@ The evaluator's tokens are not counted as reading effort. Reading effort should
|
|
|
128
142
|
A hub ([gentl-hub](https://github.com/magland/gentl-hub); the public one is https://gentl-hub.jeremy-magland.workers.dev) holds tasks with titles, shows the command to run each, and collects uploaded runs. gentl contacts a hub only when given a hub task URL or asked to upload.
|
|
129
143
|
|
|
130
144
|
- `gentl run https://<hub>/tasks/<id>` fetches the task, prints its title and text, and asks before running it (`--yes` skips this). Hub tasks always clone the published repo, never a local copy, so uploaded results match public code.
|
|
131
|
-
- `--upload` uploads each run that finished
|
|
145
|
+
- `--upload` uploads each run that finished, hit the turn limit or was aborted (Ctrl-C) to that hub; `gentl upload runs/<id>` uploads one later, and `gentl upload runs/<batch>` uploads every run in a batch. A run goes only to the hub its task came from.
|
|
132
146
|
- The first upload to a hub asks for your name (shown with your runs) and the hub's passcode, and offers to remember them in `~/.config/gentl/hubs.json` (mode 0600). `GENTL_HUB_USER` and `GENTL_HUB_PASSCODE` override it.
|
|
133
147
|
- The upload holds `summary.json`, `evaluation.json`, `final-state.md`, and each turn as the agent saw it (notes, action, shown result); full command output stays local.
|
|
134
148
|
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import * as readline from "node:readline";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
6
7
|
import { checkDocker, ensureDefaultImage } from "./docker.js";
|
|
7
8
|
import { fetchHubTask, hubCredentials, parseHubTaskUrl, runDirsIn, runHub, uploadRun } from "./hub.js";
|
|
9
|
+
import { resolveSharedHome } from "./host.js";
|
|
8
10
|
import { findRepo, prepareLocalCopy, removeLocalCopy } from "./localcopy.js";
|
|
9
11
|
import { evaluate, runTask } from "./run.js";
|
|
10
12
|
import { runBatch, suiteTasks } from "./suite.js";
|
|
@@ -25,6 +27,11 @@ options:
|
|
|
25
27
|
--image IMG Docker image for the sandbox (default: built from gentl's sandbox/Dockerfile)
|
|
26
28
|
--host run in a fresh temp directory on this machine instead of Docker
|
|
27
29
|
--confirm ask before each action (the default with --host)
|
|
30
|
+
--share-home PATH with --host, link this part of your home directory (e.g. .matlab) into the
|
|
31
|
+
agent's otherwise empty HOME; repeatable
|
|
32
|
+
--pass-env NAME with --host, pass this environment variable through as well; repeatable
|
|
33
|
+
--matlab-on-host use the MATLAB installed on this machine: implies --host and shares the
|
|
34
|
+
home folders MATLAB's licensing needs (.matlab, .MathWorks, .MATLABConnector)
|
|
28
35
|
--yes don't ask: run a hub task without confirming it, and with --host, run actions
|
|
29
36
|
without asking
|
|
30
37
|
--upload upload each finished run to the hub its task came from
|
|
@@ -34,7 +41,7 @@ options:
|
|
|
34
41
|
claude-code (your Claude Code login, via claude -p); default from
|
|
35
42
|
GENTL_BACKEND, else openrouter
|
|
36
43
|
--base-url URL OpenAI-compatible endpoint (default ${DEFAULT_BASE_URL})
|
|
37
|
-
--max-turns N stop after N turns (default
|
|
44
|
+
--max-turns N stop after N turns (default 250)
|
|
38
45
|
--repeat N run each task N times and report the success rate and median read tokens
|
|
39
46
|
--local serve clones of the task's repo from your checkout, uncommitted changes included
|
|
40
47
|
(the default when the task file, suite or current directory is in a git repo)
|
|
@@ -43,6 +50,11 @@ options:
|
|
|
43
50
|
|
|
44
51
|
The API key is read from OPENROUTER_API_KEY, in the environment or in ./.env.
|
|
45
52
|
Runs are written to ./runs/<id>/.`;
|
|
53
|
+
/** The home folders a licensed MATLAB keeps its licensing state in (R2025b, online licensing). */
|
|
54
|
+
const MATLAB_HOME_DIRS = [".matlab", ".MathWorks", ".MATLABConnector"];
|
|
55
|
+
function onPath(cmd) {
|
|
56
|
+
return (process.env.PATH ?? "").split(path.delimiter).some((d) => d && fs.existsSync(path.join(d, cmd)));
|
|
57
|
+
}
|
|
46
58
|
/** A variable from the environment, or from ./.env. */
|
|
47
59
|
function envValue(name) {
|
|
48
60
|
if (process.env[name])
|
|
@@ -71,13 +83,16 @@ async function run(argv) {
|
|
|
71
83
|
confirm: { type: "boolean" },
|
|
72
84
|
yes: { type: "boolean" },
|
|
73
85
|
local: { type: "boolean" },
|
|
86
|
+
"share-home": { type: "string", multiple: true },
|
|
87
|
+
"matlab-on-host": { type: "boolean" },
|
|
88
|
+
"pass-env": { type: "string", multiple: true },
|
|
74
89
|
upload: { type: "boolean" },
|
|
75
90
|
remote: { type: "boolean" },
|
|
76
91
|
model: { type: "string", default: DEFAULT_MODEL },
|
|
77
92
|
"evaluator-model": { type: "string", default: DEFAULT_MODEL },
|
|
78
93
|
backend: { type: "string" },
|
|
79
94
|
"base-url": { type: "string", default: DEFAULT_BASE_URL },
|
|
80
|
-
"max-turns": { type: "string", default: "
|
|
95
|
+
"max-turns": { type: "string", default: "250" },
|
|
81
96
|
repeat: { type: "string", default: "1" },
|
|
82
97
|
keep: { type: "boolean" },
|
|
83
98
|
help: { type: "boolean", short: "h" },
|
|
@@ -89,6 +104,12 @@ async function run(argv) {
|
|
|
89
104
|
}
|
|
90
105
|
if ((values.task === undefined) === (positionals.length !== 1))
|
|
91
106
|
fail("give one task: a task file or -t TEXT");
|
|
107
|
+
// --matlab-on-host is --host plus MATLAB's home folders; set host first, since confirmation depends on it.
|
|
108
|
+
if (values["matlab-on-host"]) {
|
|
109
|
+
if (values.image)
|
|
110
|
+
fail("--matlab-on-host runs on this machine, so --image does not apply");
|
|
111
|
+
values.host = true;
|
|
112
|
+
}
|
|
92
113
|
if (values.host && values.image)
|
|
93
114
|
fail("--image applies to the Docker sandbox, not --host");
|
|
94
115
|
if (values.confirm && values.yes)
|
|
@@ -106,8 +127,24 @@ async function run(argv) {
|
|
|
106
127
|
fail("--repeat must be a positive integer");
|
|
107
128
|
const modelFor = modelConfig(values.backend, values["base-url"]);
|
|
108
129
|
let sandbox;
|
|
130
|
+
const shareHome = [...(values["share-home"] ?? []),
|
|
131
|
+
...(values["matlab-on-host"] ? MATLAB_HOME_DIRS.filter((d) => fs.existsSync(path.join(os.homedir(), d))) : [])];
|
|
132
|
+
if (values["matlab-on-host"] && !onPath("matlab")) {
|
|
133
|
+
console.log("warning: --matlab-on-host, but there is no matlab command on your PATH, so the agent will not find one either");
|
|
134
|
+
}
|
|
135
|
+
const passEnv = values["pass-env"] ?? [];
|
|
136
|
+
if (!values.host && (shareHome.length || passEnv.length))
|
|
137
|
+
fail("--share-home and --pass-env apply only with --host");
|
|
138
|
+
for (const p of shareHome) {
|
|
139
|
+
try {
|
|
140
|
+
resolveSharedHome(p);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
fail(e.message);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
109
146
|
if (values.host) {
|
|
110
|
-
sandbox = { kind: "host" };
|
|
147
|
+
sandbox = { kind: "host", shareHome, passEnv };
|
|
111
148
|
if (!confirm)
|
|
112
149
|
console.log("warning: host mode without confirmation; the agent's commands run on this machine unchecked");
|
|
113
150
|
}
|
package/dist/evaluate.js
CHANGED
|
@@ -52,6 +52,8 @@ function formatActionForEvaluator(a) {
|
|
|
52
52
|
switch (a.type) {
|
|
53
53
|
case "run":
|
|
54
54
|
return `run:\n${fence(String(a.cmd), "bash")}`;
|
|
55
|
+
case "read_file":
|
|
56
|
+
return `read_file ${a.path}${a.start_line !== undefined || a.end_line !== undefined ? ` lines ${a.start_line ?? 1}-${a.end_line ?? "end"}` : ""}`;
|
|
55
57
|
case "write_file":
|
|
56
58
|
return `write_file ${a.path}:\n${fence(clip(String(a.content), RESULT_CAP))}`;
|
|
57
59
|
case "edit_file":
|
|
@@ -91,7 +93,9 @@ export function buildEvaluatorInput(runDir) {
|
|
|
91
93
|
const asks = (summary.asks ?? []);
|
|
92
94
|
const finish = summary.finished
|
|
93
95
|
? `success: ${summary.self_reported_success}\nsummary: ${summary.self_report_summary}\nfriction: ${summary.self_report_friction ?? "(none given)"}`
|
|
94
|
-
:
|
|
96
|
+
: summary.stop_reason === "aborted"
|
|
97
|
+
? `The person running gentl aborted the run (Ctrl-C) after turn ${summary.turns}, before the agent finished. The verdict is therefore a failure; write the reasons and the friction report from what happened up to that point.`
|
|
98
|
+
: `The agent did not finish (stop reason: ${summary.stop_reason}).`;
|
|
95
99
|
const lc = summary.local_copy
|
|
96
100
|
? ` 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
101
|
: "";
|
|
@@ -111,8 +115,12 @@ export async function evaluateRun(runDir, cfg, signal, log) {
|
|
|
111
115
|
catch {
|
|
112
116
|
throw new Error(`could not parse the evaluator's response: ${reply.text.slice(0, 300)}`);
|
|
113
117
|
}
|
|
118
|
+
// An aborted run is a failure whatever the evaluator says; its reasons and friction report still stand.
|
|
119
|
+
const aborted = JSON.parse(fs.readFileSync(path.join(runDir, "summary.json"), "utf8")).stop_reason === "aborted";
|
|
120
|
+
const reasons = String(parsed.reasons ?? "");
|
|
114
121
|
const ev = {
|
|
115
|
-
model: cfg.model, success:
|
|
122
|
+
model: cfg.model, success: aborted ? false : parsed.success,
|
|
123
|
+
reasons: aborted ? `Aborted by the person running gentl before the agent finished. ${reasons}` : reasons,
|
|
116
124
|
friction_report: String(parsed.friction_report ?? ""), structured_output: client.structured,
|
|
117
125
|
usage: reply.usage, cost_usd: Number(reply.usage.cost ?? 0), input_chars: input.length, created: new Date().toISOString(),
|
|
118
126
|
};
|
package/dist/host.js
CHANGED
|
@@ -17,27 +17,51 @@ function killGroup(pid) {
|
|
|
17
17
|
// already gone
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
/** A --share-home path: relative to HOME, inside it, and existing. Returns the absolute path or throws. */
|
|
21
|
+
export function resolveSharedHome(p) {
|
|
22
|
+
const home = os.homedir();
|
|
23
|
+
const abs = path.resolve(home, p.replace(/^~\//, ""));
|
|
24
|
+
if (abs === home || !abs.startsWith(home + path.sep))
|
|
25
|
+
throw new Error(`--share-home ${p}: give a path inside your home directory, such as .matlab`);
|
|
26
|
+
if (!fs.existsSync(abs))
|
|
27
|
+
throw new Error(`--share-home ${p}: ${abs} does not exist`);
|
|
28
|
+
return abs;
|
|
29
|
+
}
|
|
20
30
|
export class HostSandbox extends Sandbox {
|
|
31
|
+
opts;
|
|
21
32
|
kind = "host";
|
|
22
33
|
root;
|
|
23
34
|
workdir;
|
|
24
35
|
env = {};
|
|
25
36
|
groups = new Set(); // process groups of all commands, killed on remove()
|
|
26
|
-
|
|
37
|
+
links = []; // links into the real HOME, removed before the workspace is deleted
|
|
38
|
+
constructor(opts = { shareHome: [], passEnv: [] }) {
|
|
27
39
|
super();
|
|
40
|
+
this.opts = opts;
|
|
28
41
|
this.root = fs.mkdtempSync(path.join(os.tmpdir(), "gentl-"));
|
|
29
42
|
this.workdir = path.join(this.root, "work");
|
|
30
43
|
const home = path.join(this.root, "home");
|
|
31
44
|
fs.mkdirSync(this.workdir);
|
|
32
45
|
fs.mkdirSync(home);
|
|
33
|
-
|
|
46
|
+
// Shared parts of the real HOME are linked in, not copied, so programs that keep state there (MATLAB's
|
|
47
|
+
// licensing) see and update the real thing. Everything else in the real HOME stays out of reach.
|
|
48
|
+
for (const p of opts.shareHome) {
|
|
49
|
+
const real = resolveSharedHome(p);
|
|
50
|
+
const link = path.join(home, path.relative(os.homedir(), real));
|
|
51
|
+
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
52
|
+
fs.symlinkSync(real, link);
|
|
53
|
+
this.links.push(link);
|
|
54
|
+
}
|
|
55
|
+
for (const k of [...PASSTHROUGH_ENV, ...opts.passEnv])
|
|
34
56
|
if (process.env[k] !== undefined)
|
|
35
57
|
this.env[k] = process.env[k];
|
|
36
58
|
Object.assign(this.env, { HOME: home, TERM: "dumb", NO_COLOR: "1", PAGER: "cat", GIT_PAGER: "cat",
|
|
37
59
|
GIT_TERMINAL_PROMPT: "0" });
|
|
38
60
|
}
|
|
39
61
|
describe() {
|
|
40
|
-
|
|
62
|
+
const shared = this.opts.shareHome.length ? `, sharing ~/${this.opts.shareHome.join(", ~/")} from your home` : "";
|
|
63
|
+
const env = this.opts.passEnv.length ? `, passing ${this.opts.passEnv.join(", ")}` : "";
|
|
64
|
+
return `host, workspace ${this.root}${shared}${env}`;
|
|
41
65
|
}
|
|
42
66
|
start(cmd) {
|
|
43
67
|
const child = spawn("bash", ["-c", cmd], { cwd: this.workdir, env: this.env, detached: true,
|
|
@@ -52,6 +76,9 @@ export class HostSandbox extends Sandbox {
|
|
|
52
76
|
remove() {
|
|
53
77
|
for (const pid of this.groups)
|
|
54
78
|
killGroup(pid);
|
|
79
|
+
// Unlink the shared parts of the real HOME first, so nothing below can reach into them.
|
|
80
|
+
for (const link of this.links)
|
|
81
|
+
fs.rmSync(link, { force: true });
|
|
55
82
|
try {
|
|
56
83
|
fs.rmSync(this.root, { recursive: true, force: true });
|
|
57
84
|
}
|
package/dist/protocol.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// What the agent sees and how its responses are parsed.
|
|
2
|
-
export const ACTION_TYPES = ["run", "write_file", "edit_file", "ask", "finish"];
|
|
2
|
+
export const ACTION_TYPES = ["run", "read_file", "write_file", "edit_file", "ask", "finish"];
|
|
3
|
+
export const READ_MAX_CHARS = 20000; // a read_file result stops at a line boundary before this
|
|
3
4
|
export const ASK_RESULT = "No one is available to answer. Your question is recorded as a gap in the docs. Decide from the docs and code as best you can.";
|
|
4
|
-
export const DEFAULT_MAX_CHARS =
|
|
5
|
+
export const DEFAULT_MAX_CHARS = 4000;
|
|
5
6
|
export const MAX_MAX_CHARS = 20000;
|
|
6
7
|
export const CMD_TIMEOUT_S = 300;
|
|
7
8
|
export function systemPrompt(workdir, platform) {
|
|
@@ -9,18 +10,34 @@ export function systemPrompt(workdir, platform) {
|
|
|
9
10
|
|
|
10
11
|
Treat the software as unfamiliar. Go by its docs and code, not by what you remember about it, since the version here may differ.
|
|
11
12
|
|
|
12
|
-
This is a test of how readable the software and its docs are. We count the tokens
|
|
13
|
+
This is a test of how readable the software and its docs are. We count the tokens of the results you read as a proxy for the effort a human newcomer would need, and we collect what was missing or unclear. So read in a targeted way (grep, sed -n, head, --help) rather than dumping whole files or long pages. When the docs and code don't answer something you need, don't keep searching or guess silently: use the ask action, then make a reasonable decision and move on. A gap in the docs is useful feedback, not a failure on your part.
|
|
13
14
|
|
|
14
|
-
You have no memory between turns. Each turn you see only the task, your notes, and your last action with its result. The result is shown once and is gone on the next turn; your notes are all you carry forward. They are your working memory
|
|
15
|
+
You have no memory between turns. Each turn you see only the task, your notes, and your last action with its result. The result is shown once and is gone on the next turn; your notes are all you carry forward. They are your working memory and your record of what you have done. They start empty.
|
|
15
16
|
|
|
16
|
-
So every turn has two steps. First, update your notes
|
|
17
|
+
So every turn has two steps. First, update your notes with what the result (or the task, on the first turn) told you, and set "notes_keep": false; almost every result is worth at least a line (what you ran and what came of it), so set "notes_keep": true only if the result told you nothing worth keeping. Second, choose the action.
|
|
18
|
+
|
|
19
|
+
Be generous with notes. Forgetting is what costs: an agent with thin notes rereads files and reruns commands, and goes in circles. Notes of several hundred to a couple of thousand words are normal on a long task; a short note that forces you to read something again is the expensive choice. Record, specifically:
|
|
20
|
+
- each step you have taken and its outcome, as soon as the result confirms it (for example: "edit 1 applied to +pkg/list.m: added the --json flag at line 23; verified");
|
|
21
|
+
- what you have read and what it told you: file and line range or doc section, the key facts, and any exact snippets you will need again (a function signature, the lines you will edit);
|
|
22
|
+
- commands that worked, written out exactly, with the key part of their output; commands that failed, and why;
|
|
23
|
+
- your plan and the next step.
|
|
24
|
+
A layout that stays the same from turn to turn makes this easy, with new lines appended under each heading:
|
|
25
|
+
Goal: ...
|
|
26
|
+
Done: (each step and its outcome)
|
|
27
|
+
Learned: (file:lines and what they say; exact snippets)
|
|
28
|
+
Commands: (exact commands that worked, with key output; failures and why)
|
|
29
|
+
Plan: ...
|
|
30
|
+
Before acting, check whether your notes say it is already done or already read. Grow the notes with "notes_append" and "notes_edit" rather than rewriting them from scratch, so that nothing you still need is lost; rewrite only to reorganize, and remove things only once they are finished and will not be needed again.
|
|
31
|
+
|
|
32
|
+
You cannot hold a whole file in your notes, so don't collect one piece by piece. Instead, act on a result in the turn you see it: to change code, read the whole region you need at once and make the edit in your response to that result, copying the exact text to replace from it.
|
|
17
33
|
|
|
18
34
|
Each turn, respond with one JSON object and nothing else:
|
|
19
35
|
{"notes_keep": false, "notes": "...", "notes_edit": {"old": "...", "new": "..."}, "notes_append": "...", "action": {...}}
|
|
20
36
|
"notes_keep" and "action" are required. With "notes_keep": false, give at least one of "notes" (replaces your notes), "notes_edit" (replaces text that occurs exactly once in them) and "notes_append" (adds a line at the end); they are applied in that order. "notes_keep": true leaves your notes as they are.
|
|
21
37
|
|
|
22
38
|
Actions:
|
|
23
|
-
- {"type": "run", "cmd": "..."}: run with bash in your working directory. Each command starts a fresh shell (cd and exported variables do not persist) with no input. stdout and stderr are combined; output longer than ${DEFAULT_MAX_CHARS} characters is cut in the middle (add "max_chars": N, at most ${MAX_MAX_CHARS}, to see more). Timeout ${CMD_TIMEOUT_S} s.
|
|
39
|
+
- {"type": "run", "cmd": "..."}: run with bash in your working directory. Each command starts a fresh shell (cd and exported variables do not persist) with no input. stdout and stderr are combined; output longer than ${DEFAULT_MAX_CHARS} characters is cut in the middle, keeping its start and end, and the result's first line says so (add "max_chars": N, at most ${MAX_MAX_CHARS}, to see more). Timeout ${CMD_TIMEOUT_S} s.
|
|
40
|
+
- {"type": "read_file", "path": "...", "start_line": N, "end_line": M}: show lines N to M of a file, numbered (both optional: the whole file, or from N to the end). It shows exactly the lines asked for and is never cut in the middle; a range over ${READ_MAX_CHARS} characters stops early and says where to go on. Use it, not cat or sed, to read files.
|
|
24
41
|
- {"type": "write_file", "path": "...", "content": "..."}: create or overwrite a file. Relative paths are under your working directory.
|
|
25
42
|
- {"type": "edit_file", "path": "...", "old": "...", "new": "..."}: replace text that occurs exactly once in the file.
|
|
26
43
|
- {"type": "ask", "question": "..."}: ask the maintainers something the docs and code don't tell you. No one will answer during this test; the question is recorded as feedback on the docs.
|
|
@@ -41,6 +58,7 @@ export const RESPONSE_SCHEMA = obj({
|
|
|
41
58
|
action: {
|
|
42
59
|
anyOf: [
|
|
43
60
|
obj({ type: actionType("run"), cmd: str, max_chars: { type: "integer" } }, ["type", "cmd"]),
|
|
61
|
+
obj({ type: actionType("read_file"), path: str, start_line: { type: "integer" }, end_line: { type: "integer" } }, ["type", "path"]),
|
|
44
62
|
obj({ type: actionType("write_file"), path: str, content: str }, ["type", "path", "content"]),
|
|
45
63
|
obj({ type: actionType("edit_file"), path: str, old: str, new: str }, ["type", "path", "old", "new"]),
|
|
46
64
|
obj({ type: actionType("ask"), question: str }, ["type", "question"]),
|
|
@@ -156,6 +174,14 @@ export function validateAction(a) {
|
|
|
156
174
|
if (a.max_chars !== undefined && typeof a.max_chars !== "number")
|
|
157
175
|
return '"max_chars" must be a number';
|
|
158
176
|
return null;
|
|
177
|
+
case "read_file":
|
|
178
|
+
if (!str("path"))
|
|
179
|
+
return 'read_file needs "path" (string)';
|
|
180
|
+
for (const k of ["start_line", "end_line"]) {
|
|
181
|
+
if (a[k] !== undefined && !(Number.isInteger(a[k]) && a[k] >= 1))
|
|
182
|
+
return `"${k}" must be a whole number, at least 1`;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
159
185
|
case "write_file":
|
|
160
186
|
return str("path") && str("content") ? null : 'write_file needs "path" and "content" (strings)';
|
|
161
187
|
case "edit_file":
|
|
@@ -177,6 +203,8 @@ export function describeAction(a) {
|
|
|
177
203
|
switch (a.type) {
|
|
178
204
|
case "run":
|
|
179
205
|
return a.max_chars !== undefined && a.max_chars !== DEFAULT_MAX_CHARS ? `run (max_chars ${a.max_chars}): ${a.cmd}` : `run: ${a.cmd}`;
|
|
206
|
+
case "read_file":
|
|
207
|
+
return `read_file: ${a.path}${a.start_line !== undefined || a.end_line !== undefined ? ` (lines ${a.start_line ?? 1}-${a.end_line ?? "end"})` : ""}`;
|
|
180
208
|
case "write_file":
|
|
181
209
|
case "edit_file":
|
|
182
210
|
return `${a.type}: ${a.path}`;
|
package/dist/run.js
CHANGED
|
@@ -18,6 +18,8 @@ function formatAction(a) {
|
|
|
18
18
|
switch (a.type) {
|
|
19
19
|
case "run":
|
|
20
20
|
return `### Action: run${a.max_chars !== undefined ? ` (max_chars ${a.max_chars})` : ""}\n${fence(String(a.cmd), "bash")}`;
|
|
21
|
+
case "read_file":
|
|
22
|
+
return `### Action: read_file \`${a.path}\`${a.start_line !== undefined || a.end_line !== undefined ? ` lines ${a.start_line ?? 1}-${a.end_line ?? "end"}` : ""}\n`;
|
|
21
23
|
case "write_file":
|
|
22
24
|
return `### Action: write_file \`${a.path}\`\n${fence(String(a.content))}`;
|
|
23
25
|
case "edit_file":
|
|
@@ -45,6 +47,7 @@ function formatSummary(s) {
|
|
|
45
47
|
`${s.parse_errors} / ${s.invalid_actions} / ${s.repeated_actions} / ${s.declined_actions}`],
|
|
46
48
|
["Questions asked", s.asks.length],
|
|
47
49
|
["Notes reminders (responses without a notes decision)", s.notes_reminders],
|
|
50
|
+
["Notes shrink warnings (a rewrite lost over half the notes)", s.notes_shrink_warnings],
|
|
48
51
|
["Cost", `$${s.cost_usd.toFixed(4)}${s.backend === "claude-code" ? " (notional; Claude Code subscription)" : ""}`],
|
|
49
52
|
["Duration", `${Math.round(s.duration_s)} s`],
|
|
50
53
|
];
|
|
@@ -105,6 +108,8 @@ function actionLine(a) {
|
|
|
105
108
|
switch (a.type) {
|
|
106
109
|
case "run":
|
|
107
110
|
return `run: ${firstLine(String(a.cmd), 90)}`;
|
|
111
|
+
case "read_file":
|
|
112
|
+
return `read_file: ${a.path}${a.start_line !== undefined || a.end_line !== undefined ? ` ${a.start_line ?? 1}-${a.end_line ?? "end"}` : ""}`;
|
|
108
113
|
case "write_file":
|
|
109
114
|
case "edit_file":
|
|
110
115
|
return `${a.type}: ${a.path}`;
|
|
@@ -128,20 +133,23 @@ export async function runTask(opts) {
|
|
|
128
133
|
const sb = opts.sandbox.kind === "docker"
|
|
129
134
|
// The random suffix keeps names unique when two gentl processes start the same task in the same second.
|
|
130
135
|
? new DockerSandbox(`gentl-${runId.toLowerCase().replace(/[^a-z0-9_.-]/g, "-")}-${randomBytes(3).toString("hex")}`, opts.sandbox.image, lc ? [{ host: lc.mirrorDir, container: MIRROR_MOUNT }] : [])
|
|
131
|
-
: new HostSandbox();
|
|
136
|
+
: new HostSandbox({ shareHome: opts.sandbox.shareHome, passEnv: opts.sandbox.passEnv });
|
|
132
137
|
// Signal handlers go in as soon as the sandbox exists, so no signal can leave it behind.
|
|
133
|
-
// Ctrl-C
|
|
134
|
-
//
|
|
135
|
-
// During cleanup further signals are ignored, so a
|
|
138
|
+
// Ctrl-C aborts the run wherever it is (a running command is killed, a model call cancelled); the run is
|
|
139
|
+
// then evaluated as a failure and, with --upload, reported. SIGTERM and SIGHUP end it without either.
|
|
140
|
+
// The run directory is completed in every case. During cleanup further signals are ignored, so a
|
|
141
|
+
// second Ctrl-C cannot leave the sandbox behind.
|
|
136
142
|
let abortModel = null;
|
|
137
|
-
let
|
|
143
|
+
let abortRequested = false; // Ctrl-C
|
|
144
|
+
let stopRequested = false; // SIGTERM, SIGHUP
|
|
138
145
|
let cleaningUp = false;
|
|
139
146
|
const onSigint = () => {
|
|
140
147
|
if (cleaningUp)
|
|
141
148
|
return console.log(" still cleaning up (removing the sandbox); please wait");
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
if (!abortRequested)
|
|
150
|
+
console.log("\n aborting the run (Ctrl-C); it will be evaluated as a failure and, with --upload, reported");
|
|
151
|
+
abortRequested = true;
|
|
152
|
+
sb.interrupt();
|
|
145
153
|
abortModel?.abort();
|
|
146
154
|
};
|
|
147
155
|
const onTerm = () => {
|
|
@@ -176,11 +184,13 @@ export async function runTask(opts) {
|
|
|
176
184
|
base_url: opts.model.backend === "openrouter" ? opts.model.baseUrl : null,
|
|
177
185
|
sandbox: sb.kind, image: sb instanceof DockerSandbox ? sb.image : null,
|
|
178
186
|
image_id: sb instanceof DockerSandbox ? sb.imageId : null, confirm: opts.confirm,
|
|
187
|
+
host_share_home: opts.sandbox.kind === "host" ? opts.sandbox.shareHome : [],
|
|
188
|
+
host_pass_env: opts.sandbox.kind === "host" ? opts.sandbox.passEnv : [],
|
|
179
189
|
local_copy: lc && !localCopyError ? localCopyInfo(lc) : null, structured_output: true, started: started.toISOString(), ended: "", duration_s: 0,
|
|
180
190
|
system_prompt: system, overhead_tokens: ntok(system) + ntok(opts.task), turns: 0,
|
|
181
191
|
read_tokens_total: 0, notes_tokens_total: 0, notes_tokens_max: 0, result_tokens_total: 0, result_tokens_max: 0,
|
|
182
192
|
model_output_tokens: 0, provider_prompt_tokens: 0, provider_completion_tokens: 0, cost_usd: 0,
|
|
183
|
-
parse_errors: 0, invalid_actions: 0, repeated_actions: 0, declined_actions: 0, notes_reminders: 0, asks: [],
|
|
193
|
+
parse_errors: 0, invalid_actions: 0, repeated_actions: 0, declined_actions: 0, notes_reminders: 0, notes_shrink_warnings: 0, asks: [],
|
|
184
194
|
stop_reason: null, error: null, finished: false,
|
|
185
195
|
self_reported_success: null, self_report_summary: null, self_report_friction: null, final_repos: null,
|
|
186
196
|
};
|
|
@@ -203,10 +213,10 @@ export async function runTask(opts) {
|
|
|
203
213
|
backend: opts.model.backend, base_url: s.base_url, sandbox: sb.kind, sandbox_description: sb.describe(), workdir: sb.workdir, confirm: opts.confirm, system_prompt: system });
|
|
204
214
|
let notes = "";
|
|
205
215
|
let last = null;
|
|
206
|
-
|
|
216
|
+
const seen = new Map(); // action key -> turns it was taken
|
|
207
217
|
try {
|
|
208
218
|
for (let turn = 1; turn <= opts.maxTurns; turn++) {
|
|
209
|
-
if (stopRequested)
|
|
219
|
+
if (stopRequested || abortRequested)
|
|
210
220
|
throw new Interrupted();
|
|
211
221
|
let user = `# Task\n${opts.task}\n\n# Your notes\n${notes || "(empty)"}\n`;
|
|
212
222
|
if (last)
|
|
@@ -274,8 +284,16 @@ export async function runTask(opts) {
|
|
|
274
284
|
emit({ ...ev, parse_error: msg });
|
|
275
285
|
continue;
|
|
276
286
|
}
|
|
287
|
+
const notesBefore = notesTok;
|
|
277
288
|
const n = applyNotes(notes, parsed);
|
|
278
289
|
notes = n.notes;
|
|
290
|
+
// A rewrite that loses much of the notes is pointed out on the next turn: details dropped in a careless
|
|
291
|
+
// rewrite are the commonest reason an agent redoes work.
|
|
292
|
+
const notesAfter = ntok(notes);
|
|
293
|
+
if (n.ops.includes("rewritten") && notesBefore >= 100 && notesAfter < notesBefore / 2) {
|
|
294
|
+
s.notes_shrink_warnings++;
|
|
295
|
+
n.feedback.push(`note: your notes went from ${notesBefore} to ${notesAfter} tokens with this rewrite; check that nothing you still need was lost (grow the notes with notes_append and notes_edit rather than rewriting them).`);
|
|
296
|
+
}
|
|
279
297
|
const a = parsed.action;
|
|
280
298
|
const line = actionLine(a);
|
|
281
299
|
console.log(prefix + line);
|
|
@@ -291,13 +309,16 @@ export async function runTask(opts) {
|
|
|
291
309
|
emit(ev);
|
|
292
310
|
break;
|
|
293
311
|
}
|
|
312
|
+
// An action the agent already took, at any earlier turn, is pointed out: rereading the same thing in a
|
|
313
|
+
// cycle is the commonest way a memoryless agent gets stuck.
|
|
294
314
|
const notices = [];
|
|
295
|
-
const
|
|
296
|
-
|
|
315
|
+
const key = a.type === "run" ? `run ${String(a.cmd).replace(/\s+/g, " ").trim()}` : JSON.stringify(a);
|
|
316
|
+
const earlier = seen.get(key) ?? [];
|
|
317
|
+
if (earlier.length) {
|
|
297
318
|
s.repeated_actions++;
|
|
298
|
-
notices.push(
|
|
319
|
+
notices.push(`note: you already ran this exact ${a.type === "run" ? "command" : "action"} at turn${earlier.length > 1 ? "s" : ""} ${earlier.join(", ")}. If you are rereading, act on what your notes hold, or record what you need from it this time.`);
|
|
299
320
|
}
|
|
300
|
-
|
|
321
|
+
seen.set(key, [...earlier, turn]);
|
|
301
322
|
let full;
|
|
302
323
|
let shown;
|
|
303
324
|
if (invalid) {
|
|
@@ -323,6 +344,9 @@ export async function runTask(opts) {
|
|
|
323
344
|
Object.assign(ev, { truncation: r.truncation ?? undefined, exit_code: r.exitCode, signal: r.signal, cmd_seconds: r.seconds, timed_out: r.timedOut,
|
|
324
345
|
interrupted: r.interrupted });
|
|
325
346
|
}
|
|
347
|
+
else if (a.type === "read_file") {
|
|
348
|
+
full = shown = sb.readFile(String(a.path), a.start_line, a.end_line);
|
|
349
|
+
}
|
|
326
350
|
else if (a.type === "write_file") {
|
|
327
351
|
full = shown = sb.writeFile(String(a.path), String(a.content));
|
|
328
352
|
}
|
|
@@ -340,8 +364,10 @@ export async function runTask(opts) {
|
|
|
340
364
|
s.stop_reason = "max_turns";
|
|
341
365
|
}
|
|
342
366
|
catch (e) {
|
|
367
|
+
// Ctrl-C at a confirmation prompt arrives as Interrupted without a signal, so anything not SIGTERM or
|
|
368
|
+
// SIGHUP counts as the person's abort.
|
|
343
369
|
if (e instanceof Interrupted)
|
|
344
|
-
s.stop_reason = "interrupted";
|
|
370
|
+
s.stop_reason = stopRequested ? "interrupted" : "aborted";
|
|
345
371
|
else {
|
|
346
372
|
s.stop_reason = "error";
|
|
347
373
|
s.error = e.message;
|
|
@@ -380,14 +406,15 @@ export async function runTask(opts) {
|
|
|
380
406
|
console.log(`Agent's summary: ${s.self_report_summary}\n`);
|
|
381
407
|
if (s.self_report_friction)
|
|
382
408
|
console.log(`Agent's friction report: ${s.self_report_friction}\n`);
|
|
383
|
-
|
|
409
|
+
const evaluable = s.stop_reason === "finish" || s.stop_reason === "max_turns" || s.stop_reason === "aborted";
|
|
410
|
+
if (evaluable) {
|
|
384
411
|
const ev = await evaluate(runDir, opts.evaluatorModel);
|
|
385
412
|
if (ev)
|
|
386
413
|
s.evaluation = { model: ev.model, success: ev.success, reasons: ev.reasons, cost_usd: ev.cost_usd };
|
|
387
414
|
}
|
|
388
415
|
console.log(`Run directory: ${runDir}`);
|
|
389
416
|
if (opts.upload) {
|
|
390
|
-
if (
|
|
417
|
+
if (evaluable) {
|
|
391
418
|
try {
|
|
392
419
|
console.log(`Uploaded: ${await uploadRun(runDir, opts.upload)}`);
|
|
393
420
|
}
|
package/dist/sandbox.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Where the agent's actions run. A sandbox runs commands with a timeout in their own process group, writes and
|
|
2
2
|
// edits files, and runs short commands for gentl itself (e.g. to record the final state).
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { CMD_TIMEOUT_S, countOccurrences, DEFAULT_MAX_CHARS, MAX_MAX_CHARS } from "./protocol.js";
|
|
4
|
+
import { CMD_TIMEOUT_S, countOccurrences, DEFAULT_MAX_CHARS, MAX_MAX_CHARS, READ_MAX_CHARS } from "./protocol.js";
|
|
5
5
|
const CAPTURE_HEAD = 1 << 20; // bytes of output kept from the start of a command
|
|
6
6
|
const CAPTURE_TAIL = 1 << 20; // and from the end
|
|
7
7
|
/** Keep the head and tail, with a note on what was cut and how to see more. */
|
|
@@ -76,8 +76,10 @@ export class Sandbox {
|
|
|
76
76
|
if (classified.keep)
|
|
77
77
|
cap.add(Buffer.from(classified.keep));
|
|
78
78
|
const out = cap.text();
|
|
79
|
-
|
|
79
|
+
let status = cur.interrupted ? "stopped by the person running gentl (Ctrl-C)"
|
|
80
80
|
: timedOut ? `timed out after ${CMD_TIMEOUT_S} s` : code !== null ? `exit code ${code}` : `killed by ${signal}`;
|
|
81
|
+
if (out.length > maxChars)
|
|
82
|
+
status += `; output cut in the middle (${out.length - Math.floor((maxChars * 3) / 5) - Math.floor((maxChars * 2) / 5)} of ${out.length} chars left out)`;
|
|
81
83
|
const truncation = out.length > maxChars
|
|
82
84
|
? { head: status.length + 1 + Math.floor((maxChars * 3) / 5), tail: Math.floor((maxChars * 2) / 5) } : null;
|
|
83
85
|
resolve({ exitCode: code, signal, full: `${status}\n${out}`, shown: `${status}\n${truncate(out, maxChars)}`,
|
|
@@ -109,17 +111,76 @@ export class Sandbox {
|
|
|
109
111
|
const r = this.exec(["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", p], undefined, content);
|
|
110
112
|
return r.ok ? `wrote ${p}` : `error: ${r.out.trim()}`;
|
|
111
113
|
}
|
|
114
|
+
/** Lines `start` to `end` of a file, numbered; never cut in the middle, stopped at a line boundary if too long. */
|
|
115
|
+
readFile(p, start, end) {
|
|
116
|
+
const r = this.exec(["cat", "--", p]);
|
|
117
|
+
if (!r.ok)
|
|
118
|
+
return `error: ${r.out.trim()}`;
|
|
119
|
+
if (r.out.includes("\0"))
|
|
120
|
+
return `error: ${p} is a binary file`;
|
|
121
|
+
const lines = r.out.split("\n");
|
|
122
|
+
if (lines.length && lines[lines.length - 1] === "")
|
|
123
|
+
lines.pop();
|
|
124
|
+
const total = lines.length;
|
|
125
|
+
const from = Math.max(1, start ?? 1);
|
|
126
|
+
const to = Math.min(total, end ?? total);
|
|
127
|
+
if (from > total)
|
|
128
|
+
return `${p} has ${total} lines; nothing at line ${from}`;
|
|
129
|
+
const width = String(to).length;
|
|
130
|
+
let body = "";
|
|
131
|
+
let last = from - 1;
|
|
132
|
+
for (let i = from; i <= to; i++) {
|
|
133
|
+
const line = `${String(i).padStart(width)} ${lines[i - 1]}\n`;
|
|
134
|
+
if (body.length + line.length > READ_MAX_CHARS && i > from)
|
|
135
|
+
break;
|
|
136
|
+
body += line;
|
|
137
|
+
last = i;
|
|
138
|
+
}
|
|
139
|
+
const head = `${p}: lines ${from}-${last} of ${total}`;
|
|
140
|
+
const more = last < to ? `\n[stopped at line ${last} to stay under ${READ_MAX_CHARS} chars; read on with "start_line": ${last + 1}]` : "";
|
|
141
|
+
return `${head}\n${body}${more}`;
|
|
142
|
+
}
|
|
112
143
|
editFile(p, old, replacement) {
|
|
113
144
|
const r = this.exec(["cat", "--", p]);
|
|
114
145
|
if (!r.ok)
|
|
115
146
|
return `error: ${r.out.trim()}`;
|
|
116
147
|
const n = countOccurrences(r.out, old);
|
|
117
|
-
if (n
|
|
118
|
-
return `error: the old text occurs ${n} times in ${p}; it must occur exactly once`;
|
|
148
|
+
if (n > 1)
|
|
149
|
+
return `error: the old text occurs ${n} times in ${p}; it must occur exactly once (include more of the surrounding lines)`;
|
|
150
|
+
if (n === 0)
|
|
151
|
+
return `error: the old text occurs 0 times in ${p}; it must occur exactly once${editHint(r.out, old)}`;
|
|
119
152
|
const w = this.exec(["sh", "-c", 'cat > "$1"', "sh", p], undefined, r.out.replace(old, () => replacement));
|
|
120
153
|
return w.ok ? `edited ${p}` : `error: ${w.out.trim()}`;
|
|
121
154
|
}
|
|
122
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Why an edit's old text may not have matched, for the error message: the commonest cause is a backslash
|
|
158
|
+
* sequence in the code (a \\n inside a string) written in JSON as a real line break or tab; otherwise the
|
|
159
|
+
* file's lines that come closest to the old text's first line, so the agent can copy them exactly.
|
|
160
|
+
*/
|
|
161
|
+
export function editHint(text, old) {
|
|
162
|
+
const unescaped = old.replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
163
|
+
for (const candidate of [old.replace(/\t/g, "\\t"), unescaped]) {
|
|
164
|
+
if (candidate !== old && countOccurrences(text, candidate) === 1) {
|
|
165
|
+
return ". It would match if a line break or tab inside the old text were the two characters \\n or \\t, as the file has them: in JSON, write a backslash in the code as \\\\ (\\n in the code is \"\\\\n\")";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const first = old.split("\n").map((l) => l.trim()).find((l) => l.length >= 3);
|
|
169
|
+
if (!first)
|
|
170
|
+
return "";
|
|
171
|
+
const lines = text.split("\n");
|
|
172
|
+
const words = first.split(/\W+/).filter((w) => w.length >= 3);
|
|
173
|
+
const scored = lines.map((l, i) => {
|
|
174
|
+
const t = l.trim();
|
|
175
|
+
const score = t === first ? 1000 : t.includes(first) ? 500 : words.filter((w) => t.includes(w)).length;
|
|
176
|
+
return { i, l, score };
|
|
177
|
+
}).filter((x) => x.score > 0 && (x.score >= 500 || x.score >= Math.max(2, Math.ceil(words.length / 2))));
|
|
178
|
+
scored.sort((a, b) => b.score - a.score || a.i - b.i);
|
|
179
|
+
if (!scored.length)
|
|
180
|
+
return "";
|
|
181
|
+
const shown = scored.slice(0, 3).sort((a, b) => a.i - b.i).map((x) => `${x.i + 1}: ${x.l}`).join("\n");
|
|
182
|
+
return `. The file's lines closest to the old text's first line (copy the old text exactly, including indentation; read_file shows the lines around them):\n${shown}`;
|
|
183
|
+
}
|
|
123
184
|
/** spawnSync wrapper returning stdout (plus stderr on failure). */
|
|
124
185
|
export function execLocal(cmd, args, opts) {
|
|
125
186
|
const p = spawnSync(cmd, args, { ...opts, encoding: "utf8", maxBuffer: 64 << 20 });
|
package/dist/suite.js
CHANGED
|
@@ -91,8 +91,8 @@ export async function runBatch(name, tasks, repeat, base) {
|
|
|
91
91
|
const out = await runTask({ ...base, task: t.task, taskName: repeat > 1 ? `${t.taskName}-r${rep}` : t.taskName,
|
|
92
92
|
taskSource: t.taskSource, runsDir: batchDir });
|
|
93
93
|
rows.push(rowFor(t.taskName, rep, batchDir, out.runDir, out.summary));
|
|
94
|
-
if (out.summary.stop_reason === "interrupted") {
|
|
95
|
-
console.log(
|
|
94
|
+
if (out.summary.stop_reason === "interrupted" || out.summary.stop_reason === "aborted") {
|
|
95
|
+
console.log(`\nRun ${out.summary.stop_reason}; skipping the remaining runs.`);
|
|
96
96
|
stopped = true;
|
|
97
97
|
}
|
|
98
98
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magland/gentl",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A readability test for codebases: a memoryless agent carries out a newcomer task and gentl measures how much it had to read.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Jeremy Magland",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"node": ">=20"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
|
-
"build": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\" && tsc",
|
|
35
|
+
"build": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\" && tsc && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
|
|
36
36
|
"prepare": "npm run build"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|