ai-runtime-engine 2.7.0 → 2.9.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/CHANGELOG.md +108 -0
- package/dist/agents/roles.d.ts +36 -0
- package/dist/agents/roles.js +44 -0
- package/dist/agents/synthesize.d.ts +44 -0
- package/dist/agents/synthesize.js +60 -0
- package/dist/agents/task.d.ts +95 -6
- package/dist/agents/task.js +40 -2
- package/dist/agents/worker.d.ts +32 -1
- package/dist/agents/worker.js +150 -19
- package/dist/cli/cli.js +1 -1
- package/dist/cli/interactive/lanes.d.ts +69 -0
- package/dist/cli/interactive/lanes.js +181 -0
- package/dist/cli/interactive/repl.js +52 -0
- package/dist/cli/interactive/session.d.ts +8 -1
- package/dist/cli/interactive/session.js +62 -10
- package/dist/executions/agentTasks.d.ts +628 -0
- package/dist/executions/agentTasks.js +149 -0
- package/dist/executions/checkpoint.d.ts +5 -1
- package/dist/executions/checkpoint.js +13 -1
- package/dist/executions/execution.d.ts +23 -0
- package/dist/executions/store.d.ts +37 -0
- package/dist/executions/store.js +33 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +7 -0
- package/dist/orchestration/executor.d.ts +26 -1
- package/dist/orchestration/executor.js +50 -9
- package/dist/orchestration/orchestrator.d.ts +6 -0
- package/dist/orchestration/orchestrator.js +18 -1
- package/dist/runtime/events.d.ts +44 -0
- package/dist/runtime/events.js +4 -0
- package/dist/runtime/runtime.d.ts +90 -0
- package/dist/runtime/runtime.js +510 -20
- package/dist/security/redact.js +22 -10
- package/dist/util/hash.d.ts +19 -0
- package/dist/util/hash.js +39 -0
- package/package.json +1 -1
package/dist/agents/worker.js
CHANGED
|
@@ -21,8 +21,24 @@ import { flattenClamp } from '../util/flatten.js';
|
|
|
21
21
|
import { admitFindings, contractFailed } from './admit.js';
|
|
22
22
|
import { executionCoverage } from './finding.js';
|
|
23
23
|
import { AGENT_TASK_PROJECTION, nextAgentTaskId } from './task.js';
|
|
24
|
+
import { hashOf } from '../util/hash.js';
|
|
25
|
+
import { validatePlan } from '../orchestration/plan.js';
|
|
24
26
|
/** How much of a step input may reach the inner prompt. It is model-authored, and it is fenced. */
|
|
25
27
|
export const AGENT_INPUT_MAX = 1000;
|
|
28
|
+
/** Inner observations persisted per task. The record lives in a JSON file that is rewritten every
|
|
29
|
+
* commit, so this is a durability bound, not a display one. Beyond it, the count is kept and the
|
|
30
|
+
* content dropped — an honest "there was more" rather than a silently short list. */
|
|
31
|
+
export const INNER_OBS_MAX = 20;
|
|
32
|
+
/** Admission rejections kept per task. They accumulate across attempts and are rewritten every commit. */
|
|
33
|
+
export const DIAGNOSTICS_KEPT = 50;
|
|
34
|
+
/**
|
|
35
|
+
* What makes a step THIS step. Plan step ids (`s1`, `auto1`) are model-authored and recur across
|
|
36
|
+
* replans, so binding a persisted record by id alone would hand one step's completed inner work to a
|
|
37
|
+
* different step that happens to share its id — same agent, different input, silently wrong findings.
|
|
38
|
+
*/
|
|
39
|
+
export function stepIdentity(step) {
|
|
40
|
+
return hashOf({ agent: step.agent, description: step.description, input: step.input ?? null });
|
|
41
|
+
}
|
|
26
42
|
/** Raised by the metered facade when an agent tries to exceed its reservation. */
|
|
27
43
|
class AgentCallBudgetError extends Error {
|
|
28
44
|
constructor() {
|
|
@@ -32,19 +48,49 @@ class AgentCallBudgetError extends Error {
|
|
|
32
48
|
}
|
|
33
49
|
export async function runAgentTask(step, envelope, definition, deps) {
|
|
34
50
|
const now = deps.clock.now();
|
|
35
|
-
|
|
51
|
+
// Computed from what is TRUE NOW, so a persisted record can be compared against the run it is about
|
|
52
|
+
// to be reused in. Reading them off the resumed record instead would compare a value with itself.
|
|
53
|
+
const defHash = hashOf(definition);
|
|
54
|
+
const envHash = hashOf(envelope);
|
|
55
|
+
const record = deps.resume ?? {
|
|
56
|
+
v: 1,
|
|
36
57
|
agentTaskId: nextAgentTaskId(now),
|
|
37
58
|
agentId: envelope.agentId,
|
|
38
59
|
stepId: step.id,
|
|
39
60
|
state: 'created',
|
|
40
61
|
createdAt: now,
|
|
62
|
+
updatedAt: now,
|
|
63
|
+
provenance: deps.provenance,
|
|
64
|
+
// The three identity hashes. They are computed HERE, at the only place a task is minted, so a
|
|
65
|
+
// resumed record can be compared against the run it is about to be reused in (Phase 3.5).
|
|
66
|
+
agentDefHash: defHash,
|
|
67
|
+
envelopeHash: envHash,
|
|
68
|
+
stepInputHash: stepIdentity(step),
|
|
69
|
+
attempt: 1,
|
|
70
|
+
innerCompletedSteps: [],
|
|
71
|
+
innerObservations: [],
|
|
72
|
+
innerObservationsOmitted: 0,
|
|
41
73
|
innerSteps: { total: 0, succeeded: 0 },
|
|
74
|
+
callsReserved: envelope.reservation,
|
|
42
75
|
callsUsed: 0,
|
|
76
|
+
callsRefunded: 0,
|
|
43
77
|
toolCallsUsed: 0,
|
|
44
78
|
findings: [],
|
|
45
79
|
diagnostics: [],
|
|
46
80
|
};
|
|
81
|
+
if (deps.resume) {
|
|
82
|
+
// A resumed record starts a NEW attempt: findings it mints are namespaced by the attempt number, so
|
|
83
|
+
// they can never collide with findings already on the record from the attempt that was interrupted.
|
|
84
|
+
record.attempt += 1;
|
|
85
|
+
// The stamp must describe the LATEST interruption, so it is cleared on promotion, not left to age.
|
|
86
|
+
delete record.interruption;
|
|
87
|
+
// The question has been answered (or the task is being retried); either way it is no longer pending,
|
|
88
|
+
// and leaving it set would re-elect this task as the run's wait on the very next commit.
|
|
89
|
+
delete record.pendingInner;
|
|
90
|
+
}
|
|
47
91
|
record.state = 'queued';
|
|
92
|
+
record.updatedAt = deps.clock.now();
|
|
93
|
+
deps.onRecord?.(record);
|
|
48
94
|
// (2) A child controller, so a parent abort reaches the inner run and nothing else does.
|
|
49
95
|
const child = new AbortController();
|
|
50
96
|
const onParentAbort = () => child.abort();
|
|
@@ -56,10 +102,15 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
56
102
|
const expired = () => deps.clock.now() > deadline;
|
|
57
103
|
// (3) THE METERED FACADE. Prototype-delegating, so it IS an AI (assignable to SkillContext.ai) while
|
|
58
104
|
// owning `run`. The count increments BEFORE the await, so a failed inner call is still charged.
|
|
105
|
+
// Prior spend carries across a resume: the reservation is the ceiling for the TASK, not per attempt,
|
|
106
|
+
// so a task that already burned 2 of 3 calls before a crash gets 1 more, not 3.
|
|
107
|
+
const priorCalls = deps.resume?.callsUsed ?? 0;
|
|
108
|
+
const priorToolCalls = deps.resume?.toolCallsUsed ?? 0;
|
|
59
109
|
let calls = 0;
|
|
110
|
+
const spent = () => priorCalls + calls;
|
|
60
111
|
const metered = Object.create(deps.ai);
|
|
61
112
|
metered.run = async (req) => {
|
|
62
|
-
if (
|
|
113
|
+
if (spent() >= envelope.reservation)
|
|
63
114
|
throw new AgentCallBudgetError();
|
|
64
115
|
calls += 1;
|
|
65
116
|
return deps.ai.run(req);
|
|
@@ -69,10 +120,15 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
69
120
|
const skillResults = [];
|
|
70
121
|
const finish = (state, failure) => {
|
|
71
122
|
deps.parentSignal?.removeEventListener('abort', onParentAbort);
|
|
123
|
+
deps.releaseAbort?.(record.agentTaskId);
|
|
72
124
|
record.state = state;
|
|
73
125
|
record.endedAt = deps.clock.now();
|
|
74
|
-
record.
|
|
75
|
-
record.
|
|
126
|
+
record.updatedAt = record.endedAt;
|
|
127
|
+
record.callsUsed = priorCalls + calls;
|
|
128
|
+
record.toolCallsUsed = priorToolCalls + toolCalls;
|
|
129
|
+
// Derived, for reporting only. The real refund is that the executor charges what was SPENT, so the
|
|
130
|
+
// unspent part of the reservation is simply never charged — there is no ledger to get wrong.
|
|
131
|
+
record.callsRefunded = Math.max(0, record.callsReserved - record.callsUsed);
|
|
76
132
|
if (failure)
|
|
77
133
|
record.failure = failure;
|
|
78
134
|
const row = AGENT_TASK_PROJECTION[state];
|
|
@@ -88,6 +144,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
88
144
|
...(state === 'completed' ? { output: `${envelope.agentId}: ${record.innerSteps.succeeded}/${record.innerSteps.total} inner step(s), ${record.findings.length} finding(s)` } : {}),
|
|
89
145
|
};
|
|
90
146
|
deps.emit?.({ type: 'agent.task.completed', record });
|
|
147
|
+
deps.onRecord?.(record);
|
|
91
148
|
return { observation, record };
|
|
92
149
|
};
|
|
93
150
|
// (12) A parent abort is a cancel or a pause; both project to the same observation, and the record
|
|
@@ -108,13 +165,16 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
108
165
|
return preempted;
|
|
109
166
|
record.state = 'running';
|
|
110
167
|
record.startedAt = deps.clock.now();
|
|
168
|
+
record.updatedAt = record.startedAt;
|
|
169
|
+
deps.registerAbort?.(record.agentTaskId, () => child.abort());
|
|
111
170
|
deps.emit?.({ type: 'agent.task.started', record });
|
|
171
|
+
deps.onRecord?.(record);
|
|
112
172
|
// (6) THE TOOL SEAM - defense in depth behind narrowEnvelope. Always a structured denial, never a
|
|
113
173
|
// throw: the tool contract is that a refusal is visible.
|
|
114
174
|
const innerCallTool = async (id, input) => {
|
|
115
175
|
if (!envelope.tools.includes(id))
|
|
116
176
|
return { ok: false, error: { code: 'PERMISSION', message: `'${flattenClamp(id, 40)}' is not in this agent's catalog` } };
|
|
117
|
-
if (toolCalls + 1 > envelope.maxToolCalls)
|
|
177
|
+
if (priorToolCalls + toolCalls + 1 > envelope.maxToolCalls)
|
|
118
178
|
return { ok: false, error: { code: 'PERMISSION', message: `agent tool-call budget exhausted (${envelope.maxToolCalls})` } };
|
|
119
179
|
if (expired())
|
|
120
180
|
return { ok: false, error: { code: 'TIMEOUT', message: 'agent time budget exhausted' } };
|
|
@@ -127,20 +187,59 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
127
187
|
const innerGoal = [
|
|
128
188
|
envelope.objective,
|
|
129
189
|
wrapUntrusted(`agent-input:${envelope.agentId}`, flattenClamp(JSON.stringify(step.input ?? null), AGENT_INPUT_MAX)),
|
|
190
|
+
// The answer to THIS task's own question, routed here rather than appended to the outer goal —
|
|
191
|
+
// appending it there would replan the outer plan and discard every sibling agent's work.
|
|
192
|
+
...(deps.resumeAnswer ? [wrapUntrusted(`agent-answer:${envelope.agentId}`, flattenClamp(deps.resumeAnswer, AGENT_INPUT_MAX))] : []),
|
|
130
193
|
].join('\n\n');
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
194
|
+
// A persisted inner plan is REUSED rather than regenerated — that is what makes a resume cheap and
|
|
195
|
+
// what stops completed inner work being redone. It is still re-validated against the envelope's
|
|
196
|
+
// catalog first: the plan came off disk, and `validatePlan` is the membership gate for every plan,
|
|
197
|
+
// resumed or fresh. A plan that no longer validates is discarded, not repaired.
|
|
198
|
+
let innerPlan;
|
|
199
|
+
const identical = deps.resume ? deps.resume.envelopeHash === envHash && deps.resume.agentDefHash === defHash : false;
|
|
200
|
+
// An ANSWER changes what the plan should be — that is the entire point of having asked — so a task
|
|
201
|
+
// being resumed with one always re-plans rather than replaying the plan made before the question.
|
|
202
|
+
if (deps.resume?.innerPlan && identical && !deps.resumeAnswer) {
|
|
203
|
+
const check = validatePlan(deps.resume.innerPlan, { skills: envelope.skills, tools: envelope.tools, agents: [] });
|
|
204
|
+
if (check.ok)
|
|
205
|
+
innerPlan = deps.resume.innerPlan;
|
|
139
206
|
}
|
|
140
|
-
|
|
141
|
-
|
|
207
|
+
// The record now belongs to THIS run: an edited definition or a re-narrowed envelope invalidates the
|
|
208
|
+
// old plan (handled above) but not the task, so the hashes move forward with it.
|
|
209
|
+
record.agentDefHash = defHash;
|
|
210
|
+
record.envelopeHash = envHash;
|
|
211
|
+
if (!innerPlan) {
|
|
212
|
+
let planned;
|
|
213
|
+
try {
|
|
214
|
+
planned = await generatePlan({ goal: innerGoal, ai: metered, skills: deps.skills, tools: envelope.tools, agents: [], version: 1, ...(envelope.routing ? { routing: envelope.routing } : {}) });
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
if (err instanceof AgentCallBudgetError)
|
|
218
|
+
return finish('failed', { code: 'agent-call-budget', message: err.message });
|
|
219
|
+
return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
|
|
220
|
+
}
|
|
221
|
+
if (planned.clarification) {
|
|
222
|
+
// The one inner wait that exists: the planner needs an answer. The task stays RESUMABLE and the
|
|
223
|
+
// step stays pending — this is not a failure, and it must not fail the branch.
|
|
224
|
+
record.pendingInner = { kind: 'clarification', question: flattenClamp(planned.clarification, 240), at: deps.clock.now() };
|
|
225
|
+
return finish('waiting_for_clarification', { code: 'agent-waiting', message: record.pendingInner.question });
|
|
226
|
+
}
|
|
227
|
+
if (!planned.plan) {
|
|
228
|
+
return finish('failed', { code: 'agent-failed', message: flattenClamp(planned.error ?? 'the agent could not form a plan', 160) });
|
|
229
|
+
}
|
|
230
|
+
innerPlan = planned.plan;
|
|
231
|
+
// A REGENERATED plan has new step ids. Carrying the discarded plan's completed ids into its skip
|
|
232
|
+
// set would mark steps `succeeded` that never ran — the dangerous direction of a resume, since the
|
|
233
|
+
// work is silently not done rather than merely done twice.
|
|
234
|
+
record.innerCompletedSteps = [];
|
|
235
|
+
record.innerObservations = [];
|
|
236
|
+
record.innerObservationsOmitted = 0;
|
|
142
237
|
}
|
|
143
|
-
record.
|
|
238
|
+
record.innerPlan = innerPlan;
|
|
239
|
+
record.innerPlanVersion = innerPlan.version;
|
|
240
|
+
// The plan exists and nothing has run: a crash from here on resumes against a real inner plan.
|
|
241
|
+
record.updatedAt = deps.clock.now();
|
|
242
|
+
deps.onRecord?.(record);
|
|
144
243
|
const aborted = abortedNow();
|
|
145
244
|
if (aborted)
|
|
146
245
|
return aborted;
|
|
@@ -150,7 +249,30 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
150
249
|
// a skill's own model calls, which a wave gate cannot see.
|
|
151
250
|
let inner;
|
|
152
251
|
try {
|
|
153
|
-
inner = await executePlan(
|
|
252
|
+
inner = await executePlan(innerPlan, {
|
|
253
|
+
skip: new Set(record.innerCompletedSteps),
|
|
254
|
+
// THE seam that makes inner work survive a crash. Everything between `running` and `finish()` was
|
|
255
|
+
// otherwise invisible to disk, so a resumed task had nothing to skip and re-ran the whole inner plan.
|
|
256
|
+
onProgress: (snap) => {
|
|
257
|
+
record.innerPlan = snap.plan;
|
|
258
|
+
record.innerCompletedSteps = snap.plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
|
|
259
|
+
const room = INNER_OBS_MAX - record.innerObservations.length;
|
|
260
|
+
if (room > 0)
|
|
261
|
+
record.innerObservations.push(...snap.observations.slice(0, room));
|
|
262
|
+
record.innerObservationsOmitted += Math.max(0, snap.observations.length - Math.max(0, room));
|
|
263
|
+
record.callsUsed = priorCalls + calls;
|
|
264
|
+
record.toolCallsUsed = priorToolCalls + toolCalls;
|
|
265
|
+
record.updatedAt = deps.clock.now();
|
|
266
|
+
record.innerSteps = { total: snap.plan.steps.length, succeeded: record.innerCompletedSteps.length };
|
|
267
|
+
deps.onRecord?.(record);
|
|
268
|
+
// One event per WAVE, plus one at the end. `wave-partition` fires BEFORE the wave runs, so on
|
|
269
|
+
// its own it reports the count from before — for a single-wave inner plan (the common case)
|
|
270
|
+
// that means the only progress event says 0/N, which is exactly what `started` already said,
|
|
271
|
+
// and a display would read 0/N until the task simply finished. `plan-end` fires once and is the
|
|
272
|
+
// only snapshot carrying the final count. Still not per-step: waves + 1 events per task.
|
|
273
|
+
if (snap.at === 'wave-partition' || snap.at === 'plan-end')
|
|
274
|
+
deps.emit?.({ type: 'agent.task.progress', record });
|
|
275
|
+
},
|
|
154
276
|
runSkill: async (id, i) => {
|
|
155
277
|
const out = await deps.runSkill(id, i, { permissions: envelope.permissions, signal: child.signal, ai: metered });
|
|
156
278
|
skillResults.push({ stepId: id, result: out.result, validation: out.validation });
|
|
@@ -171,6 +293,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
171
293
|
return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
|
|
172
294
|
}
|
|
173
295
|
record.innerSteps = { total: inner.plan.steps.length, succeeded: inner.plan.steps.filter((s) => s.status === 'succeeded').length };
|
|
296
|
+
record.innerCompletedSteps = inner.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
|
|
174
297
|
const afterRun = abortedNow();
|
|
175
298
|
if (afterRun)
|
|
176
299
|
return afterRun;
|
|
@@ -239,8 +362,16 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
239
362
|
provenance: deps.provenance,
|
|
240
363
|
now: deps.clock.now(),
|
|
241
364
|
});
|
|
242
|
-
|
|
243
|
-
|
|
365
|
+
// MERGE, never assign. A resumed attempt that admits fewer findings than the interrupted one would
|
|
366
|
+
// otherwise drop the earlier ones on the floor — and observations already committed to disk reference
|
|
367
|
+
// them by id. Attempt >1 namespaces its ids so the two attempts can never collide.
|
|
368
|
+
const mint = (f) => (record.attempt > 1 ? { ...f, id: `${f.id}@a${record.attempt}` } : f);
|
|
369
|
+
const fresh = [...denials, ...admission.admitted].map(mint);
|
|
370
|
+
const seenFinding = new Set(record.findings.map((f) => f.id));
|
|
371
|
+
record.findings = [...record.findings, ...fresh.filter((f) => !seenFinding.has(f.id))];
|
|
372
|
+
// Bounded: diagnostics accumulate across attempts, and every one of them is rewritten to disk on
|
|
373
|
+
// every commit. Keeping the most recent is the useful half.
|
|
374
|
+
record.diagnostics = [...record.diagnostics, ...admission.rejected].slice(-DIAGNOSTICS_KEPT);
|
|
244
375
|
if (contractFailed(admission)) {
|
|
245
376
|
return finish('failed', { code: 'finding-contract', message: 'the agent did not satisfy its declared output contract' });
|
|
246
377
|
}
|
package/dist/cli/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import { mcpCommand, mcpAddCommand, mcpRemoveCommand, mcpEnableCommand, mcpTestC
|
|
|
21
21
|
import { startRepl } from './interactive/repl.js';
|
|
22
22
|
import { printError } from './render.js';
|
|
23
23
|
const program = new Command();
|
|
24
|
-
program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('2.
|
|
24
|
+
program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('2.9.0');
|
|
25
25
|
const configOpt = ['-c, --config <path>', 'path to an ai-runtime config file'];
|
|
26
26
|
// Bare `ai-runtime` (no subcommand) opens the interactive terminal. `allowExcessArguments(false)` keeps
|
|
27
27
|
// a mistyped subcommand (e.g. `ai-runtime porviders`) failing fast instead of silently opening the REPL.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-lane agent display (Phase 3.6) — concurrent agent steps as live rows in the REPL.
|
|
3
|
+
*
|
|
4
|
+
* PURE BY CONSTRUCTION. Nothing here reads `process.stdout`, a clock, or an environment variable: the
|
|
5
|
+
* terminal width, whether colour is on, and every value rendered are parameters. That is what makes a
|
|
6
|
+
* terminal feature testable offline and deterministically, and it is the rule to keep — the moment this
|
|
7
|
+
* module reads the real terminal, its tests start describing the machine they ran on.
|
|
8
|
+
*
|
|
9
|
+
* WRAPPING IS MADE IMPOSSIBLE RATHER THAN HANDLED. In-place rewriting works by moving the cursor up N
|
|
10
|
+
* rows, which is only correct while one logical line occupies exactly one physical row. So every line is
|
|
11
|
+
* clamped to `cols - 1`: no line ever reaches the final column, nothing wraps, and the arithmetic is
|
|
12
|
+
* exact. Lines are built as plain text, clamped, and styled last, so the clamp never has to count
|
|
13
|
+
* invisible escape bytes.
|
|
14
|
+
*/
|
|
15
|
+
import type { RuntimeEvent } from '../../runtime/events.js';
|
|
16
|
+
export interface AgentLane {
|
|
17
|
+
agentTaskId: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
stepId: string;
|
|
20
|
+
/** `running` covers started + progress; the terminal states come from the completion event. */
|
|
21
|
+
state: 'running' | 'completed' | 'failed' | 'cancelled' | 'waiting';
|
|
22
|
+
succeeded: number;
|
|
23
|
+
total: number;
|
|
24
|
+
callsUsed: number;
|
|
25
|
+
findings: number;
|
|
26
|
+
}
|
|
27
|
+
/** Display width in terminal columns, not code units. */
|
|
28
|
+
export declare function displayWidth(text: string): number;
|
|
29
|
+
/** Truncate to `cols` display columns, never splitting a surrogate pair or half a wide glyph. */
|
|
30
|
+
export declare function sliceColumns(text: string, cols: number): string;
|
|
31
|
+
/**
|
|
32
|
+
* Accumulates agent lanes from the event stream. Insertion-ordered, so lanes do not jump around between
|
|
33
|
+
* frames — a display that reorders itself is unreadable even when every value in it is right.
|
|
34
|
+
*/
|
|
35
|
+
export declare class LaneSet {
|
|
36
|
+
private readonly lanes;
|
|
37
|
+
/**
|
|
38
|
+
* Fold one event in. Returns the lane it touched, or `undefined` for an event that is not an agent
|
|
39
|
+
* event — a lane rather than a boolean, so the caller neither re-narrows the event nor looks the lane
|
|
40
|
+
* up a second time.
|
|
41
|
+
*/
|
|
42
|
+
observe(event: RuntimeEvent): AgentLane | undefined;
|
|
43
|
+
list(): AgentLane[];
|
|
44
|
+
/** True once at least one lane exists and none is still in flight. */
|
|
45
|
+
settled(): boolean;
|
|
46
|
+
clear(): void;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Render the lanes as one line each. `tick` animates the running marker; it is a parameter rather than a
|
|
50
|
+
* timer so a test can render frame 3 without waiting for it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function laneLines(lanes: AgentLane[], opts: {
|
|
53
|
+
cols: number;
|
|
54
|
+
colors: boolean;
|
|
55
|
+
tick: number;
|
|
56
|
+
}): string[];
|
|
57
|
+
/**
|
|
58
|
+
* Rewrite an owned region of the terminal in place, returning the bytes to write.
|
|
59
|
+
*
|
|
60
|
+
* `prevRows === 0` opens the region at the cursor's current line. Otherwise the cursor is assumed to be
|
|
61
|
+
* on the region's LAST row (which is where this function always leaves it) and walks up from there.
|
|
62
|
+
*
|
|
63
|
+
* The cursor never ends on a fresh line below the frame: a trailing newline on the terminal's last row
|
|
64
|
+
* scrolls the viewport, and the next frame's `cursorUp` would then land one row high and duplicate the
|
|
65
|
+
* region on every tick, forever.
|
|
66
|
+
*/
|
|
67
|
+
export declare function frameDiff(prevRows: number, lines: string[], enabled: boolean): string;
|
|
68
|
+
/** How many rows `frameDiff` will have left behind — the `prevRows` for the next call. */
|
|
69
|
+
export declare function frameRows(lines: string[]): number;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-lane agent display (Phase 3.6) — concurrent agent steps as live rows in the REPL.
|
|
3
|
+
*
|
|
4
|
+
* PURE BY CONSTRUCTION. Nothing here reads `process.stdout`, a clock, or an environment variable: the
|
|
5
|
+
* terminal width, whether colour is on, and every value rendered are parameters. That is what makes a
|
|
6
|
+
* terminal feature testable offline and deterministically, and it is the rule to keep — the moment this
|
|
7
|
+
* module reads the real terminal, its tests start describing the machine they ran on.
|
|
8
|
+
*
|
|
9
|
+
* WRAPPING IS MADE IMPOSSIBLE RATHER THAN HANDLED. In-place rewriting works by moving the cursor up N
|
|
10
|
+
* rows, which is only correct while one logical line occupies exactly one physical row. So every line is
|
|
11
|
+
* clamped to `cols - 1`: no line ever reaches the final column, nothing wraps, and the arithmetic is
|
|
12
|
+
* exact. Lines are built as plain text, clamped, and styled last, so the clamp never has to count
|
|
13
|
+
* invisible escape bytes.
|
|
14
|
+
*/
|
|
15
|
+
import { style, SPINNER_FRAMES } from './ansi.js';
|
|
16
|
+
import { flattenClamp } from '../../util/flatten.js';
|
|
17
|
+
const CLEAR_LINE = '\x1b[2K';
|
|
18
|
+
/**
|
|
19
|
+
* East Asian Wide and Fullwidth code points, which a terminal draws in TWO columns. The no-wrap
|
|
20
|
+
* invariant is stated in COLUMNS, so counting UTF-16 code units would let a CJK step id — and step ids
|
|
21
|
+
* are model-authored — produce a line that measures 79 and draws 95, wrapping and breaking every
|
|
22
|
+
* cursor-up that follows it.
|
|
23
|
+
*/
|
|
24
|
+
function isWide(cp) {
|
|
25
|
+
return ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
26
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, punctuation
|
|
27
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana .. CJK compatibility
|
|
28
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
29
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified ideographs
|
|
30
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
|
31
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
32
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
33
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
34
|
+
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
|
|
35
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
36
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) || // emoji
|
|
37
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
|
|
38
|
+
(cp >= 0x20000 && cp <= 0x3fffd));
|
|
39
|
+
}
|
|
40
|
+
/** Display width in terminal columns, not code units. */
|
|
41
|
+
export function displayWidth(text) {
|
|
42
|
+
let n = 0;
|
|
43
|
+
for (const ch of text)
|
|
44
|
+
n += isWide(ch.codePointAt(0)) ? 2 : 1;
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
/** Truncate to `cols` display columns, never splitting a surrogate pair or half a wide glyph. */
|
|
48
|
+
export function sliceColumns(text, cols) {
|
|
49
|
+
let out = '';
|
|
50
|
+
let used = 0;
|
|
51
|
+
for (const ch of text) {
|
|
52
|
+
const w = isWide(ch.codePointAt(0)) ? 2 : 1;
|
|
53
|
+
if (used + w > cols)
|
|
54
|
+
break;
|
|
55
|
+
out += ch;
|
|
56
|
+
used += w;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
const cursorUp = (n) => (n > 0 ? `\x1b[${n}A` : '');
|
|
61
|
+
/** Which lane state an agent-task state projects onto. Anything unrecognised reads as still running. */
|
|
62
|
+
function laneState(state) {
|
|
63
|
+
if (state === 'completed' || state === 'failed' || state === 'cancelled')
|
|
64
|
+
return state;
|
|
65
|
+
if (state.startsWith('waiting'))
|
|
66
|
+
return 'waiting';
|
|
67
|
+
return 'running';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Accumulates agent lanes from the event stream. Insertion-ordered, so lanes do not jump around between
|
|
71
|
+
* frames — a display that reorders itself is unreadable even when every value in it is right.
|
|
72
|
+
*/
|
|
73
|
+
export class LaneSet {
|
|
74
|
+
lanes = new Map();
|
|
75
|
+
/**
|
|
76
|
+
* Fold one event in. Returns the lane it touched, or `undefined` for an event that is not an agent
|
|
77
|
+
* event — a lane rather than a boolean, so the caller neither re-narrows the event nor looks the lane
|
|
78
|
+
* up a second time.
|
|
79
|
+
*/
|
|
80
|
+
observe(event) {
|
|
81
|
+
if (!event.type.startsWith('agent.task.'))
|
|
82
|
+
return undefined;
|
|
83
|
+
const e = event;
|
|
84
|
+
const existing = this.lanes.get(e.agentTaskId);
|
|
85
|
+
const lane = existing ?? {
|
|
86
|
+
agentTaskId: e.agentTaskId,
|
|
87
|
+
agentId: e.agentId,
|
|
88
|
+
stepId: e.stepId,
|
|
89
|
+
state: 'running',
|
|
90
|
+
succeeded: 0,
|
|
91
|
+
total: 0,
|
|
92
|
+
callsUsed: 0,
|
|
93
|
+
findings: 0,
|
|
94
|
+
};
|
|
95
|
+
if ('innerSteps' in e) {
|
|
96
|
+
lane.total = e.innerSteps.total;
|
|
97
|
+
lane.succeeded = e.innerSteps.succeeded;
|
|
98
|
+
}
|
|
99
|
+
if ('callsUsed' in e)
|
|
100
|
+
lane.callsUsed = e.callsUsed;
|
|
101
|
+
if ('findings' in e)
|
|
102
|
+
lane.findings = e.findings;
|
|
103
|
+
if ('state' in e)
|
|
104
|
+
lane.state = laneState(e.state);
|
|
105
|
+
this.lanes.set(e.agentTaskId, lane);
|
|
106
|
+
return lane;
|
|
107
|
+
}
|
|
108
|
+
list() {
|
|
109
|
+
return [...this.lanes.values()];
|
|
110
|
+
}
|
|
111
|
+
/** True once at least one lane exists and none is still in flight. */
|
|
112
|
+
settled() {
|
|
113
|
+
const all = this.list();
|
|
114
|
+
return all.length > 0 && all.every((l) => l.state !== 'running');
|
|
115
|
+
}
|
|
116
|
+
clear() {
|
|
117
|
+
this.lanes.clear();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const MARK = { running: '', completed: '✓', failed: '✗', cancelled: '−', waiting: '?' };
|
|
121
|
+
const TINT = {
|
|
122
|
+
running: 'cyan',
|
|
123
|
+
completed: 'green',
|
|
124
|
+
failed: 'red',
|
|
125
|
+
cancelled: 'gray',
|
|
126
|
+
waiting: 'yellow',
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Render the lanes as one line each. `tick` animates the running marker; it is a parameter rather than a
|
|
130
|
+
* timer so a test can render frame 3 without waiting for it.
|
|
131
|
+
*/
|
|
132
|
+
export function laneLines(lanes, opts) {
|
|
133
|
+
// `|| 80` not `?? 80`: a pty can report ZERO columns, and zero is not nullish. And the floor is 1,
|
|
134
|
+
// not 20 — raising the width for a narrow terminal MAKES it wrap, which is the failure this clamp
|
|
135
|
+
// exists to prevent.
|
|
136
|
+
const width = Math.max(1, (opts.cols || 80) - 1);
|
|
137
|
+
return lanes.map((l) => {
|
|
138
|
+
const mark = l.state === 'running' ? SPINNER_FRAMES[opts.tick % SPINNER_FRAMES.length] : MARK[l.state];
|
|
139
|
+
const progress = l.total > 0 ? `${l.succeeded}/${l.total}` : '…';
|
|
140
|
+
const found = l.findings > 0 ? `, ${l.findings} finding(s)` : '';
|
|
141
|
+
// Built plain, clamped, THEN styled — so the clamp never counts escape bytes.
|
|
142
|
+
const plain = ` ${mark} ${flattenClamp(l.agentId, 24)} @ ${flattenClamp(l.stepId, 16)} ${progress} step(s), ${l.callsUsed} call(s)${found}`;
|
|
143
|
+
return style(sliceColumns(plain, width), TINT[l.state], opts.colors);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Rewrite an owned region of the terminal in place, returning the bytes to write.
|
|
148
|
+
*
|
|
149
|
+
* `prevRows === 0` opens the region at the cursor's current line. Otherwise the cursor is assumed to be
|
|
150
|
+
* on the region's LAST row (which is where this function always leaves it) and walks up from there.
|
|
151
|
+
*
|
|
152
|
+
* The cursor never ends on a fresh line below the frame: a trailing newline on the terminal's last row
|
|
153
|
+
* scrolls the viewport, and the next frame's `cursorUp` would then land one row high and duplicate the
|
|
154
|
+
* region on every tick, forever.
|
|
155
|
+
*/
|
|
156
|
+
export function frameDiff(prevRows, lines, enabled) {
|
|
157
|
+
if (!enabled)
|
|
158
|
+
return '';
|
|
159
|
+
const rows = Math.max(prevRows, lines.length);
|
|
160
|
+
if (rows === 0)
|
|
161
|
+
return '';
|
|
162
|
+
let out = '\r';
|
|
163
|
+
if (prevRows > 0)
|
|
164
|
+
out += cursorUp(prevRows - 1);
|
|
165
|
+
for (let i = 0; i < rows; i += 1) {
|
|
166
|
+
out += CLEAR_LINE + (lines[i] ?? '');
|
|
167
|
+
if (i < rows - 1)
|
|
168
|
+
out += '\n';
|
|
169
|
+
}
|
|
170
|
+
// Drew fewer rows than last time: the cursor sits on a cleared row below the frame, so walk back up
|
|
171
|
+
// to the real last line, keeping the "cursor is on the last row" invariant true for the next call.
|
|
172
|
+
// CLOSING the region (`lines` empty) has no last line, so the target is the region's TOP row — which
|
|
173
|
+
// is one higher than the shrink case, and the reason this is `max(1, …)` rather than `lines.length`.
|
|
174
|
+
if (rows > lines.length)
|
|
175
|
+
out += `\r${cursorUp(rows - Math.max(1, lines.length))}`;
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/** How many rows `frameDiff` will have left behind — the `prevRows` for the next call. */
|
|
179
|
+
export function frameRows(lines) {
|
|
180
|
+
return lines.length;
|
|
181
|
+
}
|
|
@@ -13,6 +13,7 @@ import { print, printChunk, printError } from '../render.js';
|
|
|
13
13
|
import { summarizeWorkspace } from '../../runtime/workspace/workspace.js';
|
|
14
14
|
import { ReplSession, SLASH_COMMANDS } from './session.js';
|
|
15
15
|
import { makeCompleter } from './complete.js';
|
|
16
|
+
import { LaneSet, laneLines, frameDiff, frameRows } from './lanes.js';
|
|
16
17
|
import { colorEnabled, bold, cyan, dim, gray, SPINNER_FRAMES, statusLine, clearLine } from './ansi.js';
|
|
17
18
|
function banner(rt, colors) {
|
|
18
19
|
const ws = rt.workspaceInfo();
|
|
@@ -96,6 +97,22 @@ export async function startRepl(configPath) {
|
|
|
96
97
|
};
|
|
97
98
|
let streamedThisRun = false;
|
|
98
99
|
let spinner;
|
|
100
|
+
// Phase 3.6: concurrent agent steps render as live lanes. The region opens on the first agent event —
|
|
101
|
+
// long after the spinner has been stopped by the very first event of the run — so the two never own
|
|
102
|
+
// the cursor at the same time. With agents disabled no agent event is ever emitted, so the region
|
|
103
|
+
// never opens and output is byte-identical.
|
|
104
|
+
const lanes = new LaneSet();
|
|
105
|
+
let laneRows = 0;
|
|
106
|
+
let laneTick = 0;
|
|
107
|
+
// A late event — one arriving after the run has already returned — must not open a region on top of
|
|
108
|
+
// the prompt and leave `laneRows` set for the NEXT run's cursor arithmetic to walk into.
|
|
109
|
+
let runInFlight = false;
|
|
110
|
+
const closeLanes = () => {
|
|
111
|
+
if (laneRows > 0)
|
|
112
|
+
printChunk(frameDiff(laneRows, [], colors));
|
|
113
|
+
laneRows = 0;
|
|
114
|
+
lanes.clear();
|
|
115
|
+
};
|
|
99
116
|
// Live progress + token streaming. Events are already redacted; a throwing observer can't break a run.
|
|
100
117
|
rt.on((e) => {
|
|
101
118
|
spinner?.stop(); // any event means work has started producing output — drop the spinner first
|
|
@@ -104,6 +121,30 @@ export async function startRepl(configPath) {
|
|
|
104
121
|
streamedThisRun = true;
|
|
105
122
|
return;
|
|
106
123
|
}
|
|
124
|
+
const lane = lanes.observe(e);
|
|
125
|
+
if (lane) {
|
|
126
|
+
if (!runInFlight)
|
|
127
|
+
return; // see `runInFlight`
|
|
128
|
+
laneTick += 1;
|
|
129
|
+
if (colors) {
|
|
130
|
+
// The region must fit the viewport: `cursorUp` saturates at row 0, so a frame taller than the
|
|
131
|
+
// pane can never walk back to its own top and would redraw itself downward forever.
|
|
132
|
+
const budget = Math.max(1, (process.stdout.rows || 24) - 2);
|
|
133
|
+
const all = lanes.list();
|
|
134
|
+
const shown = all.length > budget ? all.slice(0, budget - 1) : all;
|
|
135
|
+
const lines = laneLines(shown, { cols: process.stdout.columns || 80, colors, tick: laneTick });
|
|
136
|
+
if (all.length > shown.length)
|
|
137
|
+
lines.push(dim(` … ${all.length - shown.length} more agent task(s)`, colors));
|
|
138
|
+
printChunk(frameDiff(laneRows, lines, colors));
|
|
139
|
+
laneRows = frameRows(lines);
|
|
140
|
+
}
|
|
141
|
+
else if (e.type !== 'agent.task.progress') {
|
|
142
|
+
// Piped, NO_COLOR or a dumb terminal: cursor games would be garbage in a log file, so report
|
|
143
|
+
// the transitions append-only instead. Progress ticks are dropped — in a log they are noise.
|
|
144
|
+
print(` · agent ${lane.agentId} @ ${lane.stepId}: ${lane.state}`);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
107
148
|
const line = progressLine(e, colors);
|
|
108
149
|
if (line)
|
|
109
150
|
print(line);
|
|
@@ -125,18 +166,29 @@ export async function startRepl(configPath) {
|
|
|
125
166
|
let result;
|
|
126
167
|
streamedThisRun = false;
|
|
127
168
|
spinner = startSpinner(colors);
|
|
169
|
+
runInFlight = true;
|
|
170
|
+
// Readline keeps echoing keypresses while the handler awaits, and the cursor sits inside the lane
|
|
171
|
+
// region — so a keystroke mid-run writes into the frame and its clear-to-end-of-screen erases the
|
|
172
|
+
// rows below it. Pausing buffers the input instead; it is resumed on both exits below.
|
|
173
|
+
rl.pause();
|
|
128
174
|
try {
|
|
129
175
|
result = await session.handle(line);
|
|
130
176
|
}
|
|
131
177
|
catch (err) {
|
|
178
|
+
runInFlight = false;
|
|
179
|
+
rl.resume();
|
|
132
180
|
spinner.stop();
|
|
181
|
+
closeLanes();
|
|
133
182
|
if (streamedThisRun)
|
|
134
183
|
process.stdout.write('\n');
|
|
135
184
|
printError(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
136
185
|
rl.prompt();
|
|
137
186
|
continue;
|
|
138
187
|
}
|
|
188
|
+
runInFlight = false;
|
|
189
|
+
rl.resume();
|
|
139
190
|
spinner.stop();
|
|
191
|
+
closeLanes(); // hand the cursor back before any result line is printed
|
|
140
192
|
if (streamedThisRun)
|
|
141
193
|
process.stdout.write('\n'); // close the streamed line before printing result lines
|
|
142
194
|
if (result.clear)
|
|
@@ -11,7 +11,10 @@ export interface HandleResult {
|
|
|
11
11
|
clear?: boolean;
|
|
12
12
|
}
|
|
13
13
|
/** Top-level slash commands, for REPL tab-completion (Phase 21b). Kept in sync with the `handle` dispatch. */
|
|
14
|
-
export declare const SLASH_COMMANDS: readonly ["help", "status", "info", "doctor", "cleanup", "mode", "compare", "models", "config", "providers", "tools", "capabilities", "mcp", "skills", "memory", "conversations", "executions", "resume", "resume-execution", "pause", "cancel", "approve", "deny", "learning", "feedback", "permissions", "budget", "stream", "dry-run", "clear", "exit", "quit"];
|
|
14
|
+
export declare const SLASH_COMMANDS: readonly ["help", "status", "info", "doctor", "cleanup", "mode", "compare", "models", "config", "providers", "tools", "capabilities", "mcp", "skills", "memory", "conversations", "executions", "agents", "resume", "resume-execution", "pause", "cancel", "approve", "deny", "learning", "feedback", "permissions", "budget", "stream", "dry-run", "clear", "exit", "quit"];
|
|
15
|
+
/** Exported so a test can prove every reachable command is documented — the three touch points below
|
|
16
|
+
* are synced by hand, and `/agents` shipped tab-completable but absent from this list. */
|
|
17
|
+
export declare const HELP: string[];
|
|
15
18
|
export declare class ReplSession {
|
|
16
19
|
private readonly runtime;
|
|
17
20
|
private mode;
|
|
@@ -36,6 +39,10 @@ export declare class ReplSession {
|
|
|
36
39
|
private learning;
|
|
37
40
|
private permissions;
|
|
38
41
|
private conversationsList;
|
|
42
|
+
/** Agent tasks across this project's executions, newest first. */
|
|
43
|
+
private agentsList;
|
|
44
|
+
/** Stop one agent task. Every outcome is reported — a stop that looks like nothing happened is a bug. */
|
|
45
|
+
private agentStop;
|
|
39
46
|
private executionsList;
|
|
40
47
|
private resumeExecution;
|
|
41
48
|
private resume;
|