ai-runtime-engine 2.7.0 → 2.8.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 +60 -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 +58 -6
- package/dist/agents/task.js +18 -2
- package/dist/agents/worker.d.ts +23 -0
- package/dist/agents/worker.js +140 -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/executions/agentTasks.d.ts +627 -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 +4 -0
- package/dist/index.js +7 -0
- package/dist/orchestration/executor.d.ts +26 -1
- package/dist/orchestration/executor.js +45 -8
- package/dist/orchestration/orchestrator.d.ts +6 -0
- package/dist/orchestration/orchestrator.js +18 -1
- package/dist/runtime/runtime.d.ts +60 -0
- package/dist/runtime/runtime.js +345 -18
- 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);
|
|
@@ -71,8 +122,12 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
71
122
|
deps.parentSignal?.removeEventListener('abort', onParentAbort);
|
|
72
123
|
record.state = state;
|
|
73
124
|
record.endedAt = deps.clock.now();
|
|
74
|
-
record.
|
|
75
|
-
record.
|
|
125
|
+
record.updatedAt = record.endedAt;
|
|
126
|
+
record.callsUsed = priorCalls + calls;
|
|
127
|
+
record.toolCallsUsed = priorToolCalls + toolCalls;
|
|
128
|
+
// Derived, for reporting only. The real refund is that the executor charges what was SPENT, so the
|
|
129
|
+
// unspent part of the reservation is simply never charged — there is no ledger to get wrong.
|
|
130
|
+
record.callsRefunded = Math.max(0, record.callsReserved - record.callsUsed);
|
|
76
131
|
if (failure)
|
|
77
132
|
record.failure = failure;
|
|
78
133
|
const row = AGENT_TASK_PROJECTION[state];
|
|
@@ -88,6 +143,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
88
143
|
...(state === 'completed' ? { output: `${envelope.agentId}: ${record.innerSteps.succeeded}/${record.innerSteps.total} inner step(s), ${record.findings.length} finding(s)` } : {}),
|
|
89
144
|
};
|
|
90
145
|
deps.emit?.({ type: 'agent.task.completed', record });
|
|
146
|
+
deps.onRecord?.(record);
|
|
91
147
|
return { observation, record };
|
|
92
148
|
};
|
|
93
149
|
// (12) A parent abort is a cancel or a pause; both project to the same observation, and the record
|
|
@@ -108,13 +164,15 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
108
164
|
return preempted;
|
|
109
165
|
record.state = 'running';
|
|
110
166
|
record.startedAt = deps.clock.now();
|
|
167
|
+
record.updatedAt = record.startedAt;
|
|
111
168
|
deps.emit?.({ type: 'agent.task.started', record });
|
|
169
|
+
deps.onRecord?.(record);
|
|
112
170
|
// (6) THE TOOL SEAM - defense in depth behind narrowEnvelope. Always a structured denial, never a
|
|
113
171
|
// throw: the tool contract is that a refusal is visible.
|
|
114
172
|
const innerCallTool = async (id, input) => {
|
|
115
173
|
if (!envelope.tools.includes(id))
|
|
116
174
|
return { ok: false, error: { code: 'PERMISSION', message: `'${flattenClamp(id, 40)}' is not in this agent's catalog` } };
|
|
117
|
-
if (toolCalls + 1 > envelope.maxToolCalls)
|
|
175
|
+
if (priorToolCalls + toolCalls + 1 > envelope.maxToolCalls)
|
|
118
176
|
return { ok: false, error: { code: 'PERMISSION', message: `agent tool-call budget exhausted (${envelope.maxToolCalls})` } };
|
|
119
177
|
if (expired())
|
|
120
178
|
return { ok: false, error: { code: 'TIMEOUT', message: 'agent time budget exhausted' } };
|
|
@@ -127,20 +185,59 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
127
185
|
const innerGoal = [
|
|
128
186
|
envelope.objective,
|
|
129
187
|
wrapUntrusted(`agent-input:${envelope.agentId}`, flattenClamp(JSON.stringify(step.input ?? null), AGENT_INPUT_MAX)),
|
|
188
|
+
// The answer to THIS task's own question, routed here rather than appended to the outer goal —
|
|
189
|
+
// appending it there would replan the outer plan and discard every sibling agent's work.
|
|
190
|
+
...(deps.resumeAnswer ? [wrapUntrusted(`agent-answer:${envelope.agentId}`, flattenClamp(deps.resumeAnswer, AGENT_INPUT_MAX))] : []),
|
|
130
191
|
].join('\n\n');
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
192
|
+
// A persisted inner plan is REUSED rather than regenerated — that is what makes a resume cheap and
|
|
193
|
+
// what stops completed inner work being redone. It is still re-validated against the envelope's
|
|
194
|
+
// catalog first: the plan came off disk, and `validatePlan` is the membership gate for every plan,
|
|
195
|
+
// resumed or fresh. A plan that no longer validates is discarded, not repaired.
|
|
196
|
+
let innerPlan;
|
|
197
|
+
const identical = deps.resume ? deps.resume.envelopeHash === envHash && deps.resume.agentDefHash === defHash : false;
|
|
198
|
+
// An ANSWER changes what the plan should be — that is the entire point of having asked — so a task
|
|
199
|
+
// being resumed with one always re-plans rather than replaying the plan made before the question.
|
|
200
|
+
if (deps.resume?.innerPlan && identical && !deps.resumeAnswer) {
|
|
201
|
+
const check = validatePlan(deps.resume.innerPlan, { skills: envelope.skills, tools: envelope.tools, agents: [] });
|
|
202
|
+
if (check.ok)
|
|
203
|
+
innerPlan = deps.resume.innerPlan;
|
|
139
204
|
}
|
|
140
|
-
|
|
141
|
-
|
|
205
|
+
// The record now belongs to THIS run: an edited definition or a re-narrowed envelope invalidates the
|
|
206
|
+
// old plan (handled above) but not the task, so the hashes move forward with it.
|
|
207
|
+
record.agentDefHash = defHash;
|
|
208
|
+
record.envelopeHash = envHash;
|
|
209
|
+
if (!innerPlan) {
|
|
210
|
+
let planned;
|
|
211
|
+
try {
|
|
212
|
+
planned = await generatePlan({ goal: innerGoal, ai: metered, skills: deps.skills, tools: envelope.tools, agents: [], version: 1, ...(envelope.routing ? { routing: envelope.routing } : {}) });
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
if (err instanceof AgentCallBudgetError)
|
|
216
|
+
return finish('failed', { code: 'agent-call-budget', message: err.message });
|
|
217
|
+
return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
|
|
218
|
+
}
|
|
219
|
+
if (planned.clarification) {
|
|
220
|
+
// The one inner wait that exists: the planner needs an answer. The task stays RESUMABLE and the
|
|
221
|
+
// step stays pending — this is not a failure, and it must not fail the branch.
|
|
222
|
+
record.pendingInner = { kind: 'clarification', question: flattenClamp(planned.clarification, 240), at: deps.clock.now() };
|
|
223
|
+
return finish('waiting_for_clarification', { code: 'agent-waiting', message: record.pendingInner.question });
|
|
224
|
+
}
|
|
225
|
+
if (!planned.plan) {
|
|
226
|
+
return finish('failed', { code: 'agent-failed', message: flattenClamp(planned.error ?? 'the agent could not form a plan', 160) });
|
|
227
|
+
}
|
|
228
|
+
innerPlan = planned.plan;
|
|
229
|
+
// A REGENERATED plan has new step ids. Carrying the discarded plan's completed ids into its skip
|
|
230
|
+
// set would mark steps `succeeded` that never ran — the dangerous direction of a resume, since the
|
|
231
|
+
// work is silently not done rather than merely done twice.
|
|
232
|
+
record.innerCompletedSteps = [];
|
|
233
|
+
record.innerObservations = [];
|
|
234
|
+
record.innerObservationsOmitted = 0;
|
|
142
235
|
}
|
|
143
|
-
record.
|
|
236
|
+
record.innerPlan = innerPlan;
|
|
237
|
+
record.innerPlanVersion = innerPlan.version;
|
|
238
|
+
// The plan exists and nothing has run: a crash from here on resumes against a real inner plan.
|
|
239
|
+
record.updatedAt = deps.clock.now();
|
|
240
|
+
deps.onRecord?.(record);
|
|
144
241
|
const aborted = abortedNow();
|
|
145
242
|
if (aborted)
|
|
146
243
|
return aborted;
|
|
@@ -150,7 +247,22 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
150
247
|
// a skill's own model calls, which a wave gate cannot see.
|
|
151
248
|
let inner;
|
|
152
249
|
try {
|
|
153
|
-
inner = await executePlan(
|
|
250
|
+
inner = await executePlan(innerPlan, {
|
|
251
|
+
skip: new Set(record.innerCompletedSteps),
|
|
252
|
+
// THE seam that makes inner work survive a crash. Everything between `running` and `finish()` was
|
|
253
|
+
// otherwise invisible to disk, so a resumed task had nothing to skip and re-ran the whole inner plan.
|
|
254
|
+
onProgress: (snap) => {
|
|
255
|
+
record.innerPlan = snap.plan;
|
|
256
|
+
record.innerCompletedSteps = snap.plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
|
|
257
|
+
const room = INNER_OBS_MAX - record.innerObservations.length;
|
|
258
|
+
if (room > 0)
|
|
259
|
+
record.innerObservations.push(...snap.observations.slice(0, room));
|
|
260
|
+
record.innerObservationsOmitted += Math.max(0, snap.observations.length - Math.max(0, room));
|
|
261
|
+
record.callsUsed = priorCalls + calls;
|
|
262
|
+
record.toolCallsUsed = priorToolCalls + toolCalls;
|
|
263
|
+
record.updatedAt = deps.clock.now();
|
|
264
|
+
deps.onRecord?.(record);
|
|
265
|
+
},
|
|
154
266
|
runSkill: async (id, i) => {
|
|
155
267
|
const out = await deps.runSkill(id, i, { permissions: envelope.permissions, signal: child.signal, ai: metered });
|
|
156
268
|
skillResults.push({ stepId: id, result: out.result, validation: out.validation });
|
|
@@ -171,6 +283,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
171
283
|
return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
|
|
172
284
|
}
|
|
173
285
|
record.innerSteps = { total: inner.plan.steps.length, succeeded: inner.plan.steps.filter((s) => s.status === 'succeeded').length };
|
|
286
|
+
record.innerCompletedSteps = inner.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
|
|
174
287
|
const afterRun = abortedNow();
|
|
175
288
|
if (afterRun)
|
|
176
289
|
return afterRun;
|
|
@@ -239,8 +352,16 @@ export async function runAgentTask(step, envelope, definition, deps) {
|
|
|
239
352
|
provenance: deps.provenance,
|
|
240
353
|
now: deps.clock.now(),
|
|
241
354
|
});
|
|
242
|
-
|
|
243
|
-
|
|
355
|
+
// MERGE, never assign. A resumed attempt that admits fewer findings than the interrupted one would
|
|
356
|
+
// otherwise drop the earlier ones on the floor — and observations already committed to disk reference
|
|
357
|
+
// them by id. Attempt >1 namespaces its ids so the two attempts can never collide.
|
|
358
|
+
const mint = (f) => (record.attempt > 1 ? { ...f, id: `${f.id}@a${record.attempt}` } : f);
|
|
359
|
+
const fresh = [...denials, ...admission.admitted].map(mint);
|
|
360
|
+
const seenFinding = new Set(record.findings.map((f) => f.id));
|
|
361
|
+
record.findings = [...record.findings, ...fresh.filter((f) => !seenFinding.has(f.id))];
|
|
362
|
+
// Bounded: diagnostics accumulate across attempts, and every one of them is rewritten to disk on
|
|
363
|
+
// every commit. Keeping the most recent is the useful half.
|
|
364
|
+
record.diagnostics = [...record.diagnostics, ...admission.rejected].slice(-DIAGNOSTICS_KEPT);
|
|
244
365
|
if (contractFailed(admission)) {
|
|
245
366
|
return finish('failed', { code: 'finding-contract', message: 'the agent did not satisfy its declared output contract' });
|
|
246
367
|
}
|
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.8.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
|
+
}
|