@zenera/neo 1.1.23 → 1.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/embeddings/rate-limit.d.ts +7 -0
- package/dist/embeddings/rate-limit.js +33 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/inspect/client.d.ts +1 -1
- package/dist/inspect/client.js +7 -2
- package/dist/lock.d.ts +27 -0
- package/dist/lock.js +66 -0
- package/dist/match.d.ts +44 -0
- package/dist/match.js +100 -0
- package/dist/memory/diff.d.ts +39 -0
- package/dist/memory/diff.js +95 -0
- package/dist/memory/files.d.ts +1 -1
- package/dist/memory/grep.d.ts +57 -0
- package/dist/memory/grep.js +182 -0
- package/dist/memory/index.d.ts +10 -2
- package/dist/memory/index.js +15 -4
- package/dist/memory/merge.js +1 -31
- package/dist/memory/recall.d.ts +1 -1
- package/dist/memory/recall.js +1 -1
- package/dist/memory/store.d.ts +8 -5
- package/dist/memory/store.js +26 -55
- package/dist/memory/tools.js +105 -9
- package/dist/memory/twin.d.ts +6 -0
- package/dist/memory/twin.js +40 -0
- package/dist/memory/types.d.ts +7 -2
- package/dist/models/factory.d.ts +67 -2
- package/dist/models/factory.js +256 -10
- package/dist/project/config.js +1 -1
- package/dist/project/load.d.ts +7 -0
- package/dist/project/load.js +29 -6
- package/dist/runner.js +7 -0
- package/dist/tools/exa.js +1 -1
- package/dist/tools/files.d.ts +81 -0
- package/dist/tools/files.js +1472 -0
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/tools/sandbox.d.ts +44 -0
- package/dist/tools/sandbox.js +162 -5
- package/dist/tools/workspace.js +81 -3
- package/dist/trajectory.d.ts +19 -1
- package/dist/trajectory.js +53 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.js +1 -0
- package/package.json +1 -1
package/dist/tools/index.d.ts
CHANGED
package/dist/tools/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tools a host may hand to a project. Nothing here is wired in by default:
|
|
2
2
|
// what an agent can reach is a deployment decision, so it is opted into.
|
|
3
3
|
export * from "./exa.js";
|
|
4
|
+
export * from "./files.js";
|
|
4
5
|
export * from "./sandbox.js";
|
|
5
|
-
export * from "./workspace.js";
|
|
6
6
|
//# sourceMappingURL=index.js.map
|
package/dist/tools/sandbox.d.ts
CHANGED
|
@@ -120,6 +120,15 @@ export interface ProcResult {
|
|
|
120
120
|
}
|
|
121
121
|
export type Runner = (bin: string, args: readonly string[], opts?: ProcOptions) => Promise<ProcResult>;
|
|
122
122
|
export declare const runProcess: Runner;
|
|
123
|
+
/**
|
|
124
|
+
* `run`, retried while the engine could not be reached at all.
|
|
125
|
+
*
|
|
126
|
+
* Empty stdout is the guard that keeps this from retrying real work: a command
|
|
127
|
+
* that produced output ran, whatever its stderr says afterwards. The wait is
|
|
128
|
+
* jittered because the burst being backed off from is N clients arriving
|
|
129
|
+
* together, and a fixed delay would only reassemble it.
|
|
130
|
+
*/
|
|
131
|
+
export declare function resilient(run: Runner, attempts?: number): Runner;
|
|
123
132
|
interface Resolved extends Required<Omit<SandboxSpec, 'user'>> {
|
|
124
133
|
user?: string;
|
|
125
134
|
root: string;
|
|
@@ -143,12 +152,30 @@ export interface ExecResult {
|
|
|
143
152
|
truncated?: boolean;
|
|
144
153
|
timed_out?: boolean;
|
|
145
154
|
duration_ms: number;
|
|
155
|
+
out_of_memory?: OutOfMemory;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Why a command was killed for memory, which is a different question from
|
|
159
|
+
* whether it was. A container that overran its own `--memory` will do it again
|
|
160
|
+
* however long anyone waits; one the kernel sacrificed for a neighbour may
|
|
161
|
+
* simply succeed later. Exit code 137 is all either of them says.
|
|
162
|
+
*/
|
|
163
|
+
export interface OutOfMemory {
|
|
164
|
+
/** what this container was allowed */
|
|
165
|
+
limit_mib: number;
|
|
166
|
+
/** high-water mark for the container's whole life, not for this command */
|
|
167
|
+
peak_mib?: number;
|
|
168
|
+
/** false when the limit itself is the problem, so an unchanged rerun ends the same way */
|
|
169
|
+
retryable: boolean;
|
|
170
|
+
hint: string;
|
|
146
171
|
}
|
|
147
172
|
interface Job {
|
|
148
173
|
id: string;
|
|
149
174
|
command: string;
|
|
150
175
|
cwd: string;
|
|
151
176
|
startedAt: number;
|
|
177
|
+
/** worked out once, because a log is read many times and the evidence is consumed */
|
|
178
|
+
oom?: OutOfMemory;
|
|
152
179
|
}
|
|
153
180
|
/**
|
|
154
181
|
* The container, started when it is first needed and not before. A project
|
|
@@ -195,6 +222,23 @@ export declare class Sandbox {
|
|
|
195
222
|
*/
|
|
196
223
|
inside(path?: string): string;
|
|
197
224
|
}
|
|
225
|
+
export interface SandboxLimits {
|
|
226
|
+
/** MiB */
|
|
227
|
+
memory: number;
|
|
228
|
+
/** fractional cores */
|
|
229
|
+
cpus: number;
|
|
230
|
+
hardening: SandboxHardening;
|
|
231
|
+
/** whether the numbers above were written down, or are this profile's defaults */
|
|
232
|
+
declared: boolean;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* What a spec's ceilings come to, for a report that has no container to ask.
|
|
236
|
+
*
|
|
237
|
+
* It exists so `zen sandbox status` cannot disagree with what a run actually
|
|
238
|
+
* applies: the defaults are stated once, here and in `resolveSpec`, and
|
|
239
|
+
* nothing outside this module repeats them.
|
|
240
|
+
*/
|
|
241
|
+
export declare function sandboxLimits(spec?: Pick<SandboxSpec, 'cpus' | 'memory' | 'hardening'>): SandboxLimits;
|
|
198
242
|
export declare class SandboxPool {
|
|
199
243
|
#private;
|
|
200
244
|
constructor(opts: SandboxPoolOptions);
|
package/dist/tools/sandbox.js
CHANGED
|
@@ -39,6 +39,8 @@ const MAX_TIMEOUT_MS = 3_600_000;
|
|
|
39
39
|
const DEFAULT_PIDS = 1024;
|
|
40
40
|
/** How long a container gets to stop itself before it is killed. */
|
|
41
41
|
const STOP_GRACE_S = 2;
|
|
42
|
+
/** SIGKILL, which is how the kernel's out-of-memory killer arrives. */
|
|
43
|
+
const KILLED = 137;
|
|
42
44
|
// Standard mode's resource ceilings. Even without the strict profile, an
|
|
43
45
|
// unbounded container can exhaust host cores or memory during runaway builds.
|
|
44
46
|
const DEFAULT_CPUS = 4;
|
|
@@ -138,6 +140,57 @@ export const runProcess = (bin, args, opts = {}) => new Promise((settle, fail) =
|
|
|
138
140
|
child.stdin.end();
|
|
139
141
|
}
|
|
140
142
|
});
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Reaching the engine at all
|
|
145
|
+
//
|
|
146
|
+
// On macOS and Windows the client is remote: unless it has been pointed at the
|
|
147
|
+
// machine's forwarded endpoint, every invocation opens its own ssh connection
|
|
148
|
+
// into the virtual machine, and sshd refuses new ones past `MaxStartups` — ten
|
|
149
|
+
// by default, with a random drop above that. A wide batch therefore loses
|
|
150
|
+
// calls for a reason that has nothing to do with the command, on a different
|
|
151
|
+
// task each time.
|
|
152
|
+
//
|
|
153
|
+
// A refused connection is the one failure that can be retried without asking
|
|
154
|
+
// what the command was: it happened before authentication, so nothing ran.
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
const TRANSPORT = /kex_exchange_identification|ssh: handshake failed|ssh: unexpected packet|connection closed by remote host|connection reset by peer|connection refused/i;
|
|
157
|
+
const ATTEMPTS = 4;
|
|
158
|
+
/**
|
|
159
|
+
* `run`, retried while the engine could not be reached at all.
|
|
160
|
+
*
|
|
161
|
+
* Empty stdout is the guard that keeps this from retrying real work: a command
|
|
162
|
+
* that produced output ran, whatever its stderr says afterwards. The wait is
|
|
163
|
+
* jittered because the burst being backed off from is N clients arriving
|
|
164
|
+
* together, and a fixed delay would only reassemble it.
|
|
165
|
+
*/
|
|
166
|
+
export function resilient(run, attempts = ATTEMPTS) {
|
|
167
|
+
return async (bin, args, opts = {}) => {
|
|
168
|
+
for (let n = 1;; n++) {
|
|
169
|
+
const res = await run(bin, args, opts);
|
|
170
|
+
if (n >= attempts ||
|
|
171
|
+
res.code === 0 ||
|
|
172
|
+
res.stdout !== '' ||
|
|
173
|
+
opts.signal?.aborted ||
|
|
174
|
+
!TRANSPORT.test(res.stderr)) {
|
|
175
|
+
return res;
|
|
176
|
+
}
|
|
177
|
+
await new Promise((go) => setTimeout(go, 2 ** n * 50 + Math.random() * 100));
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Asked of the container after a kill. Builtins only: a slim image may have no
|
|
183
|
+
* `awk`, and strict hardening leaves nowhere writable to install one. `oom_kill`
|
|
184
|
+
* counts kills in this container's cgroup and no other, which is what separates
|
|
185
|
+
* the two cases. Unreadable values print empty, and the caller reads that as
|
|
186
|
+
* "could not tell".
|
|
187
|
+
*/
|
|
188
|
+
const CGROUP = [
|
|
189
|
+
`peak=; kills=`,
|
|
190
|
+
`read peak < /sys/fs/cgroup/memory.peak 2>/dev/null`,
|
|
191
|
+
`while read -r k v; do [ "$k" = oom_kill ] && kills=$v; done < /sys/fs/cgroup/memory.events 2>/dev/null`,
|
|
192
|
+
`echo "peak=$peak kills=$kills"`,
|
|
193
|
+
].join('\n');
|
|
141
194
|
/**
|
|
142
195
|
* The container, started when it is first needed and not before. A project
|
|
143
196
|
* whose agents never shell out never pays for an image pull, and a session
|
|
@@ -150,9 +203,11 @@ export class Sandbox {
|
|
|
150
203
|
#jobs = new Map();
|
|
151
204
|
#ready;
|
|
152
205
|
#created = false;
|
|
206
|
+
/** kills counted in this container's cgroup as of the last probe */
|
|
207
|
+
#kills;
|
|
153
208
|
constructor(opts) {
|
|
154
209
|
this.spec = resolveSpec(opts);
|
|
155
|
-
this.#run = opts.exec ?? runProcess;
|
|
210
|
+
this.#run = resilient(opts.exec ?? runProcess);
|
|
156
211
|
this.name = containerName(this.spec);
|
|
157
212
|
}
|
|
158
213
|
/** Whether anything was actually started — `dispose` is free when not. */
|
|
@@ -188,7 +243,10 @@ export class Sandbox {
|
|
|
188
243
|
this.#created = true;
|
|
189
244
|
return;
|
|
190
245
|
}
|
|
191
|
-
await this.#
|
|
246
|
+
const made = await this.#podman(this.#createArgs());
|
|
247
|
+
if (made.code !== 0) {
|
|
248
|
+
throw new SandboxError(`could not create container ${this.name}: ${message(made)}`, LOCKS.test(made.stderr) ? locksHint(this.spec.engine) : undefined);
|
|
249
|
+
}
|
|
192
250
|
this.#created = true;
|
|
193
251
|
}
|
|
194
252
|
#createArgs() {
|
|
@@ -265,7 +323,13 @@ export class Sandbox {
|
|
|
265
323
|
const cwd = this.inside(opts.cwd);
|
|
266
324
|
const timeoutMs = this.#timeout(opts.timeout);
|
|
267
325
|
const startedAt = Date.now();
|
|
268
|
-
const
|
|
326
|
+
const once = () => this.#run(this.spec.engine, ['exec', '--interactive', '--workdir', cwd, this.name, '/bin/sh', '-s'], { input: command, timeoutMs, signal: opts.signal });
|
|
327
|
+
let res = await once();
|
|
328
|
+
if (gone(res)) {
|
|
329
|
+
// podman refused before running anything, so the retry cannot run the command twice.
|
|
330
|
+
await this.#revive(res);
|
|
331
|
+
res = await once();
|
|
332
|
+
}
|
|
269
333
|
return {
|
|
270
334
|
exit_code: res.code,
|
|
271
335
|
stdout: res.stdout,
|
|
@@ -273,6 +337,7 @@ export class Sandbox {
|
|
|
273
337
|
truncated: res.truncated || undefined,
|
|
274
338
|
timed_out: res.timedOut || undefined,
|
|
275
339
|
duration_ms: Date.now() - startedAt,
|
|
340
|
+
out_of_memory: await this.#oom(res.code, res.timedOut, opts.signal),
|
|
276
341
|
};
|
|
277
342
|
}
|
|
278
343
|
/**
|
|
@@ -353,17 +418,24 @@ export class Sandbox {
|
|
|
353
418
|
const output = rest.join('\n---\n');
|
|
354
419
|
const lines = Number(meta.lines ?? 0);
|
|
355
420
|
const shown = output ? output.replace(/\n$/, '').split('\n').length : 0;
|
|
421
|
+
const exit = meta.exit === undefined ? undefined : Number(meta.exit);
|
|
422
|
+
// Kept on the job: a second read would find the kill counter unchanged
|
|
423
|
+
// and report this container's own overrun as the machine's doing.
|
|
424
|
+
if (exit === KILLED && !job.oom) {
|
|
425
|
+
job.oom = await this.#oom(exit, false);
|
|
426
|
+
}
|
|
356
427
|
return {
|
|
357
428
|
job_id: id,
|
|
358
429
|
command: job.command,
|
|
359
430
|
running: meta.exit === undefined,
|
|
360
|
-
exit_code:
|
|
431
|
+
exit_code: exit,
|
|
361
432
|
duration_ms: Date.now() - job.startedAt,
|
|
362
433
|
start_line: first,
|
|
363
434
|
end_line: first + shown - 1,
|
|
364
435
|
lines,
|
|
365
436
|
truncated: first + shown - 1 < lines || undefined,
|
|
366
437
|
output,
|
|
438
|
+
out_of_memory: job.oom,
|
|
367
439
|
};
|
|
368
440
|
}
|
|
369
441
|
/** Signals a job's whole process group, so its children go too. */
|
|
@@ -422,9 +494,60 @@ export class Sandbox {
|
|
|
422
494
|
const wanted = seconds && seconds > 0 ? seconds * 1000 : this.spec.timeout * 1000;
|
|
423
495
|
return Math.min(wanted || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
424
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* Turns a bare 137 into a reason, by asking the container which cgroup did
|
|
499
|
+
* the killing. Only a kill counted against *this* container is the caller's
|
|
500
|
+
* to fix; the rest is the machine being busy, and telling someone to shrink
|
|
501
|
+
* a command that already fit would send them off after the wrong thing.
|
|
502
|
+
*/
|
|
503
|
+
async #oom(code, timedOut, signal) {
|
|
504
|
+
// A timeout or a cancel kills podman on this side, not the container,
|
|
505
|
+
// and an uncapped container has no limit it could have exceeded.
|
|
506
|
+
if (code !== KILLED || timedOut || signal?.aborted || this.spec.memory <= 0) {
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
const res = await this.#run(this.spec.engine, ['exec', '--interactive', this.name, '/bin/sh', '-s'], { input: CGROUP, timeoutMs: 15_000 }).catch(() => undefined);
|
|
510
|
+
const out = res?.stdout ?? '';
|
|
511
|
+
const peak = Number(/peak=(\d+)/.exec(out)?.[1]);
|
|
512
|
+
const kills = Number(/kills=(\d+)/.exec(out)?.[1]);
|
|
513
|
+
const counted = Number.isFinite(kills);
|
|
514
|
+
// Nothing readable is read as our own doing: that costs one wasted
|
|
515
|
+
// retry, where the other guess costs an agent retrying forever a
|
|
516
|
+
// command that was never going to fit.
|
|
517
|
+
const own = !counted || kills > (this.#kills ?? 0);
|
|
518
|
+
if (counted) {
|
|
519
|
+
this.#kills = kills;
|
|
520
|
+
}
|
|
521
|
+
const limit = this.spec.memory;
|
|
522
|
+
return {
|
|
523
|
+
limit_mib: limit,
|
|
524
|
+
peak_mib: Number.isFinite(peak) ? Math.round(peak / (1024 * 1024)) : undefined,
|
|
525
|
+
retryable: !own,
|
|
526
|
+
hint: own
|
|
527
|
+
? `this container is limited to ${limit} MiB and the command wanted more. ` +
|
|
528
|
+
`Running it again unchanged will end the same way: give the sandbox more ` +
|
|
529
|
+
`memory in the project configuration, or do the work in smaller pieces.`
|
|
530
|
+
: `the machine ran out of memory while this container was inside its ` +
|
|
531
|
+
`${limit} MiB limit, so it was killed for something another container did. ` +
|
|
532
|
+
`The command itself does not need to change, and may work once the machine ` +
|
|
533
|
+
`is less busy.`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
425
536
|
#podman(args) {
|
|
426
537
|
return this.#run(this.spec.engine, args, { timeoutMs: 120_000 });
|
|
427
538
|
}
|
|
539
|
+
/** The container stopped or vanished under us (machine slept, restarted, or someone removed it). */
|
|
540
|
+
async #revive(res) {
|
|
541
|
+
this.#ready = undefined;
|
|
542
|
+
try {
|
|
543
|
+
await this.start();
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
throw new SandboxError(`the sandbox container ${this.name} stopped (${message(res)}) and could not be restarted: ` +
|
|
547
|
+
(err instanceof Error ? err.message : String(err)), `the ${this.spec.engine} machine may have stopped or restarted — check \`${this.spec.engine} machine list\`, ` +
|
|
548
|
+
`start it with \`${this.spec.engine} machine start\`, then retry`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
428
551
|
async #must(args, what) {
|
|
429
552
|
const res = await this.#podman(args);
|
|
430
553
|
if (res.code !== 0) {
|
|
@@ -439,6 +562,23 @@ function mount(m) {
|
|
|
439
562
|
function message(res) {
|
|
440
563
|
return (res.stderr.trim() || res.stdout.trim() || `exit ${res.code}`).split('\n')[0];
|
|
441
564
|
}
|
|
565
|
+
/** podman's words for "every lock is taken": `allocating lock … exceeded num_locks (2048)`. */
|
|
566
|
+
const LOCKS = /exceeded num_locks|allocating lock/i;
|
|
567
|
+
/**
|
|
568
|
+
* A stopped container holds a lock as surely as a running one, and a
|
|
569
|
+
* persisted sandbox is stopped, never removed — so they pile up one per
|
|
570
|
+
* session until the engine can create nothing at all, for anyone.
|
|
571
|
+
*/
|
|
572
|
+
function locksHint(engine) {
|
|
573
|
+
return (`the ${engine} engine has no locks left: every container it holds, stopped ones ` +
|
|
574
|
+
`included, takes one. Remove the stopped sandbox containers with ` +
|
|
575
|
+
`\`${engine} container prune --force --filter label=zenera=1\`, then retry`);
|
|
576
|
+
}
|
|
577
|
+
/** podman's own exit code for "could not exec at all", with the two reasons that mean the container is not there to exec in. */
|
|
578
|
+
function gone(res) {
|
|
579
|
+
return (res.code === 125 &&
|
|
580
|
+
/can only create exec sessions on running containers|container state improper|no such container/i.test(res.stderr));
|
|
581
|
+
}
|
|
442
582
|
/** Only ever applied to paths this module derived, never to a model's text. */
|
|
443
583
|
function quote(s) {
|
|
444
584
|
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
@@ -474,6 +614,23 @@ function resolveSpec(opts) {
|
|
|
474
614
|
engine: opts.engine ?? 'podman',
|
|
475
615
|
};
|
|
476
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* What a spec's ceilings come to, for a report that has no container to ask.
|
|
619
|
+
*
|
|
620
|
+
* It exists so `zen sandbox status` cannot disagree with what a run actually
|
|
621
|
+
* applies: the defaults are stated once, here and in `resolveSpec`, and
|
|
622
|
+
* nothing outside this module repeats them.
|
|
623
|
+
*/
|
|
624
|
+
export function sandboxLimits(spec = {}) {
|
|
625
|
+
const hardening = spec.hardening ?? 'standard';
|
|
626
|
+
const strict = hardening === 'strict';
|
|
627
|
+
return {
|
|
628
|
+
memory: spec.memory ?? (strict ? STRICT_MEMORY : DEFAULT_MEMORY),
|
|
629
|
+
cpus: spec.cpus ?? (strict ? STRICT_CPUS : DEFAULT_CPUS),
|
|
630
|
+
hardening,
|
|
631
|
+
declared: spec.memory !== undefined || spec.cpus !== undefined,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
477
634
|
/**
|
|
478
635
|
* The container's name is a function of its configuration. Change the image
|
|
479
636
|
* and a new container appears rather than an old one quietly persisting with
|
|
@@ -585,7 +742,7 @@ export function sandboxTools(opts) {
|
|
|
585
742
|
group: SANDBOX_GROUP,
|
|
586
743
|
description: 'Runs a shell command in a Linux container and waits for it to finish. The ' +
|
|
587
744
|
`workspace is mounted at ${SANDBOX_MOUNT} and is the working directory, so files you ` +
|
|
588
|
-
'read and write here are the same files the
|
|
745
|
+
'read and write here are the same files the file tools see. Everything ' +
|
|
589
746
|
'else is throwaway. Use this for builds, tests, and package installs; use ' +
|
|
590
747
|
'run_command_background for anything that does not end on its own, such as a ' +
|
|
591
748
|
'server or a watcher.',
|
package/dist/tools/workspace.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { realpathSync, statSync } from 'node:fs';
|
|
2
|
-
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { cp, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { tool } from "../types.js";
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
@@ -684,7 +684,7 @@ function patchLines(lines, chunks, file) {
|
|
|
684
684
|
const at = findHeading(out, heading, cursor);
|
|
685
685
|
if (at < 0) {
|
|
686
686
|
// A heading only narrows the search. Models invent them —
|
|
687
|
-
// `@@ class
|
|
687
|
+
// `@@ class RiskScanner` for a file of plain functions — and
|
|
688
688
|
// when the context occurs once the heading was never needed, so
|
|
689
689
|
// refusing spends a turn to learn nothing.
|
|
690
690
|
if (countMatches(out, chunk.before, cursor) === 1) {
|
|
@@ -1064,6 +1064,75 @@ export function workspaceTools(opts) {
|
|
|
1064
1064
|
return { from: ws.show(source), to: ws.show(target), moved: true };
|
|
1065
1065
|
},
|
|
1066
1066
|
});
|
|
1067
|
+
// The one mutating tool whose source may sit in a read-only tree, and the
|
|
1068
|
+
// reason it earns its place: reaching a mounted file otherwise means
|
|
1069
|
+
// reading it into the context and writing it back out, which costs a turn,
|
|
1070
|
+
// truncates past the read cap and cannot carry a binary at all.
|
|
1071
|
+
const copyPath = tool({
|
|
1072
|
+
name: 'copy_file',
|
|
1073
|
+
group: GROUP,
|
|
1074
|
+
description: 'Copies a file, making the destination parent directories as needed. The source ' +
|
|
1075
|
+
'may be in a read-only tree, so this is how something mounted for reference is ' +
|
|
1076
|
+
'brought into the workspace; the destination must be writable. The bytes are ' +
|
|
1077
|
+
'copied directly and are not read into the conversation, so size is no object ' +
|
|
1078
|
+
'and a binary copies as faithfully as text. Refuses to clobber an existing ' +
|
|
1079
|
+
'destination unless overwrite is set, and only copies a directory, with ' +
|
|
1080
|
+
'everything in it, when recursive is set.',
|
|
1081
|
+
parameters: {
|
|
1082
|
+
type: 'object',
|
|
1083
|
+
properties: {
|
|
1084
|
+
from: { type: 'string', description: `What to copy. ${inside}` },
|
|
1085
|
+
to: { type: 'string', description: `Where the copy should land. ${inside}` },
|
|
1086
|
+
overwrite: {
|
|
1087
|
+
type: 'boolean',
|
|
1088
|
+
description: 'Replace the destination if it exists.',
|
|
1089
|
+
},
|
|
1090
|
+
recursive: {
|
|
1091
|
+
type: 'boolean',
|
|
1092
|
+
description: 'Required to copy a directory and its contents.',
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
required: ['from', 'to'],
|
|
1096
|
+
additionalProperties: false,
|
|
1097
|
+
},
|
|
1098
|
+
execute: async ({ from, to, overwrite, recursive }) => {
|
|
1099
|
+
ws.mutable();
|
|
1100
|
+
const source = ws.within(from);
|
|
1101
|
+
const target = ws.within(to, { write: true });
|
|
1102
|
+
if (ws.isRoot(target)) {
|
|
1103
|
+
return { error: 'refusing to overwrite the workspace root' };
|
|
1104
|
+
}
|
|
1105
|
+
if (source === target) {
|
|
1106
|
+
return { error: 'the source and the destination are the same path' };
|
|
1107
|
+
}
|
|
1108
|
+
const info = await stat(source); // ENOENT here is the honest error
|
|
1109
|
+
if (info.isDirectory()) {
|
|
1110
|
+
if (!recursive) {
|
|
1111
|
+
return {
|
|
1112
|
+
error: `${ws.show(source)} is a directory`,
|
|
1113
|
+
hint: 'pass recursive to copy it and everything in it',
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
if (target.startsWith(source + sep)) {
|
|
1117
|
+
return { error: `cannot copy ${ws.show(source)} into itself` };
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (await statOrNull(target)) {
|
|
1121
|
+
if (!overwrite) {
|
|
1122
|
+
return {
|
|
1123
|
+
error: `${ws.show(target)} already exists`,
|
|
1124
|
+
hint: 'pass overwrite to replace it',
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
// cp() will not replace a directory with a file, or the reverse.
|
|
1128
|
+
await rm(target, { recursive: true, force: true });
|
|
1129
|
+
}
|
|
1130
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1131
|
+
await cp(source, target, { recursive: recursive === true });
|
|
1132
|
+
const out = { from: ws.show(source), to: ws.show(target), copied: true };
|
|
1133
|
+
return info.isDirectory() ? { ...out, directory: true } : { ...out, bytes: info.size };
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1067
1136
|
const deleteFile = tool({
|
|
1068
1137
|
name: 'delete_file',
|
|
1069
1138
|
group: GROUP,
|
|
@@ -1154,7 +1223,16 @@ export function workspaceTools(opts) {
|
|
|
1154
1223
|
});
|
|
1155
1224
|
return named(ws, opts.readOnly
|
|
1156
1225
|
? [readFileTool, listDir, findFiles]
|
|
1157
|
-
: [
|
|
1226
|
+
: [
|
|
1227
|
+
readFileTool,
|
|
1228
|
+
listDir,
|
|
1229
|
+
findFiles,
|
|
1230
|
+
writeFileTool,
|
|
1231
|
+
applyPatch,
|
|
1232
|
+
copyPath,
|
|
1233
|
+
movePath,
|
|
1234
|
+
deleteFile,
|
|
1235
|
+
]);
|
|
1158
1236
|
}
|
|
1159
1237
|
/** What each errno means, said the way the rest of these tools say things. */
|
|
1160
1238
|
const ERRNO = {
|
package/dist/trajectory.d.ts
CHANGED
|
@@ -67,7 +67,7 @@ export interface MemoryRecallNode extends NodeBase {
|
|
|
67
67
|
}
|
|
68
68
|
export interface MemoryOpNode extends NodeBase {
|
|
69
69
|
type: 'memory_op';
|
|
70
|
-
op: 'commit' | 'forget' | 'load';
|
|
70
|
+
op: 'commit' | 'forget' | 'load' | 'grep';
|
|
71
71
|
/** sha256(runId, callId) — deterministic, so a replay is deduplicated */
|
|
72
72
|
opId: string;
|
|
73
73
|
nodes: {
|
|
@@ -202,6 +202,24 @@ export declare function projected(nodes: TrajectoryNode[]): TrajectoryNode[];
|
|
|
202
202
|
* added, since counting it too would double every branch.
|
|
203
203
|
*/
|
|
204
204
|
export declare function totalUsage(trajectory: TrajectoryNode[]): TokenUsage;
|
|
205
|
+
export interface ModelUsage {
|
|
206
|
+
/** the exact model id the call was served by, as recorded on the node */
|
|
207
|
+
model: string;
|
|
208
|
+
calls: number;
|
|
209
|
+
usage: TokenUsage;
|
|
210
|
+
}
|
|
211
|
+
/** A summarizer's tokens are real spend, but no node records which model burned them. */
|
|
212
|
+
export declare const COMPACTION_MODEL = "(compaction)";
|
|
213
|
+
/**
|
|
214
|
+
* `totalUsage`, split by the model that spent it. The same walk and the same
|
|
215
|
+
* recursion into branches, so the rows always add back up to the total — which
|
|
216
|
+
* is the point: a run that used three models must not report one number that
|
|
217
|
+
* belongs to none of them. Heaviest first, since that is the one being asked
|
|
218
|
+
* about.
|
|
219
|
+
*/
|
|
220
|
+
export declare function usageByModel(trajectory: TrajectoryNode[]): ModelUsage[];
|
|
221
|
+
/** Folds splits together: many turns of one run, or many items of a batch. */
|
|
222
|
+
export declare function mergeModelUsage(...lists: readonly (readonly ModelUsage[])[]): ModelUsage[];
|
|
205
223
|
export declare function lastOfType<T extends TrajectoryNode['type']>(trajectory: TrajectoryNode[], type: T): Extract<TrajectoryNode, {
|
|
206
224
|
type: T;
|
|
207
225
|
}> | undefined;
|
package/dist/trajectory.js
CHANGED
|
@@ -47,6 +47,59 @@ export function totalUsage(trajectory) {
|
|
|
47
47
|
return acc;
|
|
48
48
|
}, zeroUsage());
|
|
49
49
|
}
|
|
50
|
+
/** A summarizer's tokens are real spend, but no node records which model burned them. */
|
|
51
|
+
export const COMPACTION_MODEL = '(compaction)';
|
|
52
|
+
/**
|
|
53
|
+
* `totalUsage`, split by the model that spent it. The same walk and the same
|
|
54
|
+
* recursion into branches, so the rows always add back up to the total — which
|
|
55
|
+
* is the point: a run that used three models must not report one number that
|
|
56
|
+
* belongs to none of them. Heaviest first, since that is the one being asked
|
|
57
|
+
* about.
|
|
58
|
+
*/
|
|
59
|
+
export function usageByModel(trajectory) {
|
|
60
|
+
const into = new Map();
|
|
61
|
+
collect(trajectory, into);
|
|
62
|
+
return sorted(into);
|
|
63
|
+
}
|
|
64
|
+
/** Folds splits together: many turns of one run, or many items of a batch. */
|
|
65
|
+
export function mergeModelUsage(...lists) {
|
|
66
|
+
const into = new Map();
|
|
67
|
+
for (const list of lists) {
|
|
68
|
+
for (const one of list) {
|
|
69
|
+
fold(into, one);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return sorted(into);
|
|
73
|
+
}
|
|
74
|
+
function collect(trajectory, into) {
|
|
75
|
+
for (const n of trajectory) {
|
|
76
|
+
if (n.type === 'llm_call') {
|
|
77
|
+
fold(into, { model: n.model, calls: 1, usage: n.usage });
|
|
78
|
+
}
|
|
79
|
+
else if (n.type === 'compaction') {
|
|
80
|
+
fold(into, { model: COMPACTION_MODEL, calls: 1, usage: n.usage });
|
|
81
|
+
}
|
|
82
|
+
else if (n.type === 'join') {
|
|
83
|
+
for (const branch of n.branches) {
|
|
84
|
+
collect(branch.nodes, into);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function fold(into, one) {
|
|
90
|
+
const had = into.get(one.model);
|
|
91
|
+
into.set(one.model, had
|
|
92
|
+
? {
|
|
93
|
+
model: had.model,
|
|
94
|
+
calls: had.calls + one.calls,
|
|
95
|
+
usage: addUsage(had.usage, one.usage),
|
|
96
|
+
}
|
|
97
|
+
: { model: one.model, calls: one.calls, usage: one.usage });
|
|
98
|
+
}
|
|
99
|
+
function sorted(into) {
|
|
100
|
+
const spent = (m) => m.usage.inputTokens + m.usage.outputTokens;
|
|
101
|
+
return [...into.values()].sort((a, b) => spent(b) - spent(a) || a.model.localeCompare(b.model));
|
|
102
|
+
}
|
|
50
103
|
export function lastOfType(trajectory, type) {
|
|
51
104
|
for (let i = trajectory.length - 1; i >= 0; i--) {
|
|
52
105
|
if (trajectory[i].type === type) {
|
package/dist/types.d.ts
CHANGED
|
@@ -184,6 +184,7 @@ export declare const HANDOFF_PREFIX = "transfer_to_";
|
|
|
184
184
|
export declare const FORK_TOOL = "fork";
|
|
185
185
|
export declare const FINAL_OUTPUT_TOOL = "final_output";
|
|
186
186
|
export declare const MEMORY_SEARCH_TOOL = "memory_search";
|
|
187
|
+
export declare const MEMORY_GREP_TOOL = "memory_grep";
|
|
187
188
|
export declare const MEMORY_LOAD_TOOL = "memory_load";
|
|
188
189
|
export declare const MEMORY_COMMIT_TOOL = "memory_commit";
|
|
189
190
|
export declare const MEMORY_FORGET_TOOL = "memory_forget";
|
package/dist/types.js
CHANGED
|
@@ -144,6 +144,7 @@ export const HANDOFF_PREFIX = 'transfer_to_';
|
|
|
144
144
|
export const FORK_TOOL = 'fork';
|
|
145
145
|
export const FINAL_OUTPUT_TOOL = 'final_output';
|
|
146
146
|
export const MEMORY_SEARCH_TOOL = 'memory_search';
|
|
147
|
+
export const MEMORY_GREP_TOOL = 'memory_grep';
|
|
147
148
|
export const MEMORY_LOAD_TOOL = 'memory_load';
|
|
148
149
|
export const MEMORY_COMMIT_TOOL = 'memory_commit';
|
|
149
150
|
export const MEMORY_FORGET_TOOL = 'memory_forget';
|