@aeondave/pi-persona 1.17.1 → 1.17.3
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/AGENTS.md +59 -28
- package/CHANGELOG.md +234 -0
- package/README.md +16 -12
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +183 -55
- package/docs/EXPERIENCE.md +19 -9
- package/docs/MONITORS.md +62 -3
- package/docs/REFERENCE.md +82 -32
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +9 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +20 -0
- package/src/bridge.ts +3 -2
- package/src/core/brief.ts +429 -356
- package/src/core/config.ts +15 -10
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +1 -1
- package/src/core/timer.ts +34 -9
- package/src/engine/adapter.ts +12 -8
- package/src/engine/async.ts +165 -146
- package/src/engine/child.ts +2 -2
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +12 -7
- package/src/engine/stream.ts +4 -20
- package/src/exocom/install.ts +2 -1
- package/src/extension/engine.ts +43 -16
- package/src/extension/hooks.ts +106 -36
- package/src/extension/persona-model-settings.ts +31 -13
- package/src/extension/shared.ts +15 -7
- package/src/extension.ts +505 -241
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +26 -5
- package/src/orchestration/flow-run.ts +38 -13
- package/src/orchestration/flow.ts +9 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/sdk.ts +17 -5
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- package/src/persona/persona.ts +7 -3
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/background-run.ts +151 -0
- package/src/tools/council.ts +77 -52
- package/src/tools/delegate-tool.ts +310 -280
- package/src/tools/delegate.ts +22 -210
- package/src/tools/exocom-work.ts +18 -53
- package/src/tools/exocom.ts +15 -19
- package/src/tools/flow.ts +85 -28
- package/src/tools/intercom-tool.ts +108 -118
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +19 -9
- package/src/tools/monitor.ts +39 -6
- package/src/tools/timer.ts +11 -13
- package/src/ui/agent-overlay.ts +55 -14
- package/src/ui/agent-tree.ts +84 -18
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/persona-settings.ts +9 -3
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +189 -0
- package/src/ui/wake-rows.ts +262 -0
- package/teams.yaml +5 -1
package/src/engine/async.ts
CHANGED
|
@@ -6,20 +6,21 @@
|
|
|
6
6
|
*
|
|
7
7
|
* NOTE: live *steering* of a running child-process agent (resume/redirect) needs
|
|
8
8
|
* the cross-process broker (v0.5) or the InProcessEngine (v0.4). This module
|
|
9
|
-
* covers launch + peek + completion-notify
|
|
9
|
+
* covers launch + peek + completion-notify.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { ProgressSnapshot } from "./stream.ts";
|
|
12
|
+
import type { ProgressSnapshot, ToolEvent } from "./stream.ts";
|
|
13
13
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { DEFAULT_ASYNC_RETAIN } from "../core/config.ts";
|
|
15
|
+
import { MAX_DISPLAY_LABEL_CHARS, sanitizeDisplayLabel, sanitizeStatusLabel } from "../core/display-label.ts";
|
|
15
16
|
import { compactTokens } from "../core/format.ts";
|
|
16
17
|
import { formatDuration } from "../core/time.ts";
|
|
17
18
|
|
|
18
19
|
export { MAX_DISPLAY_LABEL_CHARS } from "../core/display-label.ts";
|
|
19
20
|
|
|
20
|
-
/** "possibly stuck" — the soft stall signal shared by the
|
|
21
|
-
* deliberately patient: a long scan, a big generation, or a blocking command shows no
|
|
22
|
-
* progress yet is perfectly healthy, so
|
|
21
|
+
/** "possibly stuck" — the soft stall signal shared by the operator card, the peek digest and the UI
|
|
22
|
+
* badge. It is deliberately patient: a long scan, a big generation, or a blocking command shows no
|
|
23
|
+
* visible progress yet is perfectly healthy, so every surface flags a leg only after a genuinely long quiet
|
|
23
24
|
* spell. Purely advisory (no auto-abort); the idle watchdog + token budget are the enforcing backstops. */
|
|
24
25
|
export const STALL_FLAG_MS = 90_000;
|
|
25
26
|
|
|
@@ -40,15 +41,16 @@ export interface AsyncRun {
|
|
|
40
41
|
/** The already-shortened model (e.g. "sonnet") this run was launched with — kept pre-shortened
|
|
41
42
|
* so this module needs no import from delegate.ts just to render it. */
|
|
42
43
|
model?: string;
|
|
43
|
-
/** Clock (ms) this run
|
|
44
|
-
* "that leg took 2m 5s" reading is computed from. Both
|
|
44
|
+
/** Clock (ms) this run started executing (launch, or admission for a queued one), and the clock it
|
|
45
|
+
* settled at — the pair the supervisor's "that leg took 2m 5s" reading is computed from. Both
|
|
46
|
+
* come from the tracker's INJECTED clock,
|
|
45
47
|
* so a test can settle a two-hour run without waiting. Optional because runs also reach the
|
|
46
48
|
* renderers from call sites that never stamped them; absent ⇒ no duration is claimed. */
|
|
47
49
|
startedAt?: number;
|
|
48
50
|
settledAt?: number;
|
|
49
|
-
/** Clock (ms) of the last time
|
|
50
|
-
*
|
|
51
|
-
*
|
|
51
|
+
/** Clock (ms) of the last time the run made {@link madeHeadway} — NOT merely the last event. A worker
|
|
52
|
+
* looping without headway, or a tool streaming output forever, keeps emitting but never bumps this,
|
|
53
|
+
* so `now - lastAdvanceAt` is what tells "working" from "stuck". */
|
|
52
54
|
lastAdvanceAt?: number;
|
|
53
55
|
/** Waiting for semaphore admission; queued time is not agent execution time. */
|
|
54
56
|
queued?: boolean;
|
|
@@ -65,6 +67,36 @@ export interface AsyncRun {
|
|
|
65
67
|
* run INSIDE {@link AsyncRunTracker.launch}, before the caller holds the id, so a registration
|
|
66
68
|
* performed after launch() returns would re-insert an already-dead run and leak it. */
|
|
67
69
|
mutates?: boolean;
|
|
70
|
+
/** The id of the one `tasks` call this leg was launched by. Its members are delivered together, once the
|
|
71
|
+
* last of them settles (see {@link AsyncRunTracker.batchSettled}); a single-leg launch has none. */
|
|
72
|
+
batch?: string;
|
|
73
|
+
/** What a running run is blocked on, when it is the OPERATOR (a flow checkpoint modal): shown in the
|
|
74
|
+
* digest instead of a stall verdict, because silence while a human decides is not a wedge. */
|
|
75
|
+
awaiting?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What {@link madeHeadway} reads of one progress reading: a tracker snapshot, or a leg's `AgentProgress`
|
|
79
|
+
* (which carries no turns). */
|
|
80
|
+
export interface ProgressReading {
|
|
81
|
+
output?: string | undefined;
|
|
82
|
+
tokens?: number | undefined;
|
|
83
|
+
turns?: number | undefined;
|
|
84
|
+
toolEvent?: ToolEvent | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The ONE rule for "this run advanced": the assistant produced more (output grew, tokens or turns rose) or a
|
|
89
|
+
* tool call just COMPLETED. Raw tool output is deliberately not headway - a straggler that streams text
|
|
90
|
+
* forever (`npm run dev`, `--watch`, `tail -f`) must still read as stalled, so the operator-only card can
|
|
91
|
+
* flag it. Nothing is killed and the model is not woken; a legitimately noisy tool earns an advisory only.
|
|
92
|
+
* A completion counts once: the transient `toolEvent` rides the one snapshot it was emitted on, so
|
|
93
|
+
* replaying that snapshot credits nothing. Pure.
|
|
94
|
+
*/
|
|
95
|
+
export function madeHeadway(prev: ProgressReading | undefined, next: ProgressReading): boolean {
|
|
96
|
+
return (next.output?.length ?? 0) > (prev?.output?.length ?? 0)
|
|
97
|
+
|| (next.tokens ?? 0) > (prev?.tokens ?? 0)
|
|
98
|
+
|| (next.turns ?? 0) > (prev?.turns ?? 0)
|
|
99
|
+
|| (next.toolEvent?.phase === "end" && next.toolEvent !== prev?.toolEvent);
|
|
68
100
|
}
|
|
69
101
|
|
|
70
102
|
/** The run's id is passed in so the launcher can wire a steer handle keyed by it. */
|
|
@@ -88,14 +120,25 @@ export class AsyncRunTracker {
|
|
|
88
120
|
private readonly now: () => number;
|
|
89
121
|
|
|
90
122
|
/** `now` is injected so the stall clock is deterministic under test (real time by default).
|
|
91
|
-
* `maxRetained` (default
|
|
92
|
-
* knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
|
|
123
|
+
* `maxRetained` (default {@link DEFAULT_ASYNC_RETAIN}; the extension passes `config.asyncRetain`) is the config-driven
|
|
124
|
+
* PI_PERSONA_ASYNC_RETAIN knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
|
|
93
125
|
constructor(opts?: { now?: () => number; maxRetained?: number }) {
|
|
94
126
|
this.now = opts?.now ?? ((): number => Date.now());
|
|
95
|
-
this.maxRetained = opts?.maxRetained ??
|
|
127
|
+
this.maxRetained = opts?.maxRetained ?? DEFAULT_ASYNC_RETAIN;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** How many run ids this lineage has issued: what a restored instance must continue from. */
|
|
131
|
+
get issued(): number {
|
|
132
|
+
return this.seq;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Continue the id counter from a previous instance of the same session so ids never repeat within
|
|
136
|
+
* one history. Monotonic: anything that would rewind it, or is not a whole number, is ignored. */
|
|
137
|
+
continueFrom(issued: number): void {
|
|
138
|
+
if (Number.isSafeInteger(issued) && issued > this.seq) this.seq = issued;
|
|
96
139
|
}
|
|
97
140
|
|
|
98
|
-
launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean }, run: RunThunk): string {
|
|
141
|
+
launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"] }, run: RunThunk): string {
|
|
99
142
|
this.seq += 1;
|
|
100
143
|
const id = `run-${this.seq.toString(36)}`;
|
|
101
144
|
const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
|
|
@@ -109,6 +152,7 @@ export class AsyncRunTracker {
|
|
|
109
152
|
...(meta.queued ? { queued: true } : { lastAdvanceAt: startedAt }),
|
|
110
153
|
};
|
|
111
154
|
if (meta.mutates) entry.mutates = true;
|
|
155
|
+
if (meta.batch) entry.batch = meta.batch;
|
|
112
156
|
// Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
|
|
113
157
|
// once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
|
|
114
158
|
// those views; renderers still sanitize defensively when they compose a label.
|
|
@@ -124,14 +168,8 @@ export class AsyncRunTracker {
|
|
|
124
168
|
try {
|
|
125
169
|
Promise.resolve(
|
|
126
170
|
run((s) => {
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
const prev = entry.progress;
|
|
130
|
-
const toolOutputAdvanced = s.toolOutputAt !== undefined && Number.isFinite(s.toolOutputAt)
|
|
131
|
-
&& s.toolOutputAt >= 0 && s.toolOutputAt !== prev.toolOutputAt;
|
|
132
|
-
if (!entry.queued && (s.output.length > prev.output.length || s.tokens > prev.tokens || s.turns > prev.turns || toolOutputAdvanced)) {
|
|
133
|
-
entry.lastAdvanceAt = this.now();
|
|
134
|
-
}
|
|
171
|
+
// The injected clock stamps the advance, never an event's own timestamp.
|
|
172
|
+
if (!entry.queued && madeHeadway(entry.progress, s)) entry.lastAdvanceAt = this.now();
|
|
135
173
|
entry.progress = s;
|
|
136
174
|
}, id),
|
|
137
175
|
)
|
|
@@ -167,8 +205,8 @@ export class AsyncRunTracker {
|
|
|
167
205
|
entry.settledAt = this.now();
|
|
168
206
|
this.settledOrder.push(entry.id);
|
|
169
207
|
this.prune();
|
|
170
|
-
// Iterate a SNAPSHOT: a
|
|
171
|
-
// splicing the live array under the cursor would shift the next
|
|
208
|
+
// Iterate a SNAPSHOT: a listener may unsubscribe from inside its own callback, and
|
|
209
|
+
// splicing the live array under the cursor would shift the next listener past it.
|
|
172
210
|
// A listener's throw must likewise not skip the ones after it — and on the natural-settle
|
|
173
211
|
// path this runs inside an unobserved promise chain, where an escaping throw becomes a
|
|
174
212
|
// process-killing unhandled rejection.
|
|
@@ -183,15 +221,36 @@ export class AsyncRunTracker {
|
|
|
183
221
|
}
|
|
184
222
|
}
|
|
185
223
|
|
|
186
|
-
/**
|
|
224
|
+
/** A queued run acquired its concurrency slot: execution, and its stall window, start now. */
|
|
187
225
|
markAdmitted(id: string): boolean {
|
|
188
226
|
const entry = this.runs.get(id);
|
|
189
227
|
if (!entry || entry.status !== "running" || entry.settled || entry.queued !== true) return false;
|
|
190
228
|
delete entry.queued;
|
|
191
|
-
entry.lastAdvanceAt = this.now();
|
|
229
|
+
entry.startedAt = entry.lastAdvanceAt = this.now();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Mark a running run as waiting on the operator (`label`), or release it (`undefined`). The clock
|
|
234
|
+
* restarts on release, so the human's think time is never counted as the run's own silence. */
|
|
235
|
+
setAwaiting(id: string, label: string | undefined): boolean {
|
|
236
|
+
const entry = this.runs.get(id);
|
|
237
|
+
if (!entry || entry.status !== "running" || entry.settled) return false;
|
|
238
|
+
if (label === undefined) {
|
|
239
|
+
delete entry.awaiting;
|
|
240
|
+
this.advance(id);
|
|
241
|
+
} else {
|
|
242
|
+
entry.awaiting = sanitizeStatusLabel(label, "waiting for operator", 120);
|
|
243
|
+
}
|
|
192
244
|
return true;
|
|
193
245
|
}
|
|
194
246
|
|
|
247
|
+
/** Credit headway to a running run whose own snapshot cannot show it: a council or flow aggregates its
|
|
248
|
+
* legs' progress, so one leg's advance is invisible in the sum. Queued, settled and unknown runs are left alone. */
|
|
249
|
+
advance(id: string): void {
|
|
250
|
+
const entry = this.runs.get(id);
|
|
251
|
+
if (entry && entry.status === "running" && !entry.settled && !entry.queued) entry.lastAdvanceAt = this.now();
|
|
252
|
+
}
|
|
253
|
+
|
|
195
254
|
/**
|
|
196
255
|
* Force a still-"running" run to settle as stopped and fire its completion listeners. Escape hatch
|
|
197
256
|
* for a run whose hard-stop handle is gone (consumed by a prior abort, or never registered) while
|
|
@@ -280,6 +339,14 @@ export class AsyncRunTracker {
|
|
|
280
339
|
return this.list().filter((r) => r.status === "running");
|
|
281
340
|
}
|
|
282
341
|
|
|
342
|
+
/** True when `run` may be reported: it belongs to no batch, or no member of its batch is still
|
|
343
|
+
* running. Asked of the live tracker, never counted from a delivered set, so a member collected
|
|
344
|
+
* early (`intercom result`), stopped, or failed inside launch() can never keep its batch waiting. */
|
|
345
|
+
batchSettled(run: AsyncRun): boolean {
|
|
346
|
+
const batch = run.batch;
|
|
347
|
+
return batch === undefined || !this.running().some((r) => r.batch === batch);
|
|
348
|
+
}
|
|
349
|
+
|
|
283
350
|
onComplete(cb: (run: AsyncRun) => void): () => void {
|
|
284
351
|
this.completeListeners.push(cb);
|
|
285
352
|
return () => {
|
|
@@ -287,44 +354,9 @@ export class AsyncRunTracker {
|
|
|
287
354
|
if (i >= 0) this.completeListeners.splice(i, 1);
|
|
288
355
|
};
|
|
289
356
|
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Wait (join) until every listed run settles, or `timeoutMs` elapses — never rejects:
|
|
293
|
-
* it resolves with the runs' CURRENT states, so the caller reports what settled and
|
|
294
|
-
* what is still going. Backs the `intercom wait` action (the supervisor holds its turn
|
|
295
|
-
* to collect async results it now needs before its next step).
|
|
296
|
-
*/
|
|
297
|
-
waitFor(ids: string[], timeoutMs: number, signal?: AbortSignal): Promise<AsyncRun[]> {
|
|
298
|
-
const targets = ids.map((id) => this.runs.get(id)).filter((r): r is AsyncRun => r !== undefined);
|
|
299
|
-
if (targets.every((r) => r.status !== "running")) return Promise.resolve(targets);
|
|
300
|
-
return new Promise((resolve) => {
|
|
301
|
-
let off = (): void => {};
|
|
302
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
303
|
-
const finish = (): void => {
|
|
304
|
-
off();
|
|
305
|
-
if (timer) clearTimeout(timer);
|
|
306
|
-
signal?.removeEventListener("abort", finish);
|
|
307
|
-
resolve(targets);
|
|
308
|
-
};
|
|
309
|
-
off = this.onComplete(() => {
|
|
310
|
-
if (targets.every((r) => r.status !== "running")) finish();
|
|
311
|
-
});
|
|
312
|
-
timer = setTimeout(finish, timeoutMs);
|
|
313
|
-
timer.unref?.();
|
|
314
|
-
if (signal) {
|
|
315
|
-
if (signal.aborted) finish();
|
|
316
|
-
else signal.addEventListener("abort", finish, { once: true });
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
357
|
}
|
|
321
358
|
|
|
322
|
-
/**
|
|
323
|
-
* A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
|
|
324
|
-
* routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
|
|
325
|
-
* window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
|
|
326
|
-
* periodic FAST wakeup does NOT send this digest — it sends the focused {@link buildPeekAlert}.
|
|
327
|
-
*/
|
|
359
|
+
/** First occurrence of each run id, in order. */
|
|
328
360
|
export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
|
|
329
361
|
const byId = new Map<string, AsyncRun>();
|
|
330
362
|
for (const r of runs) if (!byId.has(r.id)) byId.set(r.id, r);
|
|
@@ -346,13 +378,14 @@ export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">)
|
|
|
346
378
|
*
|
|
347
379
|
* Wall time is the supervisor's cheapest signal about a delegation — whether the leg it is reading
|
|
348
380
|
* came back in seconds or ground for two hours changes what to do next — and no completion surface
|
|
349
|
-
* used to say it. Undefined (rather than "?" or "0s") when the pair is missing or
|
|
381
|
+
* used to say it. Undefined (rather than "?" or "0s") when the pair is missing, inverted or not finite, so an
|
|
350
382
|
* unstamped run renders exactly as it did before instead of gaining a fabricated reading.
|
|
351
383
|
*/
|
|
352
384
|
export function runDurationLabel(run: AsyncRun): string | undefined {
|
|
353
385
|
const { startedAt, settledAt } = run;
|
|
354
|
-
if (startedAt === undefined || settledAt === undefined
|
|
355
|
-
|
|
386
|
+
if (startedAt === undefined || settledAt === undefined) return undefined;
|
|
387
|
+
const span = settledAt - startedAt;
|
|
388
|
+
return Number.isFinite(span) && span >= 0 ? formatDuration(span) : undefined;
|
|
356
389
|
}
|
|
357
390
|
|
|
358
391
|
/** The " in 2m 5s" / " after 2m 5s" fragment for a settled leg, or "" when it has no duration. */
|
|
@@ -404,20 +437,6 @@ export function boundCompletionSurface(
|
|
|
404
437
|
|
|
405
438
|
const resultActionHint = (id: string): string => `intercom { action:"result", to:"${id}" }`;
|
|
406
439
|
|
|
407
|
-
/** A timeout is a status update, not an invitation to poll. The passive completion notifier is
|
|
408
|
-
* the normal delivery path, so send the supervisor back to useful work and reserve peek/steer/stop
|
|
409
|
-
* for intervention. */
|
|
410
|
-
export function buildWaitTimeoutNote(ids: readonly string[], timeoutMs: number): string {
|
|
411
|
-
const visibleIds = ids.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
412
|
-
const omitted = ids.length - visibleIds.length;
|
|
413
|
-
const idSummary = `${visibleIds.join(", ")}${omitted > 0 ? `, … +${omitted} more` : ""}`;
|
|
414
|
-
return (
|
|
415
|
-
`⏳ still running after ${timeoutMs}ms: ${idSummary}. ` +
|
|
416
|
-
"Continue useful supervisor work; completion will notify you automatically. " +
|
|
417
|
-
"Use peek only when status is needed, steer to redirect, or stop a truly stalled run — do not immediately wait again."
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
440
|
/**
|
|
422
441
|
* The explicit overflow signal for the one case retention cannot cover: more runs settled than the
|
|
423
442
|
* tracker keeps, so some of the ids this very report names can no longer be fetched. Saying it here,
|
|
@@ -464,6 +483,12 @@ export function isAsyncRunStalled(run: AsyncRun, now: number, stallMs: number):
|
|
|
464
483
|
&& now - run.lastAdvanceAt >= stallMs;
|
|
465
484
|
}
|
|
466
485
|
|
|
486
|
+
/**
|
|
487
|
+
* A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
|
|
488
|
+
* routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
|
|
489
|
+
* window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
|
|
490
|
+
* periodic stall check never wakes the model with this digest; it files it as an operator-only card.
|
|
491
|
+
*/
|
|
467
492
|
export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number; waitingForSupervisor?: ReadonlySet<string> }): string {
|
|
468
493
|
if (runs.length === 0) return "No async runs.";
|
|
469
494
|
const running = runs.filter((r) => r.status === "running").length;
|
|
@@ -478,8 +503,13 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
|
|
|
478
503
|
const name = runDisplayName(r);
|
|
479
504
|
const head = `[${r.id}] ${name} — ${r.status}`;
|
|
480
505
|
if (r.status === "running") {
|
|
481
|
-
|
|
506
|
+
// A tracked council/flow reports tokens only (its legs carry no turn count): "0 turns" would be false.
|
|
507
|
+
let line = `${head} (${r.progress.turns > 0 ? `${r.progress.turns} turns, ` : ""}${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
|
|
508
|
+
// Queued time is not execution time, so a queued run claims no "running for".
|
|
509
|
+
const ran = r.queued ? undefined : runDurationLabel({ ...r, ...(now !== undefined ? { settledAt: now } : {}) });
|
|
510
|
+
if (ran) line += ` · running for ${ran}`;
|
|
482
511
|
if (r.queued) line += " · queued (waiting for concurrency slot)";
|
|
512
|
+
else if (r.awaiting) line += ` · ${r.awaiting}`;
|
|
483
513
|
else if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
|
|
484
514
|
else if (now !== undefined && stallMs !== undefined && isAsyncRunStalled(r, now, stallMs)) {
|
|
485
515
|
const stalledFor = now - r.lastAdvanceAt!;
|
|
@@ -504,7 +534,7 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
|
|
|
504
534
|
|
|
505
535
|
/**
|
|
506
536
|
* Turns the periodic peek from a poll into an EXCEPTION signal. Tracks which running legs have already
|
|
507
|
-
* been surfaced as stalled, so the
|
|
537
|
+
* been surfaced as stalled, so the operator is told ONCE when a leg crosses the stall window — never
|
|
508
538
|
* every tick while it stays stalled, and never at all while it is making progress. A leg that advances
|
|
509
539
|
* again (its `lastAdvanceAt` moves) re-arms, so a later stall re-alerts; legs that leave the list are
|
|
510
540
|
* forgotten. Pure w.r.t. the injected `now`, so it is unit-tested without a real clock.
|
|
@@ -512,20 +542,6 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
|
|
|
512
542
|
export class PeekWatcher {
|
|
513
543
|
/** runId → the `lastAdvanceAt` value at which the operator episode was reported. */
|
|
514
544
|
private readonly reportedAt = new Map<string, number>();
|
|
515
|
-
/** Runs whose one lifetime model stall wake was successfully delivered. */
|
|
516
|
-
private readonly modelWoken = new Set<string>();
|
|
517
|
-
|
|
518
|
-
firstWakeCandidates(runs: AsyncRun[]): AsyncRun[] {
|
|
519
|
-
return runs.filter((run) => !this.modelWoken.has(run.id));
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
acknowledgeModelWake(runIds: readonly string[]): void {
|
|
523
|
-
for (const id of runIds) this.modelWoken.add(id);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
retryReports(runs: readonly AsyncRun[]): void {
|
|
527
|
-
for (const run of runs) this.reportedAt.delete(run.id);
|
|
528
|
-
}
|
|
529
545
|
|
|
530
546
|
/** The running legs that have NEWLY crossed the stall window since we last reported them. */
|
|
531
547
|
poll(runs: AsyncRun[], now: number, stallMs: number, waitingForSupervisor: ReadonlySet<string> = new Set()): AsyncRun[] {
|
|
@@ -538,8 +554,8 @@ export class PeekWatcher {
|
|
|
538
554
|
this.reportedAt.delete(r.id);
|
|
539
555
|
continue;
|
|
540
556
|
}
|
|
541
|
-
if (waitingForSupervisor.has(r.id)) {
|
|
542
|
-
this.reportedAt.delete(r.id); // a live ask is not a stall; re-evaluate
|
|
557
|
+
if (waitingForSupervisor.has(r.id) || r.awaiting) {
|
|
558
|
+
this.reportedAt.delete(r.id); // a live ask or an operator decision is not a stall; re-evaluate when it settles
|
|
543
559
|
continue;
|
|
544
560
|
}
|
|
545
561
|
if (r.lastAdvanceAt === undefined) continue;
|
|
@@ -557,35 +573,9 @@ export class PeekWatcher {
|
|
|
557
573
|
/** Forget all tracked state (session start / reload hygiene). */
|
|
558
574
|
reset(): void {
|
|
559
575
|
this.reportedAt.clear();
|
|
560
|
-
this.modelWoken.clear();
|
|
561
576
|
}
|
|
562
577
|
}
|
|
563
578
|
|
|
564
|
-
/**
|
|
565
|
-
* The periodic peek's wake message for a batch of NEWLY-stalled legs (from {@link PeekWatcher}), or ""
|
|
566
|
-
* when none. Unlike {@link buildPeekDigest} (the full, on-demand `/peek` view), this is an exception
|
|
567
|
-
* signal, not a status dump: a long operation looks identical to a stall from outside, so the framing is
|
|
568
|
-
* patience-first, and intervention means asking the leg — never reaching into its environment to check.
|
|
569
|
-
*/
|
|
570
|
-
export function buildPeekAlert(stuck: AsyncRun[], opts: { now: number }): string {
|
|
571
|
-
if (stuck.length === 0) return "";
|
|
572
|
-
const visibleStuck = stuck.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
573
|
-
const lines = visibleStuck.map((r) => {
|
|
574
|
-
const secs = Math.round((opts.now - (r.lastAdvanceAt ?? opts.now)) / 1000);
|
|
575
|
-
return `⚠ ${r.id} (${runDisplayName(r)}) — no visible progress for ${secs}s (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
|
|
576
|
-
});
|
|
577
|
-
const omitted = stuck.length - visibleStuck.length;
|
|
578
|
-
if (omitted > 0) lines.push(`… ${omitted} additional stalled-run alerts omitted from this bounded view.`);
|
|
579
|
-
lines.push(PROGRESS_TOKEN_NOTE);
|
|
580
|
-
return [
|
|
581
|
-
`${stuck.length} background ${stuck.length === 1 ? "leg" : "legs"} may be stalled:`,
|
|
582
|
-
...lines,
|
|
583
|
-
"A long operation (a slow scan, a big generation, a blocking command) looks the same as a stall from out here. " +
|
|
584
|
-
"If it's likely still working, leave it — the result returns to you as a follow-up. If you think it's genuinely " +
|
|
585
|
-
"wedged, ask the leg for a one-line status (a light `steer`) or `stop` it — don't run commands in its environment to check.",
|
|
586
|
-
].join("\n");
|
|
587
|
-
}
|
|
588
|
-
|
|
589
579
|
/** The operator-only periodic status card. It carries the progress digest without model instructions. */
|
|
590
580
|
export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string>; reason?: "routine" | "stall" }): string {
|
|
591
581
|
const reason = opts.reason ?? "routine";
|
|
@@ -597,20 +587,29 @@ export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: num
|
|
|
597
587
|
* burst into a single message is what keeps completions from piling up as separate queued
|
|
598
588
|
* follow-ups (pi renders one sticky line per queued message); the explicit guidance keeps the
|
|
599
589
|
* supervisor from blindly re-issuing a delegation that just failed. `fence` wraps untrusted
|
|
600
|
-
* sub-agent text so the supervisor treats it as data, never as instructions.
|
|
590
|
+
* sub-agent text so the supervisor treats it as data, never as instructions. `bounded: false` lifts
|
|
591
|
+
* every cap, for the headless inline return where the caller is parked in the call and nothing is injected.
|
|
601
592
|
*/
|
|
602
|
-
export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string): string {
|
|
593
|
+
export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string, opts: { bounded?: boolean } = {}): string {
|
|
603
594
|
const done = runs.filter((r) => r.status === "done");
|
|
604
595
|
const failed = runs.filter((r) => r.status === "failed");
|
|
605
596
|
const stopped = runs.filter((r) => r.status === "stopped");
|
|
606
597
|
// Share the notification budget across every leg that can contribute payload. This keeps a
|
|
607
598
|
// large fan-out fair: the first result cannot consume the whole follow-up while later statuses
|
|
608
599
|
// are still waiting to be rendered. The final report cap remains a hard backstop for metadata.
|
|
600
|
+
const bounded = opts.bounded !== false;
|
|
601
|
+
const cap = (chars: number): number => (bounded ? chars : Number.POSITIVE_INFINITY);
|
|
609
602
|
const payloadRuns = runs.filter((r) => r.status === "done" || r.progress.output.trim());
|
|
610
|
-
const fairRunChars = Math.min(
|
|
603
|
+
const fairRunChars = cap(Math.min(
|
|
611
604
|
MAX_COMPLETION_RUN_CHARS,
|
|
612
605
|
Math.max(256, Math.floor(MAX_COMPLETION_REPORT_CHARS / Math.max(1, payloadRuns.length * 2))),
|
|
613
|
-
);
|
|
606
|
+
));
|
|
607
|
+
// Each failed leg is clipped on its own line, from a budget of its own: every id and cause stays
|
|
608
|
+
// listed however wide the fan-out, and the drill-down names a real run.
|
|
609
|
+
const failureChars = cap(Math.min(
|
|
610
|
+
MAX_COMPLETION_RUN_CHARS,
|
|
611
|
+
Math.max(200, Math.floor(MAX_COMPLETION_REPORT_CHARS / (4 * Math.max(1, failed.length)))),
|
|
612
|
+
));
|
|
614
613
|
// Failures are always reported — the supervisor must know so it can adjust or tell
|
|
615
614
|
// the user. Blind retry loops are prevented at RUNTIME by the DelegationLedger
|
|
616
615
|
// (an identical delegation that failed twice is vetoed before it spawns), not by
|
|
@@ -640,9 +639,9 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
|
|
|
640
639
|
}
|
|
641
640
|
}
|
|
642
641
|
if (failed.length > 0) {
|
|
643
|
-
const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${r.error ?? "(no detail)"}`).join("\n");
|
|
642
|
+
const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${clipRunOutput(r.error ?? "(no detail)", r.id, failureChars, "failure detail")}`).join("\n");
|
|
644
643
|
blocks.push(
|
|
645
|
-
`\n❌ ${failed.length} failed:\n${fence(
|
|
644
|
+
`\n❌ ${failed.length} failed:\n${fence(reasons)}\n\n` +
|
|
646
645
|
"Handle each failure deliberately: retry ONCE with a different model or approach, or report it to the user. " +
|
|
647
646
|
"If a failed/aborted leg left partial output below, salvage what's usable instead of re-running from scratch. " +
|
|
648
647
|
"Do not re-issue the same failing delegation repeatedly.",
|
|
@@ -656,17 +655,24 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
|
|
|
656
655
|
}
|
|
657
656
|
}
|
|
658
657
|
}
|
|
659
|
-
|
|
658
|
+
const report = blocks.join("\n");
|
|
659
|
+
if (!bounded) return report;
|
|
660
|
+
// The cap cuts whole legs from the middle of a wide fan-out. Name every run in the tail, which the
|
|
661
|
+
// cut keeps, so a cut leg is still fetchable by id instead of silently missing.
|
|
662
|
+
const named = report.length > MAX_COMPLETION_REPORT_CHARS
|
|
663
|
+
? `${report}\n\nRun ids in this report: ${runs.slice(0, MAX_ASYNC_STATUS_ROWS).map((r) => r.id).join(", ")}`
|
|
664
|
+
: report;
|
|
665
|
+
return boundCompletionSurface(named);
|
|
660
666
|
}
|
|
661
667
|
|
|
662
668
|
/**
|
|
663
669
|
* The completion report PLUS the premature-surrender counterweight. When a settled DONE leg's report
|
|
664
|
-
* carries a surrender marker, `scan` returns the note to append (else undefined).
|
|
665
|
-
* completion notifier
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
+
* carries a surrender marker, `scan` returns the note to append (else undefined). The background
|
|
671
|
+
* completion notifier renders through this, so a blocked leg gets the counterweight on the
|
|
672
|
+
* automatic follow-up — the sync `tool_result` hook never sees a background run. Only DONE legs
|
|
673
|
+
* are scanned: a FAILED leg is already surfaced as a failure by {@link buildCompletionReport},
|
|
674
|
+
* so the guard and the append agree (no done-vs-all mismatch). `scan` is injected
|
|
675
|
+
* (PersistenceNudge.scan) to keep this module decoupled from the nudge.
|
|
670
676
|
*/
|
|
671
677
|
export function renderCompletion(
|
|
672
678
|
runs: AsyncRun[],
|
|
@@ -720,6 +726,10 @@ export interface IdleNotifierDeps<T> {
|
|
|
720
726
|
* drain keeps pace with small items instead of being pinned to the worst case. Either form is
|
|
721
727
|
* clamped to at least one item: a queue must always drain. */
|
|
722
728
|
maxBatchItems?: number | ((pending: readonly T[]) => number);
|
|
729
|
+
/** Whether a buffered item may be delivered yet. A held item stays buffered (peekable, discardable)
|
|
730
|
+
* and is not polled: it ships on the first flush after the `notify()`/`kick()` that made it ready.
|
|
731
|
+
* Unset ⇒ everything is ready. */
|
|
732
|
+
isReady?: (item: T) => boolean;
|
|
723
733
|
/** Clock hook, injected for deterministic tests (defaults to `Date.now`). */
|
|
724
734
|
now?: () => number;
|
|
725
735
|
}
|
|
@@ -728,7 +738,8 @@ export interface IdleNotifierDeps<T> {
|
|
|
728
738
|
* Coalesces items into a single supervisor message, delivered only while the supervisor is idle.
|
|
729
739
|
* A stale host follow-up is NOT a blocker: an idle delivery starts the clean turn that can drain it.
|
|
730
740
|
* Self-healing: while busy it re-arms; sync throws and async rejections restore the whole batch.
|
|
731
|
-
* Used for async-run completions and child
|
|
741
|
+
* Used for async-run completions (a batch's members held until the last one settles) and child
|
|
742
|
+
* intercom asks.
|
|
732
743
|
*/
|
|
733
744
|
export class IdleCoalescingNotifier<T> {
|
|
734
745
|
private readonly deps: IdleNotifierDeps<T>;
|
|
@@ -784,15 +795,15 @@ export class IdleCoalescingNotifier<T> {
|
|
|
784
795
|
}
|
|
785
796
|
|
|
786
797
|
/** A snapshot of the buffered-but-not-yet-delivered items (e.g. runs that SETTLED but whose
|
|
787
|
-
* follow-up hasn't flushed yet). Lets `intercom
|
|
788
|
-
* settle→deliver gap instead of reporting nothing
|
|
789
|
-
* the pending flush won't re-deliver it. */
|
|
798
|
+
* follow-up hasn't flushed yet). Lets `intercom peek` surface results in the
|
|
799
|
+
* settle→deliver gap instead of reporting nothing. Peeking does not collect: only an explicit
|
|
800
|
+
* `result` `discard`s the item so the pending flush won't re-deliver it. */
|
|
790
801
|
peekPending(): T[] {
|
|
791
802
|
return [...this.pending];
|
|
792
803
|
}
|
|
793
804
|
|
|
794
805
|
/** Drop buffered items matching `pred` — e.g. results the supervisor already collected
|
|
795
|
-
*
|
|
806
|
+
* explicitly (intercom `result`), which must not be re-delivered as a follow-up. */
|
|
796
807
|
discard(pred: (item: T) => boolean): void {
|
|
797
808
|
for (let i = this.pending.length - 1; i >= 0; i--) {
|
|
798
809
|
if (pred(this.pending[i] as T)) this.pending.splice(i, 1);
|
|
@@ -816,7 +827,8 @@ export class IdleCoalescingNotifier<T> {
|
|
|
816
827
|
flushIfIdle(): Promise<void> {
|
|
817
828
|
if (this.flushing) return this.flushing;
|
|
818
829
|
this.clearArmedTimer();
|
|
819
|
-
|
|
830
|
+
const ready = this.readyItems();
|
|
831
|
+
if (ready.length === 0) return Promise.resolve();
|
|
820
832
|
|
|
821
833
|
const generation = this.generation;
|
|
822
834
|
let batch: T[] | undefined;
|
|
@@ -841,8 +853,9 @@ export class IdleCoalescingNotifier<T> {
|
|
|
841
853
|
}
|
|
842
854
|
}
|
|
843
855
|
|
|
844
|
-
const take = this.maxBatchItems === undefined ?
|
|
845
|
-
batch =
|
|
856
|
+
const take = this.maxBatchItems === undefined ? ready.length : Math.max(1, Math.floor(this.maxBatchItems(ready)));
|
|
857
|
+
batch = ready.slice(0, take);
|
|
858
|
+
for (const item of batch) this.pending.splice(this.pending.indexOf(item), 1);
|
|
846
859
|
const message = this.deps.render(batch);
|
|
847
860
|
if (!message) {
|
|
848
861
|
// Empty text from a non-empty batch is a renderer failure, not an acknowledgement: put the
|
|
@@ -917,8 +930,14 @@ export class IdleCoalescingNotifier<T> {
|
|
|
917
930
|
this.handle = undefined;
|
|
918
931
|
}
|
|
919
932
|
|
|
933
|
+
/** The buffered items that may be delivered now, in order. */
|
|
934
|
+
private readyItems(): T[] {
|
|
935
|
+
const { isReady } = this.deps;
|
|
936
|
+
return isReady ? this.pending.filter(isReady) : [...this.pending];
|
|
937
|
+
}
|
|
938
|
+
|
|
920
939
|
private arm(ms: number): void {
|
|
921
|
-
if (this.handle !== undefined || this.
|
|
940
|
+
if (this.handle !== undefined || this.readyItems().length === 0) return;
|
|
922
941
|
let scheduled!: unknown;
|
|
923
942
|
scheduled = this.deps.setTimer(() => {
|
|
924
943
|
if (this.handle !== scheduled) return; // cancelled/replaced timer racing its callback
|
package/src/engine/child.ts
CHANGED
|
@@ -242,7 +242,7 @@ export async function runChildAgent(
|
|
|
242
242
|
const onLine = (line: string) => {
|
|
243
243
|
if (!line.trim()) return;
|
|
244
244
|
try {
|
|
245
|
-
const toolEvent = applyEvent(state, JSON.parse(line)
|
|
245
|
+
const toolEvent = applyEvent(state, JSON.parse(line));
|
|
246
246
|
// Ordinary stream progress stays coalesced to one snapshot per stdout chunk below.
|
|
247
247
|
// Tool lifecycle transitions are the exception: several can share one chunk and each
|
|
248
248
|
// authoritative transition must cross the progress seam exactly once.
|
|
@@ -509,7 +509,7 @@ export async function runChildAgent(
|
|
|
509
509
|
// start and was waiting on a slow first response.
|
|
510
510
|
result.errorMessage = `agent produced no progress within the ${opts.startupTimeoutMs}ms startup window — either it never started (a stalled init, e.g. an MCP adapter hanging) or its first provider response was slower than the window (a queued or rate-limited provider, a cold local model); raise or disable the window with PI_PERSONA_AGENT_STARTUP_MS (0 disables)${streamErr ? ` (last error: ${streamErr})` : ""}`;
|
|
511
511
|
} else if (timedOut) {
|
|
512
|
-
result.errorMessage = `agent timed out — no output for ${opts.timeoutMs}ms${streamErr ? ` (last error: ${streamErr})` : ""}`;
|
|
512
|
+
result.errorMessage = `agent timed out — no output for ${opts.timeoutMs}ms (raise this leg's timeoutMs if its tool is legitimately silent)${streamErr ? ` (last error: ${streamErr})` : ""}`;
|
|
513
513
|
} else if (aborted) {
|
|
514
514
|
result.errorMessage = `agent aborted${streamErr ? ` (last error: ${streamErr})` : ""}`;
|
|
515
515
|
} else if (oversizedLine) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A missing hashed chunk in Pi's own bundle is a local host problem, not a provider outage.
|
|
2
|
+
* Pure and deliberately narrow: ordinary missing packages or a worker's own imports get no hint. */
|
|
3
|
+
export function piHostModuleHint(error: string): string | undefined {
|
|
4
|
+
const normalized = error.replaceAll("\\", "/");
|
|
5
|
+
const match = /Cannot find module (["'])([^"'\r\n]+\/@earendil-works\/pi-coding-agent\/dist\/bundle\/chunks\/[A-Za-z0-9_-]+-[A-Z0-9]{8}\.js)\1 imported from ([^\r\n]+)/.exec(normalized);
|
|
6
|
+
const missing = match?.[2];
|
|
7
|
+
const importer = match?.[3]?.trim();
|
|
8
|
+
if (!missing || !importer) return undefined;
|
|
9
|
+
const directory = missing.slice(0, missing.lastIndexOf("/") + 1);
|
|
10
|
+
if (!importer.startsWith(directory) || !/^[^/]+\.js$/.test(importer.slice(directory.length))) return undefined;
|
|
11
|
+
return "A local Pi host bundle module could not load; the host may have been updated in place while this session was running. Retry in a fresh Pi process with the same provider/model; only if the fresh process also fails, repair the Pi installation. Switching provider/model does not repair a missing local module.";
|
|
12
|
+
}
|
package/src/engine/inproc.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { roleHint } from "../orchestration/roster.ts";
|
|
|
33
33
|
import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
34
34
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
35
35
|
import { looksLikeProviderError } from "./errors.ts";
|
|
36
|
+
import { piHostModuleHint } from "./host-module.ts";
|
|
36
37
|
import { nextChildHandle } from "./handles.ts";
|
|
37
38
|
import { combineSignals } from "./signals.ts";
|
|
38
39
|
import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
@@ -666,7 +667,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
666
667
|
// One progress tick, routed exactly as the subscription routes them: the per-call sink
|
|
667
668
|
// takes the AgentProgress shape (no `turns`), else the construction-time sink.
|
|
668
669
|
const emitProgress = (snap: ProgressSnapshot): void => {
|
|
669
|
-
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.
|
|
670
|
+
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.usage ? { usage: snap.usage } : {}), ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
|
|
670
671
|
else if (deps.onProgress) deps.onProgress(snap);
|
|
671
672
|
};
|
|
672
673
|
let contractDef: ContractDef | undefined = requestedDef;
|
|
@@ -674,7 +675,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
674
675
|
try {
|
|
675
676
|
unsub = session.subscribe((ev) => {
|
|
676
677
|
armIdle(); // any event = activity → reset the idle clock
|
|
677
|
-
const toolEvent = applyEvent(state, ev
|
|
678
|
+
const toolEvent = applyEvent(state, ev);
|
|
678
679
|
// Track the open calls so an abandoned one can be closed once the run settles.
|
|
679
680
|
if (toolEvent) {
|
|
680
681
|
if (toolEvent.phase === "start") openToolCalls.set(toolEvent.callId, toolEvent.name);
|
|
@@ -735,7 +736,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
735
736
|
// launch path (extension.ts) only has the latter.
|
|
736
737
|
const snap = snapshot(state);
|
|
737
738
|
const activity = `✉ from ${env.from}`;
|
|
738
|
-
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.
|
|
739
|
+
if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.usage ? { usage: snap.usage } : {}), activity });
|
|
739
740
|
else if (deps.onProgress) deps.onProgress({ ...snap, activity });
|
|
740
741
|
}
|
|
741
742
|
};
|
|
@@ -825,7 +826,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
825
826
|
|
|
826
827
|
const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}) };
|
|
827
828
|
// A timeout/abort is the *cause of death* — label it first (mirrors the child engine).
|
|
828
|
-
if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
|
|
829
|
+
if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms (raise this leg's timeoutMs if its tool is legitimately silent)${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
|
|
829
830
|
// The deadline cannot tell the two causes apart, so the message names both: claiming
|
|
830
831
|
// "it never started" would misdirect the operator of a leg that did start and was
|
|
831
832
|
// simply waiting on a slow first response (mirrors the child engine's wording).
|
|
@@ -836,6 +837,8 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
836
837
|
else if (state.errorMessage) result.error = `${tag} ${state.errorMessage}`;
|
|
837
838
|
else if (thrownError) result.error = `${tag} ${thrownError}`;
|
|
838
839
|
else if (!ok) result.error = `${tag} agent failed (${state.stopReason ?? "unknown"})`;
|
|
840
|
+
const hostModuleHint = !ok ? piHostModuleHint(state.errorMessage ?? thrownError ?? "") : undefined;
|
|
841
|
+
if (hostModuleHint && result.error) result.error += ` — ${hostModuleHint}`;
|
|
839
842
|
// Classify the failure so the fallback decorator can reroute ONLY provider errors,
|
|
840
843
|
// never an abort/timeout/hard-cap/agent miss. A stream `error` stop reason is pi's
|
|
841
844
|
// own report of an API failure → provider. A THROWN error is not automatically a
|
|
@@ -846,9 +849,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
846
849
|
? "timeout"
|
|
847
850
|
: aborted
|
|
848
851
|
? "abort"
|
|
849
|
-
:
|
|
850
|
-
? "
|
|
851
|
-
: "
|
|
852
|
+
: hostModuleHint
|
|
853
|
+
? "agent"
|
|
854
|
+
: state.stopReason === "error" || (thrownError !== undefined && looksLikeProviderError(thrownError))
|
|
855
|
+
? "provider"
|
|
856
|
+
: "agent";
|
|
852
857
|
|
|
853
858
|
if (contractDef) {
|
|
854
859
|
const v = parseAndValidate(state.output, contractDef);
|