pi-famulus 0.1.2 → 0.1.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/package.json +28 -11
- package/src/agents/definition.ts +12 -6
- package/src/behavior-guidelines.ts +1 -0
- package/src/clock.ts +5 -0
- package/src/config.ts +25 -1
- package/src/format.ts +62 -1
- package/src/index.ts +36 -1
- package/src/manager-client.ts +1 -1
- package/src/notify.ts +7 -7
- package/src/subagent/child-bash.ts +59 -42
- package/src/subagent/model-spec.ts +134 -16
- package/src/subagent/overrun.ts +176 -0
- package/src/subagent/pi-runtime.ts +2 -2
- package/src/subagent/registry.ts +81 -3
- package/src/subagent/runner.ts +242 -51
- package/src/subagent/tool.ts +83 -10
- package/src/subagent/types.ts +11 -2
- package/src/thinking-levels.ts +26 -0
- package/src/tui/message-renderers.ts +36 -2
- package/src/wake-delivery.ts +137 -0
- package/src/wake.ts +238 -4
package/src/subagent/runner.ts
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
* starts a new generation on the same session):
|
|
10
10
|
* - a per-generation admission slot is acquired via the optional `acquire`
|
|
11
11
|
* hook (the registry uses it for the global concurrency cap);
|
|
12
|
-
* - `timeoutMs` is a
|
|
12
|
+
* - `timeoutMs` is a SOFT deadline per user turn: reaching it calls
|
|
13
|
+
* `onOverrun` (the parent gets a subagent-overrun wake) and repeats every
|
|
14
|
+
* `overrunRepeatMs`; the child is not aborted and its shell keeps running.
|
|
15
|
+
* `extend()` re-arms the deadline; steer/followUp after a reminder postpone
|
|
16
|
+
* the next one. See design.md §4.6 "Child lifecycle".
|
|
17
|
+
* - `hardTimeoutMs` (opt-in, default off) is the abort ceiling per turn:
|
|
18
|
+
* abort -> result {status:"interrupted", error:"timeout"};
|
|
13
19
|
* - a stall watchdog aborts the child after `stallMs` (default 10min) without
|
|
14
20
|
* any session event. A stall is treated as transient (a silently dropped
|
|
15
21
|
* provider stream): the child is aborted and auto-resumed on the SAME
|
|
@@ -21,9 +27,10 @@
|
|
|
21
27
|
* rejects prompt() while a run is still active ("Agent is already
|
|
22
28
|
* processing"). The wait is bounded by `stallMs` — an abort that never
|
|
23
29
|
* completes means the stream ignored it and the session is dead.
|
|
24
|
-
* -
|
|
25
|
-
*
|
|
26
|
-
*
|
|
30
|
+
* - deadlines belong to the whole user turn: stall retries re-arm the soft
|
|
31
|
+
* deadline / reminder schedule and the hard ceiling from turn-level fields,
|
|
32
|
+
* never from the retry. A reminder landing during a retry delay is sent and
|
|
33
|
+
* the retry still runs; a hard ceiling landing there still settles.
|
|
27
34
|
* - session/prompt exceptions -> {status:"failed", error}.
|
|
28
35
|
*
|
|
29
36
|
* Note on pi semantics: AgentSession.prompt() resolves only after the whole
|
|
@@ -32,6 +39,7 @@
|
|
|
32
39
|
* steer/followUp processing.
|
|
33
40
|
*/
|
|
34
41
|
import { realClock, TimerScope, type Clock, type ClockTimer } from "../clock";
|
|
42
|
+
import type { OverrunTick } from "./overrun";
|
|
35
43
|
import type {
|
|
36
44
|
ChildResult,
|
|
37
45
|
ChildRunRequest,
|
|
@@ -48,6 +56,8 @@ export const DEFAULT_STALL_MS = 5 * 60 * 1000;
|
|
|
48
56
|
export const DEFAULT_STALL_RETRIES = 1;
|
|
49
57
|
/** Pause between the stall abort and the retry prompt (ms). */
|
|
50
58
|
export const DEFAULT_STALL_RETRY_DELAY_MS = 5_000;
|
|
59
|
+
/** Overrun reminder interval while a child stays past its soft budget (ms). */
|
|
60
|
+
export const DEFAULT_OVERRUN_REPEAT_MS = 10 * 60 * 1000;
|
|
51
61
|
|
|
52
62
|
/** Continuation prompt for a stall retry: the transcript holds the context. */
|
|
53
63
|
export function stallRetryPrompt(stallMs: number): string {
|
|
@@ -57,6 +67,12 @@ export function stallRetryPrompt(stallMs: number): string {
|
|
|
57
67
|
);
|
|
58
68
|
}
|
|
59
69
|
|
|
70
|
+
/** Identity of one generation's slot request (see InProcessRunnerOptions.acquire). */
|
|
71
|
+
export interface AdmissionTicket {
|
|
72
|
+
/** False once the requesting generation settled or was superseded. */
|
|
73
|
+
current: () => boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
export interface InProcessRunnerOptions {
|
|
61
77
|
createSession: CreateSessionFn;
|
|
62
78
|
/** Stall watchdog timeout (ms). Default 10 minutes. */
|
|
@@ -67,6 +83,15 @@ export interface InProcessRunnerOptions {
|
|
|
67
83
|
stallRetryDelayMs?: number;
|
|
68
84
|
/** Called on every stall detection (before any retry), with the 1-based attempt. */
|
|
69
85
|
onStall?: (childId: string, attempt: number) => void;
|
|
86
|
+
/** Overrun reminder interval (ms). Default 10 minutes. */
|
|
87
|
+
overrunRepeatMs?: number;
|
|
88
|
+
/** Abort ceiling per user turn (ms). 0/absent = off: the soft deadline never aborts. */
|
|
89
|
+
hardTimeoutMs?: number;
|
|
90
|
+
/**
|
|
91
|
+
* The child passed its soft deadline (first reminder) or is still past it
|
|
92
|
+
* (later reminders). The child keeps running; the parent decides.
|
|
93
|
+
*/
|
|
94
|
+
onOverrun?: (tick: OverrunTick) => void;
|
|
70
95
|
/** Shared time source and scheduler. */
|
|
71
96
|
clock?: Clock;
|
|
72
97
|
/**
|
|
@@ -74,7 +99,7 @@ export interface InProcessRunnerOptions {
|
|
|
74
99
|
* resolved releaser is called when the generation settles. Rejecting
|
|
75
100
|
* cancels the generation as {status:"interrupted", error}.
|
|
76
101
|
*/
|
|
77
|
-
acquire?: (req: ChildRunRequest) => Promise<() => void>;
|
|
102
|
+
acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<() => void>;
|
|
78
103
|
/**
|
|
79
104
|
* Called after the child's conversation may have changed (a message or
|
|
80
105
|
* tool finished, or the generation settled). Used to persist transcripts.
|
|
@@ -107,8 +132,11 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
107
132
|
private readonly stallRetries: number;
|
|
108
133
|
private readonly stallRetryDelayMs: number;
|
|
109
134
|
private readonly onStall?: (childId: string, attempt: number) => void;
|
|
135
|
+
private readonly overrunRepeatMs: number;
|
|
136
|
+
private readonly hardTimeoutMs: number;
|
|
137
|
+
private readonly onOverrun?: (tick: OverrunTick) => void;
|
|
110
138
|
private readonly clock: Clock;
|
|
111
|
-
private readonly acquire?: (req: ChildRunRequest) => Promise<() => void>;
|
|
139
|
+
private readonly acquire?: (req: ChildRunRequest, ticket: AdmissionTicket) => Promise<() => void>;
|
|
112
140
|
private readonly onActivity?: (childId: string) => void;
|
|
113
141
|
|
|
114
142
|
private session: ChildSessionAdapter | null = null;
|
|
@@ -121,11 +149,19 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
121
149
|
/** User-turn start: reset on launch and user resume(), not on stall retries. */
|
|
122
150
|
private runStartedAt: number;
|
|
123
151
|
/**
|
|
124
|
-
* When the
|
|
152
|
+
* When the turn's deadlines start counting. Set after admission and session
|
|
125
153
|
* creation (queue wait must not eat the budget), and NOT reset by stall
|
|
126
154
|
* retries — a stall already consumes budget time by definition.
|
|
127
155
|
*/
|
|
128
156
|
private turnBudgetStart: number;
|
|
157
|
+
/** This turn's soft budget: spawn timeoutMs, or resume()'s timeoutMs. */
|
|
158
|
+
private turnBudgetMs: number;
|
|
159
|
+
/** Soft deadline (absolute). Moved by extend(). Null = no soft deadline. */
|
|
160
|
+
private softDeadlineAt: number | null = null;
|
|
161
|
+
/** When the soft timer fires next: softDeadlineAt, then each reminder. */
|
|
162
|
+
private nextReminderAt: number | null = null;
|
|
163
|
+
/** Overrun reminders sent this turn. */
|
|
164
|
+
private reminders = 0;
|
|
129
165
|
/** Abort kicked off by the latest stall detection; awaited (bounded) by the retry. */
|
|
130
166
|
private abortPromise: Promise<void> | null = null;
|
|
131
167
|
private generation = 0;
|
|
@@ -140,7 +176,8 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
140
176
|
* the previous turn's settle by definition).
|
|
141
177
|
*/
|
|
142
178
|
private settleWaiters: Array<() => void> = [];
|
|
143
|
-
private
|
|
179
|
+
private softTimer: ClockTimer | null = null;
|
|
180
|
+
private hardTimer: ClockTimer | null = null;
|
|
144
181
|
private stallTimer: ClockTimer | null = null;
|
|
145
182
|
private retryTimer: { scope: TimerScope; id: ClockTimer } | null = null;
|
|
146
183
|
private timerScope: TimerScope | null = null;
|
|
@@ -168,6 +205,15 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
168
205
|
? Math.max(0, Math.floor(rawDelay))
|
|
169
206
|
: DEFAULT_STALL_RETRY_DELAY_MS;
|
|
170
207
|
this.onStall = opts.onStall;
|
|
208
|
+
const rawRepeat = opts.overrunRepeatMs ?? DEFAULT_OVERRUN_REPEAT_MS;
|
|
209
|
+
this.overrunRepeatMs =
|
|
210
|
+
Number.isFinite(rawRepeat) && rawRepeat > 0
|
|
211
|
+
? Math.max(1, Math.floor(rawRepeat))
|
|
212
|
+
: DEFAULT_OVERRUN_REPEAT_MS;
|
|
213
|
+
const rawHard = opts.hardTimeoutMs ?? 0;
|
|
214
|
+
this.hardTimeoutMs = Number.isFinite(rawHard) && rawHard > 0 ? Math.floor(rawHard) : 0;
|
|
215
|
+
this.onOverrun = opts.onOverrun;
|
|
216
|
+
this.turnBudgetMs = req.timeoutMs;
|
|
171
217
|
this.clock = opts.clock ?? realClock;
|
|
172
218
|
this.acquire = opts.acquire;
|
|
173
219
|
this.onActivity = opts.onActivity;
|
|
@@ -201,7 +247,10 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
201
247
|
return this.resolvedModel_ ?? this.session?.resolvedModel;
|
|
202
248
|
}
|
|
203
249
|
|
|
204
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* need_decision pending: pause the stall watchdog (nested with tool
|
|
252
|
+
* execution) and hold overrun reminders. The soft budget keeps counting.
|
|
253
|
+
*/
|
|
205
254
|
pauseStall(): void {
|
|
206
255
|
this.decisionPaused = true;
|
|
207
256
|
this.clearStall();
|
|
@@ -211,6 +260,9 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
211
260
|
this.decisionPaused = false;
|
|
212
261
|
this.lastEvent = this.clock.now();
|
|
213
262
|
if (this.status_ === "running" && this.toolDepth === 0) this.armStall(this.generation);
|
|
263
|
+
// Deliver a reminder held during the decision (due now, so it fires at
|
|
264
|
+
// once); otherwise this re-arms the unchanged schedule.
|
|
265
|
+
if (this.status_ === "running" && !this.settledFlag) this.armSoftDeadline(this.generation);
|
|
214
266
|
}
|
|
215
267
|
|
|
216
268
|
conversation() {
|
|
@@ -230,7 +282,9 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
230
282
|
// The previous generation was aborted; delivery would be undefined.
|
|
231
283
|
throw new Error(`subagent ${this.req.childId} is restarting after a stall; retry shortly`);
|
|
232
284
|
}
|
|
285
|
+
const gen = this.generation;
|
|
233
286
|
await this.session.steer(message);
|
|
287
|
+
this.parentActed(gen);
|
|
234
288
|
}
|
|
235
289
|
|
|
236
290
|
async followUp(message: string): Promise<void> {
|
|
@@ -240,10 +294,57 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
240
294
|
if (this.retiredGen !== null) {
|
|
241
295
|
throw new Error(`subagent ${this.req.childId} is restarting after a stall; retry shortly`);
|
|
242
296
|
}
|
|
297
|
+
const gen = this.generation;
|
|
243
298
|
await this.session.followUp(message);
|
|
299
|
+
this.parentActed(gen);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Parent action on an overrun: move the soft deadline to now + timeoutMs
|
|
304
|
+
* (default: the spawn budget). The reminder count continues; the hard
|
|
305
|
+
* ceiling (if any) does not move.
|
|
306
|
+
*/
|
|
307
|
+
extend(timeoutMs?: number): { deadlineAt: number; hardDeadlineAt: number | null } {
|
|
308
|
+
if (this.status_ !== "running") {
|
|
309
|
+
throw new Error(`subagent ${this.req.childId} is not running (status: ${this.status_})`);
|
|
310
|
+
}
|
|
311
|
+
const ms = timeoutMs ?? this.req.timeoutMs;
|
|
312
|
+
if (!(ms > 0)) throw new Error("extend needs a positive timeout_ms");
|
|
313
|
+
this.softDeadlineAt = this.now() + ms;
|
|
314
|
+
this.nextReminderAt = this.softDeadlineAt;
|
|
315
|
+
this.armSoftDeadline(this.generation);
|
|
316
|
+
return {
|
|
317
|
+
deadlineAt: this.softDeadlineAt,
|
|
318
|
+
hardDeadlineAt: this.hardTimeoutMs > 0 ? this.turnBudgetStart + this.hardTimeoutMs : null,
|
|
319
|
+
};
|
|
244
320
|
}
|
|
245
321
|
|
|
246
|
-
|
|
322
|
+
/**
|
|
323
|
+
* steer/followUp after a reminder: the parent is handling the overrun, so
|
|
324
|
+
* the next reminder waits a full interval. Before the first reminder the
|
|
325
|
+
* deadline is untouched, and so is a deadline that extend() moved into the
|
|
326
|
+
* future: the child is inside its budget again, and the next wake belongs
|
|
327
|
+
* at that deadline, not one repeat from now.
|
|
328
|
+
*/
|
|
329
|
+
private parentActed(gen: number): void {
|
|
330
|
+
// The delivery was awaited: if its turn settled (and maybe a resume
|
|
331
|
+
// started a new one) meanwhile, it must not move the new schedule.
|
|
332
|
+
if (gen !== this.generation || this.settledFlag) return;
|
|
333
|
+
if (this.reminders === 0 || this.status_ !== "running") return;
|
|
334
|
+
const now = this.now();
|
|
335
|
+
if (this.softDeadlineAt !== null && now < this.softDeadlineAt) return;
|
|
336
|
+
this.nextReminderAt = now + this.overrunRepeatMs;
|
|
337
|
+
this.armSoftDeadline(this.generation);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Start a new user turn. Resolves once the request is accepted, not when
|
|
342
|
+
* the turn starts: draining a still-unwinding abort and waiting for an
|
|
343
|
+
* admission slot run in the background, so the parent's tool call never
|
|
344
|
+
* waits for a slot. Until admission the child is "pending" (queued).
|
|
345
|
+
* Failures after acceptance settle the new turn instead of rejecting.
|
|
346
|
+
*/
|
|
347
|
+
async resume(message: string, opts: { timeoutMs?: number } = {}): Promise<void> {
|
|
247
348
|
if (this.status_ === "running" || this.status_ === "pending") {
|
|
248
349
|
throw new Error(
|
|
249
350
|
`subagent ${this.req.childId} is still ${this.status_}; use steer for a running subagent`,
|
|
@@ -255,35 +356,57 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
255
356
|
if (!this.session) {
|
|
256
357
|
throw new Error(`subagent ${this.req.childId} has no session to resume`);
|
|
257
358
|
}
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
// the session is dead and cannot be resumed.
|
|
261
|
-
const pendingAbort = this.abortPromise;
|
|
262
|
-
this.abortPromise = null;
|
|
263
|
-
if (pendingAbort && !(await this.awaitAbortBounded(pendingAbort))) {
|
|
264
|
-
throw new Error(
|
|
265
|
-
`subagent ${this.req.childId} session did not go idle after the abort; cannot resume`,
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
if (this.disposed) {
|
|
269
|
-
throw new Error(`subagent ${this.req.childId} has been disposed`);
|
|
270
|
-
}
|
|
271
|
-
// A new user turn gets a fresh stall budget and a fresh timeout budget.
|
|
359
|
+
// A new user turn gets a fresh stall budget and a fresh soft budget:
|
|
360
|
+
// the one passed with resume, else the spawn budget.
|
|
272
361
|
this.stallAttempts = 0;
|
|
362
|
+
this.turnBudgetMs =
|
|
363
|
+
opts.timeoutMs !== undefined && opts.timeoutMs > 0 ? opts.timeoutMs : this.req.timeoutMs;
|
|
364
|
+
this.reminders = 0;
|
|
273
365
|
this.runStartedAt = this.clock.now();
|
|
274
|
-
//
|
|
275
|
-
//
|
|
366
|
+
// The turn's generation exists from here: interrupt()/dispose() while it
|
|
367
|
+
// is queued settle it, and its result promise is observable through
|
|
368
|
+
// registry.getResult() before admission.
|
|
369
|
+
const gen = ++this.generation;
|
|
370
|
+
this.settledFlag = false;
|
|
371
|
+
this.retiredGen = null;
|
|
372
|
+
this.status_ = "pending";
|
|
373
|
+
this.startedAt = this.runStartedAt;
|
|
374
|
+
this.lastEvent = this.runStartedAt;
|
|
276
375
|
this.resultPromise = new Promise((resolve) => {
|
|
277
376
|
this.resolveResult = resolve;
|
|
278
377
|
});
|
|
279
|
-
|
|
378
|
+
const pendingAbort = this.abortPromise;
|
|
379
|
+
this.abortPromise = null;
|
|
380
|
+
void this.startResumedTurn(gen, message, pendingAbort);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Background half of resume(): drain a stall/timeout abort that is still
|
|
385
|
+
* unwinding (real pi rejects prompt() while the aborted run is active),
|
|
386
|
+
* then admission and prompt. Bounded: a hung abort means the session is
|
|
387
|
+
* dead, and the turn settles failed.
|
|
388
|
+
*/
|
|
389
|
+
private async startResumedTurn(gen: number, message: string, pendingAbort: Promise<void> | null): Promise<void> {
|
|
390
|
+
if (pendingAbort && !(await this.awaitAbortBounded(pendingAbort))) {
|
|
391
|
+
this.settle(gen, {
|
|
392
|
+
status: "failed",
|
|
393
|
+
text: "",
|
|
394
|
+
error: `subagent ${this.req.childId} session did not go idle after the abort; cannot resume`,
|
|
395
|
+
durationMs: this.now() - this.runStartedAt,
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (this.disposed || this.isSettled(gen)) return;
|
|
400
|
+
await this.beginGeneration(message, false, false, gen);
|
|
280
401
|
}
|
|
281
402
|
|
|
282
403
|
async interrupt(): Promise<void> {
|
|
283
404
|
if (this.status_ !== "running" && this.status_ !== "pending") return;
|
|
284
405
|
this.settle(this.generation, {
|
|
285
406
|
status: "interrupted",
|
|
286
|
-
text
|
|
407
|
+
// A pending turn has produced nothing yet; the last assistant text
|
|
408
|
+
// belongs to the previous turn.
|
|
409
|
+
text: this.status_ === "pending" ? "" : this.partialText(),
|
|
287
410
|
durationMs: this.now() - this.startedAt,
|
|
288
411
|
});
|
|
289
412
|
try {
|
|
@@ -326,8 +449,14 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
326
449
|
* slot acquired by the stalled generation is still held (no settle happened),
|
|
327
450
|
* so admission is not re-run and the slot is not double-counted.
|
|
328
451
|
*/
|
|
329
|
-
private async beginGeneration(
|
|
330
|
-
|
|
452
|
+
private async beginGeneration(
|
|
453
|
+
prompt: string,
|
|
454
|
+
first: boolean,
|
|
455
|
+
reuseSlot = false,
|
|
456
|
+
resumedGen?: number,
|
|
457
|
+
): Promise<void> {
|
|
458
|
+
// A resumed turn allocated its generation when it was requested.
|
|
459
|
+
const gen = resumedGen ?? ++this.generation;
|
|
331
460
|
this.retiredGen = null;
|
|
332
461
|
this.clearRetryTimer();
|
|
333
462
|
this.timerScope?.dispose();
|
|
@@ -343,7 +472,12 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
343
472
|
|
|
344
473
|
if (this.acquire && !reuseSlot) {
|
|
345
474
|
try {
|
|
346
|
-
|
|
475
|
+
// The ticket ties the slot request to THIS generation: a request
|
|
476
|
+
// whose generation settled while queued (interrupt, then a new
|
|
477
|
+
// resume) must not admit the child's next generation.
|
|
478
|
+
this.releaseSlot = await this.acquire(this.req, {
|
|
479
|
+
current: () => !this.disposed && !this.isSettled(gen),
|
|
480
|
+
});
|
|
347
481
|
} catch (err) {
|
|
348
482
|
// Admission denied (e.g. fail_fast cancellation while queued).
|
|
349
483
|
this.settle(gen, {
|
|
@@ -414,13 +548,19 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
414
548
|
return;
|
|
415
549
|
}
|
|
416
550
|
|
|
417
|
-
// The
|
|
551
|
+
// The turn's deadlines start only after admission and session creation:
|
|
418
552
|
// time spent queued for an admission slot is not the child's budget.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
553
|
+
// Stall retries (reuseSlot) keep the turn's deadlines and schedule.
|
|
554
|
+
if (!reuseSlot) {
|
|
555
|
+
this.turnBudgetStart = this.now();
|
|
556
|
+
this.softDeadlineAt = this.turnBudgetMs > 0 ? this.turnBudgetStart + this.turnBudgetMs : null;
|
|
557
|
+
this.nextReminderAt = this.softDeadlineAt;
|
|
558
|
+
}
|
|
559
|
+
this.armHardCeiling(gen);
|
|
560
|
+
// A stall can spend the whole hard budget before this generation starts;
|
|
561
|
+
// armHardCeiling settles in that case and prompting must not proceed.
|
|
423
562
|
if (this.isSettled(gen)) return;
|
|
563
|
+
this.armSoftDeadline(gen);
|
|
424
564
|
this.armStall(gen);
|
|
425
565
|
// Tell the child which model it is — otherwise only the parent/fleet knows.
|
|
426
566
|
const prompted =
|
|
@@ -541,16 +681,63 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
541
681
|
}
|
|
542
682
|
}
|
|
543
683
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
684
|
+
/**
|
|
685
|
+
* Soft deadline / reminder timer for the current generation scope, due at
|
|
686
|
+
* nextReminderAt (turn-level, so a stall retry re-arms the same schedule).
|
|
687
|
+
*/
|
|
688
|
+
private armSoftDeadline(gen: number): void {
|
|
689
|
+
if (this.softTimer !== null) {
|
|
690
|
+
this.timerScope?.clearTimeout(this.softTimer);
|
|
691
|
+
this.softTimer = null;
|
|
692
|
+
}
|
|
693
|
+
if (this.nextReminderAt === null) return;
|
|
694
|
+
const delay = Math.max(0, this.nextReminderAt - this.now());
|
|
695
|
+
this.softTimer = this.timerScope?.setTimeout(() => {
|
|
696
|
+
this.softTimer = null;
|
|
697
|
+
// Deliberately NOT isCurrent(): during a stall's retry delay the child
|
|
698
|
+
// is still running from the parent's view, so the reminder is due.
|
|
699
|
+
if (this.settledFlag || this.disposed || gen !== this.generation) return;
|
|
700
|
+
// A pending need_decision already put a supervisor-request wake in
|
|
701
|
+
// front of the parent: hold the reminder (nextReminderAt stays due)
|
|
702
|
+
// until resumeStall() re-arms it when the decision resolves.
|
|
703
|
+
if (this.decisionPaused) return;
|
|
704
|
+
this.fireOverrun(gen);
|
|
705
|
+
}, delay) ?? null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private fireOverrun(gen: number): void {
|
|
709
|
+
const now = this.now();
|
|
710
|
+
this.reminders++;
|
|
711
|
+
this.nextReminderAt = now + this.overrunRepeatMs;
|
|
712
|
+
try {
|
|
713
|
+
this.onOverrun?.({
|
|
714
|
+
childId: this.req.childId,
|
|
715
|
+
elapsedMs: now - this.turnBudgetStart,
|
|
716
|
+
budgetMs: (this.softDeadlineAt ?? now) - this.turnBudgetStart,
|
|
717
|
+
reminder: this.reminders,
|
|
718
|
+
nextReminderMs: this.overrunRepeatMs,
|
|
719
|
+
lastEventAt: this.lastEvent,
|
|
720
|
+
hardRemainingMs:
|
|
721
|
+
this.hardTimeoutMs > 0 ? Math.max(0, this.turnBudgetStart + this.hardTimeoutMs - now) : null,
|
|
722
|
+
});
|
|
723
|
+
} catch {
|
|
724
|
+
// overrun observers must not break the schedule
|
|
548
725
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
726
|
+
this.armSoftDeadline(gen);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Opt-in abort ceiling (hardTimeoutMs). The ceiling is per user turn: a
|
|
731
|
+
* stall retry arms only what the stalled generation did not already
|
|
732
|
+
* consume, and the budget clock starts after admission (turnBudgetStart).
|
|
733
|
+
*/
|
|
734
|
+
private armHardCeiling(gen: number): void {
|
|
735
|
+
if (this.hardTimer !== null) {
|
|
736
|
+
this.timerScope?.clearTimeout(this.hardTimer);
|
|
737
|
+
this.hardTimer = null;
|
|
738
|
+
}
|
|
739
|
+
if (!(this.hardTimeoutMs > 0)) return;
|
|
740
|
+
const remaining = this.hardTimeoutMs - (this.now() - this.turnBudgetStart);
|
|
554
741
|
if (remaining <= 0) {
|
|
555
742
|
this.settle(gen, {
|
|
556
743
|
status: "interrupted",
|
|
@@ -560,9 +747,9 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
560
747
|
});
|
|
561
748
|
return;
|
|
562
749
|
}
|
|
563
|
-
this.
|
|
564
|
-
this.
|
|
565
|
-
// Deliberately NOT isCurrent(): a
|
|
750
|
+
this.hardTimer = this.timerScope?.setTimeout(() => {
|
|
751
|
+
this.hardTimer = null;
|
|
752
|
+
// Deliberately NOT isCurrent(): a ceiling landing during a stall's
|
|
566
753
|
// retry delay must still fire — the retry has not started a new
|
|
567
754
|
// generation, and the budget is spent.
|
|
568
755
|
if (this.settledFlag || this.disposed || gen !== this.generation) return;
|
|
@@ -697,9 +884,13 @@ class InProcessChildHandle implements DisposableChildHandle {
|
|
|
697
884
|
}
|
|
698
885
|
|
|
699
886
|
private clearTimers(): void {
|
|
700
|
-
if (this.
|
|
701
|
-
this.timerScope?.clearTimeout(this.
|
|
702
|
-
this.
|
|
887
|
+
if (this.softTimer !== null) {
|
|
888
|
+
this.timerScope?.clearTimeout(this.softTimer);
|
|
889
|
+
this.softTimer = null;
|
|
890
|
+
}
|
|
891
|
+
if (this.hardTimer !== null) {
|
|
892
|
+
this.timerScope?.clearTimeout(this.hardTimer);
|
|
893
|
+
this.hardTimer = null;
|
|
703
894
|
}
|
|
704
895
|
this.clearStall();
|
|
705
896
|
this.clearRetryTimer();
|
package/src/subagent/tool.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { VALID_THINKING_LEVELS } from "../thinking-levels";
|
|
18
19
|
import { formatSubagentHandover, formatSubagentNotification, truncateTail } from "../format";
|
|
19
20
|
import { realClock, type Clock, type ClockTimer } from "../clock";
|
|
20
21
|
|
|
@@ -73,13 +74,18 @@ const subagentParameters = Type.Object({
|
|
|
73
74
|
Type.String({
|
|
74
75
|
description:
|
|
75
76
|
'Model override for all subagents: fuzzy ("haiku"), qualified ("provider/id"), ' +
|
|
76
|
-
|
|
77
|
+
`optionally with ":<thinking>" suffix (${VALID_THINKING_LEVELS.join(", ")}; ` +
|
|
78
|
+
'an unknown suffix is dropped with a warning when the base resolves; ' +
|
|
79
|
+
'an unknown base still errors). Default: current model. ' +
|
|
77
80
|
'Use action:"models" to list selectable values.',
|
|
78
81
|
}),
|
|
79
82
|
),
|
|
80
83
|
timeout_ms: Type.Optional(
|
|
81
84
|
Type.Number({
|
|
82
|
-
description:
|
|
85
|
+
description:
|
|
86
|
+
`Time budget per subagent turn in ms (default 1800000, max ${MAX_TIMEOUT_MS}). ` +
|
|
87
|
+
'Passing it does not stop the subagent: you get <pi-famulus-wake kind="subagent-overrun"> ' +
|
|
88
|
+
"and choose extend, steer, or interrupt. With resume: the resumed turn's budget. With extend: the new budget from now.",
|
|
83
89
|
maximum: MAX_TIMEOUT_MS,
|
|
84
90
|
}),
|
|
85
91
|
),
|
|
@@ -92,13 +98,16 @@ const subagentParameters = Type.Object({
|
|
|
92
98
|
Type.Literal("interrupt"),
|
|
93
99
|
Type.Literal("resume"),
|
|
94
100
|
Type.Literal("steer"),
|
|
101
|
+
Type.Literal("extend"),
|
|
95
102
|
Type.Literal("models"),
|
|
96
103
|
],
|
|
97
104
|
{ description: "Manage an existing run (or list selectable models) instead of starting a new one" },
|
|
98
105
|
),
|
|
99
106
|
),
|
|
100
107
|
run_id: Type.Optional(Type.String({ description: "Target run for action" })),
|
|
101
|
-
child_id: Type.Optional(
|
|
108
|
+
child_id: Type.Optional(
|
|
109
|
+
Type.String({ description: "Target child (id or name) for steer/interrupt/resume/extend" }),
|
|
110
|
+
),
|
|
102
111
|
message: Type.Optional(Type.String({ description: "Message content for steer/resume" })),
|
|
103
112
|
});
|
|
104
113
|
|
|
@@ -110,7 +119,7 @@ type SubagentParams = {
|
|
|
110
119
|
fail_fast?: boolean;
|
|
111
120
|
model?: string;
|
|
112
121
|
timeout_ms?: number;
|
|
113
|
-
action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "models";
|
|
122
|
+
action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "extend" | "models";
|
|
114
123
|
run_id?: string;
|
|
115
124
|
child_id?: string;
|
|
116
125
|
message?: string;
|
|
@@ -222,6 +231,7 @@ function toNotificationInfo(record: RunRecord, now: number) {
|
|
|
222
231
|
status: c.status,
|
|
223
232
|
text: c.result?.text ?? "",
|
|
224
233
|
error: c.result?.error,
|
|
234
|
+
...(c.result?.warning !== undefined ? { warning: c.result.warning } : {}),
|
|
225
235
|
...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
|
|
226
236
|
})),
|
|
227
237
|
};
|
|
@@ -285,9 +295,23 @@ export function createSubagentTool(
|
|
|
285
295
|
return registry;
|
|
286
296
|
};
|
|
287
297
|
|
|
298
|
+
/** Last terminal state a subagent-done was sent for, per run. */
|
|
299
|
+
const runDoneSent = new Map<string, string>();
|
|
288
300
|
const notifyRunCompleted = (registry: SubagentRegistry, runId: string): void => {
|
|
289
301
|
const record = registry.get(runId);
|
|
290
|
-
if (!record)
|
|
302
|
+
if (!record) {
|
|
303
|
+
runDoneSent.delete(runId);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// A resumed child can outlive the run's first-launch completion. The
|
|
307
|
+
// run is not done while it runs; its own settle sends this wake.
|
|
308
|
+
if (record.children.some((c) => c.status === "pending" || c.status === "running")) return;
|
|
309
|
+
// Several observers (first-launch completion, each resumed turn's settle)
|
|
310
|
+
// can see the same all-terminal state. One subagent-done per terminal
|
|
311
|
+
// state, identified by every child's turn number.
|
|
312
|
+
const terminalKey = record.children.map((c) => `${c.childId}:${c.turn ?? 1}`).join("|");
|
|
313
|
+
if (runDoneSent.get(runId) === terminalKey) return;
|
|
314
|
+
runDoneSent.set(runId, terminalKey);
|
|
291
315
|
deps.getNotifyCenter()?.notify(formatSubagentNotification(toNotificationInfo(record, clock.now())));
|
|
292
316
|
};
|
|
293
317
|
|
|
@@ -320,6 +344,7 @@ export function createSubagentTool(
|
|
|
320
344
|
prompt: child.prompt ?? "",
|
|
321
345
|
text: child.result?.text ?? "",
|
|
322
346
|
...(child.result?.error !== undefined ? { error: child.result.error } : {}),
|
|
347
|
+
...(child.result?.warning !== undefined ? { warning: child.result.warning } : {}),
|
|
323
348
|
stillRunning,
|
|
324
349
|
}),
|
|
325
350
|
);
|
|
@@ -590,12 +615,41 @@ export function createSubagentTool(
|
|
|
590
615
|
};
|
|
591
616
|
}
|
|
592
617
|
|
|
618
|
+
if (action === "extend") {
|
|
619
|
+
const handle = resolveSingleActiveChild(registry, record, params.child_id, "extend");
|
|
620
|
+
const child = record.children.find((c) => c.childId === handle.childId);
|
|
621
|
+
const ms = params.timeout_ms === undefined ? undefined : clampTimeout(params.timeout_ms, deps.defaultTimeoutMs);
|
|
622
|
+
const { deadlineAt, hardDeadlineAt } = handle.extend(ms);
|
|
623
|
+
const now = clock.now();
|
|
624
|
+
const hard =
|
|
625
|
+
hardDeadlineAt === null
|
|
626
|
+
? ""
|
|
627
|
+
: ` The configured hard ceiling (hardTimeoutMs) is not moved: it stops this subagent in ${formatDurationMs(Math.max(0, hardDeadlineAt - now))}.`;
|
|
628
|
+
return {
|
|
629
|
+
content: [
|
|
630
|
+
{
|
|
631
|
+
type: "text",
|
|
632
|
+
text:
|
|
633
|
+
`Extended subagent ${child?.name ?? handle.childId} (${handle.childId}) in run ${record.runId}: ` +
|
|
634
|
+
`the next <pi-famulus-wake kind="subagent-overrun"> comes in ${formatDurationMs(deadlineAt - now)} if it is still running. ` +
|
|
635
|
+
`Its result arrives as a wake when it finishes; do not poll.${hard}`,
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
details: { run_id: record.runId, child_id: handle.childId, deadline_at: deadlineAt, hard_deadline_at: hardDeadlineAt },
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
593
642
|
// resume
|
|
594
643
|
if (!params.message) throw new Error("message is required for resume");
|
|
595
644
|
const child = resolveSingleTerminalChild(record, params.child_id);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
645
|
+
// A resume is a new turn with its own soft budget: timeout_ms when given,
|
|
646
|
+
// else the child's spawn budget (the runner's default). Returns once the
|
|
647
|
+
// request is accepted; it never waits for an admission slot.
|
|
648
|
+
const { queuedBehind } = await registry.resumeChild(
|
|
649
|
+
child.childId,
|
|
650
|
+
params.message,
|
|
651
|
+
params.timeout_ms === undefined ? {} : { timeoutMs: clampTimeout(params.timeout_ms, deps.defaultTimeoutMs) },
|
|
652
|
+
);
|
|
599
653
|
// Resume is asynchronous. If siblings are still running, hand this child
|
|
600
654
|
// back as soon as it finishes; otherwise the run-complete wake covers it.
|
|
601
655
|
const handedOver = new Set<string>();
|
|
@@ -610,11 +664,12 @@ export function createSubagentTool(
|
|
|
610
664
|
type: "text",
|
|
611
665
|
text:
|
|
612
666
|
`Resumed subagent ${child.name} (${child.childId}) in run ${record.runId}. ` +
|
|
667
|
+
queuedText(queuedBehind) +
|
|
613
668
|
"You will be notified via <pi-famulus-wake kind=\"subagent-handover\"> if others are still running, " +
|
|
614
669
|
"otherwise via <pi-famulus-wake kind=\"subagent-done\"> when it completes. Do not poll.",
|
|
615
670
|
},
|
|
616
671
|
],
|
|
617
|
-
details: { run_id: record.runId, child_id: child.childId },
|
|
672
|
+
details: { run_id: record.runId, child_id: child.childId, queued_behind: queuedBehind },
|
|
618
673
|
};
|
|
619
674
|
};
|
|
620
675
|
|
|
@@ -626,7 +681,10 @@ export function createSubagentTool(
|
|
|
626
681
|
"interpolation). By default the call waits up to a foreground budget (default 45s); longer runs " +
|
|
627
682
|
"continue in the background. Each child that finishes while others are still running wakes you with " +
|
|
628
683
|
"<pi-famulus-wake kind=\"subagent-handover\"> (its prompt and result). The whole run wakes you with <pi-famulus-wake kind=\"subagent-done\">. " +
|
|
629
|
-
"Never poll or sleep to wait.
|
|
684
|
+
"Never poll or sleep to wait. " +
|
|
685
|
+
"A subagent still running past its timeout_ms is not stopped; it wakes you with " +
|
|
686
|
+
"<pi-famulus-wake kind=\"subagent-overrun\"> so you can extend, steer, or interrupt it. " +
|
|
687
|
+
"Use action=list/get/status/interrupt/resume/steer/extend to manage existing runs.",
|
|
630
688
|
promptSnippet: "Fan out subagents in parallel or sequence them in a chain",
|
|
631
689
|
promptGuidelines: [
|
|
632
690
|
'When a <pi-famulus-wake kind="subagent-handover"> arrives, read <prompt> and <result> immediately and continue: subagent({action:"resume", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.',
|
|
@@ -694,6 +752,13 @@ export function createSubagentTool(
|
|
|
694
752
|
};
|
|
695
753
|
}
|
|
696
754
|
|
|
755
|
+
/** Resume result clause for a turn waiting for an admission slot ("" when it started at once). */
|
|
756
|
+
function queuedText(queuedBehind: number | null): string {
|
|
757
|
+
if (queuedBehind === null) return "";
|
|
758
|
+
const ahead = queuedBehind === 0 ? "" : ` and ${queuedBehind} subagent(s) are waiting ahead of it`;
|
|
759
|
+
return `Every subagent slot is busy${ahead}, so it is queued and starts when a slot frees. `;
|
|
760
|
+
}
|
|
761
|
+
|
|
697
762
|
function resolveSingleActiveChild(
|
|
698
763
|
registry: SubagentRegistry,
|
|
699
764
|
record: RunRecord,
|
|
@@ -728,6 +793,14 @@ function resolveSingleTerminalChild(record: RunRecord, childIdOrName: string | u
|
|
|
728
793
|
const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
|
|
729
794
|
throw new Error(`no child "${childIdOrName}" in run ${record.runId} (children: ${known})`);
|
|
730
795
|
}
|
|
796
|
+
if (child.status === "pending") {
|
|
797
|
+
// Queued for an admission slot (a launch or an earlier resume): steer
|
|
798
|
+
// would fail too, and a second resume adds nothing.
|
|
799
|
+
throw new Error(
|
|
800
|
+
`subagent ${child.name} (${child.childId}) is already queued and starts when a subagent slot frees; ` +
|
|
801
|
+
"its result arrives as a wake when it finishes. Do not resume it again.",
|
|
802
|
+
);
|
|
803
|
+
}
|
|
731
804
|
if (!matches(child)) {
|
|
732
805
|
throw new Error(`subagent ${child.name} (${child.childId}) is still ${child.status}; use steer instead`);
|
|
733
806
|
}
|