@rulvar/rulvar 1.19.0 → 1.21.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/README.md +10 -1
- package/dist/index.d.ts +65 -2
- package/dist/index.js +539 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
The batteries-included Rulvar install: re-exports the entire
|
|
4
4
|
`@rulvar/core` surface plus both first-class adapters (`anthropic`,
|
|
5
|
-
`openai`),
|
|
5
|
+
`openai`), two terminal progress renderers, and `recommendedDefaults`,
|
|
6
6
|
the only place the project names strong default models for the
|
|
7
7
|
orchestrate and plan roles. Also installable through the unscoped alias
|
|
8
8
|
package `rulvar`, which re-exports this one.
|
|
9
9
|
|
|
10
|
+
The renderers are `progress()`, the live view (one row per agent with a
|
|
11
|
+
status glyph, a running timer, token counts, and USD, per-role
|
|
12
|
+
sub-timings when one call spans several invocation phases, the run
|
|
13
|
+
header with spend against the ceiling, and a final per-role cost
|
|
14
|
+
summary; repaints in place on a TTY and degrades to append-only lines in
|
|
15
|
+
pipes and CI), and `renderProgress()`, the minimal one line per
|
|
16
|
+
lifecycle fact. Both consume the public `WorkflowEvent` stream and
|
|
17
|
+
nothing else.
|
|
18
|
+
|
|
10
19
|
Part of [Rulvar](https://rulvar.com), an embeddable TypeScript engine
|
|
11
20
|
for durable, budget-bounded multi-agent LLM workflows, where a completed
|
|
12
21
|
LLM call is never paid for twice. Full documentation:
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InvocationRole, ModelSpec, QualityFloors, WorkflowEvent } from "@rulvar/core";
|
|
1
|
+
import { InvocationRole, ModelSpec, QualityFloors, RunHandle, WorkflowEvent } from "@rulvar/core";
|
|
2
2
|
import { ANTHROPIC_MODELS, AnthropicAdapterOptions, anthropic } from "@rulvar/anthropic";
|
|
3
3
|
import { OPENAI_MODELS, OpenAiAdapterOptions, openai } from "@rulvar/openai";
|
|
4
4
|
export * from "@rulvar/core";
|
|
@@ -16,6 +16,69 @@ interface RenderProgressOptions {
|
|
|
16
16
|
*/
|
|
17
17
|
declare function renderProgress(events: AsyncIterable<WorkflowEvent>, options?: RenderProgressOptions): Promise<void>;
|
|
18
18
|
//#endregion
|
|
19
|
+
//#region src/live-progress.d.ts
|
|
20
|
+
/** Raw output sink; chunks may contain ANSI and partial lines. */
|
|
21
|
+
interface ProgressSink {
|
|
22
|
+
write(chunk: string): void;
|
|
23
|
+
isTTY?: boolean;
|
|
24
|
+
columns?: number;
|
|
25
|
+
rows?: number;
|
|
26
|
+
}
|
|
27
|
+
/** Injectable time source; every() returns a cancel function. */
|
|
28
|
+
interface ProgressClock {
|
|
29
|
+
now(): number;
|
|
30
|
+
every(ms: number, fn: () => void): () => void;
|
|
31
|
+
}
|
|
32
|
+
type ProgressMode = "auto" | "tty" | "lines" | "off";
|
|
33
|
+
interface ProgressOptions {
|
|
34
|
+
/** Defaults to process.stderr so application stdout stays clean. */
|
|
35
|
+
sink?: ProgressSink;
|
|
36
|
+
/** Defaults to Date.now plus setInterval. */
|
|
37
|
+
clock?: ProgressClock;
|
|
38
|
+
/**
|
|
39
|
+
* 'auto' (default) picks 'tty' when the sink reports a TTY and the
|
|
40
|
+
* environment is not CI or TERM=dumb, else 'lines'.
|
|
41
|
+
*/
|
|
42
|
+
mode?: ProgressMode;
|
|
43
|
+
/** Repaints per second in tty mode, clamped to 1..30. Default 10. */
|
|
44
|
+
fps?: number;
|
|
45
|
+
/** SGR colors. Default: true in tty mode unless NO_COLOR is set. */
|
|
46
|
+
color?: boolean;
|
|
47
|
+
/** Column override. Default sink.columns, else 80. */
|
|
48
|
+
width?: number;
|
|
49
|
+
/** Body rows before the oldest completed rows collapse. Default 24. */
|
|
50
|
+
maxRows?: number;
|
|
51
|
+
/** Header title. Default: the workflow name from run:start. */
|
|
52
|
+
title?: string;
|
|
53
|
+
}
|
|
54
|
+
interface ProgressHandle {
|
|
55
|
+
/** The resolved mode after auto detection. */
|
|
56
|
+
readonly mode: "tty" | "lines" | "off";
|
|
57
|
+
/** Settles after the final frame is written; never rejects. */
|
|
58
|
+
readonly done: Promise<void>;
|
|
59
|
+
/** Force an immediate repaint outside the tick (tests, custom pacing). */
|
|
60
|
+
render(): void;
|
|
61
|
+
/**
|
|
62
|
+
* Idempotent. final=true (default) paints the settle frame; false
|
|
63
|
+
* freezes the current frame in scrollback. Always restores the cursor
|
|
64
|
+
* and resolves `done`.
|
|
65
|
+
*/
|
|
66
|
+
stop(final?: boolean): void;
|
|
67
|
+
}
|
|
68
|
+
type ProgressSource = RunHandle<unknown> | Promise<RunHandle<unknown>> | AsyncIterable<WorkflowEvent>;
|
|
69
|
+
/**
|
|
70
|
+
* Attaches a live progress view to a run and returns its handle. Accepts
|
|
71
|
+
* a RunHandle (subscribes through `on()`, leaving `handle.events` free
|
|
72
|
+
* for the host, and enriches the final frame from `RunOutcome.cost`;
|
|
73
|
+
* `orchestrate` returns exactly such a handle, so
|
|
74
|
+
* `progress(orchestrate(...))` composes directly), a promise resolving
|
|
75
|
+
* to a handle (for wrappers that construct one asynchronously), or a
|
|
76
|
+
* raw WorkflowEvent iterable (the gapless path for resumes:
|
|
77
|
+
* `progress(resumed.events)`; note it consumes that one-shot iterable).
|
|
78
|
+
* The view auto-stops when the run settles.
|
|
79
|
+
*/
|
|
80
|
+
declare function progress(source: ProgressSource, options?: ProgressOptions): ProgressHandle;
|
|
81
|
+
//#endregion
|
|
19
82
|
//#region src/defaults.d.ts
|
|
20
83
|
/**
|
|
21
84
|
* Drop-in engine defaults: `createEngine({ ..., defaults: { routing:
|
|
@@ -31,4 +94,4 @@ declare const recommendedDefaults: {
|
|
|
31
94
|
floors: QualityFloors;
|
|
32
95
|
};
|
|
33
96
|
//#endregion
|
|
34
|
-
export { ANTHROPIC_MODELS, type AnthropicAdapterOptions, OPENAI_MODELS, type OpenAiAdapterOptions, type RenderProgressOptions, anthropic, openai, recommendedDefaults, renderProgress };
|
|
97
|
+
export { ANTHROPIC_MODELS, type AnthropicAdapterOptions, OPENAI_MODELS, type OpenAiAdapterOptions, type ProgressClock, type ProgressHandle, type ProgressMode, type ProgressOptions, type ProgressSink, type ProgressSource, type RenderProgressOptions, anthropic, openai, progress, recommendedDefaults, renderProgress };
|
package/dist/index.js
CHANGED
|
@@ -49,6 +49,544 @@ async function renderProgress(events, options) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
//#endregion
|
|
52
|
+
//#region src/live-progress.ts
|
|
53
|
+
function fmtDuration(ms) {
|
|
54
|
+
const s = ms / 1e3;
|
|
55
|
+
if (s < 10) return `${s.toFixed(1)}s`;
|
|
56
|
+
if (s < 60) return `${String(Math.floor(s))}s`;
|
|
57
|
+
const minutes = Math.floor(s / 60);
|
|
58
|
+
if (minutes < 60) return `${String(minutes)}m ${String(Math.floor(s % 60)).padStart(2, "0")}s`;
|
|
59
|
+
return `${String(Math.floor(minutes / 60))}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
60
|
+
}
|
|
61
|
+
function fmtTokens(count) {
|
|
62
|
+
if (count < 1e3) return String(count);
|
|
63
|
+
if (count < 1e4) return `${(count / 1e3).toFixed(1)}k`;
|
|
64
|
+
if (count < 1e6) return `${String(Math.round(count / 1e3))}k`;
|
|
65
|
+
return `${(count / 1e6).toFixed(1)}M`;
|
|
66
|
+
}
|
|
67
|
+
function fmtUsd(amount) {
|
|
68
|
+
if (amount === 0) return "$0";
|
|
69
|
+
if (amount >= 100) return `$${String(Math.round(amount))}`;
|
|
70
|
+
if (amount >= 1) return `$${amount.toFixed(2)}`;
|
|
71
|
+
if (amount >= .01) return `$${amount.toFixed(3)}`;
|
|
72
|
+
return `$${amount.toFixed(4)}`;
|
|
73
|
+
}
|
|
74
|
+
function bar(spent, ceiling, cells) {
|
|
75
|
+
const ratio = ceiling <= 0 ? 1 : Math.min(1, spent / ceiling);
|
|
76
|
+
const filled = Math.round(ratio * cells);
|
|
77
|
+
return "#".repeat(filled) + ".".repeat(cells - filled);
|
|
78
|
+
}
|
|
79
|
+
const SPINNER = [
|
|
80
|
+
"|",
|
|
81
|
+
"/",
|
|
82
|
+
"-",
|
|
83
|
+
"\\"
|
|
84
|
+
];
|
|
85
|
+
function newState(title) {
|
|
86
|
+
return {
|
|
87
|
+
...title === void 0 ? {} : { title },
|
|
88
|
+
resumed: false,
|
|
89
|
+
status: "running",
|
|
90
|
+
spentUsd: 0,
|
|
91
|
+
nodes: /* @__PURE__ */ new Map(),
|
|
92
|
+
roots: [],
|
|
93
|
+
notices: [],
|
|
94
|
+
wakes: 0,
|
|
95
|
+
admitted: 0,
|
|
96
|
+
rejected: 0,
|
|
97
|
+
dirty: true
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function nodeOf(state, event, kind, title) {
|
|
101
|
+
const existing = state.nodes.get(event.spanId);
|
|
102
|
+
if (existing !== void 0) return existing;
|
|
103
|
+
const node = {
|
|
104
|
+
spanId: event.spanId,
|
|
105
|
+
...event.parentSpanId === void 0 ? {} : { parentSpanId: event.parentSpanId },
|
|
106
|
+
kind,
|
|
107
|
+
title,
|
|
108
|
+
status: "running",
|
|
109
|
+
replayed: event.replayed === true,
|
|
110
|
+
roles: [],
|
|
111
|
+
streamedChars: 0,
|
|
112
|
+
toolCount: 0,
|
|
113
|
+
children: [],
|
|
114
|
+
startedAt: 0
|
|
115
|
+
};
|
|
116
|
+
state.nodes.set(event.spanId, node);
|
|
117
|
+
const parent = event.parentSpanId === void 0 || event.parentSpanId === event.spanId ? void 0 : state.nodes.get(event.parentSpanId);
|
|
118
|
+
if (parent !== void 0) parent.children.push(event.spanId);
|
|
119
|
+
else state.roots.push(event.spanId);
|
|
120
|
+
return node;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Untrusted wire strings (model ids, tool names, error messages) may
|
|
124
|
+
* carry control characters; a raw newline or escape sequence in a frame
|
|
125
|
+
* would break the repaint arithmetic or leak terminal control. One
|
|
126
|
+
* space per control run, SGR added only by paint() afterwards.
|
|
127
|
+
*/
|
|
128
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]+/gu;
|
|
129
|
+
function scrub(text) {
|
|
130
|
+
return text.replace(CONTROL_CHARS, " ");
|
|
131
|
+
}
|
|
132
|
+
function agentTitle(event) {
|
|
133
|
+
const base = event.agentType === void 0 || event.agentType === "" ? "agent" : scrub(event.agentType);
|
|
134
|
+
return event.label === void 0 || event.label === "" ? base : `${base} (${scrub(event.label)})`;
|
|
135
|
+
}
|
|
136
|
+
/** The tool event's span is the agent's own span or a child of it. */
|
|
137
|
+
function toolTarget(state, event) {
|
|
138
|
+
return state.nodes.get(event.spanId) ?? (event.parentSpanId === void 0 ? void 0 : state.nodes.get(event.parentSpanId));
|
|
139
|
+
}
|
|
140
|
+
function applyEvent(state, event, now) {
|
|
141
|
+
state.dirty = true;
|
|
142
|
+
switch (event.type) {
|
|
143
|
+
case "run:start":
|
|
144
|
+
state.runId = event.runId;
|
|
145
|
+
state.resumed = event.resumed === true;
|
|
146
|
+
state.startedAt ??= now;
|
|
147
|
+
state.title ??= scrub(event.workflow);
|
|
148
|
+
break;
|
|
149
|
+
case "run:end":
|
|
150
|
+
state.status = event.status;
|
|
151
|
+
state.totalUsd = event.totalUsd;
|
|
152
|
+
state.endedAt = now;
|
|
153
|
+
for (const node of state.nodes.values()) {
|
|
154
|
+
if (node.endedAt === void 0 && node.kind !== "phase") {
|
|
155
|
+
node.endedAt = now;
|
|
156
|
+
if (node.status === "running" || node.status === "queued") node.status = "interrupted";
|
|
157
|
+
}
|
|
158
|
+
const open = node.roles.at(-1);
|
|
159
|
+
if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case "phase:start": {
|
|
163
|
+
const node = nodeOf(state, event, "phase", scrub(event.phase));
|
|
164
|
+
node.startedAt = now;
|
|
165
|
+
node.status = "ok";
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "budget:update":
|
|
169
|
+
state.spentUsd = event.spentUsd;
|
|
170
|
+
state.ceilingUsd = event.remainingUsd === null ? void 0 : event.spentUsd + event.remainingUsd;
|
|
171
|
+
break;
|
|
172
|
+
case "log":
|
|
173
|
+
if (event.level === "warn" || event.level === "error") {
|
|
174
|
+
state.notices.push(scrub(`${event.level}: ${event.msg}`));
|
|
175
|
+
if (state.notices.length > 2) state.notices.shift();
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
case "external:waiting":
|
|
179
|
+
state.banner = scrub(`waiting on external: ${event.key}`);
|
|
180
|
+
break;
|
|
181
|
+
case "approval:pending":
|
|
182
|
+
state.banner = scrub(`approval pending: ${event.toolName}`);
|
|
183
|
+
break;
|
|
184
|
+
case "child:start": {
|
|
185
|
+
const node = nodeOf(state, event, "child", scrub(`${event.workflow} (${event.scope})`));
|
|
186
|
+
node.startedAt = now;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "child:end": {
|
|
190
|
+
const node = state.nodes.get(event.spanId);
|
|
191
|
+
if (node !== void 0) {
|
|
192
|
+
node.status = event.status;
|
|
193
|
+
node.endedAt = now;
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "agent:queued": {
|
|
198
|
+
const node = nodeOf(state, event, "agent", agentTitle(event));
|
|
199
|
+
node.status = "queued";
|
|
200
|
+
node.startedAt = now;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case "agent:start": {
|
|
204
|
+
state.banner = void 0;
|
|
205
|
+
const node = nodeOf(state, event, "agent", agentTitle(event));
|
|
206
|
+
if (node.status === "queued" || node.roles.length === 0) node.startedAt = now;
|
|
207
|
+
node.status = "running";
|
|
208
|
+
node.model = scrub(event.model);
|
|
209
|
+
if (event.replayed === true) node.replayed = true;
|
|
210
|
+
const open = node.roles.at(-1);
|
|
211
|
+
if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
|
|
212
|
+
node.roles.push({
|
|
213
|
+
role: event.role,
|
|
214
|
+
startedAt: now
|
|
215
|
+
});
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case "agent:stream": {
|
|
219
|
+
const node = state.nodes.get(event.spanId);
|
|
220
|
+
if (node !== void 0) node.streamedChars += event.delta.length;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case "agent:error": {
|
|
224
|
+
const node = state.nodes.get(event.spanId);
|
|
225
|
+
if (node !== void 0) node.badge = event.willRetry ? "retry" : scrub(`error: ${event.error.message}`);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case "agent:schema-retry": {
|
|
229
|
+
const node = state.nodes.get(event.spanId);
|
|
230
|
+
if (node !== void 0) node.badge = `schema ${String(event.attempt)}/${String(event.maxAttempts)}`;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case "agent:end": {
|
|
234
|
+
state.banner = void 0;
|
|
235
|
+
const node = nodeOf(state, event, "agent", agentTitle(event));
|
|
236
|
+
node.status = event.status;
|
|
237
|
+
node.endedAt = now;
|
|
238
|
+
node.usage = {
|
|
239
|
+
input: event.usage.inputTokens,
|
|
240
|
+
output: event.usage.outputTokens
|
|
241
|
+
};
|
|
242
|
+
node.costUsd = event.costUsd;
|
|
243
|
+
if (event.replayed === true) node.replayed = true;
|
|
244
|
+
if (node.status === "ok") node.badge = void 0;
|
|
245
|
+
const open = node.roles.at(-1);
|
|
246
|
+
if (open !== void 0 && open.endedAt === void 0) open.endedAt = now;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case "tool:start": {
|
|
250
|
+
const node = toolTarget(state, event);
|
|
251
|
+
if (node !== void 0) node.toolActive = scrub(event.toolName);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case "tool:end": {
|
|
255
|
+
const node = toolTarget(state, event);
|
|
256
|
+
if (node !== void 0) {
|
|
257
|
+
node.toolActive = void 0;
|
|
258
|
+
node.toolCount += 1;
|
|
259
|
+
if (event.outcome === "denied") node.badge = scrub(`tool ${event.toolName} denied`);
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case "orchestrator:woke":
|
|
264
|
+
state.wakes += 1;
|
|
265
|
+
break;
|
|
266
|
+
case "spawn:admitted":
|
|
267
|
+
state.admitted += 1;
|
|
268
|
+
break;
|
|
269
|
+
case "spawn:rejected":
|
|
270
|
+
state.rejected += 1;
|
|
271
|
+
state.notices.push(`spawn rejected: ${event.code}`);
|
|
272
|
+
if (state.notices.length > 2) state.notices.shift();
|
|
273
|
+
break;
|
|
274
|
+
default: break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const GLYPHS = {
|
|
278
|
+
ok: "*",
|
|
279
|
+
running: "",
|
|
280
|
+
queued: "o",
|
|
281
|
+
error: "x",
|
|
282
|
+
cancelled: "x",
|
|
283
|
+
exhausted: "x",
|
|
284
|
+
interrupted: "x",
|
|
285
|
+
escalated: "!"
|
|
286
|
+
};
|
|
287
|
+
function paint(text, code, style) {
|
|
288
|
+
return style.color ? `[${code}m${text}[0m` : text;
|
|
289
|
+
}
|
|
290
|
+
function nodeRow(node, now, spinner, style) {
|
|
291
|
+
const running = node.endedAt === void 0 && node.status === "running";
|
|
292
|
+
const glyph = running ? node.replayed ? "*" : spinner : GLYPHS[node.status] ?? "*";
|
|
293
|
+
const parts = [glyph === "" ? spinner : glyph, node.title];
|
|
294
|
+
if (node.model !== void 0) parts.push(paint(node.model, "2", style));
|
|
295
|
+
const currentRole = node.roles.at(-1)?.role;
|
|
296
|
+
if (currentRole !== void 0 && currentRole !== "loop") parts.push(paint(`> ${currentRole}`, "36", style));
|
|
297
|
+
if (node.replayed) parts.push(paint("replay", "2", style));
|
|
298
|
+
else if (node.startedAt > 0 && node.kind !== "phase") {
|
|
299
|
+
const elapsed = (node.endedAt ?? now) - node.startedAt;
|
|
300
|
+
parts.push(fmtDuration(elapsed));
|
|
301
|
+
}
|
|
302
|
+
if (node.usage !== void 0) parts.push(`in ${fmtTokens(node.usage.input)} out ${fmtTokens(node.usage.output)}`);
|
|
303
|
+
else if (running && node.streamedChars > 0) parts.push(paint(`~${fmtTokens(Math.ceil(node.streamedChars / 4))} out`, "2", style));
|
|
304
|
+
if (node.costUsd !== void 0 && !node.replayed) parts.push(fmtUsd(node.costUsd));
|
|
305
|
+
if (node.toolActive !== void 0) parts.push(paint(`tool: ${node.toolActive}`, "33", style));
|
|
306
|
+
else if (node.toolCount > 0) parts.push(paint(`${String(node.toolCount)} tool${node.toolCount === 1 ? "" : "s"}`, "2", style));
|
|
307
|
+
if (node.badge !== void 0) parts.push(paint(node.badge, node.badge.startsWith("error") ? "31" : "33", style));
|
|
308
|
+
if (node.status === "queued") parts.push(paint("queued", "2", style));
|
|
309
|
+
return parts.join(" ");
|
|
310
|
+
}
|
|
311
|
+
function roleRow(node, now) {
|
|
312
|
+
if (node.roles.length < 2) return;
|
|
313
|
+
return `roles: ${node.roles.map((slice) => {
|
|
314
|
+
const elapsed = (slice.endedAt ?? now) - slice.startedAt;
|
|
315
|
+
return `${slice.role} ${fmtDuration(elapsed)}${slice.endedAt === void 0 ? ".." : ""}`;
|
|
316
|
+
}).join(" · ")}`;
|
|
317
|
+
}
|
|
318
|
+
function composeTree(state, now, spinner, style) {
|
|
319
|
+
const lines = [];
|
|
320
|
+
const visited = /* @__PURE__ */ new Set();
|
|
321
|
+
const walk = (spanId, depth) => {
|
|
322
|
+
const node = state.nodes.get(spanId);
|
|
323
|
+
if (node === void 0 || visited.has(spanId)) return;
|
|
324
|
+
visited.add(spanId);
|
|
325
|
+
const indent = " ".repeat(depth + 1);
|
|
326
|
+
if (node.kind === "phase") lines.push(`${indent}${paint(`[${node.title}]`, "1", style)}`);
|
|
327
|
+
else {
|
|
328
|
+
lines.push(indent + nodeRow(node, now, spinner, style));
|
|
329
|
+
const roles = roleRow(node, now);
|
|
330
|
+
if (roles !== void 0) lines.push(indent + " " + paint(roles, "2", style));
|
|
331
|
+
}
|
|
332
|
+
for (const child of node.children) walk(child, depth + 1);
|
|
333
|
+
};
|
|
334
|
+
for (const root of state.roots) walk(root, 0);
|
|
335
|
+
return lines;
|
|
336
|
+
}
|
|
337
|
+
function composeFrame(state, now, tick, style, width, maxRows, maxHeight) {
|
|
338
|
+
const spinner = SPINNER[tick % SPINNER.length];
|
|
339
|
+
const lines = [];
|
|
340
|
+
const running = state.endedAt === void 0;
|
|
341
|
+
const head = [running ? spinner : state.status === "ok" ? "*" : "x", state.title ?? "run"];
|
|
342
|
+
if (state.runId !== void 0) head.push(paint(`run ${state.runId}`, "2", style));
|
|
343
|
+
if (state.resumed) head.push(paint("(resumed)", "2", style));
|
|
344
|
+
if (state.startedAt !== void 0) head.push(fmtDuration((state.endedAt ?? now) - state.startedAt));
|
|
345
|
+
const spent = state.totalUsd ?? state.spentUsd;
|
|
346
|
+
if (state.ceilingUsd !== void 0) {
|
|
347
|
+
head.push(`${fmtUsd(spent)} / ${fmtUsd(state.ceilingUsd)}`);
|
|
348
|
+
head.push(bar(spent, state.ceilingUsd, 12));
|
|
349
|
+
} else if (spent > 0) head.push(fmtUsd(spent));
|
|
350
|
+
lines.push(head.join(" "));
|
|
351
|
+
let body = composeTree(state, now, spinner, style);
|
|
352
|
+
if (body.length > maxRows) {
|
|
353
|
+
const hidden = body.length - maxRows;
|
|
354
|
+
body = [paint(` ... ${String(hidden)} earlier rows hidden`, "2", style), ...body.slice(hidden)];
|
|
355
|
+
}
|
|
356
|
+
lines.push(...body);
|
|
357
|
+
if (state.wakes + state.admitted + state.rejected > 0) lines.push(" " + paint(`orch: wakes ${String(state.wakes)} · spawns ${String(state.admitted)} admitted, ${String(state.rejected)} rejected`, "2", style));
|
|
358
|
+
if (state.banner !== void 0 && running) lines.push(" " + paint(state.banner, "33", style));
|
|
359
|
+
for (const notice of state.notices) lines.push(" " + paint(notice, "33", style));
|
|
360
|
+
if (!running) {
|
|
361
|
+
const roleCosts = state.cost?.byRole;
|
|
362
|
+
if (roleCosts !== void 0) {
|
|
363
|
+
const nonzero = Object.entries(roleCosts).filter(([, value]) => value > 0);
|
|
364
|
+
if (nonzero.length > 0) lines.push(" " + nonzero.map(([role, value]) => `${role} ${fmtUsd(value)}`).join(" · "));
|
|
365
|
+
}
|
|
366
|
+
lines.push(`${state.status === "ok" ? "*" : "x"} ${state.status}` + (state.totalUsd === void 0 ? "" : ` total ${fmtUsd(state.totalUsd)}`));
|
|
367
|
+
}
|
|
368
|
+
let clamped = lines;
|
|
369
|
+
if (maxHeight !== void 0 && lines.length > maxHeight && maxHeight >= 3) {
|
|
370
|
+
const keepTail = Math.min(running ? 1 : 3, maxHeight - 2);
|
|
371
|
+
const hidden = lines.length - 1 - keepTail - 1;
|
|
372
|
+
clamped = [
|
|
373
|
+
lines[0] ?? "",
|
|
374
|
+
paint(` ... ${String(hidden)} lines hidden (terminal too short)`, "2", style),
|
|
375
|
+
...lines.slice(lines.length - keepTail)
|
|
376
|
+
];
|
|
377
|
+
}
|
|
378
|
+
return clamped.map((line) => {
|
|
379
|
+
const plain = line.replace(/\[[0-9;]*m/gu, "");
|
|
380
|
+
if (plain.length <= width - 1) return line;
|
|
381
|
+
return plain.slice(0, Math.max(0, width - 4)) + "...";
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
function defaultSink() {
|
|
385
|
+
return process.stderr;
|
|
386
|
+
}
|
|
387
|
+
function defaultClock() {
|
|
388
|
+
return {
|
|
389
|
+
now: () => performance.now(),
|
|
390
|
+
every: (ms, fn) => {
|
|
391
|
+
const timer = setInterval(fn, ms);
|
|
392
|
+
return () => clearInterval(timer);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function resolveMode(option, sink) {
|
|
397
|
+
if (option !== void 0 && option !== "auto") return option;
|
|
398
|
+
const dumb = process.env.TERM === "dumb" || process.env.CI !== void 0;
|
|
399
|
+
return sink.isTTY === true && !dumb ? "tty" : "lines";
|
|
400
|
+
}
|
|
401
|
+
function isHandle(source) {
|
|
402
|
+
return typeof source.on === "function" && typeof source.result?.then === "function";
|
|
403
|
+
}
|
|
404
|
+
/** Every event type the reducer renders; handle mode subscribes per type. */
|
|
405
|
+
const CONSUMED_TYPES = [
|
|
406
|
+
"run:start",
|
|
407
|
+
"run:end",
|
|
408
|
+
"phase:start",
|
|
409
|
+
"budget:update",
|
|
410
|
+
"log",
|
|
411
|
+
"external:waiting",
|
|
412
|
+
"approval:pending",
|
|
413
|
+
"child:start",
|
|
414
|
+
"child:end",
|
|
415
|
+
"agent:queued",
|
|
416
|
+
"agent:start",
|
|
417
|
+
"agent:stream",
|
|
418
|
+
"agent:error",
|
|
419
|
+
"agent:schema-retry",
|
|
420
|
+
"agent:end",
|
|
421
|
+
"tool:start",
|
|
422
|
+
"tool:end",
|
|
423
|
+
"orchestrator:woke",
|
|
424
|
+
"spawn:admitted",
|
|
425
|
+
"spawn:rejected"
|
|
426
|
+
];
|
|
427
|
+
/**
|
|
428
|
+
* Attaches a live progress view to a run and returns its handle. Accepts
|
|
429
|
+
* a RunHandle (subscribes through `on()`, leaving `handle.events` free
|
|
430
|
+
* for the host, and enriches the final frame from `RunOutcome.cost`;
|
|
431
|
+
* `orchestrate` returns exactly such a handle, so
|
|
432
|
+
* `progress(orchestrate(...))` composes directly), a promise resolving
|
|
433
|
+
* to a handle (for wrappers that construct one asynchronously), or a
|
|
434
|
+
* raw WorkflowEvent iterable (the gapless path for resumes:
|
|
435
|
+
* `progress(resumed.events)`; note it consumes that one-shot iterable).
|
|
436
|
+
* The view auto-stops when the run settles.
|
|
437
|
+
*/
|
|
438
|
+
function progress(source, options) {
|
|
439
|
+
const sink = options?.sink ?? defaultSink();
|
|
440
|
+
const clock = options?.clock ?? defaultClock();
|
|
441
|
+
const mode = resolveMode(options?.mode, sink);
|
|
442
|
+
const style = { color: options?.color ?? (mode === "tty" && process.env.NO_COLOR === void 0) };
|
|
443
|
+
const width = options?.width ?? sink.columns ?? 80;
|
|
444
|
+
const maxRows = options?.maxRows ?? Math.max(6, Math.min(24, (sink.rows ?? 32) - 8));
|
|
445
|
+
const fps = Math.min(30, Math.max(1, options?.fps ?? 10));
|
|
446
|
+
const state = newState(options?.title);
|
|
447
|
+
let settled = false;
|
|
448
|
+
let resolveDone = () => void 0;
|
|
449
|
+
const done = new Promise((resolve) => {
|
|
450
|
+
resolveDone = resolve;
|
|
451
|
+
});
|
|
452
|
+
if (mode === "off") {
|
|
453
|
+
if (!isHandle(source) && typeof source.then === "function") source.catch(() => void 0);
|
|
454
|
+
settled = true;
|
|
455
|
+
resolveDone();
|
|
456
|
+
return {
|
|
457
|
+
mode,
|
|
458
|
+
done,
|
|
459
|
+
render: () => void 0,
|
|
460
|
+
stop: () => void 0
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
let tick = 0;
|
|
464
|
+
let paintedLines = 0;
|
|
465
|
+
let lastLinesBudgetAt;
|
|
466
|
+
const paintFrame = () => {
|
|
467
|
+
const maxHeight = sink.rows === void 0 ? void 0 : Math.max(3, sink.rows - 1);
|
|
468
|
+
const frame = composeFrame(state, clock.now(), tick, style, width, maxRows, maxHeight);
|
|
469
|
+
const erase = paintedLines > 0 ? `[${String(paintedLines)}A[0J` : "";
|
|
470
|
+
sink.write(erase + frame.join("\n") + "\n");
|
|
471
|
+
paintedLines = frame.length;
|
|
472
|
+
state.dirty = false;
|
|
473
|
+
};
|
|
474
|
+
const lineFor = (event, now) => {
|
|
475
|
+
switch (event.type) {
|
|
476
|
+
case "run:start": return `run ${event.runId} started: ${event.workflow}${event.resumed ? " (resumed)" : ""}`;
|
|
477
|
+
case "phase:start": return `phase: ${event.phase}`;
|
|
478
|
+
case "agent:start": {
|
|
479
|
+
const node = state.nodes.get(event.spanId);
|
|
480
|
+
const inner = node !== void 0 && node.roles.length > 1;
|
|
481
|
+
return `agent ${agentTitle(event)} -> ${event.model} (${event.role})${inner ? " [inner phase]" : ""}`;
|
|
482
|
+
}
|
|
483
|
+
case "agent:end": {
|
|
484
|
+
const node = state.nodes.get(event.spanId);
|
|
485
|
+
const elapsed = node === void 0 || node.replayed || node.startedAt <= 0 ? "" : ` in ${fmtDuration((node.endedAt ?? now) - node.startedAt)}`;
|
|
486
|
+
const roles = node !== void 0 && node.roles.length > 1 ? ` [${node.roles.map((slice) => slice.role).join(" > ")}]` : "";
|
|
487
|
+
return `agent ${agentTitle(event)} ${event.status}${elapsed}: in ${fmtTokens(event.usage.inputTokens)} out ${fmtTokens(event.usage.outputTokens)}, ${fmtUsd(event.costUsd)}${roles}${event.replayed === true ? " (replay)" : ""}`;
|
|
488
|
+
}
|
|
489
|
+
case "agent:error": return `agent ${agentTitle(event)} error: ${event.error.message}` + (event.willRetry ? " (will retry)" : "");
|
|
490
|
+
case "budget:update":
|
|
491
|
+
if (lastLinesBudgetAt !== void 0 && now - lastLinesBudgetAt < 1e3) return;
|
|
492
|
+
lastLinesBudgetAt = now;
|
|
493
|
+
return `budget: ${fmtUsd(event.spentUsd)}` + (event.remainingUsd === null ? "" : ` of ${fmtUsd(event.spentUsd + event.remainingUsd)}`);
|
|
494
|
+
case "log": return event.level === "warn" || event.level === "error" ? `[${event.level}] ${event.msg}` : void 0;
|
|
495
|
+
case "external:waiting": return `waiting on external: ${event.key}`;
|
|
496
|
+
case "approval:pending": return `approval pending: ${event.toolName}`;
|
|
497
|
+
case "run:end": return `run finished: ${event.status} (total ${fmtUsd(event.totalUsd)})`;
|
|
498
|
+
default: return;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const onEvent = (event) => {
|
|
502
|
+
const now = clock.now();
|
|
503
|
+
applyEvent(state, event, now);
|
|
504
|
+
if (mode === "lines") {
|
|
505
|
+
const line = lineFor(event, now);
|
|
506
|
+
if (line !== void 0) sink.write(line + "\n");
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const finishLines = () => {
|
|
510
|
+
const roleCosts = state.cost?.byRole;
|
|
511
|
+
if (roleCosts === void 0) return;
|
|
512
|
+
const nonzero = Object.entries(roleCosts).filter(([, value]) => value > 0);
|
|
513
|
+
if (nonzero.length > 0) sink.write(`cost by role: ${nonzero.map(([r, v]) => `${r} ${fmtUsd(v)}`).join(", ")}\n`);
|
|
514
|
+
};
|
|
515
|
+
let cancelTick;
|
|
516
|
+
let unsubscribe = [];
|
|
517
|
+
const handleApi = {
|
|
518
|
+
mode,
|
|
519
|
+
done,
|
|
520
|
+
render: () => {
|
|
521
|
+
if (!settled && mode === "tty") {
|
|
522
|
+
tick += 1;
|
|
523
|
+
paintFrame();
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
stop: (final = true) => {
|
|
527
|
+
if (settled) return;
|
|
528
|
+
settled = true;
|
|
529
|
+
cancelTick?.();
|
|
530
|
+
for (const off of unsubscribe) off();
|
|
531
|
+
unsubscribe = [];
|
|
532
|
+
if (mode === "tty") {
|
|
533
|
+
if (final) paintFrame();
|
|
534
|
+
sink.write("\x1B[?25h");
|
|
535
|
+
} else if (final) finishLines();
|
|
536
|
+
resolveDone();
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
if (mode === "tty") {
|
|
540
|
+
sink.write("\x1B[?25l");
|
|
541
|
+
cancelTick = clock.every(Math.round(1e3 / fps), () => {
|
|
542
|
+
if (settled) return;
|
|
543
|
+
tick += 1;
|
|
544
|
+
const anyRunning = state.endedAt === void 0;
|
|
545
|
+
if (state.dirty || anyRunning) paintFrame();
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const attachHandle = (handle) => {
|
|
549
|
+
if (settled) {
|
|
550
|
+
handle.result.catch(() => void 0);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
for (const type of CONSUMED_TYPES) unsubscribe.push(handle.on(type, onEvent));
|
|
554
|
+
handle.result.then((outcome) => {
|
|
555
|
+
state.cost = outcome.cost;
|
|
556
|
+
state.status = outcome.status;
|
|
557
|
+
state.totalUsd = outcome.cost.totalUsd;
|
|
558
|
+
state.endedAt ??= clock.now();
|
|
559
|
+
}).catch((thrown) => {
|
|
560
|
+
state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
|
|
561
|
+
state.endedAt ??= clock.now();
|
|
562
|
+
}).finally(() => {
|
|
563
|
+
handleApi.stop(true);
|
|
564
|
+
});
|
|
565
|
+
};
|
|
566
|
+
if (isHandle(source)) attachHandle(source);
|
|
567
|
+
else if (typeof source.then === "function") source.then((handle) => {
|
|
568
|
+
attachHandle(handle);
|
|
569
|
+
}).catch((thrown) => {
|
|
570
|
+
state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
|
|
571
|
+
state.endedAt ??= clock.now();
|
|
572
|
+
handleApi.stop(true);
|
|
573
|
+
});
|
|
574
|
+
else (async () => {
|
|
575
|
+
try {
|
|
576
|
+
for await (const event of source) {
|
|
577
|
+
if (settled) break;
|
|
578
|
+
onEvent(event);
|
|
579
|
+
}
|
|
580
|
+
} catch (thrown) {
|
|
581
|
+
state.notices.push(`error: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
|
|
582
|
+
} finally {
|
|
583
|
+
state.endedAt ??= clock.now();
|
|
584
|
+
handleApi.stop(true);
|
|
585
|
+
}
|
|
586
|
+
})();
|
|
587
|
+
return handleApi;
|
|
588
|
+
}
|
|
589
|
+
//#endregion
|
|
52
590
|
//#region src/defaults.ts
|
|
53
591
|
/**
|
|
54
592
|
* Drop-in engine defaults: `createEngine({ ..., defaults: { routing:
|
|
@@ -98,4 +636,4 @@ const recommendedDefaults = {
|
|
|
98
636
|
} }
|
|
99
637
|
};
|
|
100
638
|
//#endregion
|
|
101
|
-
export { ANTHROPIC_MODELS, OPENAI_MODELS, anthropic, openai, recommendedDefaults, renderProgress };
|
|
639
|
+
export { ANTHROPIC_MODELS, OPENAI_MODELS, anthropic, openai, progress, recommendedDefaults, renderProgress };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/rulvar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "Rulvar umbrella package: re-exports @rulvar/core, both first-class adapters, the file store, and the terminal progress renderer. Also installable through the unscoped alias package rulvar, which re-exports this one.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,16 +22,16 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@rulvar/core": "1.
|
|
26
|
-
"@rulvar/anthropic": "1.
|
|
27
|
-
"@rulvar/openai": "1.
|
|
25
|
+
"@rulvar/core": "1.21.0",
|
|
26
|
+
"@rulvar/anthropic": "1.21.0",
|
|
27
|
+
"@rulvar/openai": "1.21.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^22.20.0",
|
|
31
31
|
"tsdown": "^0.22.3",
|
|
32
32
|
"typescript": "~6.0.3",
|
|
33
33
|
"zod": "^4.4.3",
|
|
34
|
-
"@rulvar/testing": "1.
|
|
34
|
+
"@rulvar/testing": "1.21.0"
|
|
35
35
|
},
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|