@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/run.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// The agent loop: one model call per turn, one action per turn, everything recorded in ./runs/<id>/.
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as readline from "node:readline";
|
|
6
|
+
import { createModelClient, Interrupted } from "./model.js";
|
|
7
|
+
import { evaluateRun } from "./evaluate.js";
|
|
8
|
+
import { describeFinalState } from "./finalstate.js";
|
|
9
|
+
import { uploadRun } from "./hub.js";
|
|
10
|
+
import { configureLocalCopy, describeLocalCopy, localCopyInfo, prefixCollisions } from "./localcopy.js";
|
|
11
|
+
import { applyNotes, ASK_RESULT, describeAction, hasNotesDecision, NOTES_REMINDER, parseResponse, RESPONSE_SCHEMA, systemPrompt, validateAction } from "./protocol.js";
|
|
12
|
+
import { fence } from "./format.js";
|
|
13
|
+
import { ntok } from "./tokens.js";
|
|
14
|
+
import { DockerSandbox } from "./docker.js";
|
|
15
|
+
import { HostSandbox } from "./host.js";
|
|
16
|
+
export const FORMAT_VERSION = 1;
|
|
17
|
+
function formatAction(a) {
|
|
18
|
+
switch (a.type) {
|
|
19
|
+
case "run":
|
|
20
|
+
return `### Action: run${a.max_chars !== undefined ? ` (max_chars ${a.max_chars})` : ""}\n${fence(String(a.cmd), "bash")}`;
|
|
21
|
+
case "write_file":
|
|
22
|
+
return `### Action: write_file \`${a.path}\`\n${fence(String(a.content))}`;
|
|
23
|
+
case "edit_file":
|
|
24
|
+
return `### Action: edit_file \`${a.path}\`\nold:\n${fence(String(a.old))}new:\n${fence(String(a.new))}`;
|
|
25
|
+
case "ask":
|
|
26
|
+
return `### Action: ask\n\n${a.question}\n`;
|
|
27
|
+
case "finish":
|
|
28
|
+
return `### Action: finish (success: ${a.success})\n\n${a.summary}\n${a.friction ? `\nFriction:\n\n${a.friction}\n` : ""}`;
|
|
29
|
+
default:
|
|
30
|
+
return `### Action (unrecognized)\n${fence(JSON.stringify(a, null, 2), "json")}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function formatSummary(s) {
|
|
34
|
+
const outcome = s.finished ? `finished, self-reported ${s.self_reported_success ? "success" : "failure"}`
|
|
35
|
+
: `did not finish (${s.stop_reason}${s.error ? `: ${s.error}` : ""})`;
|
|
36
|
+
const rows = [
|
|
37
|
+
["Outcome", outcome],
|
|
38
|
+
["Turns", s.turns],
|
|
39
|
+
["Read tokens total", s.read_tokens_total],
|
|
40
|
+
["Notes tokens (total / max)", `${s.notes_tokens_total} / ${s.notes_tokens_max}`],
|
|
41
|
+
["Result tokens (total / max)", `${s.result_tokens_total} / ${s.result_tokens_max}`],
|
|
42
|
+
["Overhead tokens (system prompt + task, once)", s.overhead_tokens],
|
|
43
|
+
["Model output tokens", s.model_output_tokens],
|
|
44
|
+
["Parse errors / invalid / repeated / declined actions",
|
|
45
|
+
`${s.parse_errors} / ${s.invalid_actions} / ${s.repeated_actions} / ${s.declined_actions}`],
|
|
46
|
+
["Questions asked", s.asks.length],
|
|
47
|
+
["Notes reminders (responses without a notes decision)", s.notes_reminders],
|
|
48
|
+
["Cost", `$${s.cost_usd.toFixed(4)}${s.backend === "claude-code" ? " (notional; Claude Code subscription)" : ""}`],
|
|
49
|
+
["Duration", `${Math.round(s.duration_s)} s`],
|
|
50
|
+
];
|
|
51
|
+
return rows.map(([k, v]) => `- ${k}: ${v}`).join("\n") + "\n";
|
|
52
|
+
}
|
|
53
|
+
function firstLine(s, max) {
|
|
54
|
+
const line = s.split("\n")[0];
|
|
55
|
+
const cut = line.length > max ? line.slice(0, max) : line;
|
|
56
|
+
return cut === s ? cut : `${cut} …`;
|
|
57
|
+
}
|
|
58
|
+
function indent(text, maxLines) {
|
|
59
|
+
const lines = text.replace(/\n$/, "").split("\n");
|
|
60
|
+
const shown = lines.slice(0, maxLines).map((l) => ` ${l}`);
|
|
61
|
+
if (lines.length > maxLines)
|
|
62
|
+
shown.push(` (${lines.length - maxLines} more lines)`);
|
|
63
|
+
return shown.join("\n");
|
|
64
|
+
}
|
|
65
|
+
let activePrompt = null;
|
|
66
|
+
/** Ask a question on the terminal. Ctrl-C or Ctrl-D ends the run. */
|
|
67
|
+
function ask(question) {
|
|
68
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
69
|
+
activePrompt = rl;
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
let answered = false;
|
|
72
|
+
rl.on("SIGINT", () => {
|
|
73
|
+
process.stdout.write("\n");
|
|
74
|
+
rl.close();
|
|
75
|
+
});
|
|
76
|
+
rl.on("close", () => {
|
|
77
|
+
activePrompt = null;
|
|
78
|
+
if (!answered)
|
|
79
|
+
reject(new Interrupted());
|
|
80
|
+
});
|
|
81
|
+
rl.question(question, (answer) => {
|
|
82
|
+
answered = true;
|
|
83
|
+
rl.close();
|
|
84
|
+
resolve(answer.trim());
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/** Show the action and ask for approval. Returns null if approved, else the reason given (possibly empty). */
|
|
89
|
+
async function confirm(a, lineShown) {
|
|
90
|
+
if (a.type === "run" && lineShown !== a.cmd)
|
|
91
|
+
console.log(indent(String(a.cmd), 40));
|
|
92
|
+
if (a.type === "write_file")
|
|
93
|
+
console.log(indent(String(a.content), 30));
|
|
94
|
+
if (a.type === "edit_file")
|
|
95
|
+
console.log(` old:\n${indent(String(a.old), 15)}\n new:\n${indent(String(a.new), 15)}`);
|
|
96
|
+
for (;;) {
|
|
97
|
+
const ans = (await ask(" ok? [y/n] ")).toLowerCase();
|
|
98
|
+
if (ans === "y" || ans === "yes")
|
|
99
|
+
return null;
|
|
100
|
+
if (ans === "n" || ans === "no")
|
|
101
|
+
return await ask(" reason (optional): ");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function actionLine(a) {
|
|
105
|
+
switch (a.type) {
|
|
106
|
+
case "run":
|
|
107
|
+
return `run: ${firstLine(String(a.cmd), 90)}`;
|
|
108
|
+
case "write_file":
|
|
109
|
+
case "edit_file":
|
|
110
|
+
return `${a.type}: ${a.path}`;
|
|
111
|
+
case "ask":
|
|
112
|
+
return `ask: ${firstLine(String(a.question), 90)}`;
|
|
113
|
+
case "finish":
|
|
114
|
+
return `finish: ${a.success ? "success" : "failure"}`;
|
|
115
|
+
default:
|
|
116
|
+
return firstLine(JSON.stringify(a), 90);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function timestamp(d) {
|
|
120
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
121
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
122
|
+
}
|
|
123
|
+
export async function runTask(opts) {
|
|
124
|
+
const started = new Date();
|
|
125
|
+
const runId = `${timestamp(started)}-${opts.taskName}`;
|
|
126
|
+
const lc = opts.localCopy;
|
|
127
|
+
const MIRROR_MOUNT = "/srv/git";
|
|
128
|
+
const sb = opts.sandbox.kind === "docker"
|
|
129
|
+
// The random suffix keeps names unique when two gentl processes start the same task in the same second.
|
|
130
|
+
? 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();
|
|
132
|
+
// Signal handlers go in as soon as the sandbox exists, so no signal can leave it behind.
|
|
133
|
+
// Ctrl-C stops the running command, or cancels the model call and ends the run.
|
|
134
|
+
// SIGTERM and SIGHUP end the run wherever it is; the run directory is still completed.
|
|
135
|
+
// During cleanup further signals are ignored, so a second Ctrl-C cannot leave the sandbox behind.
|
|
136
|
+
let abortModel = null;
|
|
137
|
+
let stopRequested = false;
|
|
138
|
+
let cleaningUp = false;
|
|
139
|
+
const onSigint = () => {
|
|
140
|
+
if (cleaningUp)
|
|
141
|
+
return console.log(" still cleaning up (removing the sandbox); please wait");
|
|
142
|
+
if (sb.interrupt())
|
|
143
|
+
return;
|
|
144
|
+
stopRequested = true;
|
|
145
|
+
abortModel?.abort();
|
|
146
|
+
};
|
|
147
|
+
const onTerm = () => {
|
|
148
|
+
if (cleaningUp)
|
|
149
|
+
return;
|
|
150
|
+
stopRequested = true;
|
|
151
|
+
sb.interrupt();
|
|
152
|
+
abortModel?.abort();
|
|
153
|
+
activePrompt?.close();
|
|
154
|
+
};
|
|
155
|
+
process.on("SIGINT", onSigint);
|
|
156
|
+
process.on("SIGTERM", onTerm);
|
|
157
|
+
process.on("SIGHUP", onTerm);
|
|
158
|
+
const localCopyError = lc
|
|
159
|
+
? configureLocalCopy(sb, lc, sb.kind === "docker" ? `${MIRROR_MOUNT}/${lc.mirrorName}` : path.join(lc.mirrorDir, lc.mirrorName))
|
|
160
|
+
: null;
|
|
161
|
+
const runDir = path.resolve(opts.runsDir, runId);
|
|
162
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
163
|
+
const events = fs.openSync(path.join(runDir, "events.jsonl"), "w");
|
|
164
|
+
const transcript = fs.openSync(path.join(runDir, "transcript.md"), "w");
|
|
165
|
+
const emit = (ev) => fs.writeSync(events, JSON.stringify(ev) + "\n");
|
|
166
|
+
const tr = (text) => fs.writeSync(transcript, text + "\n");
|
|
167
|
+
const log = (msg) => {
|
|
168
|
+
console.log(` ${msg}`);
|
|
169
|
+
tr(`_${msg}_\n`);
|
|
170
|
+
};
|
|
171
|
+
const system = systemPrompt(sb.workdir, sb.kind === "docker" ? "linux" : process.platform);
|
|
172
|
+
const client = createModelClient(opts.model, { name: "turn", schema: RESPONSE_SCHEMA }, log, sb.kind === "host" ? sb.workdir : undefined);
|
|
173
|
+
const s = {
|
|
174
|
+
format: FORMAT_VERSION, gentl_version: opts.version, run_id: runId, task_name: opts.taskName,
|
|
175
|
+
task_source: opts.taskSource, task: opts.task, model: opts.model.model, backend: opts.model.backend,
|
|
176
|
+
base_url: opts.model.backend === "openrouter" ? opts.model.baseUrl : null,
|
|
177
|
+
sandbox: sb.kind, image: sb instanceof DockerSandbox ? sb.image : null,
|
|
178
|
+
image_id: sb instanceof DockerSandbox ? sb.imageId : null, confirm: opts.confirm,
|
|
179
|
+
local_copy: lc && !localCopyError ? localCopyInfo(lc) : null, structured_output: true, started: started.toISOString(), ended: "", duration_s: 0,
|
|
180
|
+
system_prompt: system, overhead_tokens: ntok(system) + ntok(opts.task), turns: 0,
|
|
181
|
+
read_tokens_total: 0, notes_tokens_total: 0, notes_tokens_max: 0, result_tokens_total: 0, result_tokens_max: 0,
|
|
182
|
+
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: [],
|
|
184
|
+
stop_reason: null, error: null, finished: false,
|
|
185
|
+
self_reported_success: null, self_report_summary: null, self_report_friction: null, final_repos: null,
|
|
186
|
+
};
|
|
187
|
+
const ts = opts.taskSource;
|
|
188
|
+
const source = ts.kind === "file" ? ts.path : ts.kind === "hub" ? `${ts.title} (${ts.url})` : "given on the command line";
|
|
189
|
+
console.log(`gentl ${opts.version} run ${runId}`);
|
|
190
|
+
console.log(`model ${opts.model.model} (${opts.model.backend}) ${sb.describe()}${opts.confirm ? " (confirm each action)" : ""}`);
|
|
191
|
+
if (lc && !localCopyError) {
|
|
192
|
+
console.log(`local copy: ${describeLocalCopy(lc)}`);
|
|
193
|
+
for (const u of prefixCollisions(opts.task, lc.urls)) {
|
|
194
|
+
console.log(`warning: the task mentions ${u}, which the local copy would also redirect (git insteadOf matches by prefix); use --remote if that is wrong`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (localCopyError)
|
|
198
|
+
console.log(`warning: local copy disabled: ${localCopyError}`);
|
|
199
|
+
console.log(`\nturn +read (cumulative) notes action`);
|
|
200
|
+
tr(`# gentl run ${runId}\n\n- Model: ${opts.model.model} (${opts.model.backend})\n- Task: ${source}\n- Sandbox: ${sb.describe()}\n- Local copy: ${lc && !localCopyError ? describeLocalCopy(lc) : "none"}\n` +
|
|
201
|
+
`- Started: ${started.toISOString()}\n\n## Task\n\n${opts.task}\n\n## System prompt\n\n${fence(system)}`);
|
|
202
|
+
emit({ type: "start", run_id: runId, task: opts.task, task_source: opts.taskSource, model: opts.model.model,
|
|
203
|
+
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
|
+
let notes = "";
|
|
205
|
+
let last = null;
|
|
206
|
+
let lastActionJson = null;
|
|
207
|
+
try {
|
|
208
|
+
for (let turn = 1; turn <= opts.maxTurns; turn++) {
|
|
209
|
+
if (stopRequested)
|
|
210
|
+
throw new Interrupted();
|
|
211
|
+
let user = `# Task\n${opts.task}\n\n# Your notes\n${notes || "(empty)"}\n`;
|
|
212
|
+
if (last)
|
|
213
|
+
user += `\n# Last action\n${last.action}\n\n# Result\n${last.result}\n`;
|
|
214
|
+
const notesTok = ntok(notes);
|
|
215
|
+
let resultTok = last ? ntok(last.action) + ntok(last.result) : 0;
|
|
216
|
+
s.turns = turn;
|
|
217
|
+
s.notes_tokens_total += notesTok;
|
|
218
|
+
s.notes_tokens_max = Math.max(s.notes_tokens_max, notesTok);
|
|
219
|
+
s.result_tokens_total += resultTok;
|
|
220
|
+
s.result_tokens_max = Math.max(s.result_tokens_max, resultTok);
|
|
221
|
+
s.read_tokens_total = s.notes_tokens_total + s.result_tokens_total;
|
|
222
|
+
tr(`---\n\n## Turn ${turn}\n\nRead this turn: notes ${notesTok} + result ${resultTok} tokens (cumulative ${s.read_tokens_total})\n`);
|
|
223
|
+
const callModel = async (input) => {
|
|
224
|
+
const t0 = Date.now();
|
|
225
|
+
abortModel = new AbortController();
|
|
226
|
+
const reply = await client.call(system, input, abortModel.signal);
|
|
227
|
+
abortModel = null;
|
|
228
|
+
s.structured_output = client.structured;
|
|
229
|
+
const outTok = ntok(reply.text);
|
|
230
|
+
s.model_output_tokens += outTok;
|
|
231
|
+
s.provider_prompt_tokens += Number(reply.usage.prompt_tokens ?? 0);
|
|
232
|
+
s.provider_completion_tokens += Number(reply.usage.completion_tokens ?? 0);
|
|
233
|
+
s.cost_usd += Number(reply.usage.cost ?? 0);
|
|
234
|
+
const seconds = (Date.now() - t0) / 1000;
|
|
235
|
+
tr(`_model: ${seconds.toFixed(1)} s, ${outTok} output tokens_\n`);
|
|
236
|
+
return { reply, seconds };
|
|
237
|
+
};
|
|
238
|
+
const tryParse = (text) => {
|
|
239
|
+
try {
|
|
240
|
+
return { parsed: parseResponse(text), error: null };
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
return { parsed: null, error: e.message };
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
let { reply, seconds: modelSeconds } = await callModel(user);
|
|
247
|
+
let { parsed, error: parseError } = tryParse(reply.text);
|
|
248
|
+
const ev = { type: "turn", turn, input: user, notes_tokens: notesTok, usage: reply.usage };
|
|
249
|
+
// No notes decision: ask again within the turn, while the result is still in view. The reminder counts as read.
|
|
250
|
+
if (parsed && !hasNotesDecision(parsed)) {
|
|
251
|
+
s.notes_reminders++;
|
|
252
|
+
const reminderTok = ntok(NOTES_REMINDER);
|
|
253
|
+
resultTok += reminderTok;
|
|
254
|
+
s.result_tokens_total += reminderTok;
|
|
255
|
+
s.result_tokens_max = Math.max(s.result_tokens_max, resultTok);
|
|
256
|
+
s.read_tokens_total = s.notes_tokens_total + s.result_tokens_total;
|
|
257
|
+
tr(`### No notes decision; reminder sent (${reminderTok} tokens)\n\nFirst response:\n${fence(reply.text)}`);
|
|
258
|
+
Object.assign(ev, { response_without_notes: reply.text, usage_without_notes: reply.usage });
|
|
259
|
+
const retryInput = `${user}\n# Reminder\n${NOTES_REMINDER}\n`;
|
|
260
|
+
({ reply, seconds: modelSeconds } = await callModel(retryInput));
|
|
261
|
+
({ parsed, error: parseError } = tryParse(reply.text));
|
|
262
|
+
Object.assign(ev, { input: retryInput, usage: reply.usage, notes_reminder: true });
|
|
263
|
+
}
|
|
264
|
+
Object.assign(ev, { response: reply.text, result_tokens: resultTok, model_seconds: modelSeconds });
|
|
265
|
+
const prefix = `t${String(turn).padStart(2, "0")} +${resultTok + notesTok} (${s.read_tokens_total.toLocaleString("en-US")})`.padEnd(26) +
|
|
266
|
+
String(notesTok).padEnd(7) + (ev.notes_reminder ? "[reminded] " : "");
|
|
267
|
+
if (!parsed) {
|
|
268
|
+
s.parse_errors++;
|
|
269
|
+
const msg = parseError;
|
|
270
|
+
console.log(`${prefix}parse error: ${msg}`);
|
|
271
|
+
tr(`### Parse error: ${msg}\n\nRaw response:\n${fence(reply.text)}`);
|
|
272
|
+
last = { action: "(none: your response could not be parsed)",
|
|
273
|
+
result: `error: ${msg}. Respond with one JSON object as described.` };
|
|
274
|
+
emit({ ...ev, parse_error: msg });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const n = applyNotes(notes, parsed);
|
|
278
|
+
notes = n.notes;
|
|
279
|
+
const a = parsed.action;
|
|
280
|
+
const line = actionLine(a);
|
|
281
|
+
console.log(prefix + line);
|
|
282
|
+
tr(`### Notes (${n.ops.length ? n.ops.join(", ") : parsed.notes_keep === true ? "kept" : "unchanged"}; ${ntok(notes)} tokens)\n\n${fence(notes || "(empty)")}`);
|
|
283
|
+
for (const f of n.feedback)
|
|
284
|
+
tr(`_${f}_\n`);
|
|
285
|
+
tr(formatAction(a));
|
|
286
|
+
Object.assign(ev, { parsed, notes, notes_ops: n.ops, notes_feedback: n.feedback });
|
|
287
|
+
const invalid = validateAction(a);
|
|
288
|
+
if (!invalid && a.type === "finish") {
|
|
289
|
+
Object.assign(s, { finished: true, stop_reason: "finish", self_reported_success: a.success,
|
|
290
|
+
self_report_summary: a.summary, self_report_friction: a.friction ?? null });
|
|
291
|
+
emit(ev);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
const notices = [];
|
|
295
|
+
const actionJson = JSON.stringify(a);
|
|
296
|
+
if (actionJson === lastActionJson) {
|
|
297
|
+
s.repeated_actions++;
|
|
298
|
+
notices.push("note: this action is identical to your previous one.");
|
|
299
|
+
}
|
|
300
|
+
lastActionJson = actionJson;
|
|
301
|
+
let full;
|
|
302
|
+
let shown;
|
|
303
|
+
if (invalid) {
|
|
304
|
+
s.invalid_actions++;
|
|
305
|
+
full = shown = `error: malformed action: ${invalid}`;
|
|
306
|
+
}
|
|
307
|
+
else if (a.type === "ask") {
|
|
308
|
+
s.asks.push({ turn, question: String(a.question) });
|
|
309
|
+
full = shown = ASK_RESULT;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const reason = opts.confirm ? await confirm(a, line.replace(/^run: /, "")) : null;
|
|
313
|
+
if (reason !== null) {
|
|
314
|
+
s.declined_actions++;
|
|
315
|
+
full = shown = `The person running gentl declined this action.${reason ? ` Their reason: ${reason}` : ""}`;
|
|
316
|
+
ev.declined = reason;
|
|
317
|
+
}
|
|
318
|
+
else if (a.type === "run") {
|
|
319
|
+
const r = await sb.run(String(a.cmd), a.max_chars);
|
|
320
|
+
if (r.sandboxError)
|
|
321
|
+
throw new Error(`the sandbox failed: ${r.sandboxError.trim()}`);
|
|
322
|
+
({ full, shown } = r);
|
|
323
|
+
Object.assign(ev, { truncation: r.truncation ?? undefined, exit_code: r.exitCode, signal: r.signal, cmd_seconds: r.seconds, timed_out: r.timedOut,
|
|
324
|
+
interrupted: r.interrupted });
|
|
325
|
+
}
|
|
326
|
+
else if (a.type === "write_file") {
|
|
327
|
+
full = shown = sb.writeFile(String(a.path), String(a.content));
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
full = shown = sb.editFile(String(a.path), String(a.old), String(a.new));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
shown = [...notices, ...n.feedback, shown].join("\n");
|
|
334
|
+
last = { action: describeAction(a), result: shown };
|
|
335
|
+
emit({ ...ev, notices, result_full: full, result_shown: shown });
|
|
336
|
+
const truncNote = shown.length < full.length ? `; truncated, full result ${full.length} chars in events.jsonl` : "";
|
|
337
|
+
tr(`### Result (${ntok(shown)} tokens${truncNote})\n${fence(shown)}`);
|
|
338
|
+
}
|
|
339
|
+
if (!s.stop_reason)
|
|
340
|
+
s.stop_reason = "max_turns";
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
if (e instanceof Interrupted)
|
|
344
|
+
s.stop_reason = "interrupted";
|
|
345
|
+
else {
|
|
346
|
+
s.stop_reason = "error";
|
|
347
|
+
s.error = e.message;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
cleaningUp = true;
|
|
352
|
+
const ended = new Date();
|
|
353
|
+
s.ended = ended.toISOString();
|
|
354
|
+
s.duration_s = (ended.getTime() - started.getTime()) / 1000;
|
|
355
|
+
try {
|
|
356
|
+
const fin = describeFinalState(sb);
|
|
357
|
+
fs.writeFileSync(path.join(runDir, "final-state.md"), fin.text);
|
|
358
|
+
s.final_repos = fin.repos;
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
log(`could not record the final state: ${e.message}`);
|
|
362
|
+
}
|
|
363
|
+
fs.writeFileSync(path.join(runDir, "summary.json"), JSON.stringify(s, null, 2) + "\n");
|
|
364
|
+
emit({ type: "end", stop_reason: s.stop_reason, error: s.error });
|
|
365
|
+
tr(`---\n\n## Summary\n\n${formatSummary(s)}`);
|
|
366
|
+
fs.closeSync(events);
|
|
367
|
+
fs.closeSync(transcript);
|
|
368
|
+
if (!opts.keep)
|
|
369
|
+
sb.remove();
|
|
370
|
+
else if (sb instanceof DockerSandbox)
|
|
371
|
+
console.log(`\ncontainer kept: ${sb.name} (remove with: docker rm -f ${sb.name})`);
|
|
372
|
+
else if (sb instanceof HostSandbox)
|
|
373
|
+
console.log(`\nworkspace kept: ${sb.root}`);
|
|
374
|
+
process.off("SIGINT", onSigint);
|
|
375
|
+
process.off("SIGTERM", onTerm);
|
|
376
|
+
process.off("SIGHUP", onTerm);
|
|
377
|
+
}
|
|
378
|
+
console.log(`\n${formatSummary(s)}`);
|
|
379
|
+
if (s.self_report_summary)
|
|
380
|
+
console.log(`Agent's summary: ${s.self_report_summary}\n`);
|
|
381
|
+
if (s.self_report_friction)
|
|
382
|
+
console.log(`Agent's friction report: ${s.self_report_friction}\n`);
|
|
383
|
+
if (s.stop_reason === "finish" || s.stop_reason === "max_turns") {
|
|
384
|
+
const ev = await evaluate(runDir, opts.evaluatorModel);
|
|
385
|
+
if (ev)
|
|
386
|
+
s.evaluation = { model: ev.model, success: ev.success, reasons: ev.reasons, cost_usd: ev.cost_usd };
|
|
387
|
+
}
|
|
388
|
+
console.log(`Run directory: ${runDir}`);
|
|
389
|
+
if (opts.upload) {
|
|
390
|
+
if (s.stop_reason === "finish" || s.stop_reason === "max_turns") {
|
|
391
|
+
try {
|
|
392
|
+
console.log(`Uploaded: ${await uploadRun(runDir, opts.upload)}`);
|
|
393
|
+
}
|
|
394
|
+
catch (e) {
|
|
395
|
+
console.log(`Upload failed (${e.message}). Retry with: gentl upload ${path.relative(process.cwd(), runDir)}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
else
|
|
399
|
+
console.log(`Not uploaded: the run did not finish (${s.stop_reason}).`);
|
|
400
|
+
}
|
|
401
|
+
return { runDir, summary: s };
|
|
402
|
+
}
|
|
403
|
+
/** Run the evaluator on a run directory and print its verdict and friction report. Ctrl-C skips it. */
|
|
404
|
+
export async function evaluate(runDir, cfg) {
|
|
405
|
+
console.log(`Evaluating with ${cfg.model} ...`);
|
|
406
|
+
const ac = new AbortController();
|
|
407
|
+
const onSigint = () => ac.abort();
|
|
408
|
+
process.on("SIGINT", onSigint);
|
|
409
|
+
try {
|
|
410
|
+
const ev = await evaluateRun(runDir, cfg, ac.signal, (msg) => console.log(` ${msg}`));
|
|
411
|
+
console.log(`\nVerdict: ${ev.success ? "success" : "failure"}\n\n${ev.reasons}\n\nFriction report:\n\n${ev.friction_report}\n`);
|
|
412
|
+
console.log(`Evaluator cost: $${ev.cost_usd.toFixed(4)}; written to friction.md, evaluation.json and summary.json`);
|
|
413
|
+
return ev;
|
|
414
|
+
}
|
|
415
|
+
catch (e) {
|
|
416
|
+
const msg = e instanceof Interrupted ? "interrupted" : e.message;
|
|
417
|
+
console.log(`Evaluation skipped (${msg}). Run it later with: gentl evaluate ${path.relative(process.cwd(), runDir)}`);
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
process.off("SIGINT", onSigint);
|
|
422
|
+
}
|
|
423
|
+
}
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Where the agent's actions run. A sandbox runs commands with a timeout in their own process group, writes and
|
|
2
|
+
// edits files, and runs short commands for gentl itself (e.g. to record the final state).
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { CMD_TIMEOUT_S, countOccurrences, DEFAULT_MAX_CHARS, MAX_MAX_CHARS } from "./protocol.js";
|
|
5
|
+
const CAPTURE_HEAD = 1 << 20; // bytes of output kept from the start of a command
|
|
6
|
+
const CAPTURE_TAIL = 1 << 20; // and from the end
|
|
7
|
+
/** Keep the head and tail, with a note on what was cut and how to see more. */
|
|
8
|
+
export function truncate(out, maxChars) {
|
|
9
|
+
if (out.length <= maxChars)
|
|
10
|
+
return out;
|
|
11
|
+
const head = out.slice(0, Math.floor((maxChars * 3) / 5));
|
|
12
|
+
const tail = out.slice(out.length - Math.floor((maxChars * 2) / 5));
|
|
13
|
+
const omitted = out.slice(head.length, out.length - tail.length);
|
|
14
|
+
const lines = countOccurrences(omitted, "\n");
|
|
15
|
+
return `${head}\n[... ${omitted.length} chars, ${lines} lines omitted; narrow the command (grep, sed -n, head) or raise max_chars (at most ${MAX_MAX_CHARS}) ...]\n${tail}`;
|
|
16
|
+
}
|
|
17
|
+
/** Collects output, keeping only the first and last megabyte of very long output. */
|
|
18
|
+
class Capture {
|
|
19
|
+
head = Buffer.alloc(0);
|
|
20
|
+
tail = [];
|
|
21
|
+
tailLen = 0;
|
|
22
|
+
dropped = 0;
|
|
23
|
+
add(b) {
|
|
24
|
+
if (this.head.length < CAPTURE_HEAD) {
|
|
25
|
+
const n = Math.min(CAPTURE_HEAD - this.head.length, b.length);
|
|
26
|
+
this.head = Buffer.concat([this.head, b.subarray(0, n)]);
|
|
27
|
+
b = b.subarray(n);
|
|
28
|
+
}
|
|
29
|
+
if (!b.length)
|
|
30
|
+
return;
|
|
31
|
+
this.tail.push(b);
|
|
32
|
+
this.tailLen += b.length;
|
|
33
|
+
while (this.tailLen - this.tail[0].length >= CAPTURE_TAIL) {
|
|
34
|
+
const first = this.tail.shift();
|
|
35
|
+
this.tailLen -= first.length;
|
|
36
|
+
this.dropped += first.length;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
text() {
|
|
40
|
+
const gap = this.dropped ? `\n[... ${this.dropped} bytes not captured ...]\n` : "";
|
|
41
|
+
return this.head.toString("utf8") + gap + Buffer.concat(this.tail).toString("utf8");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class Sandbox {
|
|
45
|
+
current = null;
|
|
46
|
+
run(cmd, maxChars = DEFAULT_MAX_CHARS) {
|
|
47
|
+
maxChars = Math.max(200, Math.min(MAX_MAX_CHARS, Math.floor(maxChars)));
|
|
48
|
+
const t0 = Date.now();
|
|
49
|
+
const started = this.start(cmd);
|
|
50
|
+
const { child } = started;
|
|
51
|
+
const cur = { started, interrupted: false };
|
|
52
|
+
this.current = cur;
|
|
53
|
+
const cap = new Capture();
|
|
54
|
+
child.stdout.on("data", (b) => cap.add(b));
|
|
55
|
+
let sandboxStderr = "";
|
|
56
|
+
child.stderr.on("data", (b) => {
|
|
57
|
+
if (started.sandboxStderr)
|
|
58
|
+
sandboxStderr += b.toString("utf8");
|
|
59
|
+
else
|
|
60
|
+
cap.add(b);
|
|
61
|
+
});
|
|
62
|
+
let timedOut = false;
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
timedOut = true;
|
|
65
|
+
started.kill();
|
|
66
|
+
}, CMD_TIMEOUT_S * 1000);
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let done = false;
|
|
69
|
+
const finish = (code, signal) => {
|
|
70
|
+
if (done)
|
|
71
|
+
return;
|
|
72
|
+
done = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
this.current = null;
|
|
75
|
+
const classified = started.sandboxStderr?.(sandboxStderr) ?? { keep: "", fatal: null };
|
|
76
|
+
if (classified.keep)
|
|
77
|
+
cap.add(Buffer.from(classified.keep));
|
|
78
|
+
const out = cap.text();
|
|
79
|
+
const status = cur.interrupted ? "stopped by the person running gentl (Ctrl-C)"
|
|
80
|
+
: timedOut ? `timed out after ${CMD_TIMEOUT_S} s` : code !== null ? `exit code ${code}` : `killed by ${signal}`;
|
|
81
|
+
const truncation = out.length > maxChars
|
|
82
|
+
? { head: status.length + 1 + Math.floor((maxChars * 3) / 5), tail: Math.floor((maxChars * 2) / 5) } : null;
|
|
83
|
+
resolve({ exitCode: code, signal, full: `${status}\n${out}`, shown: `${status}\n${truncate(out, maxChars)}`,
|
|
84
|
+
truncation, seconds: (Date.now() - t0) / 1000, timedOut, interrupted: cur.interrupted,
|
|
85
|
+
sandboxError: classified.fatal });
|
|
86
|
+
};
|
|
87
|
+
child.on("error", (e) => {
|
|
88
|
+
cap.add(Buffer.from(`error: ${e.message}\n`));
|
|
89
|
+
finish(null, "spawn error");
|
|
90
|
+
});
|
|
91
|
+
child.on("close", (code, signal) => finish(code, signal));
|
|
92
|
+
// A background process (`server &`) can keep the pipes open after the command exits; don't wait for it.
|
|
93
|
+
child.on("exit", (code, signal) => setTimeout(() => {
|
|
94
|
+
child.stdout.destroy();
|
|
95
|
+
child.stderr.destroy();
|
|
96
|
+
finish(code, signal);
|
|
97
|
+
}, 1000));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** Stop the running command (Ctrl-C). Returns false if no command is running. */
|
|
101
|
+
interrupt() {
|
|
102
|
+
if (!this.current)
|
|
103
|
+
return false;
|
|
104
|
+
this.current.interrupted = true;
|
|
105
|
+
this.current.started.kill();
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
writeFile(p, content) {
|
|
109
|
+
const r = this.exec(["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1"', "sh", p], undefined, content);
|
|
110
|
+
return r.ok ? `wrote ${p}` : `error: ${r.out.trim()}`;
|
|
111
|
+
}
|
|
112
|
+
editFile(p, old, replacement) {
|
|
113
|
+
const r = this.exec(["cat", "--", p]);
|
|
114
|
+
if (!r.ok)
|
|
115
|
+
return `error: ${r.out.trim()}`;
|
|
116
|
+
const n = countOccurrences(r.out, old);
|
|
117
|
+
if (n !== 1)
|
|
118
|
+
return `error: the old text occurs ${n} times in ${p}; it must occur exactly once`;
|
|
119
|
+
const w = this.exec(["sh", "-c", 'cat > "$1"', "sh", p], undefined, r.out.replace(old, () => replacement));
|
|
120
|
+
return w.ok ? `edited ${p}` : `error: ${w.out.trim()}`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** spawnSync wrapper returning stdout (plus stderr on failure). */
|
|
124
|
+
export function execLocal(cmd, args, opts) {
|
|
125
|
+
const p = spawnSync(cmd, args, { ...opts, encoding: "utf8", maxBuffer: 64 << 20 });
|
|
126
|
+
if (p.error)
|
|
127
|
+
return { ok: false, out: p.error.message };
|
|
128
|
+
return { ok: p.status === 0, out: (p.stdout ?? "") + (p.status === 0 ? "" : p.stderr ?? "") };
|
|
129
|
+
}
|
package/dist/suite.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Batches: a suite (a directory of task files) and/or repeated runs (--repeat N). Runs go one after another into
|
|
2
|
+
// runs/<timestamp>-<name>/, and a results table is printed and written there.
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { runTask, timestamp } from "./run.js";
|
|
6
|
+
/** Task files in a suite directory: *.md, except README.md and files starting with "_". */
|
|
7
|
+
export function suiteTasks(dir) {
|
|
8
|
+
return fs.readdirSync(dir)
|
|
9
|
+
.filter((f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md" && !f.startsWith("_"))
|
|
10
|
+
.sort()
|
|
11
|
+
.map((f) => {
|
|
12
|
+
const file = path.join(dir, f);
|
|
13
|
+
return { taskName: path.basename(f, ".md"), task: fs.readFileSync(file, "utf8").trim(), taskSource: { kind: "file", path: file } };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function quantile(sorted, q) {
|
|
17
|
+
const pos = (sorted.length - 1) * q;
|
|
18
|
+
const lo = Math.floor(pos);
|
|
19
|
+
const hi = Math.ceil(pos);
|
|
20
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
|
21
|
+
}
|
|
22
|
+
function stats(values) {
|
|
23
|
+
if (!values.length)
|
|
24
|
+
return null;
|
|
25
|
+
const s = [...values].sort((a, b) => a - b);
|
|
26
|
+
return { median: quantile(s, 0.5), q1: quantile(s, 0.25), q3: quantile(s, 0.75), min: s[0], max: s[s.length - 1] };
|
|
27
|
+
}
|
|
28
|
+
function aggregate(task, rows) {
|
|
29
|
+
const ended = rows.filter((r) => r.run_dir !== null);
|
|
30
|
+
const ok = ended.filter((r) => r.verdict === true);
|
|
31
|
+
return {
|
|
32
|
+
task, runs: ended.length, successes: ok.length,
|
|
33
|
+
read_tokens: stats(ok.map((r) => r.read_tokens_total)), turns: stats(ok.map((r) => r.turns)),
|
|
34
|
+
notes_tokens_max: stats(ok.map((r) => r.notes_tokens_max)),
|
|
35
|
+
cost_usd: ended.reduce((a, r) => a + (r.cost_usd ?? 0), 0),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function rowFor(task, rep, batchDir, runDir, s) {
|
|
39
|
+
return {
|
|
40
|
+
task, rep, run_dir: path.relative(batchDir, runDir), stop_reason: s.stop_reason ?? "unknown",
|
|
41
|
+
verdict: s.evaluation?.success ?? null, self_reported_success: s.self_reported_success, turns: s.turns,
|
|
42
|
+
read_tokens_total: s.read_tokens_total, notes_tokens_max: s.notes_tokens_max, asks: s.asks.length,
|
|
43
|
+
cost_usd: s.cost_usd + (s.evaluation?.cost_usd ?? 0),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function emptyRow(task, rep, stop) {
|
|
47
|
+
return { task, rep, run_dir: null, stop_reason: stop, verdict: null, self_reported_success: null, turns: null,
|
|
48
|
+
read_tokens_total: null, notes_tokens_max: null, asks: null, cost_usd: null };
|
|
49
|
+
}
|
|
50
|
+
function table(header, body) {
|
|
51
|
+
const widths = header.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
|
|
52
|
+
const line = (cells) => `| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
|
|
53
|
+
return [line(header), line(widths.map((w) => "-".repeat(w))), ...body.map(line)].join("\n") + "\n";
|
|
54
|
+
}
|
|
55
|
+
const num = (v) => (v === null ? "-" : Math.round(v).toLocaleString("en-US"));
|
|
56
|
+
const cost = (v) => (v === null ? "-" : `$${v.toFixed(4)}`);
|
|
57
|
+
function runsTable(rows, repeat) {
|
|
58
|
+
const yn = (v) => (v === null ? "-" : v ? "yes" : "no");
|
|
59
|
+
return table(["task", ...(repeat > 1 ? ["rep"] : []), "verdict", "self-report", "stop", "turns", "read tokens", "notes max", "asks", "cost"], rows.map((r) => [r.task, ...(repeat > 1 ? [String(r.rep)] : []), r.verdict === null ? "-" : r.verdict ? "success" : "failure",
|
|
60
|
+
yn(r.self_reported_success), r.stop_reason, num(r.turns), num(r.read_tokens_total), num(r.notes_tokens_max),
|
|
61
|
+
num(r.asks), cost(r.cost_usd)]));
|
|
62
|
+
}
|
|
63
|
+
function aggregateTable(aggs) {
|
|
64
|
+
const range = (s) => (s ? `${num(s.min)}-${num(s.max)}` : "-");
|
|
65
|
+
const iqr = (s) => (s ? `${num(s.q1)}-${num(s.q3)}` : "-");
|
|
66
|
+
return table(["task", "success", "read median", "read IQR", "read range", "turns median", "notes max median", "cost"], aggs.map((a) => [a.task, `${a.successes}/${a.runs}`, num(a.read_tokens?.median ?? null), iqr(a.read_tokens),
|
|
67
|
+
range(a.read_tokens), num(a.turns?.median ?? null), num(a.notes_tokens_max?.median ?? null), cost(a.cost_usd)]));
|
|
68
|
+
}
|
|
69
|
+
/** Run each task `repeat` times. Returns false if any run failed with an error. */
|
|
70
|
+
export async function runBatch(name, tasks, repeat, base) {
|
|
71
|
+
if (!tasks.length)
|
|
72
|
+
throw new Error("no tasks to run");
|
|
73
|
+
const started = new Date();
|
|
74
|
+
const batchDir = path.resolve("runs", `${timestamp(started)}-${name}`);
|
|
75
|
+
fs.mkdirSync(batchDir, { recursive: true });
|
|
76
|
+
const total = tasks.length * repeat;
|
|
77
|
+
console.log(`${name}: ${tasks.length} task(s) x ${repeat}, runs in ${batchDir}`);
|
|
78
|
+
const rows = [];
|
|
79
|
+
let stopped = false;
|
|
80
|
+
let i = 0;
|
|
81
|
+
for (const t of tasks) {
|
|
82
|
+
for (let rep = 1; rep <= repeat; rep++) {
|
|
83
|
+
i++;
|
|
84
|
+
if (stopped) {
|
|
85
|
+
rows.push(emptyRow(t.taskName, rep, "skipped"));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const label = repeat > 1 ? `${t.taskName} (run ${rep}/${repeat})` : t.taskName;
|
|
89
|
+
console.log(`\n=== ${i}/${total}: ${label} ===\n`);
|
|
90
|
+
try {
|
|
91
|
+
const out = await runTask({ ...base, task: t.task, taskName: repeat > 1 ? `${t.taskName}-r${rep}` : t.taskName,
|
|
92
|
+
taskSource: t.taskSource, runsDir: batchDir });
|
|
93
|
+
rows.push(rowFor(t.taskName, rep, batchDir, out.runDir, out.summary));
|
|
94
|
+
if (out.summary.stop_reason === "interrupted") {
|
|
95
|
+
console.log("\nRun interrupted; skipping the remaining runs.");
|
|
96
|
+
stopped = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
console.log(`error: ${e.message}`);
|
|
101
|
+
rows.push(emptyRow(t.taskName, rep, "error"));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const aggs = tasks.map((t) => aggregate(t.taskName, rows.filter((r) => r.task === t.taskName)));
|
|
106
|
+
let report = runsTable(rows, repeat);
|
|
107
|
+
if (repeat > 1)
|
|
108
|
+
report = `Per task (read tokens, turns and notes over runs the evaluator judged successful):\n\n${aggregateTable(aggs)}\nAll runs:\n\n${report}`;
|
|
109
|
+
fs.writeFileSync(path.join(batchDir, "results.md"), `# ${name}\n\nStarted ${started.toISOString()}; ${tasks.length} task(s), ${repeat} run(s) each.\n\n${report}`);
|
|
110
|
+
fs.writeFileSync(path.join(batchDir, "results.json"), JSON.stringify({ format: 1, name, repeat, started: started.toISOString(),
|
|
111
|
+
ended: new Date().toISOString(), tasks: aggs, runs: rows }, null, 2) + "\n");
|
|
112
|
+
console.log(`\n=== ${name} ===\n\n${report}\nBatch directory: ${batchDir}`);
|
|
113
|
+
return rows.every((r) => r.stop_reason !== "error");
|
|
114
|
+
}
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Tiktoken } from "js-tiktoken/lite";
|
|
2
|
+
import o200k_base from "js-tiktoken/ranks/o200k_base";
|
|
3
|
+
const enc = new Tiktoken(o200k_base);
|
|
4
|
+
/** Tokens in text (o200k_base). Special-token strings are counted as plain text. */
|
|
5
|
+
export function ntok(text) {
|
|
6
|
+
return enc.encode(text, [], []).length;
|
|
7
|
+
}
|