pi-taskflow 0.0.5 → 0.0.7
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/DESIGN.md +15 -1
- package/README.md +115 -16
- package/examples/conditional-research.json +56 -0
- package/examples/guarded-refactor.json +50 -0
- package/extensions/agents.ts +8 -1
- package/extensions/index.ts +42 -18
- package/extensions/interpolate.ts +232 -1
- package/extensions/render.ts +47 -35
- package/extensions/runner.ts +127 -80
- package/extensions/runtime.ts +480 -54
- package/extensions/schema.ts +218 -6
- package/extensions/store.ts +76 -4
- package/extensions/usage.ts +42 -0
- package/package.json +2 -2
- package/skills/taskflow/SKILL.md +146 -2
- package/skills/taskflow/configuration.md +0 -2
|
@@ -20,7 +20,7 @@ export interface InterpolationContext {
|
|
|
20
20
|
locals?: Record<string, unknown>;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
const PLACEHOLDER = /\{([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*)\}/g;
|
|
23
|
+
const PLACEHOLDER = /\{([a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*)\}/g;
|
|
24
24
|
|
|
25
25
|
export interface InterpolationResult {
|
|
26
26
|
text: string;
|
|
@@ -149,3 +149,234 @@ export function coerceArray(value: unknown): unknown[] | null {
|
|
|
149
149
|
}
|
|
150
150
|
return null;
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Conditional expressions (phase.when)
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
//
|
|
157
|
+
// A tiny, safe boolean expression language — NO eval / Function. Operands are
|
|
158
|
+
// either interpolation placeholders `{...}` (resolved to their raw value) or
|
|
159
|
+
// literals (quoted string, number, true/false/null, or a bare word treated as
|
|
160
|
+
// a string). Operators, by precedence (low → high):
|
|
161
|
+
//
|
|
162
|
+
// || logical or
|
|
163
|
+
// && logical and
|
|
164
|
+
// == != == >= <= > < comparison
|
|
165
|
+
// ! logical not / unary
|
|
166
|
+
// ( ) grouping
|
|
167
|
+
//
|
|
168
|
+
// A bare operand is evaluated for truthiness. Parse errors fail OPEN (return
|
|
169
|
+
// true) so a malformed guard never silently drops a phase.
|
|
170
|
+
|
|
171
|
+
type Tok =
|
|
172
|
+
| { t: "ref"; v: string }
|
|
173
|
+
| { t: "str"; v: string }
|
|
174
|
+
| { t: "num"; v: number }
|
|
175
|
+
| { t: "bool"; v: boolean }
|
|
176
|
+
| { t: "null" }
|
|
177
|
+
| { t: "op"; v: string };
|
|
178
|
+
|
|
179
|
+
const OPS = ["&&", "||", "==", "!=", ">=", "<=", ">", "<", "!", "(", ")"];
|
|
180
|
+
|
|
181
|
+
function tokenize(input: string): Tok[] {
|
|
182
|
+
const toks: Tok[] = [];
|
|
183
|
+
let i = 0;
|
|
184
|
+
const n = input.length;
|
|
185
|
+
while (i < n) {
|
|
186
|
+
const c = input[i];
|
|
187
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
188
|
+
i++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// placeholder {path.to.value}
|
|
192
|
+
if (c === "{") {
|
|
193
|
+
const end = input.indexOf("}", i);
|
|
194
|
+
if (end === -1) throw new Error("unterminated placeholder");
|
|
195
|
+
toks.push({ t: "ref", v: input.slice(i + 1, end).trim() });
|
|
196
|
+
i = end + 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// quoted string
|
|
200
|
+
if (c === '"' || c === "'") {
|
|
201
|
+
const end = input.indexOf(c, i + 1);
|
|
202
|
+
if (end === -1) throw new Error("unterminated string");
|
|
203
|
+
toks.push({ t: "str", v: input.slice(i + 1, end) });
|
|
204
|
+
i = end + 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// multi/single char operators
|
|
208
|
+
const op = OPS.find((o) => input.startsWith(o, i));
|
|
209
|
+
if (op) {
|
|
210
|
+
toks.push({ t: "op", v: op });
|
|
211
|
+
i += op.length;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
// number
|
|
215
|
+
const numMatch = /^-?\d+(?:\.\d+)?/.exec(input.slice(i));
|
|
216
|
+
if (numMatch) {
|
|
217
|
+
toks.push({ t: "num", v: Number(numMatch[0]) });
|
|
218
|
+
i += numMatch[0].length;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// bareword → literal (true/false/null keywords, else string)
|
|
222
|
+
const word = /^[^\s&|!=<>()"'{}]+/.exec(input.slice(i));
|
|
223
|
+
if (word) {
|
|
224
|
+
const w = word[0];
|
|
225
|
+
if (w === "true") toks.push({ t: "bool", v: true });
|
|
226
|
+
else if (w === "false") toks.push({ t: "bool", v: false });
|
|
227
|
+
else if (w === "null") toks.push({ t: "null" });
|
|
228
|
+
else toks.push({ t: "str", v: w });
|
|
229
|
+
i += w.length;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
throw new Error(`unexpected char '${c}'`);
|
|
233
|
+
}
|
|
234
|
+
return toks;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isNumeric(v: unknown): boolean {
|
|
238
|
+
if (typeof v === "number") return Number.isFinite(v);
|
|
239
|
+
if (typeof v === "string" && v.trim() !== "") return Number.isFinite(Number(v));
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function truthy(v: unknown): boolean {
|
|
244
|
+
if (v === undefined || v === null) return false;
|
|
245
|
+
if (typeof v === "boolean") return v;
|
|
246
|
+
if (typeof v === "number") return v !== 0;
|
|
247
|
+
if (typeof v === "string") {
|
|
248
|
+
const s = v.trim().toLowerCase();
|
|
249
|
+
return !(s === "" || s === "false" || s === "0" || s === "no" || s === "off" || s === "null");
|
|
250
|
+
}
|
|
251
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
252
|
+
if (typeof v === "object") return Object.keys(v as object).length > 0;
|
|
253
|
+
return Boolean(v);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function compare(a: unknown, op: string, b: unknown): boolean {
|
|
257
|
+
if (isNumeric(a) && isNumeric(b)) {
|
|
258
|
+
const x = Number(a);
|
|
259
|
+
const y = Number(b);
|
|
260
|
+
switch (op) {
|
|
261
|
+
case "==": return x === y;
|
|
262
|
+
case "!=": return x !== y;
|
|
263
|
+
case ">": return x > y;
|
|
264
|
+
case "<": return x < y;
|
|
265
|
+
case ">=": return x >= y;
|
|
266
|
+
case "<=": return x <= y;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const sa = a === undefined || a === null ? "" : String(a);
|
|
270
|
+
const sb = b === undefined || b === null ? "" : String(b);
|
|
271
|
+
switch (op) {
|
|
272
|
+
case "==": return sa === sb;
|
|
273
|
+
case "!=": return sa !== sb;
|
|
274
|
+
case ">": return sa > sb;
|
|
275
|
+
case "<": return sa < sb;
|
|
276
|
+
case ">=": return sa >= sb;
|
|
277
|
+
case "<=": return sa <= sb;
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Recursive-descent parser/evaluator over the token stream. */
|
|
283
|
+
class CondParser {
|
|
284
|
+
private pos = 0;
|
|
285
|
+
private readonly toks: Tok[];
|
|
286
|
+
private readonly ctx: InterpolationContext;
|
|
287
|
+
constructor(toks: Tok[], ctx: InterpolationContext) {
|
|
288
|
+
this.toks = toks;
|
|
289
|
+
this.ctx = ctx;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
parse(): unknown {
|
|
293
|
+
const v = this.parseOr();
|
|
294
|
+
if (this.pos < this.toks.length) throw new Error("trailing tokens");
|
|
295
|
+
return v;
|
|
296
|
+
}
|
|
297
|
+
private peek(): Tok | undefined {
|
|
298
|
+
return this.toks[this.pos];
|
|
299
|
+
}
|
|
300
|
+
private eat(op: string): boolean {
|
|
301
|
+
const t = this.peek();
|
|
302
|
+
if (t && t.t === "op" && t.v === op) {
|
|
303
|
+
this.pos++;
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
private parseOr(): unknown {
|
|
309
|
+
let left = this.parseAnd();
|
|
310
|
+
while (this.eat("||")) {
|
|
311
|
+
const right = this.parseAnd();
|
|
312
|
+
left = truthy(left) || truthy(right);
|
|
313
|
+
}
|
|
314
|
+
return left;
|
|
315
|
+
}
|
|
316
|
+
private parseAnd(): unknown {
|
|
317
|
+
let left = this.parseNot();
|
|
318
|
+
while (this.eat("&&")) {
|
|
319
|
+
const right = this.parseNot();
|
|
320
|
+
left = truthy(left) && truthy(right);
|
|
321
|
+
}
|
|
322
|
+
return left;
|
|
323
|
+
}
|
|
324
|
+
private parseNot(): unknown {
|
|
325
|
+
if (this.eat("!")) return !truthy(this.parseNot());
|
|
326
|
+
return this.parseComparison();
|
|
327
|
+
}
|
|
328
|
+
private parseComparison(): unknown {
|
|
329
|
+
const left = this.parsePrimary();
|
|
330
|
+
const t = this.peek();
|
|
331
|
+
if (t && t.t === "op" && ["==", "!=", ">", "<", ">=", "<="].includes(t.v)) {
|
|
332
|
+
this.pos++;
|
|
333
|
+
const right = this.parsePrimary();
|
|
334
|
+
return compare(left, t.v, right);
|
|
335
|
+
}
|
|
336
|
+
return left;
|
|
337
|
+
}
|
|
338
|
+
private parsePrimary(): unknown {
|
|
339
|
+
if (this.eat("(")) {
|
|
340
|
+
const v = this.parseOr();
|
|
341
|
+
if (!this.eat(")")) throw new Error("missing )");
|
|
342
|
+
return v;
|
|
343
|
+
}
|
|
344
|
+
const t = this.peek();
|
|
345
|
+
if (!t) throw new Error("unexpected end");
|
|
346
|
+
this.pos++;
|
|
347
|
+
switch (t.t) {
|
|
348
|
+
case "ref": return resolvePath(t.v, this.ctx);
|
|
349
|
+
case "str": return t.v;
|
|
350
|
+
case "num": return t.v;
|
|
351
|
+
case "bool": return t.v;
|
|
352
|
+
case "null": return null;
|
|
353
|
+
default: throw new Error(`unexpected operator '${(t as { v: string }).v}'`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Evaluate a `when` expression to a boolean. Returns `{ value, error }`.
|
|
360
|
+
* Parse errors set `error` and fail OPEN (`value: true`) so a broken guard
|
|
361
|
+
* never silently drops a phase.
|
|
362
|
+
*/
|
|
363
|
+
export function tryEvaluateCondition(
|
|
364
|
+
expr: string,
|
|
365
|
+
ctx: InterpolationContext,
|
|
366
|
+
): { value: boolean; error?: string } {
|
|
367
|
+
const trimmed = (expr ?? "").trim();
|
|
368
|
+
if (!trimmed) return { value: true };
|
|
369
|
+
try {
|
|
370
|
+
const toks = tokenize(trimmed);
|
|
371
|
+
if (toks.length === 0) return { value: true };
|
|
372
|
+
const result = new CondParser(toks, ctx).parse();
|
|
373
|
+
return { value: truthy(result) };
|
|
374
|
+
} catch (e) {
|
|
375
|
+
return { value: true, error: e instanceof Error ? e.message : String(e) };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Boolean convenience wrapper over {@link tryEvaluateCondition}. */
|
|
380
|
+
export function evaluateCondition(expr: string, ctx: InterpolationContext): boolean {
|
|
381
|
+
return tryEvaluateCondition(expr, ctx).value;
|
|
382
|
+
}
|
package/extensions/render.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
10
|
-
import {
|
|
10
|
+
import { type UsageStats } from "./usage.ts";
|
|
11
11
|
import type { PhaseState, RunState } from "./store.ts";
|
|
12
12
|
import { dependenciesOf, type Phase, topoLayers } from "./schema.ts";
|
|
13
13
|
|
|
@@ -62,23 +62,16 @@ function miniBar(done: number, total: number, theme: Theme, width = 8): string {
|
|
|
62
62
|
return theme.fg("accent", "━".repeat(filled)) + theme.fg("dim", "─".repeat(width - filled));
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
if (usage.output) parts.push(theme.fg("dim", `↓${formatTokens(usage.output)}`));
|
|
71
|
-
if (usage.cost) parts.push(theme.fg("muted", `$${usage.cost.toFixed(3)}`));
|
|
72
|
-
return parts.join(" ");
|
|
65
|
+
function agentRole(phase: Phase, ps: PhaseState | undefined, theme: Theme): string {
|
|
66
|
+
const role = phase.agent ?? phase.type ?? "agent";
|
|
67
|
+
const model = ps?.model ? shortModel(ps.model) : "";
|
|
68
|
+
if (!model) return theme.fg("accent", role);
|
|
69
|
+
return theme.fg("accent", role) + theme.fg("dim", `(${model})`);
|
|
73
70
|
}
|
|
74
71
|
|
|
75
|
-
function
|
|
76
|
-
if (!usage) return "";
|
|
77
|
-
|
|
78
|
-
if (usage.input) parts.push(theme.fg("dim", `↑${formatTokens(usage.input)}`));
|
|
79
|
-
if (usage.output) parts.push(theme.fg("dim", `↓${formatTokens(usage.output)}`));
|
|
80
|
-
if (usage.cost) parts.push(theme.fg("muted", `$${usage.cost.toFixed(3)}`));
|
|
81
|
-
return parts.join(" ");
|
|
72
|
+
function costStr(usage: UsageStats | undefined, theme: Theme): string {
|
|
73
|
+
if (!usage?.cost) return "";
|
|
74
|
+
return theme.fg("muted", `$${usage.cost.toFixed(3)}`);
|
|
82
75
|
}
|
|
83
76
|
|
|
84
77
|
function aggregateCost(state: RunState): number {
|
|
@@ -118,10 +111,10 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
118
111
|
if (ps.status === "skipped") {
|
|
119
112
|
const reason = (ps.error ?? "upstream failed").replace(/\s+/g, " ");
|
|
120
113
|
const snip = reason.length > 52 ? `${reason.slice(0, 52)}…` : reason;
|
|
121
|
-
return theme.fg("muted", `skipped · ${snip}`);
|
|
114
|
+
return theme.fg("muted", `skipped · ${snip}`) + (ps.warnings?.length ? theme.fg("warning", ` ⚠${ps.warnings.length}`) : "");
|
|
122
115
|
}
|
|
123
116
|
|
|
124
|
-
const isFanout = type === "map" || type === "parallel";
|
|
117
|
+
const isFanout = type === "map" || type === "parallel" || type === "flow";
|
|
125
118
|
|
|
126
119
|
if (ps.status === "failed") {
|
|
127
120
|
const e = (ps.error ?? "failed").replace(/\s+/g, " ");
|
|
@@ -131,30 +124,34 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
131
124
|
return (
|
|
132
125
|
theme.fg("toolOutput", `${done - failed}/${total}`) +
|
|
133
126
|
theme.fg("error", ` ${failed}✗`) +
|
|
134
|
-
(snip ? theme.fg("error", ` ${snip}`) : "")
|
|
127
|
+
(snip ? theme.fg("error", ` ${snip}`) : "") +
|
|
128
|
+
(ps.warnings?.length ? theme.fg("warning", ` ⚠${ps.warnings.length}`) : "")
|
|
135
129
|
);
|
|
136
130
|
}
|
|
137
|
-
return theme.fg("error", snip);
|
|
131
|
+
return theme.fg("error", snip) + (ps.warnings?.length ? theme.fg("warning", ` ⚠${ps.warnings.length}`) : "");
|
|
138
132
|
}
|
|
139
133
|
|
|
140
134
|
const t = phaseElapsed(ps);
|
|
141
135
|
const time = t ? theme.fg("dim", elapsed(t)) : "";
|
|
142
136
|
|
|
143
137
|
if (ps.status === "running") {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
138
|
+
const roleLabel = agentRole(phase, ps, theme);
|
|
139
|
+
const cost = costStr(ps.usage, theme);
|
|
146
140
|
if (isFanout && ps.subProgress) {
|
|
147
141
|
const { done, total, running, failed } = ps.subProgress;
|
|
148
142
|
let s = `${miniBar(done, total, theme)} ${theme.fg("toolOutput", `${done}/${total}`)}`;
|
|
149
143
|
if (running) s += theme.fg("dim", ` · ${running} run`);
|
|
150
144
|
if (failed) s += theme.fg("error", ` · ${failed}✗`);
|
|
151
|
-
|
|
145
|
+
s += ` ${roleLabel}`;
|
|
146
|
+
if (cost) s += ` ${cost}`;
|
|
152
147
|
if (time) s += ` ${time}`;
|
|
148
|
+
if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
153
149
|
return s;
|
|
154
150
|
}
|
|
155
|
-
let s =
|
|
156
|
-
if (
|
|
151
|
+
let s = roleLabel;
|
|
152
|
+
if (cost) s += ` ${cost}`;
|
|
157
153
|
if (time) s += ` ${time}`;
|
|
154
|
+
if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
158
155
|
return s;
|
|
159
156
|
}
|
|
160
157
|
|
|
@@ -163,14 +160,24 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
163
160
|
const { done = 0, total = 0, failed = 0 } = ps.subProgress ?? {};
|
|
164
161
|
let s = theme.fg("success", `${total}✓`);
|
|
165
162
|
if (failed) s = theme.fg("toolOutput", `${done - failed}/${total}`) + theme.fg("error", ` ${failed}✗`);
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
163
|
+
const cost = costStr(ps.usage, theme);
|
|
164
|
+
if (cost) s += ` ${cost}`;
|
|
168
165
|
if (time) s += ` ${time}`;
|
|
166
|
+
if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
169
167
|
return s;
|
|
170
168
|
}
|
|
171
169
|
// single-agent done
|
|
172
|
-
const
|
|
173
|
-
const
|
|
170
|
+
const roleLabel = agentRole(phase, ps, theme);
|
|
171
|
+
const cost = costStr(ps.usage, theme);
|
|
172
|
+
if (ps.approval) {
|
|
173
|
+
const d = ps.approval.decision;
|
|
174
|
+
const color = d === "reject" ? "error" : d === "edit" ? "warning" : "success";
|
|
175
|
+
let a = theme.fg("warning", "⚠") + " " + theme.fg(color as Parameters<typeof theme.fg>[0], theme.bold(d.toUpperCase()));
|
|
176
|
+
if (ps.approval.auto) a += theme.fg("dim", " auto");
|
|
177
|
+
if (time) a += ` ${time}`;
|
|
178
|
+
if (ps.warnings?.length) a += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
179
|
+
return a;
|
|
180
|
+
}
|
|
174
181
|
if (ps.gate) {
|
|
175
182
|
const badge =
|
|
176
183
|
ps.gate.verdict === "block" ? theme.fg("error", theme.bold("BLOCK")) : theme.fg("success", "PASS");
|
|
@@ -179,15 +186,18 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
179
186
|
const r = ps.gate.reason.replace(/\s+/g, " ");
|
|
180
187
|
g += theme.fg("dim", ` ${r.length > 44 ? `${r.slice(0, 44)}…` : r}`);
|
|
181
188
|
}
|
|
182
|
-
|
|
189
|
+
const cost = costStr(ps.usage, theme);
|
|
190
|
+
if (cost) g += ` ${cost}`;
|
|
183
191
|
if (time) g += ` ${time}`;
|
|
192
|
+
if (ps.warnings?.length) g += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
184
193
|
return g;
|
|
185
194
|
}
|
|
186
|
-
let s =
|
|
187
|
-
if (
|
|
188
|
-
if (
|
|
195
|
+
let s = roleLabel;
|
|
196
|
+
if (cost) s += ` ${cost}`;
|
|
197
|
+
if (ps.attempts && ps.attempts > 1) s += theme.fg("warning", ` ↻${ps.attempts - 1}`);
|
|
189
198
|
if (time) s += ` ${time}`;
|
|
190
|
-
|
|
199
|
+
if (ps.warnings?.length) s += theme.fg("warning", ` ⚠${ps.warnings.length}`);
|
|
200
|
+
return s;
|
|
191
201
|
}
|
|
192
202
|
|
|
193
203
|
/** Header line: status glyph + name + compact totals. */
|
|
@@ -217,7 +227,9 @@ function headerLine(state: RunState, theme: Theme): string {
|
|
|
217
227
|
if (failed) line += theme.fg("error", ` · ${failed}✗`);
|
|
218
228
|
if (state.status === "blocked") line += theme.fg("error", " · blocked");
|
|
219
229
|
const cost = aggregateCost(state);
|
|
220
|
-
|
|
230
|
+
const budget = state.def.budget;
|
|
231
|
+
if (budget?.maxUSD !== undefined) line += theme.fg("muted", ` · $${cost.toFixed(3)}/$${budget.maxUSD}`);
|
|
232
|
+
else if (cost) line += theme.fg("muted", ` · $${cost.toFixed(3)}`);
|
|
221
233
|
const el = runElapsed(state);
|
|
222
234
|
if (el) line += theme.fg("dim", ` · ${elapsed(el)}`);
|
|
223
235
|
return line;
|
package/extensions/runner.ts
CHANGED
|
@@ -11,20 +11,7 @@ import * as path from "node:path";
|
|
|
11
11
|
import type { Message } from "@earendil-works/pi-ai";
|
|
12
12
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { AgentConfig } from "./agents.ts";
|
|
14
|
-
|
|
15
|
-
export interface UsageStats {
|
|
16
|
-
input: number;
|
|
17
|
-
output: number;
|
|
18
|
-
cacheRead: number;
|
|
19
|
-
cacheWrite: number;
|
|
20
|
-
cost: number;
|
|
21
|
-
contextTokens: number;
|
|
22
|
-
turns: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function emptyUsage(): UsageStats {
|
|
26
|
-
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
27
|
-
}
|
|
14
|
+
import { emptyUsage, type UsageStats } from "./usage.ts";
|
|
28
15
|
|
|
29
16
|
export interface RunResult {
|
|
30
17
|
agent: string;
|
|
@@ -36,6 +23,8 @@ export interface RunResult {
|
|
|
36
23
|
model?: string;
|
|
37
24
|
stopReason?: string;
|
|
38
25
|
errorMessage?: string;
|
|
26
|
+
/** Total subagent attempts incl. retries (set by the runtime's retry wrapper). */
|
|
27
|
+
attempts?: number;
|
|
39
28
|
}
|
|
40
29
|
|
|
41
30
|
export interface LiveUpdate {
|
|
@@ -59,18 +48,123 @@ export function isFailed(r: RunResult): boolean {
|
|
|
59
48
|
return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
|
|
60
49
|
}
|
|
61
50
|
|
|
51
|
+
/** Placeholder written to a failed phase's `output` so downstream interpolation
|
|
52
|
+
* can detect "upstream failed" without being polluted by raw HTML/JSON. */
|
|
53
|
+
export const TRANSPORT_ERROR_PLACEHOLDER = "(upstream error: subagent failed; see error)";
|
|
54
|
+
|
|
55
|
+
/** Hard cap on the errorMessage field stored in PhaseState (≈ 4 KB). */
|
|
56
|
+
export const ERROR_MESSAGE_MAX_LEN = 4096;
|
|
57
|
+
|
|
58
|
+
/** Cheap HTML/JSON detector so we can summarize upstream garbage. */
|
|
59
|
+
export function looksLikeHtmlOrJson(s: string): boolean {
|
|
60
|
+
const t = s.trimStart();
|
|
61
|
+
if (!t) return false;
|
|
62
|
+
if (t.startsWith("<")) {
|
|
63
|
+
// HTML/XML/Cloudflare challenge pages
|
|
64
|
+
return /^<(?:!doctype\s+html|html|head|body|script|svg|div|iframe|span|p)\b/i.test(t);
|
|
65
|
+
}
|
|
66
|
+
if (t.startsWith("{")) {
|
|
67
|
+
// Truncated JSON. A genuine JSON envelope is fine to keep; an unwrapped
|
|
68
|
+
// {error: "..."} from an SDK is short. We only treat it as "garbage" if
|
|
69
|
+
// it parses and is huge — but that's caught by the size cap below.
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Truncate and (when obviously HTML) summarize an errorMessage before it is
|
|
77
|
+
* persisted. Returns the cleaned string. Empty input returns empty.
|
|
78
|
+
*/
|
|
79
|
+
export function sanitizeErrorMessage(raw: string | undefined): string {
|
|
80
|
+
if (!raw) return "";
|
|
81
|
+
const cleaned = raw.replace(/\s+/g, " ").trim();
|
|
82
|
+
if (!cleaned) return "";
|
|
83
|
+
// Decide the sanitization branch on the RAW length, not the whitespace-
|
|
84
|
+
// collapsed length — otherwise an HTML page padded with spaces would slip
|
|
85
|
+
// through the "looks like HTML" branch and be persisted as-is.
|
|
86
|
+
const rawLen = raw.length;
|
|
87
|
+
if (rawLen > ERROR_MESSAGE_MAX_LEN) {
|
|
88
|
+
const head = cleaned.slice(0, 200);
|
|
89
|
+
const tail = cleaned.slice(-200);
|
|
90
|
+
return `${head} ... [truncated ${rawLen - 400} chars] ... ${tail}`;
|
|
91
|
+
}
|
|
92
|
+
if (looksLikeHtmlOrJson(cleaned)) {
|
|
93
|
+
// Any document-like HTML (Cloudflare challenge pages, proxy error pages,
|
|
94
|
+
// gateway error pages) is a strong signal the upstream returned a page
|
|
95
|
+
// instead of JSON. Summarize it instead of letting HTML pollute the
|
|
96
|
+
// phase's error and downstream interpolation contexts.
|
|
97
|
+
const title = cleaned.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim();
|
|
98
|
+
const stripped = cleaned.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
99
|
+
const m = stripped.match(/(?:Unable to load site|Ray ID[: ]+([A-Za-z0-9]+)|[A-Z][a-z]+Error[: ]+(.{0,200}))/i);
|
|
100
|
+
const hint = title || (m ? (m[1] || m[0]).trim() : stripped.slice(0, 200));
|
|
101
|
+
return `Upstream returned non-JSON response (${rawLen} chars). Hint: ${hint}`;
|
|
102
|
+
}
|
|
103
|
+
return cleaned;
|
|
104
|
+
}
|
|
105
|
+
|
|
62
106
|
function getFinalOutput(messages: Message[]): string {
|
|
63
107
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
64
108
|
const msg = messages[i];
|
|
65
109
|
if (msg.role === "assistant") {
|
|
66
110
|
for (const part of msg.content) {
|
|
67
|
-
if (part.type === "text") return part.text;
|
|
111
|
+
if (part.type === "text" && part.text.trim()) return part.text;
|
|
68
112
|
}
|
|
69
113
|
}
|
|
70
114
|
}
|
|
71
115
|
return "";
|
|
72
116
|
}
|
|
73
117
|
|
|
118
|
+
/** Accumulated state folded from a subagent's NDJSON event stream. */
|
|
119
|
+
export interface EventAccumulator {
|
|
120
|
+
messages: Message[];
|
|
121
|
+
usage: UsageStats;
|
|
122
|
+
model?: string;
|
|
123
|
+
stopReason?: string;
|
|
124
|
+
errorMessage?: string;
|
|
125
|
+
lastActivity: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function newAccumulator(model?: string): EventAccumulator {
|
|
129
|
+
return { messages: [], usage: emptyUsage(), model, lastActivity: "" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Fold one NDJSON line into the accumulator. Returns a LiveUpdate when an
|
|
134
|
+
* assistant message ended (for streaming), else null. Empty, malformed, and
|
|
135
|
+
* non-`message_end` lines are ignored — making the parser robust to partial
|
|
136
|
+
* buffers/noise and unit-testable without spawning a process.
|
|
137
|
+
*/
|
|
138
|
+
export function foldEventLine(acc: EventAccumulator, line: string): LiveUpdate | null {
|
|
139
|
+
if (!line.trim()) return null;
|
|
140
|
+
let event: any;
|
|
141
|
+
try {
|
|
142
|
+
event = JSON.parse(line);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
if (event.type !== "message_end" || !event.message) return null;
|
|
147
|
+
const msg = event.message as Message;
|
|
148
|
+
acc.messages.push(msg);
|
|
149
|
+
if (msg.role !== "assistant") return null;
|
|
150
|
+
acc.usage.turns++;
|
|
151
|
+
const u = (msg as any).usage;
|
|
152
|
+
if (u) {
|
|
153
|
+
acc.usage.input += u.input || 0;
|
|
154
|
+
acc.usage.output += u.output || 0;
|
|
155
|
+
acc.usage.cacheRead += u.cacheRead || 0;
|
|
156
|
+
acc.usage.cacheWrite += u.cacheWrite || 0;
|
|
157
|
+
acc.usage.cost += u.cost?.total || 0;
|
|
158
|
+
acc.usage.contextTokens = u.totalTokens || 0;
|
|
159
|
+
}
|
|
160
|
+
if (!acc.model && (msg as any).model) acc.model = (msg as any).model;
|
|
161
|
+
if ((msg as any).stopReason) acc.stopReason = (msg as any).stopReason;
|
|
162
|
+
if ((msg as any).errorMessage) acc.errorMessage = (msg as any).errorMessage;
|
|
163
|
+
const activity = describeActivity(msg);
|
|
164
|
+
if (activity) acc.lastActivity = activity;
|
|
165
|
+
return { text: acc.lastActivity, usage: { ...acc.usage }, model: acc.model };
|
|
166
|
+
}
|
|
167
|
+
|
|
74
168
|
/** One-line description of the most recent assistant activity (text or tool call). */
|
|
75
169
|
function describeActivity(msg: Message): string {
|
|
76
170
|
if (msg.role !== "assistant") return "";
|
|
@@ -177,8 +271,7 @@ export async function runAgentTask(
|
|
|
177
271
|
let tmpPromptDir: string | null = null;
|
|
178
272
|
let tmpPromptPath: string | null = null;
|
|
179
273
|
|
|
180
|
-
const
|
|
181
|
-
let lastActivity = "";
|
|
274
|
+
const acc = newAccumulator(model);
|
|
182
275
|
const result: RunResult = {
|
|
183
276
|
agent: agentName,
|
|
184
277
|
task,
|
|
@@ -209,36 +302,8 @@ export async function runAgentTask(
|
|
|
209
302
|
let buffer = "";
|
|
210
303
|
|
|
211
304
|
const processLine = (line: string) => {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
try {
|
|
215
|
-
event = JSON.parse(line);
|
|
216
|
-
} catch {
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
if (event.type === "message_end" && event.message) {
|
|
220
|
-
const msg = event.message as Message;
|
|
221
|
-
messages.push(msg);
|
|
222
|
-
if (msg.role === "assistant") {
|
|
223
|
-
result.usage.turns++;
|
|
224
|
-
const u = (msg as any).usage;
|
|
225
|
-
if (u) {
|
|
226
|
-
result.usage.input += u.input || 0;
|
|
227
|
-
result.usage.output += u.output || 0;
|
|
228
|
-
result.usage.cacheRead += u.cacheRead || 0;
|
|
229
|
-
result.usage.cacheWrite += u.cacheWrite || 0;
|
|
230
|
-
result.usage.cost += u.cost?.total || 0;
|
|
231
|
-
result.usage.contextTokens = u.totalTokens || 0;
|
|
232
|
-
}
|
|
233
|
-
if (!result.model && (msg as any).model) result.model = (msg as any).model;
|
|
234
|
-
if ((msg as any).stopReason) result.stopReason = (msg as any).stopReason;
|
|
235
|
-
if ((msg as any).errorMessage) result.errorMessage = (msg as any).errorMessage;
|
|
236
|
-
const activity = describeActivity(msg);
|
|
237
|
-
if (activity) lastActivity = activity;
|
|
238
|
-
if (opts.onLive)
|
|
239
|
-
opts.onLive({ text: lastActivity, usage: { ...result.usage }, model: result.model });
|
|
240
|
-
}
|
|
241
|
-
}
|
|
305
|
+
const live = foldEventLine(acc, line);
|
|
306
|
+
if (live && opts.onLive) opts.onLive(live);
|
|
242
307
|
};
|
|
243
308
|
|
|
244
309
|
proc.stdout.on("data", (data) => {
|
|
@@ -270,13 +335,26 @@ export async function runAgentTask(
|
|
|
270
335
|
});
|
|
271
336
|
|
|
272
337
|
result.exitCode = exitCode;
|
|
273
|
-
result.
|
|
338
|
+
result.usage = acc.usage;
|
|
339
|
+
result.model = acc.model;
|
|
340
|
+
result.stopReason = acc.stopReason;
|
|
341
|
+
result.errorMessage = acc.errorMessage;
|
|
342
|
+
result.output = getFinalOutput(acc.messages);
|
|
274
343
|
if (wasAborted) {
|
|
275
344
|
result.stopReason = "aborted";
|
|
276
345
|
result.errorMessage = "Subagent was aborted";
|
|
277
346
|
}
|
|
347
|
+
// On failure, build a short, structured errorMessage + a placeholder
|
|
348
|
+
// output. We deliberately do NOT copy the raw errorMessage into
|
|
349
|
+
// `output`: upstream providers (e.g. a Cloudflare challenge page) can
|
|
350
|
+
// surface huge HTML/JSON in errorMessage, and that garbage would
|
|
351
|
+
// otherwise flow into downstream phase interpolations.
|
|
278
352
|
if (isFailed(result) && !result.output) {
|
|
279
|
-
result.output =
|
|
353
|
+
result.output = TRANSPORT_ERROR_PLACEHOLDER;
|
|
354
|
+
if (!result.errorMessage) {
|
|
355
|
+
result.errorMessage = result.stderr || `Subagent exited with code ${result.exitCode} (stopReason: ${result.stopReason ?? "unknown"})`;
|
|
356
|
+
}
|
|
357
|
+
result.errorMessage = sanitizeErrorMessage(result.errorMessage);
|
|
280
358
|
}
|
|
281
359
|
return result;
|
|
282
360
|
} finally {
|
|
@@ -317,34 +395,3 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
|
317
395
|
await Promise.all(workers);
|
|
318
396
|
return results;
|
|
319
397
|
}
|
|
320
|
-
|
|
321
|
-
export function formatTokens(count: number): string {
|
|
322
|
-
if (count < 1000) return count.toString();
|
|
323
|
-
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
324
|
-
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
325
|
-
return `${(count / 1000000).toFixed(1)}M`;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
export function formatUsage(usage: UsageStats, model?: string): string {
|
|
329
|
-
const parts: string[] = [];
|
|
330
|
-
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
331
|
-
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
332
|
-
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
333
|
-
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
334
|
-
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
335
|
-
if (model) parts.push(model);
|
|
336
|
-
return parts.join(" ");
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
export function aggregateUsage(usages: UsageStats[]): UsageStats {
|
|
340
|
-
const total = emptyUsage();
|
|
341
|
-
for (const u of usages) {
|
|
342
|
-
total.input += u.input;
|
|
343
|
-
total.output += u.output;
|
|
344
|
-
total.cacheRead += u.cacheRead;
|
|
345
|
-
total.cacheWrite += u.cacheWrite;
|
|
346
|
-
total.cost += u.cost;
|
|
347
|
-
total.turns += u.turns;
|
|
348
|
-
}
|
|
349
|
-
return total;
|
|
350
|
-
}
|