@ferris1225/pi-subagents 0.31.0 → 0.32.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -71
- package/package.json +1 -1
- package/src/agents.ts +8 -13
- package/src/background.ts +59 -6
- package/src/config.ts +14 -2
- package/src/dispatch.ts +1429 -396
- package/src/format.ts +29 -4
- package/src/index.ts +7 -4
- package/src/inspector-panel.ts +363 -0
- package/src/inspector.ts +369 -0
- package/src/models.ts +192 -50
- package/src/monitor.ts +114 -7
- package/src/prompt.ts +3 -2
- package/src/recovery.ts +145 -0
- package/src/rpc-run.ts +1016 -0
- package/src/runtime.ts +167 -27
- package/src/session-fork.ts +84 -0
- package/src/setup.ts +271 -184
- package/src/spawn.ts +562 -977
- package/src/tools.ts +404 -65
- package/src/trajectory.ts +503 -0
- package/src/ui.ts +32 -16
- package/src/widget.ts +17 -4
- package/src/worktree.ts +687 -0
package/src/spawn.ts
CHANGED
|
@@ -1,977 +1,562 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sub-agent
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (result
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
try {
|
|
564
|
-
if (agent.systemPrompt.trim()) {
|
|
565
|
-
const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
566
|
-
tmpPromptDir = tmp.dir;
|
|
567
|
-
tmpPromptPath = tmp.filePath;
|
|
568
|
-
args.push("--append-system-prompt", tmpPromptPath);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
let wasAborted = false;
|
|
572
|
-
|
|
573
|
-
// Increment depth so nested sub-agents can be guarded against runaway recursion.
|
|
574
|
-
const childDepth = currentSubagentDepth(options.env) + 1;
|
|
575
|
-
const childEnv: NodeJS.ProcessEnv = {
|
|
576
|
-
...(options.env ?? process.env),
|
|
577
|
-
[DEPTH_ENV_VAR]: String(childDepth),
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
const exitCode = await new Promise<number>((resolve) => {
|
|
581
|
-
const invocation = getPiInvocation(args);
|
|
582
|
-
const proc = spawn(invocation.command, invocation.args, {
|
|
583
|
-
cwd: cwd ?? options.defaultCwd,
|
|
584
|
-
shell: false,
|
|
585
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
586
|
-
env: childEnv,
|
|
587
|
-
});
|
|
588
|
-
let buffer = "";
|
|
589
|
-
let closed = false;
|
|
590
|
-
let termSent = false;
|
|
591
|
-
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
592
|
-
let abortHandler: (() => void) | undefined;
|
|
593
|
-
let lastActivityAt = Date.now();
|
|
594
|
-
let idleTimer: ReturnType<typeof setInterval> | undefined;
|
|
595
|
-
|
|
596
|
-
const finish = (code: number | null): void => {
|
|
597
|
-
if (closed) return;
|
|
598
|
-
closed = true;
|
|
599
|
-
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
600
|
-
if (idleTimer) clearInterval(idleTimer);
|
|
601
|
-
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
602
|
-
resolve(code ?? 1);
|
|
603
|
-
};
|
|
604
|
-
|
|
605
|
-
const terminate = (): void => {
|
|
606
|
-
if (closed) return;
|
|
607
|
-
if (!termSent) {
|
|
608
|
-
termSent = true;
|
|
609
|
-
terminateProcessTree(proc, false);
|
|
610
|
-
}
|
|
611
|
-
if (!forceKillTimer) {
|
|
612
|
-
forceKillTimer = setTimeout(() => {
|
|
613
|
-
if (!closed) terminateProcessTree(proc, true);
|
|
614
|
-
}, SUBAGENT_KILL_GRACE_MS);
|
|
615
|
-
}
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
const processLine = (line: string): void => {
|
|
619
|
-
if (!line.trim()) return;
|
|
620
|
-
let event: any;
|
|
621
|
-
try {
|
|
622
|
-
event = JSON.parse(line);
|
|
623
|
-
} catch {
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Live event: agent started
|
|
628
|
-
if (event.type === "agent_start" || event.type === "turn_start") {
|
|
629
|
-
if (onLive) {
|
|
630
|
-
try {
|
|
631
|
-
onLive({ kind: "status", status: "running" });
|
|
632
|
-
} catch { /* never throw from event handling */ }
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Live event: streamed assistant reasoning / output text
|
|
637
|
-
if (event.type === "message_update") {
|
|
638
|
-
const t = event.assistantMessageEvent?.type;
|
|
639
|
-
if (t === "thinking_delta" || t === "text_delta") {
|
|
640
|
-
if (onLive) {
|
|
641
|
-
try {
|
|
642
|
-
onLive({ kind: t === "thinking_delta" ? "thinking" : "text" });
|
|
643
|
-
} catch { /* never throw from event handling */ }
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// Live event: tool execution started
|
|
649
|
-
if (event.type === "tool_execution_start") {
|
|
650
|
-
if (onLive) {
|
|
651
|
-
try {
|
|
652
|
-
onLive({ kind: "tool_start", toolName: event.toolName ?? "unknown", args: event.args });
|
|
653
|
-
} catch { /* never throw from event handling */ }
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Live event: tool execution ended
|
|
658
|
-
if (event.type === "tool_execution_end") {
|
|
659
|
-
if (onLive) {
|
|
660
|
-
try {
|
|
661
|
-
onLive({ kind: "tool_end", toolName: event.toolName ?? "unknown", isError: Boolean(event.isError) });
|
|
662
|
-
} catch { /* never throw from event handling */ }
|
|
663
|
-
}
|
|
664
|
-
if (event.isError) {
|
|
665
|
-
(currentResult.failedTools ??= []).push({
|
|
666
|
-
toolName: event.toolName ?? "unknown",
|
|
667
|
-
error: extractToolErrorText(event.result?.content),
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (event.type === "message_end" && event.message) {
|
|
673
|
-
const msg = event.message as Message;
|
|
674
|
-
currentResult.messages.push(msg);
|
|
675
|
-
if (msg.role === "assistant") {
|
|
676
|
-
currentResult.usage.turns++;
|
|
677
|
-
const usage = (msg as any).usage;
|
|
678
|
-
if (usage) {
|
|
679
|
-
currentResult.usage.input += usage.input || 0;
|
|
680
|
-
currentResult.usage.output += usage.output || 0;
|
|
681
|
-
currentResult.usage.cacheRead += usage.cacheRead || 0;
|
|
682
|
-
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
|
|
683
|
-
currentResult.usage.cost += usage.cost?.total || 0;
|
|
684
|
-
currentResult.usage.contextTokens = usage.totalTokens || 0;
|
|
685
|
-
}
|
|
686
|
-
if (!currentResult.model && (msg as any).model) currentResult.model = (msg as any).model;
|
|
687
|
-
if ((msg as any).stopReason) currentResult.stopReason = (msg as any).stopReason;
|
|
688
|
-
if ((msg as any).errorMessage) currentResult.errorMessage = (msg as any).errorMessage;
|
|
689
|
-
}
|
|
690
|
-
// Live event: usage snapshot after accumulation
|
|
691
|
-
if (onLive) {
|
|
692
|
-
try {
|
|
693
|
-
onLive({ kind: "usage", usage: { ...currentResult.usage }, model: currentResult.model });
|
|
694
|
-
} catch { /* never throw from event handling */ }
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
if (event.type === "tool_result_end" && event.message) {
|
|
699
|
-
currentResult.messages.push(event.message as Message);
|
|
700
|
-
}
|
|
701
|
-
};
|
|
702
|
-
// Send the task (or a continuation prompt for a resumed session) through
|
|
703
|
-
// the child stdin pipe instead of the command line. This avoids OS
|
|
704
|
-
// argument-length limits and requires no extra temp file for the data.
|
|
705
|
-
proc.stdin?.on("error", () => undefined);
|
|
706
|
-
proc.stdin?.end(options.stdinText ?? `Task: ${task}`);
|
|
707
|
-
|
|
708
|
-
// Decode stdout through a StringDecoder so multi-byte UTF-8 characters
|
|
709
|
-
// (CJK, emoji) split across chunk boundaries never produce U+FFFD
|
|
710
|
-
// replacement characters — a corrupted JSON line would drop the whole
|
|
711
|
-
// message (including a reviewer's verdict line) from parsing.
|
|
712
|
-
const stdoutDecoder = new StringDecoder("utf8");
|
|
713
|
-
proc.stdout.on("data", (data) => {
|
|
714
|
-
lastActivityAt = Date.now();
|
|
715
|
-
buffer += stdoutDecoder.write(data);
|
|
716
|
-
const lines = buffer.split("\n");
|
|
717
|
-
buffer = lines.pop() || "";
|
|
718
|
-
for (const line of lines) processLine(line);
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
proc.stderr.on("data", (data) => {
|
|
722
|
-
currentResult.stderr += data.toString();
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
proc.on("close", (code) => {
|
|
726
|
-
// Flush any bytes still held by the decoder (a trailing incomplete
|
|
727
|
-
// multi-byte sequence) before processing the final buffer.
|
|
728
|
-
buffer += stdoutDecoder.end();
|
|
729
|
-
if (buffer.trim()) processLine(buffer);
|
|
730
|
-
// A null exit code means the process was terminated by a signal and
|
|
731
|
-
// must be reported as failure, never as a false clean completion.
|
|
732
|
-
const failed =
|
|
733
|
-
code !== 0 ||
|
|
734
|
-
wasAborted ||
|
|
735
|
-
(signal?.aborted ?? false) ||
|
|
736
|
-
currentResult.stopReason === "error" ||
|
|
737
|
-
currentResult.stopReason === "aborted";
|
|
738
|
-
if (onLive) {
|
|
739
|
-
try {
|
|
740
|
-
onLive({ kind: "status", status: failed ? "failed" : "done" });
|
|
741
|
-
} catch { /* never throw from event handling */ }
|
|
742
|
-
}
|
|
743
|
-
finish(code);
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
proc.on("error", () => {
|
|
747
|
-
// Spawn itself failed; close may never fire, so finish the run here.
|
|
748
|
-
currentResult.stopReason = "error";
|
|
749
|
-
currentResult.errorMessage ??= "Failed to start the sub-agent process.";
|
|
750
|
-
if (onLive) {
|
|
751
|
-
try {
|
|
752
|
-
onLive({ kind: "status", status: "failed" });
|
|
753
|
-
} catch { /* never throw from event handling */ }
|
|
754
|
-
}
|
|
755
|
-
finish(1);
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
if (idleTimeoutMs > 0) {
|
|
759
|
-
const checkInterval = Math.min(10_000, Math.floor(idleTimeoutMs / 3));
|
|
760
|
-
idleTimer = setInterval(() => {
|
|
761
|
-
if (closed) return;
|
|
762
|
-
if (Date.now() - lastActivityAt >= idleTimeoutMs) {
|
|
763
|
-
if (idleTimer) clearInterval(idleTimer);
|
|
764
|
-
currentResult.stopReason = "error";
|
|
765
|
-
currentResult.errorMessage = `Subagent idle timeout: no activity for ${Math.ceil(idleTimeoutMs / 1000)} seconds.`;
|
|
766
|
-
terminate();
|
|
767
|
-
}
|
|
768
|
-
}, checkInterval);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
if (signal) {
|
|
772
|
-
abortHandler = (): void => {
|
|
773
|
-
wasAborted = true;
|
|
774
|
-
terminate();
|
|
775
|
-
};
|
|
776
|
-
if (signal.aborted) abortHandler();
|
|
777
|
-
else signal.addEventListener("abort", abortHandler, { once: true });
|
|
778
|
-
}
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
currentResult.exitCode = exitCode;
|
|
782
|
-
if (wasAborted) {
|
|
783
|
-
currentResult.stopReason = "aborted";
|
|
784
|
-
currentResult.errorMessage ??= "Subagent was aborted";
|
|
785
|
-
if (onLive) {
|
|
786
|
-
try {
|
|
787
|
-
onLive({ kind: "status", status: "failed" });
|
|
788
|
-
} catch { /* never throw from event handling */ }
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
return currentResult;
|
|
792
|
-
} finally {
|
|
793
|
-
if (tmpPromptPath)
|
|
794
|
-
try {
|
|
795
|
-
unlinkSync(tmpPromptPath);
|
|
796
|
-
} catch {
|
|
797
|
-
/* ignore */
|
|
798
|
-
}
|
|
799
|
-
if (tmpPromptDir)
|
|
800
|
-
try {
|
|
801
|
-
rmdirSync(tmpPromptDir);
|
|
802
|
-
} catch {
|
|
803
|
-
/* ignore */
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Run one agent with three layers of resilience, all session-backed so a model
|
|
810
|
-
* switch RESUMES the prior context instead of re-scanning from scratch:
|
|
811
|
-
*
|
|
812
|
-
* 1. Startup retry (inner loop): a concurrent pi startup race can make the child
|
|
813
|
-
* exit before any model/tool activity. Relaunch with backoff so the startup
|
|
814
|
-
* lock clears. The SAME model is retried — the race is in the host, not the
|
|
815
|
-
* model — and only a clean, silent, zero-activity exit qualifies (see
|
|
816
|
-
* isRetryableStartupFailure), so retrying can never duplicate real work.
|
|
817
|
-
* 2. Run-level retry on the SAME configured model (middle): when the provider
|
|
818
|
-
* rejects the model with a TRANSIENT error (503/429/timeout/network/...) —
|
|
819
|
-
* NOT a terminal one (quota/billing/invalid key/auth — see isTerminalModelError)
|
|
820
|
-
* — RESUME the session on the same model up to
|
|
821
|
-
* SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS.length more times with backoff. Resuming
|
|
822
|
-
* preserves any work done before the hiccup.
|
|
823
|
-
* 3. Model fallback (outer): when the same model still fails, RESUME the session
|
|
824
|
-
* once on the main window's current model — the fallback inherits the prior
|
|
825
|
-
* context, so the user never pays to re-scan after a model switch.
|
|
826
|
-
*
|
|
827
|
-
* Terminal model errors short-circuit to the caller: the account is the
|
|
828
|
-
* bottleneck, so neither same-model retry nor a same-account fallback can help.
|
|
829
|
-
* The session is preserved on disk (the result carries sessionId/sessionDir) so
|
|
830
|
-
* a later manual resume on a working model can continue without re-scanning.
|
|
831
|
-
*
|
|
832
|
-
* The fallback is per-run only and never persisted: a transient provider hiccup
|
|
833
|
-
* must not silently downgrade the configured agent model.
|
|
834
|
-
*/
|
|
835
|
-
export async function runSingleAgentWithModelFallback(
|
|
836
|
-
options: RunSingleOptions,
|
|
837
|
-
fallbackModelRef?: string,
|
|
838
|
-
): Promise<SingleResult> {
|
|
839
|
-
const agent = options.agent;
|
|
840
|
-
const launchedRef = agent?.model;
|
|
841
|
-
const startupDelays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
|
|
842
|
-
// Run-level retry is opted out of with an explicit empty array (e.g. a test
|
|
843
|
-
// that wants to assert ONLY the fallback path runs once); undefined means
|
|
844
|
-
// "use the default 5-attempt transient-error schedule".
|
|
845
|
-
const runDelays = options.runLevelRetryDelaysMs ?? SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS;
|
|
846
|
-
|
|
847
|
-
const runWithStartupRetry = async (opts: RunSingleOptions): Promise<SingleResult> => {
|
|
848
|
-
let lastResult: SingleResult;
|
|
849
|
-
let retries = 0;
|
|
850
|
-
for (let attempt = 0; ; attempt++) {
|
|
851
|
-
const start = Date.now();
|
|
852
|
-
lastResult = await runSingleAgent(opts);
|
|
853
|
-
const durationMs = Date.now() - start;
|
|
854
|
-
if (!isRetryableStartupFailure(lastResult, durationMs)) {
|
|
855
|
-
if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
|
|
856
|
-
return lastResult;
|
|
857
|
-
}
|
|
858
|
-
const delay = startupDelays[attempt];
|
|
859
|
-
if (delay === undefined) {
|
|
860
|
-
// Exhausted: the agent never reached a model. Surface the concurrency-race
|
|
861
|
-
// cause as a dispatch-level failure (no model was ever reached, so this
|
|
862
|
-
// must NOT trigger run-level retry or model fallback) so the main agent
|
|
863
|
-
// can retry or lower maxConcurrency.
|
|
864
|
-
lastResult.errorMessage = formatStartupRetryExhaustedError(
|
|
865
|
-
lastResult.model ?? opts.agent?.model ?? "default",
|
|
866
|
-
attempt + 1,
|
|
867
|
-
);
|
|
868
|
-
lastResult.stopReason ??= "error";
|
|
869
|
-
lastResult.dispatchFailed = true;
|
|
870
|
-
return lastResult;
|
|
871
|
-
}
|
|
872
|
-
// Flip the live status back to running so the widget does not flash a
|
|
873
|
-
// false "failed" while we wait out the backoff and relaunch the child.
|
|
874
|
-
try {
|
|
875
|
-
opts.onLive?.({ kind: "status", status: "running" });
|
|
876
|
-
} catch { /* never throw from event handling */ }
|
|
877
|
-
const shouldRetry = await waitForStartupRetry(delay, opts.signal);
|
|
878
|
-
if (!shouldRetry) return lastResult;
|
|
879
|
-
retries++;
|
|
880
|
-
}
|
|
881
|
-
};
|
|
882
|
-
|
|
883
|
-
// One pi session backs the whole logical run, shared by the initial attempt
|
|
884
|
-
// and every resume (model-level retry / fallback), so a model switch inherits
|
|
885
|
-
// the prior context instead of re-scanning. Created here unless the caller
|
|
886
|
-
// passed one in (an explicit resume of a handed-back session).
|
|
887
|
-
const sessionId = options.sessionId ?? randomUUID();
|
|
888
|
-
const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
|
|
889
|
-
const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
|
|
890
|
-
|
|
891
|
-
let modelRetries = 0;
|
|
892
|
-
let result: SingleResult | undefined;
|
|
893
|
-
try {
|
|
894
|
-
result = await runWithStartupRetry(baseOptions);
|
|
895
|
-
|
|
896
|
-
// A TERMINAL model error (quota/billing/invalid key/auth) is account-scoped:
|
|
897
|
-
// neither a same-model retry nor a same-account fallback can help. Skip the
|
|
898
|
-
// automatic retry/fallback and hand the run back — the session is preserved
|
|
899
|
-
// (see finally) so a later manual resume on a working model can continue
|
|
900
|
-
// without re-scanning.
|
|
901
|
-
const terminal = isModelLevelFailure(result) && isTerminalModelError(result);
|
|
902
|
-
|
|
903
|
-
// A TRANSIENT provider failure (503/429/timeout/network/...) usually
|
|
904
|
-
// recovers on a relaunch. RESUME the same session on the same configured
|
|
905
|
-
// model up to runDelays.length more times with backoff — resuming (not
|
|
906
|
-
// restarting) preserves any work done before the hiccup. This sits outside
|
|
907
|
-
// pi-ai's per-request provider retry, which by then already tried and gave up.
|
|
908
|
-
if (!terminal && agent && launchedRef && isModelLevelFailure(result) && runDelays.length > 0) {
|
|
909
|
-
const retryOpts: RunSingleOptions = {
|
|
910
|
-
...baseOptions,
|
|
911
|
-
stdinText: buildResumePrompt(options.task, "a transient provider error"),
|
|
912
|
-
};
|
|
913
|
-
for (let attempt = 0; ; attempt++) {
|
|
914
|
-
const delay = runDelays[attempt];
|
|
915
|
-
if (delay === undefined) break;
|
|
916
|
-
try {
|
|
917
|
-
options.onLive?.({ kind: "status", status: "running" });
|
|
918
|
-
} catch { /* never throw from event handling */ }
|
|
919
|
-
const shouldRetry = await waitForStartupRetry(delay, options.signal);
|
|
920
|
-
if (!shouldRetry) break;
|
|
921
|
-
const retried = await runWithStartupRetry(retryOpts);
|
|
922
|
-
modelRetries++;
|
|
923
|
-
// failedTools reflect ONLY the final attempt (each runSingleAgent call
|
|
924
|
-
// accumulates its own), so the completion message stays accurate.
|
|
925
|
-
result = retried;
|
|
926
|
-
if (!isModelLevelFailure(retried) || isTerminalModelError(retried)) break;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// Transient retries exhausted (or none) and still a model-level failure:
|
|
931
|
-
// RESUME the session on the main window's current model exactly once. The
|
|
932
|
-
// fallback inherits the prior context (no re-scan). Skipped when there is no
|
|
933
|
-
// fallback ref, it equals the configured model, or the failure is terminal
|
|
934
|
-
// (a same-account fallback would fail identically — leave it for manual resume).
|
|
935
|
-
if (
|
|
936
|
-
!terminal &&
|
|
937
|
-
agent &&
|
|
938
|
-
launchedRef &&
|
|
939
|
-
fallbackModelRef &&
|
|
940
|
-
launchedRef !== fallbackModelRef &&
|
|
941
|
-
isModelLevelFailure(result)
|
|
942
|
-
) {
|
|
943
|
-
const retried = await runWithStartupRetry({
|
|
944
|
-
...baseOptions,
|
|
945
|
-
stdinText: buildResumePrompt(options.task, buildFallbackResumeReason(launchedRef)),
|
|
946
|
-
agent: { ...agent, model: fallbackModelRef },
|
|
947
|
-
});
|
|
948
|
-
// The fallback replaces the result wholesale: failedTools reflect ONLY the
|
|
949
|
-
// fallback (final) attempt — stale build errors from the first model are
|
|
950
|
-
// not merged, so a clean final attempt is never misattributed a failure.
|
|
951
|
-
result = { ...retried, modelFallbackFrom: launchedRef };
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// A terminal failure takes the bare result (no retries or fallback ran, so
|
|
955
|
-
// modelRetries stays undefined — matching the contract callers assert).
|
|
956
|
-
return terminal ? result : { ...result, modelRetries };
|
|
957
|
-
} finally {
|
|
958
|
-
// Keep the session on disk only for a model-level failure that did real work
|
|
959
|
-
// and is being handed back, so a later `resume` can continue it. A provider
|
|
960
|
-
// rejection before any work (no messages/tools/output) has nothing to resume,
|
|
961
|
-
// so it is cleaned up along with every success and task-level failure. Never
|
|
962
|
-
// remove a caller-provided sessionDir (an explicit resume owns its dir).
|
|
963
|
-
if (result) {
|
|
964
|
-
result.sessionId ??= sessionId;
|
|
965
|
-
result.sessionDir ??= sessionDir;
|
|
966
|
-
}
|
|
967
|
-
const hasWork =
|
|
968
|
-
!!result &&
|
|
969
|
-
(result.messages.length > 1 ||
|
|
970
|
-
(result.failedTools?.length ?? 0) > 0 ||
|
|
971
|
-
Boolean(getFinalOutput(result.messages)));
|
|
972
|
-
const keep = !!result && isModelLevelFailure(result) && hasWork;
|
|
973
|
-
if (!keep && !options.sessionDir) {
|
|
974
|
-
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Sub-agent result handling and resilient RPC launch orchestration.
|
|
3
|
+
*
|
|
4
|
+
* The process transport itself lives in rpc-run.ts. Each attempt starts pi in
|
|
5
|
+
* persistent `--mode rpc`, sends commands over strict LF-delimited JSONL, and
|
|
6
|
+
* settles only on `agent_settled`. This module preserves the existing startup
|
|
7
|
+
* retry, same-model retry, model fallback, accounting, and result formatting
|
|
8
|
+
* contracts around those attempts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { basename, join } from "node:path";
|
|
16
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
17
|
+
import type { AgentConfig } from "./agents.ts";
|
|
18
|
+
import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
|
|
19
|
+
import {
|
|
20
|
+
currentSubagentDepth,
|
|
21
|
+
DEPTH_ENV_VAR,
|
|
22
|
+
extractToolErrorText,
|
|
23
|
+
getPiInvocation,
|
|
24
|
+
RpcRunControl,
|
|
25
|
+
runRpcAgentAttempt,
|
|
26
|
+
sessionExists,
|
|
27
|
+
SUBAGENT_KILL_GRACE_MS,
|
|
28
|
+
type RpcSingleResult,
|
|
29
|
+
type SubagentLiveEvent,
|
|
30
|
+
type SubagentRecordEvent,
|
|
31
|
+
type UsageStats,
|
|
32
|
+
} from "./rpc-run.ts";
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
currentSubagentDepth,
|
|
36
|
+
DEPTH_ENV_VAR,
|
|
37
|
+
extractToolErrorText,
|
|
38
|
+
getPiInvocation,
|
|
39
|
+
RpcRunControl,
|
|
40
|
+
sessionExists,
|
|
41
|
+
SUBAGENT_KILL_GRACE_MS,
|
|
42
|
+
};
|
|
43
|
+
export type { SubagentLiveEvent, SubagentRecordEvent, UsageStats };
|
|
44
|
+
|
|
45
|
+
export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
|
|
46
|
+
/** 0 disables the watchdog; dispatch supplies the configured timeout. */
|
|
47
|
+
export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
|
|
48
|
+
export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
|
|
49
|
+
export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
|
|
50
|
+
export const SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const;
|
|
51
|
+
|
|
52
|
+
export interface SingleResult extends RpcSingleResult {}
|
|
53
|
+
|
|
54
|
+
export interface SubagentDetails {
|
|
55
|
+
mode: "single" | "parallel";
|
|
56
|
+
results: SingleResult[];
|
|
57
|
+
background?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getFinalOutput(messages: Message[]): string {
|
|
61
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
62
|
+
const msg = messages[i];
|
|
63
|
+
if (msg.role === "assistant") {
|
|
64
|
+
for (const part of msg.content) {
|
|
65
|
+
if (part.type === "text") return part.text;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Only the last standalone reviewer verdict line counts. */
|
|
73
|
+
export function reviewVerdict(output: string): "pass" | "fail" | undefined {
|
|
74
|
+
const lines = output.split("\n");
|
|
75
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
76
|
+
const match = /^\s*VERDICT:\s*REVIEW_(PASS|FAIL)\s*$/i.exec(lines[index]);
|
|
77
|
+
if (match) return match[1].toUpperCase() === "PASS" ? "pass" : "fail";
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const RESULT_LINE_MAX = 200;
|
|
83
|
+
|
|
84
|
+
export interface TruncatedOutput {
|
|
85
|
+
text: string;
|
|
86
|
+
truncated: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function truncateResultOutput(output: string, maxLines: number): TruncatedOutput {
|
|
90
|
+
const lines = output.split("\n");
|
|
91
|
+
if (lines.length <= maxLines && lines.every((line) => line.length <= RESULT_LINE_MAX)) {
|
|
92
|
+
return { text: output, truncated: false };
|
|
93
|
+
}
|
|
94
|
+
const kept = lines.slice(0, maxLines).map((line) =>
|
|
95
|
+
line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line,
|
|
96
|
+
);
|
|
97
|
+
return { text: kept.join("\n"), truncated: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function writeResultArtifact(output: string, agentName: string, cwd?: string): string {
|
|
101
|
+
const projectSlug = cwd ? basename(cwd).replace(/[^\w.-]+/g, "_") || "default" : "default";
|
|
102
|
+
const dir = join(tmpdir(), "pi-subagents-results", projectSlug);
|
|
103
|
+
mkdirSync(dir, { recursive: true });
|
|
104
|
+
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
105
|
+
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
106
|
+
const filePath = join(dir, `${unique}-${safeName}.md`);
|
|
107
|
+
writeFileSync(filePath, output, "utf8");
|
|
108
|
+
return filePath;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isFailedResult(result: SingleResult): boolean {
|
|
112
|
+
if (result.parked) return false;
|
|
113
|
+
return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function lastAssistantMessage(messages: Message[]): Extract<Message, { role: "assistant" }> | undefined {
|
|
117
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
118
|
+
const message = messages[index];
|
|
119
|
+
if (message.role === "assistant") return message;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function assistantText(message: Extract<Message, { role: "assistant" }>): string {
|
|
125
|
+
return message.content
|
|
126
|
+
.filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text")
|
|
127
|
+
.map((part) => part.text)
|
|
128
|
+
.join("");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function isModelLevelFailure(result: SingleResult): boolean {
|
|
132
|
+
if (!isFailedResult(result)) return false;
|
|
133
|
+
if (result.stopReason === "aborted") return false;
|
|
134
|
+
if (result.dispatchFailed) return false;
|
|
135
|
+
if (result.integrationStatus === "retained") return false;
|
|
136
|
+
if (result.errorMessage?.includes("idle timeout")) return true;
|
|
137
|
+
if (result.rpcPromptRejected) return true;
|
|
138
|
+
|
|
139
|
+
// Classification belongs to the final assistant turn, not the whole attempt.
|
|
140
|
+
// Earlier useful text or failed tool calls are retained session history and
|
|
141
|
+
// must not hide a later provider error (for example a second-turn 503).
|
|
142
|
+
const finalAssistant = lastAssistantMessage(result.messages);
|
|
143
|
+
if (finalAssistant) {
|
|
144
|
+
if (finalAssistant.stopReason !== "error") return false;
|
|
145
|
+
if (assistantText(finalAssistant).trim()) return false;
|
|
146
|
+
return Boolean(
|
|
147
|
+
finalAssistant.errorMessage?.trim() ||
|
|
148
|
+
result.errorMessage?.trim() ||
|
|
149
|
+
result.stderr.trim() ||
|
|
150
|
+
finalAssistant.content.length === 0
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if ((result.failedTools?.length ?? 0) > 0) return false;
|
|
155
|
+
return Boolean(result.errorMessage?.trim()) || result.stderr.trim().length > 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const TERMINAL_MODEL_ERROR_PATTERN =
|
|
159
|
+
/insufficient_quota|quota\s+exceeded|exceeded[^.\n]{0,40}quota|out\s+of\s+budget|billing|usage\s+limit|usage_limit|gousagelimiterror|freeusagelimiterror|monthly\s+usage\s+limit\s+reached|available\s+balance|invalid\s+(?:api\s+)?key|incorrect\s+api\s+key|unauthori[sz]ed|\b401\b|\b403\b|forbidden|permission\s+denied/i;
|
|
160
|
+
const PERMANENT_MODEL_CANDIDATE_ERROR_PATTERN =
|
|
161
|
+
/model[_ -]?not[_ -]?found|no\s+models?\s+(?:found|matched)|(?:model|provider)[^.\n]{0,80}(?:not\s+found|unknown|does\s+not\s+exist|unsupported|invalid)|(?:not\s+found|unknown|unsupported|invalid)[^.\n]{0,40}(?:model|provider)|\b404\b/i;
|
|
162
|
+
|
|
163
|
+
export function isTerminalModelError(result: SingleResult): boolean {
|
|
164
|
+
const message = result.errorMessage?.trim();
|
|
165
|
+
if (message) return TERMINAL_MODEL_ERROR_PATTERN.test(message);
|
|
166
|
+
const stderr = result.stderr.trim();
|
|
167
|
+
return stderr.length > 0 && TERMINAL_MODEL_ERROR_PATTERN.test(stderr);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A permanent failure of this model/provider reference (stale id, unknown
|
|
171
|
+
* provider, 404 config route). Skip same-candidate backoff, but keep advancing
|
|
172
|
+
* through backup and current-main candidates. */
|
|
173
|
+
export function isPermanentModelCandidateError(result: SingleResult): boolean {
|
|
174
|
+
const message = result.errorMessage?.trim();
|
|
175
|
+
if (message) return PERMANENT_MODEL_CANDIDATE_ERROR_PATTERN.test(message);
|
|
176
|
+
const stderr = result.stderr.trim();
|
|
177
|
+
return stderr.length > 0 && PERMANENT_MODEL_CANDIDATE_ERROR_PATTERN.test(stderr);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function isRetryableStartupFailure(result: SingleResult, durationMs: number): boolean {
|
|
181
|
+
if (result.exitCode === 0) return false;
|
|
182
|
+
if (result.stopReason === "aborted") return false;
|
|
183
|
+
if (result.dispatchFailed) return false;
|
|
184
|
+
if (result.errorMessage?.includes("idle timeout")) return false;
|
|
185
|
+
if (getFinalOutput(result.messages)) return false;
|
|
186
|
+
if (result.messages.length > 0) return false;
|
|
187
|
+
const usage = result.usage;
|
|
188
|
+
if (usage.turns || usage.input || usage.output || usage.cacheRead || usage.cacheWrite || usage.cost) return false;
|
|
189
|
+
if (durationMs > MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS) return false;
|
|
190
|
+
if (result.stderr.trim().length > 0) return false;
|
|
191
|
+
if (result.errorMessage && result.errorMessage.trim().length > 0) return false;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
|
|
196
|
+
return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child exited before any model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or temporarily lower maxConcurrency in /subagents-setup.`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
200
|
+
if (delayMs <= 0) return !signal?.aborted;
|
|
201
|
+
if (!signal) {
|
|
202
|
+
return new Promise<boolean>((resolve) => {
|
|
203
|
+
const timer = setTimeout(() => resolve(true), delayMs);
|
|
204
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (signal.aborted) return false;
|
|
208
|
+
return new Promise<boolean>((resolve) => {
|
|
209
|
+
let settled = false;
|
|
210
|
+
const finish = (shouldRetry: boolean): void => {
|
|
211
|
+
if (settled) return;
|
|
212
|
+
settled = true;
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
signal.removeEventListener("abort", onAbort);
|
|
215
|
+
resolve(shouldRetry);
|
|
216
|
+
};
|
|
217
|
+
const onAbort = (): void => finish(false);
|
|
218
|
+
const timer = setTimeout(() => finish(true), delayMs);
|
|
219
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
220
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function waitForControlledRetry(
|
|
225
|
+
delayMs: number,
|
|
226
|
+
signal: AbortSignal | undefined,
|
|
227
|
+
control: RpcRunControl | undefined,
|
|
228
|
+
): Promise<boolean> {
|
|
229
|
+
let remaining = delayMs;
|
|
230
|
+
while (remaining > 0) {
|
|
231
|
+
if (control?.isParkRequested() || control?.isStopRequested()) return false;
|
|
232
|
+
const slice = Math.min(remaining, 50);
|
|
233
|
+
if (!(await waitForStartupRetry(slice, signal))) return false;
|
|
234
|
+
remaining -= slice;
|
|
235
|
+
}
|
|
236
|
+
return !signal?.aborted && !control?.isParkRequested() && !control?.isStopRequested();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function getResultOutput(result: SingleResult): string {
|
|
240
|
+
if (isFailedResult(result)) {
|
|
241
|
+
const error = result.errorMessage || result.stderr;
|
|
242
|
+
const partial = getFinalOutput(result.messages);
|
|
243
|
+
if (error && partial) return `${error}\n\n--- Partial output ---\n${partial}`;
|
|
244
|
+
return error || partial || "(no output)";
|
|
245
|
+
}
|
|
246
|
+
return getFinalOutput(result.messages) || "(no output)";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function buildResumePrompt(task: string, reason: string): string {
|
|
250
|
+
return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Original task: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function buildFallbackResumeReason(fromModel?: string): string {
|
|
254
|
+
return fromModel
|
|
255
|
+
? `the previous model (${fromModel}) failed at the model/provider level, so the next model in its configured pool is continuing`
|
|
256
|
+
: "the previous model failed at the model/provider level, so the next model in its configured pool is continuing";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface RunSingleOptions {
|
|
260
|
+
defaultCwd: string;
|
|
261
|
+
agent: AgentConfig | undefined;
|
|
262
|
+
agentName: string;
|
|
263
|
+
task: string;
|
|
264
|
+
cwd?: string;
|
|
265
|
+
thinkingLevel?: ThinkingLevel;
|
|
266
|
+
idleTimeoutMs?: number;
|
|
267
|
+
startupRetryDelaysMs?: readonly number[];
|
|
268
|
+
runLevelRetryDelaysMs?: readonly number[];
|
|
269
|
+
sessionDir?: string;
|
|
270
|
+
sessionId?: string;
|
|
271
|
+
/** Initial RPC prompt. Kept under the old name to limit caller churn. */
|
|
272
|
+
stdinText?: string;
|
|
273
|
+
signal?: AbortSignal;
|
|
274
|
+
onLive?: (event: SubagentLiveEvent) => void;
|
|
275
|
+
/** Receives the raw streamed text/thinking deltas for the inspector
|
|
276
|
+
* transcript; forwarded to the transport alongside onLive. */
|
|
277
|
+
onRecord?: (event: SubagentRecordEvent) => void;
|
|
278
|
+
makeDetails: (results: SingleResult[]) => SubagentDetails;
|
|
279
|
+
env?: NodeJS.ProcessEnv;
|
|
280
|
+
/** Stable logical-generation controller shared across retry attempts. */
|
|
281
|
+
control?: RpcRunControl;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function controlledDisposition(options: RunSingleOptions, base?: SingleResult): SingleResult | undefined {
|
|
285
|
+
const control = options.control;
|
|
286
|
+
if (!control?.isParkRequested() && !control?.isStopRequested()) return undefined;
|
|
287
|
+
const result: SingleResult = base ?? {
|
|
288
|
+
agent: options.agentName,
|
|
289
|
+
agentSource: options.agent?.source ?? "unknown",
|
|
290
|
+
task: control.getObjective(),
|
|
291
|
+
exitCode: 0,
|
|
292
|
+
messages: [],
|
|
293
|
+
stderr: "",
|
|
294
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
295
|
+
model: options.agent?.model,
|
|
296
|
+
thinking: options.thinkingLevel,
|
|
297
|
+
sessionId: options.sessionId,
|
|
298
|
+
sessionDir: options.sessionDir,
|
|
299
|
+
};
|
|
300
|
+
result.task = control.getObjective();
|
|
301
|
+
if (control.isParkRequested()) {
|
|
302
|
+
result.parked = true;
|
|
303
|
+
result.exitCode = 0;
|
|
304
|
+
result.stopReason = undefined;
|
|
305
|
+
result.errorMessage = undefined;
|
|
306
|
+
} else {
|
|
307
|
+
result.parked = undefined;
|
|
308
|
+
result.exitCode = 1;
|
|
309
|
+
result.stopReason = "aborted";
|
|
310
|
+
result.errorMessage = control.getStopMessage();
|
|
311
|
+
}
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Spawn one RPC attempt and wait for stable settlement. */
|
|
316
|
+
export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
|
|
317
|
+
const {
|
|
318
|
+
agent,
|
|
319
|
+
agentName,
|
|
320
|
+
thinkingLevel = SUBAGENT_THINKING_LEVEL,
|
|
321
|
+
idleTimeoutMs = SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS,
|
|
322
|
+
control,
|
|
323
|
+
} = options;
|
|
324
|
+
if (!agent) {
|
|
325
|
+
return {
|
|
326
|
+
agent: agentName,
|
|
327
|
+
agentSource: "unknown",
|
|
328
|
+
task: options.task,
|
|
329
|
+
exitCode: 1,
|
|
330
|
+
messages: [],
|
|
331
|
+
stderr: `Unknown agent: "${agentName}".`,
|
|
332
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const disposition = controlledDisposition(options);
|
|
337
|
+
if (disposition) return disposition;
|
|
338
|
+
const objective = control?.getObjective() ?? options.task;
|
|
339
|
+
let prompt = options.stdinText ?? `Task: ${objective}`;
|
|
340
|
+
if (control && objective !== options.task) {
|
|
341
|
+
prompt = options.sessionDir && sessionExists(options.sessionDir, options.sessionId ?? "")
|
|
342
|
+
? `Abandon the previous objective. New objective: ${objective}`
|
|
343
|
+
: `Task: ${objective}`;
|
|
344
|
+
}
|
|
345
|
+
const result = await runRpcAgentAttempt({
|
|
346
|
+
defaultCwd: options.defaultCwd,
|
|
347
|
+
agent,
|
|
348
|
+
agentName,
|
|
349
|
+
task: objective,
|
|
350
|
+
cwd: options.cwd,
|
|
351
|
+
thinkingLevel,
|
|
352
|
+
idleTimeoutMs,
|
|
353
|
+
sessionDir: options.sessionDir,
|
|
354
|
+
sessionId: options.sessionId,
|
|
355
|
+
prompt,
|
|
356
|
+
signal: options.signal,
|
|
357
|
+
onLive: options.onLive,
|
|
358
|
+
onRecord: options.onRecord,
|
|
359
|
+
env: options.env,
|
|
360
|
+
control,
|
|
361
|
+
});
|
|
362
|
+
result.task = control?.getObjective() ?? result.task;
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Run one logical generation across an ordered model pool. Every candidate gets
|
|
368
|
+
* startup retries plus same-model retries for transient provider failures;
|
|
369
|
+
* terminal model errors skip those retries and advance immediately. All
|
|
370
|
+
* candidates resume the same retained pi session.
|
|
371
|
+
*/
|
|
372
|
+
export async function runSingleAgentWithModelFallback(
|
|
373
|
+
options: RunSingleOptions,
|
|
374
|
+
fallbackModelRefs: readonly string[] = [],
|
|
375
|
+
): Promise<SingleResult> {
|
|
376
|
+
const agent = options.agent;
|
|
377
|
+
const launchedRef = agent?.model;
|
|
378
|
+
const startupDelays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
|
|
379
|
+
const runDelays = options.runLevelRetryDelaysMs ?? SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS;
|
|
380
|
+
|
|
381
|
+
const sessionId = options.sessionId ?? randomUUID();
|
|
382
|
+
const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
|
|
383
|
+
const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
|
|
384
|
+
|
|
385
|
+
const dispatchFailure = async (error: unknown): Promise<SingleResult> => {
|
|
386
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
387
|
+
const hasSession = sessionExists(sessionDir, sessionId);
|
|
388
|
+
if (!hasSession && !options.sessionDir) {
|
|
389
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
agent: options.agentName,
|
|
393
|
+
agentSource: options.agent?.source ?? "unknown",
|
|
394
|
+
task: options.control?.getObjective() ?? options.task,
|
|
395
|
+
exitCode: 1,
|
|
396
|
+
messages: [],
|
|
397
|
+
stderr: errorMessage,
|
|
398
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
399
|
+
model: options.agent?.model,
|
|
400
|
+
thinking: options.thinkingLevel,
|
|
401
|
+
stopReason: "error",
|
|
402
|
+
errorMessage,
|
|
403
|
+
dispatchFailed: true,
|
|
404
|
+
...(hasSession || options.sessionDir ? { sessionId, sessionDir } : {}),
|
|
405
|
+
};
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const runWithStartupRetry = async (opts: RunSingleOptions): Promise<SingleResult> => {
|
|
409
|
+
let lastResult: SingleResult;
|
|
410
|
+
let retries = 0;
|
|
411
|
+
for (let attempt = 0; ; attempt++) {
|
|
412
|
+
const immediate = controlledDisposition(opts);
|
|
413
|
+
if (immediate) {
|
|
414
|
+
if (immediate.parked && !options.sessionDir && !sessionExists(sessionDir, sessionId)) {
|
|
415
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
416
|
+
immediate.sessionId = undefined;
|
|
417
|
+
immediate.sessionDir = undefined;
|
|
418
|
+
}
|
|
419
|
+
return immediate;
|
|
420
|
+
}
|
|
421
|
+
const start = Date.now();
|
|
422
|
+
try {
|
|
423
|
+
lastResult = await runSingleAgent(opts);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
const failed = await dispatchFailure(error);
|
|
426
|
+
return controlledDisposition(opts, failed) ?? failed;
|
|
427
|
+
}
|
|
428
|
+
const durationMs = Date.now() - start;
|
|
429
|
+
const controlled = controlledDisposition(opts, lastResult);
|
|
430
|
+
if (controlled) return controlled;
|
|
431
|
+
if (lastResult.parked || lastResult.stopReason === "aborted") return lastResult;
|
|
432
|
+
if (!isRetryableStartupFailure(lastResult, durationMs)) {
|
|
433
|
+
if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
|
|
434
|
+
return lastResult;
|
|
435
|
+
}
|
|
436
|
+
const delay = startupDelays[attempt];
|
|
437
|
+
if (delay === undefined) {
|
|
438
|
+
lastResult.errorMessage = formatStartupRetryExhaustedError(
|
|
439
|
+
lastResult.model ?? opts.agent?.model ?? "default",
|
|
440
|
+
attempt + 1,
|
|
441
|
+
);
|
|
442
|
+
lastResult.stopReason ??= "error";
|
|
443
|
+
lastResult.dispatchFailed = true;
|
|
444
|
+
return lastResult;
|
|
445
|
+
}
|
|
446
|
+
opts.control?.markRetrying();
|
|
447
|
+
try {
|
|
448
|
+
opts.onLive?.({ kind: "status", status: "running" });
|
|
449
|
+
} catch {
|
|
450
|
+
/* never throw from event handling */
|
|
451
|
+
}
|
|
452
|
+
if (!(await waitForControlledRetry(delay, opts.signal, opts.control))) {
|
|
453
|
+
return controlledDisposition(opts, lastResult) ?? lastResult;
|
|
454
|
+
}
|
|
455
|
+
retries++;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const fallbackRefs: string[] = [];
|
|
460
|
+
const seenRefs = new Set<string>();
|
|
461
|
+
if (launchedRef?.trim()) seenRefs.add(launchedRef.trim());
|
|
462
|
+
for (const candidate of fallbackModelRefs) {
|
|
463
|
+
const ref = candidate.trim();
|
|
464
|
+
if (!ref || seenRefs.has(ref)) continue;
|
|
465
|
+
seenRefs.add(ref);
|
|
466
|
+
fallbackRefs.push(ref);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const candidates: Array<{ agent: AgentConfig | undefined; ref?: string }> = [
|
|
470
|
+
{ agent, ref: launchedRef?.trim() || undefined },
|
|
471
|
+
];
|
|
472
|
+
if (agent) {
|
|
473
|
+
for (const ref of fallbackRefs) candidates.push({ agent: { ...agent, model: ref }, ref });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let modelRetries = 0;
|
|
477
|
+
let fallbackUsed = false;
|
|
478
|
+
let result: SingleResult | undefined;
|
|
479
|
+
|
|
480
|
+
const finish = async (settled: SingleResult): Promise<SingleResult> => {
|
|
481
|
+
const persistedSession = sessionExists(sessionDir, sessionId);
|
|
482
|
+
if (!settled.dispatchFailed || persistedSession || options.sessionDir) {
|
|
483
|
+
settled.sessionId ??= sessionId;
|
|
484
|
+
settled.sessionDir ??= sessionDir;
|
|
485
|
+
} else {
|
|
486
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
487
|
+
settled.sessionId = undefined;
|
|
488
|
+
settled.sessionDir = undefined;
|
|
489
|
+
}
|
|
490
|
+
settled.task = options.control?.getObjective() ?? settled.task;
|
|
491
|
+
if (fallbackUsed && launchedRef) settled.modelFallbackFrom = launchedRef;
|
|
492
|
+
settled.modelRetries = modelRetries;
|
|
493
|
+
options.control?.markSettled();
|
|
494
|
+
return settled;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
|
|
498
|
+
const candidate = candidates[candidateIndex];
|
|
499
|
+
fallbackUsed ||= candidateIndex > 0;
|
|
500
|
+
const previousModel = result?.model ?? candidates[candidateIndex - 1]?.ref;
|
|
501
|
+
const candidateOptions: RunSingleOptions = {
|
|
502
|
+
...baseOptions,
|
|
503
|
+
agent: candidate.agent,
|
|
504
|
+
...(candidateIndex > 0
|
|
505
|
+
? {
|
|
506
|
+
stdinText: buildResumePrompt(
|
|
507
|
+
options.control?.getObjective() ?? options.task,
|
|
508
|
+
buildFallbackResumeReason(previousModel),
|
|
509
|
+
),
|
|
510
|
+
}
|
|
511
|
+
: {}),
|
|
512
|
+
};
|
|
513
|
+
try {
|
|
514
|
+
options.onLive?.({
|
|
515
|
+
kind: "model",
|
|
516
|
+
model: candidate.ref,
|
|
517
|
+
...(candidateIndex > 0 && launchedRef ? { fallbackFrom: launchedRef } : {}),
|
|
518
|
+
});
|
|
519
|
+
} catch {
|
|
520
|
+
/* never throw from event handling */
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
result = await runWithStartupRetry(candidateOptions);
|
|
524
|
+
if (result.parked || result.stopReason === "aborted") return result;
|
|
525
|
+
if (!isModelLevelFailure(result)) return finish(result);
|
|
526
|
+
|
|
527
|
+
if (!isTerminalModelError(result) && !isPermanentModelCandidateError(result)) {
|
|
528
|
+
const retryOptions: RunSingleOptions = {
|
|
529
|
+
...candidateOptions,
|
|
530
|
+
stdinText: buildResumePrompt(
|
|
531
|
+
options.control?.getObjective() ?? options.task,
|
|
532
|
+
"a transient provider error on the same model",
|
|
533
|
+
),
|
|
534
|
+
};
|
|
535
|
+
for (const delay of runDelays) {
|
|
536
|
+
baseOptions.control?.markRetrying();
|
|
537
|
+
try {
|
|
538
|
+
options.onLive?.({ kind: "status", status: "running" });
|
|
539
|
+
} catch {
|
|
540
|
+
/* never throw from event handling */
|
|
541
|
+
}
|
|
542
|
+
if (!(await waitForControlledRetry(delay, options.signal, options.control))) {
|
|
543
|
+
return controlledDisposition(baseOptions, result) ?? result;
|
|
544
|
+
}
|
|
545
|
+
result = await runWithStartupRetry(retryOptions);
|
|
546
|
+
modelRetries++;
|
|
547
|
+
if (result.parked || result.stopReason === "aborted") return result;
|
|
548
|
+
if (
|
|
549
|
+
!isModelLevelFailure(result) ||
|
|
550
|
+
isTerminalModelError(result) ||
|
|
551
|
+
isPermanentModelCandidateError(result)
|
|
552
|
+
) break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!isModelLevelFailure(result)) return finish(result);
|
|
557
|
+
// Transient exhaustion plus terminal/permanent candidate errors advance
|
|
558
|
+
// to the next configured candidate. Ordinary task/tool failures returned above.
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return finish(result ?? (await dispatchFailure("No model candidate was attempted.")));
|
|
562
|
+
}
|