@aragon-agent/cli 0.6.31 → 0.6.33
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/CHANGELOG.md +119 -0
- package/README.md +2970 -2826
- package/dist/agent/controller.d.ts +46 -0
- package/dist/agent/controller.js +175 -7
- package/dist/agent/job-notices.d.ts +143 -0
- package/dist/agent/job-notices.js +182 -0
- package/dist/agent/reducer.d.ts +21 -1
- package/dist/agent/reducer.js +7 -0
- package/dist/agent/system-prompt.d.ts +12 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +236 -8
- package/dist/commands/builtins.js +23 -8
- package/dist/commands/perf.d.ts +77 -1
- package/dist/commands/perf.js +37 -5
- package/dist/config/cli-commands.d.ts +27 -0
- package/dist/config/cli-commands.js +54 -1
- package/dist/config/env.js +38 -0
- package/dist/config/load.js +50 -1
- package/dist/config/schema.d.ts +73 -0
- package/dist/config/schema.js +34 -0
- package/dist/config/store.js +14 -1
- package/dist/exec/events.d.ts +29 -3
- package/dist/exec/events.js +3 -12
- package/dist/exec/runner.d.ts +24 -0
- package/dist/exec/runner.js +79 -0
- package/dist/input/cursor-report.d.ts +61 -0
- package/dist/input/cursor-report.js +97 -0
- package/dist/input/stdin-filter.d.ts +22 -0
- package/dist/input/stdin-filter.js +48 -3
- package/dist/proc/limits.d.ts +76 -2
- package/dist/proc/limits.js +83 -2
- package/dist/proc/log-mirror.d.ts +59 -0
- package/dist/proc/log-mirror.js +203 -0
- package/dist/proc/log-ring.d.ts +22 -0
- package/dist/proc/log-ring.js +20 -1
- package/dist/proc/notices.d.ts +57 -0
- package/dist/proc/notices.js +101 -0
- package/dist/proc/progress.d.ts +40 -0
- package/dist/proc/progress.js +135 -0
- package/dist/proc/prompt.d.ts +24 -2
- package/dist/proc/prompt.js +51 -2
- package/dist/proc/supervisor.d.ts +35 -2
- package/dist/proc/supervisor.js +142 -43
- package/dist/proc/types.d.ts +89 -1
- package/dist/proc/wait.d.ts +57 -0
- package/dist/proc/wait.js +133 -0
- package/dist/session/persist.js +3 -1
- package/dist/session/validate-session.js +17 -0
- package/dist/tools/bash-tool.d.ts +12 -1
- package/dist/tools/bash-tool.js +100 -13
- package/dist/tools/index.d.ts +26 -2
- package/dist/tools/index.js +43 -2
- package/dist/tools/job-tools.d.ts +54 -0
- package/dist/tools/job-tools.js +329 -0
- package/dist/tools/proc-tools.d.ts +1 -1
- package/dist/tools/proc-tools.js +49 -17
- package/dist/ui/ActivityLine.js +7 -0
- package/dist/ui/App.js +98 -12
- package/dist/ui/ScrollbackShell.d.ts +62 -11
- package/dist/ui/ScrollbackShell.js +11 -3
- package/dist/ui/StatusBar.d.ts +5 -0
- package/dist/ui/StatusBar.js +1 -0
- package/dist/ui/Transcript.d.ts +14 -0
- package/dist/ui/Transcript.js +14 -2
- package/dist/ui/entries/ServiceCard.d.ts +17 -1
- package/dist/ui/entries/ServiceCard.js +39 -4
- package/dist/ui/layout/status-layout.d.ts +13 -1
- package/dist/ui/layout/status-layout.js +10 -0
- package/dist/ui/layout/virtual-window.js +14 -1
- package/dist/ui/run-status-row.d.ts +12 -0
- package/dist/ui/screen.d.ts +17 -4
- package/dist/ui/screen.js +14 -6
- package/dist/ui/scrollback/LiveTail.d.ts +79 -0
- package/dist/ui/scrollback/LiveTail.js +32 -0
- package/dist/ui/scrollback/band.d.ts +114 -0
- package/dist/ui/scrollback/band.js +130 -0
- package/dist/ui/scrollback/budget.d.ts +51 -10
- package/dist/ui/scrollback/budget.js +69 -20
- package/dist/ui/scrollback/canvas.d.ts +99 -0
- package/dist/ui/scrollback/canvas.js +113 -0
- package/dist/ui/scrollback/cursor-probe.d.ts +71 -0
- package/dist/ui/scrollback/cursor-probe.js +133 -0
- package/dist/ui/scrollback/limits.d.ts +61 -4
- package/dist/ui/scrollback/limits.js +61 -4
- package/dist/ui/scrollback/live-preview.d.ts +18 -10
- package/dist/ui/scrollback/live-preview.js +14 -11
- package/dist/ui/scrollback/scrollback-writer.d.ts +104 -16
- package/dist/ui/scrollback/scrollback-writer.js +368 -82
- package/dist/ui/scrollback/tail-window.d.ts +60 -0
- package/dist/ui/scrollback/tail-window.js +73 -0
- package/dist/ui/scrollback/use-scrollback-view.d.ts +34 -15
- package/dist/ui/scrollback/use-scrollback-view.js +110 -59
- package/dist/ui/scrollback/writer-shapes.d.ts +68 -2
- package/dist/ui/scrollback/writer-shapes.js +53 -8
- package/package.json +3 -3
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounded wait primitive (long-task-jobs §4.4.1).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* A FREE FUNCTION OVER THREE PORT METHODS, NOT A METHOD ON THE PORT (D-9).
|
|
7
|
+
* `Pick<ProcSupervisorPort, 'get' | 'read' | 'subscribe'>` is enough to answer
|
|
8
|
+
* "has it finished yet, and what did it print since my cursor", so no test double
|
|
9
|
+
* has to grow a `wait()` and the registry keeps owning exactly what it owns.
|
|
10
|
+
*
|
|
11
|
+
* FOUR RULES, and each one closes a way this turns into a hang:
|
|
12
|
+
*
|
|
13
|
+
* 1. TERMINAL FIRST, SUBSCRIBE SECOND. A job that already ended resolves
|
|
14
|
+
* synchronously with `waitedMs: 0` and registers nothing - no listener, no
|
|
15
|
+
* timer. `runBackground` carries the same defence for the same reason: a
|
|
16
|
+
* 1 ms crash is not hypothetical.
|
|
17
|
+
* 2. EVERY TIMER IS `unref`ed AND EVERY LISTENER IS REMOVED at every exit. A
|
|
18
|
+
* wait that leaks a timer is a process that will not exit, which is the
|
|
19
|
+
* failure mode this feature exists to remove.
|
|
20
|
+
* 3. ONE IDEMPOTENT `finish` FUNNEL. Terminal event, timeout and abort race, and
|
|
21
|
+
* exactly one of them may win.
|
|
22
|
+
* 4. IT DOES NOT DEPEND ON A SINGLE WAKE SOURCE. `reapSync()` used to emit
|
|
23
|
+
* nothing at all (the supervisor now emits `stopped` from it), and
|
|
24
|
+
* `dispose()` clears its listener set without emitting; the self-timeout is
|
|
25
|
+
* therefore a real second path, not belt-and-braces.
|
|
26
|
+
*/
|
|
27
|
+
import { isTerminalStatus } from './types.js';
|
|
28
|
+
/** Read one bounded page per target; `read()` returns `undefined` for a vanished id. */
|
|
29
|
+
function readPages(port, targets, since) {
|
|
30
|
+
const pages = new Map();
|
|
31
|
+
for (const id of targets) {
|
|
32
|
+
const page = port.read(id, since);
|
|
33
|
+
if (page)
|
|
34
|
+
pages.set(id, page);
|
|
35
|
+
}
|
|
36
|
+
return pages;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Does every target exist and sit in a terminal state?
|
|
40
|
+
*
|
|
41
|
+
* AN UNKNOWN ID COUNTS AS SETTLED. The supervisor evicts terminal records, so a
|
|
42
|
+
* job whose outcome the caller is asking about may have been reclaimed; waiting
|
|
43
|
+
* for it would burn the whole timeout to learn nothing. The caller distinguishes
|
|
44
|
+
* the two cases from `jobs.length` and the supervisor's own ledger.
|
|
45
|
+
*/
|
|
46
|
+
function allSettled(port, targets) {
|
|
47
|
+
return targets.every((id) => {
|
|
48
|
+
const snapshot = port.get(id);
|
|
49
|
+
return snapshot === undefined || isTerminalStatus(snapshot.status);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Wait up to `timeoutMs` for every target to reach a terminal status.
|
|
54
|
+
*
|
|
55
|
+
* Resolves - never rejects - and always after at most `timeoutMs`. `now` is
|
|
56
|
+
* injectable so a test can assert `waitedMs` without depending on wall clock.
|
|
57
|
+
*/
|
|
58
|
+
export async function waitForJob(port, req, now = Date.now) {
|
|
59
|
+
const { targets, signal } = req;
|
|
60
|
+
const startedAt = now();
|
|
61
|
+
// RULE 1. Nothing to wait for at all, or everything already ended: no
|
|
62
|
+
// subscription, no timer, `waitedMs: 0`.
|
|
63
|
+
if (targets.length === 0 || allSettled(port, targets)) {
|
|
64
|
+
return {
|
|
65
|
+
jobs: collect(port, targets),
|
|
66
|
+
terminal: true,
|
|
67
|
+
waitedMs: 0,
|
|
68
|
+
timedOut: false,
|
|
69
|
+
aborted: false,
|
|
70
|
+
pages: readPages(port, targets, req.since),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
let done = false;
|
|
75
|
+
// DECLARED BEFORE `finish` SO THE FUNNEL CANNOT READ A HALF-INITIALISED BINDING.
|
|
76
|
+
// `port.subscribe()` is free to call its listener synchronously (a test double
|
|
77
|
+
// is the obvious case), and a `finish` that closed over a `const` assigned
|
|
78
|
+
// later in the same block would then throw a temporal-dead-zone ReferenceError
|
|
79
|
+
// from inside the listener instead of resolving the wait.
|
|
80
|
+
let unsubscribe = () => { };
|
|
81
|
+
let timer;
|
|
82
|
+
/** Assigned below; declared here so `finish` never reads an uninitialised binding. */
|
|
83
|
+
let onAbort = () => { };
|
|
84
|
+
// RULE 3: the one funnel. Idempotent, and it releases every reference before
|
|
85
|
+
// resolving so a finished wait cannot hold the event loop open.
|
|
86
|
+
const finish = (reason) => {
|
|
87
|
+
if (done)
|
|
88
|
+
return;
|
|
89
|
+
done = true;
|
|
90
|
+
unsubscribe();
|
|
91
|
+
if (timer !== undefined)
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
signal?.removeEventListener('abort', onAbort);
|
|
94
|
+
resolve({
|
|
95
|
+
jobs: collect(port, targets),
|
|
96
|
+
terminal: reason === 'terminal' || allSettled(port, targets),
|
|
97
|
+
waitedMs: now() - startedAt,
|
|
98
|
+
timedOut: reason === 'timeout',
|
|
99
|
+
aborted: reason === 'abort',
|
|
100
|
+
pages: readPages(port, targets, req.since),
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
unsubscribe = port.subscribe((event) => {
|
|
104
|
+
if (!isTerminalStatus(event.service.status))
|
|
105
|
+
return;
|
|
106
|
+
if (!targets.includes(event.service.id))
|
|
107
|
+
return;
|
|
108
|
+
if (allSettled(port, targets))
|
|
109
|
+
finish('terminal');
|
|
110
|
+
});
|
|
111
|
+
// RULE 2: `unref`, so a pending wait never keeps a quitting process alive.
|
|
112
|
+
timer = setTimeout(() => finish('timeout'), Math.max(0, req.timeoutMs));
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
onAbort = () => finish('abort');
|
|
115
|
+
// `{ once: true }` AND the explicit removal in `finish`: the option alone
|
|
116
|
+
// leaves the listener registered on a signal that outlives this wait.
|
|
117
|
+
if (signal?.aborted === true) {
|
|
118
|
+
finish('abort');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Snapshots for the targets that still exist, in the requested order. */
|
|
125
|
+
function collect(port, targets) {
|
|
126
|
+
const jobs = [];
|
|
127
|
+
for (const id of targets) {
|
|
128
|
+
const snapshot = port.get(id);
|
|
129
|
+
if (snapshot)
|
|
130
|
+
jobs.push(snapshot);
|
|
131
|
+
}
|
|
132
|
+
return jobs;
|
|
133
|
+
}
|
package/dist/session/persist.js
CHANGED
|
@@ -201,7 +201,9 @@ export function normalizeLoadedEntries(entries) {
|
|
|
201
201
|
// may also carry is already terminal and is left alone.
|
|
202
202
|
if (entry.kind === 'service' &&
|
|
203
203
|
(entry.status === 'starting' || entry.status === 'ready' || entry.status === 'running')) {
|
|
204
|
-
|
|
204
|
+
// `stalled: false` for the same reason: nothing is running, so a silence
|
|
205
|
+
// badge restored from disk would describe a process that no longer exists.
|
|
206
|
+
return { ...entry, status: 'stopped', rows: [], stalled: false };
|
|
205
207
|
}
|
|
206
208
|
// THE SEVENTH CLAUSE (tui-shift-enter-copy-queue 5.2.5). Core's steering
|
|
207
209
|
// queue is memory-only, so a `queued` entry on disk is a promise this
|
|
@@ -190,6 +190,23 @@ const ENTRY_FIELDS = {
|
|
|
190
190
|
number(value, path); },
|
|
191
191
|
startedAt: number, readyAt: optional(number), endedAt: optional(number), rows: array(string),
|
|
192
192
|
rowsSeen: number, terminal: optional(boolean), killIncomplete: optional(boolean),
|
|
193
|
+
// THE FOUR FIELDS long-task-jobs adds (AC-38), and the reason this validator
|
|
194
|
+
// has to learn them: it is STRICT — an unknown field fails the whole load — so
|
|
195
|
+
// a session saved with a task in it would refuse to resume, which is a P0 in
|
|
196
|
+
// R-8's terms.
|
|
197
|
+
//
|
|
198
|
+
// ALL FOUR ARE OPTIONAL HERE, DELIBERATELY, and that is a correction to the
|
|
199
|
+
// design. The reducer always writes `jobKind` and `lastOutputAt` for a NEW
|
|
200
|
+
// save, so they are required on `ServiceSnapshot` (that is what turns a
|
|
201
|
+
// forgotten field into a compile error). But a session file written by any
|
|
202
|
+
// build BEFORE this feature has neither, and a strict validator that demanded
|
|
203
|
+
// them would make every existing session unresumable — a far worse failure
|
|
204
|
+
// than a missing progress line. Recorded in the spec's implementation notes.
|
|
205
|
+
jobKind: optional(enumeration('service', 'task')),
|
|
206
|
+
progress: optional(string),
|
|
207
|
+
lastOutputAt: optional(number),
|
|
208
|
+
stalled: optional(boolean),
|
|
209
|
+
logPath: optional(string),
|
|
193
210
|
},
|
|
194
211
|
};
|
|
195
212
|
function validateEntries(value) {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* `bash` assertions meaningful rather than rewritten.
|
|
31
31
|
*/
|
|
32
32
|
import { type AgentTool } from '@aragon-agent/core';
|
|
33
|
-
import type
|
|
33
|
+
import { type ProcSupervisorPort } from '../proc/types.js';
|
|
34
34
|
import type { ToolDeps } from './fs-tools.js';
|
|
35
35
|
export interface BashToolOptions {
|
|
36
36
|
/**
|
|
@@ -57,5 +57,16 @@ export interface BashToolOptions {
|
|
|
57
57
|
* `/bg`, no event and nothing in the result saying so.
|
|
58
58
|
*/
|
|
59
59
|
interactive?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* `cfg.jobs.enabled`, read LIVE: whether `job_wait` / `job_list` exist in this
|
|
62
|
+
* session (long-task-jobs §4.3).
|
|
63
|
+
*
|
|
64
|
+
* TWO DECISIONS DEPEND ON IT AND NEITHER IS COSMETIC: the tool description only
|
|
65
|
+
* advertises tasks when the tools that manage them are registered, and a
|
|
66
|
+
* `[note]` telling the model to call `job_wait` is only printed when
|
|
67
|
+
* `job_wait` is callable. A workaround the model cannot execute reads as a
|
|
68
|
+
* broken tool.
|
|
69
|
+
*/
|
|
70
|
+
jobsAvailable?: () => boolean;
|
|
60
71
|
}
|
|
61
72
|
export declare function makeBash(deps: ToolDeps, options?: BashToolOptions): AgentTool;
|
package/dist/tools/bash-tool.js
CHANGED
|
@@ -35,6 +35,7 @@ import { defineTool, errorResult, textResult, } from '@aragon-agent/core';
|
|
|
35
35
|
import { looksLongRunning } from '../proc/classify.js';
|
|
36
36
|
import { killTree } from '../proc/kill-tree.js';
|
|
37
37
|
import { PROC_LIMITS } from '../proc/limits.js';
|
|
38
|
+
import { isTerminalStatus } from '../proc/types.js';
|
|
38
39
|
/**
|
|
39
40
|
* The two sentences appended to the tool description when background launches
|
|
40
41
|
* are available.
|
|
@@ -50,6 +51,17 @@ const BACKGROUND_DESCRIPTION = ' Set `background: true` for anything that does n
|
|
|
50
51
|
'`nohup` or `screen` to detach a command yourself; the runtime supervises ' +
|
|
51
52
|
'background commands, and a self-detached process cannot be reported on or ' +
|
|
52
53
|
'stopped.';
|
|
54
|
+
/**
|
|
55
|
+
* The task half, appended only when the job tools are registered.
|
|
56
|
+
*
|
|
57
|
+
* APPENDED CONDITIONALLY FOR THE SAME REASON AS THE BLOCK ABOVE: with
|
|
58
|
+
* `jobs.enabled: false` this sentence would describe a `task` id and a
|
|
59
|
+
* `job_wait` the model does not have, which is worse than saying nothing.
|
|
60
|
+
*/
|
|
61
|
+
const TASK_DESCRIPTION = ' For a command that DOES finish but takes far longer than one tool call - a ' +
|
|
62
|
+
'training run, a model download, a release build - `background: true` returns a ' +
|
|
63
|
+
'task id (`t1`, `t2`) instead of a service id, and you wait for it with ' +
|
|
64
|
+
'`job_wait` instead of polling `bash_output`.';
|
|
53
65
|
/**
|
|
54
66
|
* The settle window used when a supervisor is supplied WITHOUT one.
|
|
55
67
|
*
|
|
@@ -70,22 +82,63 @@ const FALLBACK_STARTUP_SETTLE_MS = 4000;
|
|
|
70
82
|
function trackForeground(supervisor, pid) {
|
|
71
83
|
return supervisor ? supervisor.trackForeground(pid) : () => { };
|
|
72
84
|
}
|
|
73
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* The sentence appended to a timed-out `bash` result when backgrounding is
|
|
87
|
+
* available.
|
|
88
|
+
*
|
|
89
|
+
* THE HIGHEST-VALUE LINE IN THIS FEATURE. Today a long job dies with
|
|
90
|
+
* `Command timed out after 120000ms` and nothing else: no pid, no handle, no way
|
|
91
|
+
* to continue - so the model reaches for a way to detach the process by hand,
|
|
92
|
+
* which is what produced the self-detached, unreportable children this runtime
|
|
93
|
+
* spent a whole feature removing. Naming the exact re-run command turns that into
|
|
94
|
+
* a plan the model can execute with tools it already has.
|
|
95
|
+
*/
|
|
96
|
+
function backgroundRetryNote(command, cwd) {
|
|
97
|
+
return [
|
|
98
|
+
'[note] this command does not finish within the tool ceiling. If it is a long',
|
|
99
|
+
'job, re-run it in the background:',
|
|
100
|
+
` bash({ command: ${JSON.stringify(command)}, background: true, cwd: ${JSON.stringify(cwd)} })`,
|
|
101
|
+
'then wait with job_wait({ job: "t1" }).',
|
|
102
|
+
].join('\n');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Render a background launch's result block for the model (§3.5, long-task-jobs
|
|
106
|
+
* §4.2.1).
|
|
107
|
+
*
|
|
108
|
+
* THE STATUS WORDS TELL THE TRUTH ABOUT THE KIND. A `task` is `running` from the
|
|
109
|
+
* spawn, so "still alive, nothing to connect to (a watcher, most likely)" would
|
|
110
|
+
* describe a training run as if it were a watcher; and a job that WE stopped is
|
|
111
|
+
* neither `starting` nor "still coming up". Both sentences cost the model a
|
|
112
|
+
* wrong decision rather than a word.
|
|
113
|
+
*/
|
|
74
114
|
function renderServiceResult(service, opts) {
|
|
115
|
+
const noun = service.kind === 'task' ? 'task' : 'service';
|
|
75
116
|
const lines = [`$ ${service.command}`];
|
|
76
|
-
lines.push(`[background]
|
|
117
|
+
lines.push(`[background] ${noun} ${service.id} started (pid ${service.pid ?? '?'})`);
|
|
77
118
|
const elapsed = ((service.readyAt ?? service.endedAt ?? Date.now()) - service.startedAt) / 1000;
|
|
119
|
+
let terminal = false;
|
|
78
120
|
if (service.status === 'ready') {
|
|
79
121
|
const how = service.detectedBy === 'probe' ? 'port probe' : 'output';
|
|
80
122
|
const where = service.url ?? (service.port ? `port ${service.port}` : '');
|
|
81
123
|
lines.push(`[ready] ${where} (detected from ${how} after ${elapsed.toFixed(1)}s)`);
|
|
82
124
|
}
|
|
83
125
|
else if (service.status === 'running') {
|
|
84
|
-
lines.push(
|
|
126
|
+
lines.push(service.kind === 'task'
|
|
127
|
+
? `[running] still running after ${elapsed.toFixed(1)}s - it has not finished yet`
|
|
128
|
+
: '[running] still alive, nothing to connect to (a watcher, most likely)');
|
|
85
129
|
}
|
|
86
130
|
else if (service.status === 'exited' || service.status === 'failed') {
|
|
87
131
|
const code = service.exitCode === null ? `signal ${service.signal}` : `code ${service.exitCode}`;
|
|
88
132
|
lines.push(`[exited] ${code} after ${elapsed.toFixed(1)}s`);
|
|
133
|
+
terminal = true;
|
|
134
|
+
}
|
|
135
|
+
else if (service.status === 'stopped') {
|
|
136
|
+
// BEFORE long-task-jobs this fell into the `else` below and told the model
|
|
137
|
+
// "[starting] not ready yet - still coming up" about a job that had already
|
|
138
|
+
// been killed or reaped. That is a false statement in the one field the model
|
|
139
|
+
// reads first.
|
|
140
|
+
lines.push(`[stopped] before it finished, after ${elapsed.toFixed(1)}s`);
|
|
141
|
+
terminal = true;
|
|
89
142
|
}
|
|
90
143
|
else {
|
|
91
144
|
lines.push(`[starting] not ready yet after ${elapsed.toFixed(1)}s - still coming up`);
|
|
@@ -93,6 +146,8 @@ function renderServiceResult(service, opts) {
|
|
|
93
146
|
if (service.portPreoccupied && service.port !== undefined) {
|
|
94
147
|
lines.push(`[note] port ${service.port} was already in use before start`);
|
|
95
148
|
}
|
|
149
|
+
if (service.progress)
|
|
150
|
+
lines.push(`[progress] ${service.progress.text}`);
|
|
96
151
|
if (service.rows.length > 0) {
|
|
97
152
|
lines.push('[log tail]');
|
|
98
153
|
for (const row of service.rows)
|
|
@@ -101,7 +156,13 @@ function renderServiceResult(service, opts) {
|
|
|
101
156
|
if (opts.ephemeral) {
|
|
102
157
|
// D-15 / P1-8: headless has no user who could keep it alive, and saying so
|
|
103
158
|
// is the difference between a surprise and a documented behaviour.
|
|
104
|
-
lines.push(
|
|
159
|
+
lines.push(`[note] this ${noun} will be stopped when the run ends.`);
|
|
160
|
+
}
|
|
161
|
+
// A FINISHED JOB EARNS NO ADVICE TO WAIT FOR IT (AC-5). Suggesting `job_wait`
|
|
162
|
+
// for something that already exited is how a model learns to poll.
|
|
163
|
+
if (!terminal && opts.jobsAvailable && service.kind === 'task') {
|
|
164
|
+
lines.push(`[note] wait for it with job_wait({ job: "${service.id}" }) - a bounded wait ` +
|
|
165
|
+
'returns its status and only the output since your cursor.');
|
|
105
166
|
}
|
|
106
167
|
lines.push(`Read more with bash_output({ service: "${service.id}" }); ` +
|
|
107
168
|
`stop it with bash_kill({ service: "${service.id}" }).`);
|
|
@@ -118,6 +179,7 @@ function renderServiceResult(service, opts) {
|
|
|
118
179
|
export function makeBash(deps, options = {}) {
|
|
119
180
|
const supervisor = options.supervisor;
|
|
120
181
|
const backgroundAvailable = supervisor !== undefined;
|
|
182
|
+
const taskAvailable = backgroundAvailable && options.jobsAvailable?.() === true;
|
|
121
183
|
return defineTool({
|
|
122
184
|
name: 'bash',
|
|
123
185
|
label: 'Shell',
|
|
@@ -125,7 +187,8 @@ export function makeBash(deps, options = {}) {
|
|
|
125
187
|
'stdout+stderr and exit code. `timeout` (ms) may only shrink within the ' +
|
|
126
188
|
'agent tool-timeout ceiling; `cwd` defaults to the working directory. ' +
|
|
127
189
|
'Emit shell syntax compatible with the OS/shell stated in the system prompt.' +
|
|
128
|
-
(backgroundAvailable ? BACKGROUND_DESCRIPTION : '')
|
|
190
|
+
(backgroundAvailable ? BACKGROUND_DESCRIPTION : '') +
|
|
191
|
+
(taskAvailable ? TASK_DESCRIPTION : ''),
|
|
129
192
|
parameters: {
|
|
130
193
|
type: 'object',
|
|
131
194
|
properties: {
|
|
@@ -160,16 +223,23 @@ export function makeBash(deps, options = {}) {
|
|
|
160
223
|
// `false` -> foreground, always. The escape hatch; nothing overrides it.
|
|
161
224
|
// absent -> the classifier, and only where a card can be rendered.
|
|
162
225
|
if (supervisor && params.background !== false) {
|
|
226
|
+
const longRunning = looksLongRunning(command);
|
|
163
227
|
const auto = options.interactive !== false &&
|
|
164
228
|
options.autoBackground?.() !== false &&
|
|
165
|
-
|
|
229
|
+
longRunning;
|
|
166
230
|
if (params.background === true || auto) {
|
|
231
|
+
// THE KIND IS DECIDED HERE, ONCE (long-task-jobs §4.3). An explicit
|
|
232
|
+
// `background: true` on a command the classifier does not recognise is
|
|
233
|
+
// a TASK; the automatic path can only ever produce a service, because
|
|
234
|
+
// the classifier firing is what set it in motion.
|
|
167
235
|
return runBackground(supervisor, {
|
|
168
236
|
command,
|
|
169
237
|
cwd,
|
|
170
238
|
toolCallId: id,
|
|
239
|
+
kind: longRunning ? 'service' : 'task',
|
|
171
240
|
settleMs: options.startupSettleMs?.() ?? FALLBACK_STARTUP_SETTLE_MS,
|
|
172
241
|
ephemeral: options.interactive === false,
|
|
242
|
+
jobsAvailable: taskAvailable,
|
|
173
243
|
});
|
|
174
244
|
}
|
|
175
245
|
}
|
|
@@ -225,8 +295,13 @@ export function makeBash(deps, options = {}) {
|
|
|
225
295
|
const buildBody = (opts) => {
|
|
226
296
|
const body = captured();
|
|
227
297
|
if (killReason === 'timeout') {
|
|
228
|
-
// BYTE-IDENTICAL TO THE PRE-CHANGE BUILD
|
|
229
|
-
|
|
298
|
+
// BYTE-IDENTICAL TO THE PRE-CHANGE BUILD when background launches are
|
|
299
|
+
// unavailable; with them available the note below is the single
|
|
300
|
+
// highest-value sentence in this feature, because it is what turns
|
|
301
|
+
// "the tool ceiling killed my training run" into "re-run it in the
|
|
302
|
+
// background, then wait for it".
|
|
303
|
+
const timedOut = `Command timed out after ${params.timeout}ms.\n${body}`;
|
|
304
|
+
return errorResult(backgroundAvailable ? `${timedOut}\n${backgroundRetryNote(command, cwd)}` : timedOut);
|
|
230
305
|
}
|
|
231
306
|
if (killReason === 'abort') {
|
|
232
307
|
// P1-5. The captured output is the evidence the model needs, and
|
|
@@ -383,9 +458,10 @@ async function runBackground(supervisor, args) {
|
|
|
383
458
|
command: args.command,
|
|
384
459
|
cwd: args.cwd,
|
|
385
460
|
toolCallId: args.toolCallId,
|
|
461
|
+
kind: args.kind,
|
|
386
462
|
});
|
|
387
463
|
if (!started.ok || !started.service) {
|
|
388
|
-
return errorResult(started.error ?? 'Could not start the background
|
|
464
|
+
return errorResult(started.error ?? 'Could not start the background job.');
|
|
389
465
|
}
|
|
390
466
|
const id = started.service.id;
|
|
391
467
|
const settled = await new Promise((resolve) => {
|
|
@@ -407,11 +483,22 @@ async function runBackground(supervisor, args) {
|
|
|
407
483
|
});
|
|
408
484
|
const timer = setTimeout(() => finish(supervisor.get(id) ?? started.service), args.settleMs);
|
|
409
485
|
timer.unref?.();
|
|
410
|
-
// A
|
|
411
|
-
//
|
|
486
|
+
// A job that finished between `start()` and this subscription (a crash inside
|
|
487
|
+
// 1 ms is not hypothetical) would otherwise wait out the whole window.
|
|
488
|
+
//
|
|
489
|
+
// THE PREDICATE IS TERMINAL, NOT `!== 'starting'` (long-task-jobs RV-P0-1 /
|
|
490
|
+
// I-12). For a service the two happened to agree, because `starting` was its
|
|
491
|
+
// only non-terminal state on this path. A `task` is `running` from the spawn,
|
|
492
|
+
// so `!== 'starting'` was true on the FIRST tick: `bash` returned `[running]`
|
|
493
|
+
// in ~0 ms, the settle window never applied, and a training script that
|
|
494
|
+
// crashed in 300 ms was reported as still running with no output and no exit
|
|
495
|
+
// code - the exact defect this feature exists to remove.
|
|
412
496
|
const now = supervisor.get(id);
|
|
413
|
-
if (now && now.status
|
|
497
|
+
if (now && isTerminalStatus(now.status))
|
|
414
498
|
finish(now);
|
|
415
499
|
});
|
|
416
|
-
return renderServiceResult(settled, {
|
|
500
|
+
return renderServiceResult(settled, {
|
|
501
|
+
ephemeral: args.ephemeral,
|
|
502
|
+
jobsAvailable: args.jobsAvailable,
|
|
503
|
+
});
|
|
417
504
|
}
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { AgentMode } from '../agent/agent-mode.js';
|
|
|
13
13
|
import type { ToolPermission } from '../exec/permission.js';
|
|
14
14
|
import type { FilePatch } from './patch.js';
|
|
15
15
|
import type { ProcSupervisorPort } from '../proc/types.js';
|
|
16
|
+
import type { JobNotice } from '../proc/notices.js';
|
|
16
17
|
export interface ConfirmRequest {
|
|
17
18
|
tool: string;
|
|
18
19
|
summary: string;
|
|
@@ -179,6 +180,29 @@ export interface BuiltinToolsOptions {
|
|
|
179
180
|
* exactly as `--no-skills` and `--no-team` are.
|
|
180
181
|
*/
|
|
181
182
|
procTools?: AgentTool[];
|
|
183
|
+
/**
|
|
184
|
+
* `job_wait` / `job_list`, appended verbatim (long-task-jobs §4.3 / D-10).
|
|
185
|
+
*
|
|
186
|
+
* THROUGH THE FACTORY FOR THE REASON `teamTools`, `todoTools` AND `procTools`
|
|
187
|
+
* EACH RECORD IN CAPITALS: `tools.test.ts::C7` asserts `HOST_TOOL_NAMES` equals
|
|
188
|
+
* what this factory PRODUCES, so appending them in `AgentController` after the
|
|
189
|
+
* factory returned would turn that test red with a message about two lists of
|
|
190
|
+
* names. An empty array (`jobs.enabled: false`) leaves the array byte-identical
|
|
191
|
+
* to the pre-feature build.
|
|
192
|
+
*/
|
|
193
|
+
jobTools?: AgentTool[];
|
|
194
|
+
/**
|
|
195
|
+
* The completion-notice queue's read side, for the PULL channel (§4.6).
|
|
196
|
+
*
|
|
197
|
+
* A PORT, NOT THE CLASS: this file may not import `proc/notices.ts` at runtime
|
|
198
|
+
* (the same rule `procs` follows), and all the wrapper needs is "take
|
|
199
|
+
* everything and give it to me". Omitting it leaves every tool object
|
|
200
|
+
* untouched, provable by identity, which is how `-p`, the TUI and every
|
|
201
|
+
* subagent are shown to be unchanged.
|
|
202
|
+
*/
|
|
203
|
+
jobNotices?: {
|
|
204
|
+
drain(): JobNotice[];
|
|
205
|
+
};
|
|
182
206
|
/**
|
|
183
207
|
* `cfg.bash.autoBackground`, READ AT CALL TIME.
|
|
184
208
|
*
|
|
@@ -252,7 +276,7 @@ export interface BuiltinToolsOptions {
|
|
|
252
276
|
* is the whole of what this mechanism is worth, and saying so plainly beats
|
|
253
277
|
* implying it covers more.
|
|
254
278
|
*/
|
|
255
|
-
export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan", "task", "todo_write", "bash_output"];
|
|
279
|
+
export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan", "task", "todo_write", "bash_output", "job_wait", "job_list"];
|
|
256
280
|
/**
|
|
257
281
|
* The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
|
|
258
282
|
* within `HOST_TOOL_NAMES` — "the five that change something", the set this file
|
|
@@ -285,5 +309,5 @@ export declare const PLAN_MODE_BLOCKED_TOOLS: ReadonlySet<string>;
|
|
|
285
309
|
* declaration MEANS, and that reading must not change with how the CLI was
|
|
286
310
|
* started.
|
|
287
311
|
*/
|
|
288
|
-
export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan", "todo_write", "bash_output", "bash_kill", "task"];
|
|
312
|
+
export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan", "todo_write", "bash_output", "bash_kill", "job_wait", "job_list", "task"];
|
|
289
313
|
export declare function createBuiltinTools(options: BuiltinToolsOptions): AgentTool[];
|
package/dist/tools/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { planRefusal, submitPlanRefusal } from '../agent/agent-mode.js';
|
|
|
13
13
|
import { makeReadFile, makeWriteFile, makeEditFile, makeListDir } from './fs-tools.js';
|
|
14
14
|
import { makeGlob, makeGrep } from './search-tools.js';
|
|
15
15
|
import { makeBash } from './bash-tool.js';
|
|
16
|
+
import { withJobNoticeTail } from './job-tools.js';
|
|
16
17
|
const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'bash']);
|
|
17
18
|
/**
|
|
18
19
|
* Tools the ceiling always permits (D-G6). The ONLY place that knows which of
|
|
@@ -65,6 +66,14 @@ export const SKILL_TOOL_FLOOR = [
|
|
|
65
66
|
// membership test. Blocking it in plan mode would also let the agent start a
|
|
66
67
|
// service it then could not read — the dead end the floor exists to prevent.
|
|
67
68
|
'bash_output',
|
|
69
|
+
// `job_wait` / `job_list` join the FLOOR for the same reason, one feature
|
|
70
|
+
// later (long-task-jobs D-12). Waiting is READING: neither can start, stop or
|
|
71
|
+
// mutate anything, and plan mode's whole purpose - read the code, ask, then
|
|
72
|
+
// propose - is served by being able to wait for a job that was already
|
|
73
|
+
// running. Blocking them would also make a running job unreachable from the
|
|
74
|
+
// mode the user is actually in.
|
|
75
|
+
'job_wait',
|
|
76
|
+
'job_list',
|
|
68
77
|
];
|
|
69
78
|
/**
|
|
70
79
|
* The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
|
|
@@ -131,6 +140,12 @@ export const HOST_TOOL_NAMES = [
|
|
|
131
140
|
// `skills doctor` already has with `--no-skills`.
|
|
132
141
|
'bash_output',
|
|
133
142
|
'bash_kill',
|
|
143
|
+
// The two job names join the SUPERSET for exactly the reason the two above
|
|
144
|
+
// did: a skill's `allowed-tools: [job_wait]` must be answerable by `doctor`
|
|
145
|
+
// even in a session where `jobs.enabled` is false and neither is registered
|
|
146
|
+
// (long-task-jobs §4.3).
|
|
147
|
+
'job_wait',
|
|
148
|
+
'job_list',
|
|
134
149
|
// `team_send` / `team_wait` are DELIBERATELY ABSENT (P2-5). This constant
|
|
135
150
|
// answers "can a skill's `allowed-tools` declaration ever take effect?", and
|
|
136
151
|
// those two are never registered on a lead — so a skill naming them genuinely
|
|
@@ -261,6 +276,10 @@ export function createBuiltinTools(options) {
|
|
|
261
276
|
...(options.autoBackground ? { autoBackground: options.autoBackground } : {}),
|
|
262
277
|
...(options.startupSettleMs ? { startupSettleMs: options.startupSettleMs } : {}),
|
|
263
278
|
...(options.interactive !== undefined ? { interactive: options.interactive } : {}),
|
|
279
|
+
// `jobs.enabled`, spread conditionally: with the job tools off the
|
|
280
|
+
// description and the `[note]` are the ones the pre-feature build
|
|
281
|
+
// produced, byte for byte (I-3).
|
|
282
|
+
...(options.jobTools && options.jobTools.length > 0 ? { jobsAvailable: () => true } : {}),
|
|
264
283
|
}
|
|
265
284
|
: {}),
|
|
266
285
|
];
|
|
@@ -284,6 +303,12 @@ export function createBuiltinTools(options) {
|
|
|
284
303
|
if (options.procTools && options.procTools.length > 0) {
|
|
285
304
|
tools = [...tools, ...options.procTools];
|
|
286
305
|
}
|
|
306
|
+
// AFTER `procTools` (long-task-jobs §5.2): the job tools are the third group of
|
|
307
|
+
// server-side handles and belong with the two they extend. A lead's `task` is
|
|
308
|
+
// still last.
|
|
309
|
+
if (options.jobTools && options.jobTools.length > 0) {
|
|
310
|
+
tools = [...tools, ...options.jobTools];
|
|
311
|
+
}
|
|
287
312
|
// BEFORE both wrappers, so the lead's `task` is covered by the plan gate and
|
|
288
313
|
// the ceiling like everything else, and a child's comm tools are covered by
|
|
289
314
|
// everything except the ceiling (see `policyExempt`).
|
|
@@ -345,8 +370,24 @@ export function createBuiltinTools(options) {
|
|
|
345
370
|
//
|
|
346
371
|
// The plan gate is applied LAST, which makes it OUTERMOST at call time: it
|
|
347
372
|
// needs no I/O and no state, so it must not sit behind a human wait.
|
|
373
|
+
//
|
|
374
|
+
// THE NOTICE TAIL IS THE ONE EXCEPTION TO "both tail returns are identical",
|
|
375
|
+
// and it is deliberately the OUTERMOST wrapper of all (long-task-jobs §4.6):
|
|
376
|
+
// whatever a tool did - succeeded, errored, or was refused by the plan gate -
|
|
377
|
+
// its result is a delivery opportunity for a `<job_notices>` block, so the
|
|
378
|
+
// wrapper has to run after every other decision has been made.
|
|
379
|
+
//
|
|
380
|
+
// `withJobNotices` RETURNS ITS ARGUMENT when no queue was supplied, so the
|
|
381
|
+
// identity claim the `AC-G17` tests make about `--no-skills` and about every
|
|
382
|
+
// headless and subagent toolset survives verbatim.
|
|
383
|
+
const withJobNotices = (list) => {
|
|
384
|
+
const queue = options.jobNotices;
|
|
385
|
+
if (!queue)
|
|
386
|
+
return list;
|
|
387
|
+
return list.map((tool) => withJobNoticeTail(tool, () => queue.drain()));
|
|
388
|
+
};
|
|
348
389
|
if (!options.agentMode)
|
|
349
|
-
return tools;
|
|
390
|
+
return withJobNotices(tools);
|
|
350
391
|
const getMode = options.agentMode;
|
|
351
|
-
return tools.map((t) => withPlanModeGate(t, getMode));
|
|
392
|
+
return withJobNotices(tools.map((t) => withPlanModeGate(t, getMode)));
|
|
352
393
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `job_wait` and `job_list`, plus the notice tail every tool result carries
|
|
3
|
+
* (long-task-jobs §4.4.2 / §4.4.3 / §4.6).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/tools/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* WHY TWO NEW TOOLS RATHER THAN A `block` PARAMETER ON `bash_output` (D-10):
|
|
8
|
+
* the tool list IS the discoverable capability surface. A model that can SEE
|
|
9
|
+
* `job_wait` knows that waiting is a legal step in a plan; a hidden `block: 30`
|
|
10
|
+
* parameter is a feature it will never use, and extending `bash_output`'s output
|
|
11
|
+
* would also rewrite assertions that pin its current grammar word for word.
|
|
12
|
+
*
|
|
13
|
+
* EVERY RESULT IS A FIXED GRAMMAR OF AT MOST TWELVE LINES. That is not tidiness:
|
|
14
|
+
* the entire economic argument for this feature is that one wait costs ~150
|
|
15
|
+
* tokens instead of a 200-row tail, so an unbounded result would delete the
|
|
16
|
+
* reason the feature exists.
|
|
17
|
+
*/
|
|
18
|
+
import { type AgentTool } from '@aragon-agent/core';
|
|
19
|
+
import { type JobNotice } from '../proc/notices.js';
|
|
20
|
+
import type { JobKind, ProcSupervisorPort, ServiceStatus } from '../proc/types.js';
|
|
21
|
+
export interface JobToolDeps {
|
|
22
|
+
supervisor: ProcSupervisorPort;
|
|
23
|
+
/** `cfg.jobs.waitSeconds`, read LIVE (same rule as `startupSettleMs`). */
|
|
24
|
+
waitSeconds: () => number;
|
|
25
|
+
/** `cfg.jobs.stallSeconds`, read LIVE; only the `[stalled]` sentence uses it. */
|
|
26
|
+
stallSeconds: () => number;
|
|
27
|
+
/** `cfg.toolTimeoutMs`, read LIVE: I-4's second convergence. */
|
|
28
|
+
toolTimeoutMs: () => number;
|
|
29
|
+
/** `PROC_LIMITS.maxConsecutiveWaits`. */
|
|
30
|
+
maxConsecutiveWaits: number;
|
|
31
|
+
/**
|
|
32
|
+
* The terminal record for an id this session has already reported and then
|
|
33
|
+
* lost to `evictTerminal()` (RV-P2-7).
|
|
34
|
+
*
|
|
35
|
+
* WITHOUT IT "reclaimed" and "never existed" are the same message, and a model
|
|
36
|
+
* told `No job "t9"` about a 40-minute training run that finished successfully
|
|
37
|
+
* will start it again.
|
|
38
|
+
*/
|
|
39
|
+
terminalRecord?: (id: string) => {
|
|
40
|
+
kind: JobKind;
|
|
41
|
+
status: ServiceStatus;
|
|
42
|
+
} | undefined;
|
|
43
|
+
}
|
|
44
|
+
export declare function makeJobWait(deps: JobToolDeps): AgentTool;
|
|
45
|
+
export declare function makeJobList(deps: JobToolDeps): AgentTool;
|
|
46
|
+
/**
|
|
47
|
+
* Append pending `<job_notices>` to every tool result (the PULL channel).
|
|
48
|
+
*
|
|
49
|
+
* SAME SHAPE AS `withMailboxTail`, AND FOR THE SAME REASON: the engine is told
|
|
50
|
+
* nothing, the wrapper rides `{...tool, execute}`, and the notice is delivered on
|
|
51
|
+
* the next tool call whatever that call is. The queue is drained EVEN WHEN the
|
|
52
|
+
* wrapped tool errored - a refused `bash` is still a delivery opportunity.
|
|
53
|
+
*/
|
|
54
|
+
export declare function withJobNoticeTail(tool: AgentTool, drain: () => JobNotice[]): AgentTool;
|