pi-better-subagents 0.13.0 → 0.13.1
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/docs/usage.md +11 -6
- package/index.ts +4 -1
- package/package.json +1 -1
- package/shared-callback-batcher.ts +67 -4
package/docs/usage.md
CHANGED
|
@@ -246,13 +246,17 @@ given.
|
|
|
246
246
|
Its context can't clog the parent, its crash can't corrupt parent state, and
|
|
247
247
|
its output is durable in a log file.
|
|
248
248
|
- **Completions trigger one batched turn that fetches durable results.** Ordinary
|
|
249
|
-
terminal callbacks
|
|
249
|
+
terminal callbacks stay in the harness queue while the foreground agent is
|
|
250
|
+
busy. Once Pi is idle, they accumulate for 100 ms and share one
|
|
250
251
|
`pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true })` notification
|
|
251
252
|
with background-task completions from the same Pi host. Each row contains only
|
|
252
253
|
source, id, label, terminal status, and the `subagent_result` lookup. Full
|
|
253
254
|
results and logs stay in durable tools and are never embedded in the callback.
|
|
254
255
|
Set `PI_BETTER_CALLBACK_BATCH_MS` to `0` through `5000` milliseconds to tune
|
|
255
|
-
the accumulation window; invalid values use 100 ms.
|
|
256
|
+
the accumulation window; invalid values use 100 ms. `agent_settled` schedules
|
|
257
|
+
the aggregate without starting a model run inside the lifecycle handler.
|
|
258
|
+
Completions arriving during that model run, including bounded overflow,
|
|
259
|
+
stay queued for the next available boundary. A failed handoff keeps the
|
|
256
260
|
pending records retryable, and `/reload` recovers records explicitly marked
|
|
257
261
|
pending. `callback:false` sends no model message; read the durable result later
|
|
258
262
|
with `subagent_result`.
|
|
@@ -649,10 +653,11 @@ only after a successful handoff and dedupe across reloads and repeated health ti
|
|
|
649
653
|
Persisted unmarked orphaned/lost states are recovered on the health ticker after
|
|
650
654
|
`/reload` even when process evidence does not produce a fresh transition.
|
|
651
655
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
follow-ups
|
|
655
|
-
|
|
656
|
+
Ordinary completion batching does not depend on Pi's `followUpMode`: events
|
|
657
|
+
stay in the harness until availability rather than becoming separate queued
|
|
658
|
+
follow-ups during the foreground run. Urgent health/failure alerts retain their
|
|
659
|
+
existing immediate follow-up behavior. This extension does not change Pi core
|
|
660
|
+
or Pi's default follow-up mode.
|
|
656
661
|
|
|
657
662
|
### Surfacing health (tools + passive widget)
|
|
658
663
|
|
package/index.ts
CHANGED
|
@@ -116,7 +116,7 @@ import {
|
|
|
116
116
|
getSharedCapacityGate,
|
|
117
117
|
} from "./capacity.mjs";
|
|
118
118
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
119
|
-
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
119
|
+
import { cancelCallbackBatch, getCallbackBatcher, setCallbackBatchContext } from "./shared-callback-batcher.ts";
|
|
120
120
|
import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
|
|
121
121
|
import { collectRunFailures, failurePath, markFailureAttentionDelivered, pendingFailureAttention } from "./failures.ts";
|
|
122
122
|
import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
|
|
@@ -2340,6 +2340,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2340
2340
|
|
|
2341
2341
|
// ---- live-status lifecycle -----------------------------------------
|
|
2342
2342
|
pi.on("agent_start", async (_event, ctx) => {
|
|
2343
|
+
setCallbackBatchContext(pi, ctx);
|
|
2343
2344
|
uiCtx = ctx;
|
|
2344
2345
|
mainAgentStartedAt = Date.now();
|
|
2345
2346
|
mainAgentTools.clear();
|
|
@@ -2372,6 +2373,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2372
2373
|
});
|
|
2373
2374
|
|
|
2374
2375
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
2376
|
+
setCallbackBatchContext(pi, ctx);
|
|
2375
2377
|
mainAgentStartedAt = undefined;
|
|
2376
2378
|
mainAgentTools.clear();
|
|
2377
2379
|
refreshBackgroundWorkNavigator(ctx);
|
|
@@ -2387,6 +2389,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2387
2389
|
// alive, resume the ticking widget. Deferred out of the factory per pi's
|
|
2388
2390
|
// "no background resources at load" rule.
|
|
2389
2391
|
pi.on("session_start", async (_event, ctx) => {
|
|
2392
|
+
setCallbackBatchContext(pi, ctx);
|
|
2390
2393
|
agentSessionSettings.restore(ctx);
|
|
2391
2394
|
restoreDelegationMode(ctx);
|
|
2392
2395
|
uiCtx = ctx;
|
package/package.json
CHANGED
|
@@ -3,6 +3,8 @@ export type CallbackSource = "subagent" | "background-task";
|
|
|
3
3
|
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
4
4
|
|
|
5
5
|
export interface CallbackBatchHost {
|
|
6
|
+
/** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
|
|
7
|
+
events?: object;
|
|
6
8
|
sendMessage(
|
|
7
9
|
message: { customType: string; content: string; display: boolean },
|
|
8
10
|
options: Record<string, unknown>,
|
|
@@ -66,6 +68,8 @@ export interface UrgentCallbackEvent {
|
|
|
66
68
|
export interface CallbackBatcherOptions {
|
|
67
69
|
windowMs?: number;
|
|
68
70
|
retryMs?: number;
|
|
71
|
+
/** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
|
|
72
|
+
isAvailable?: () => boolean;
|
|
69
73
|
/** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
|
|
70
74
|
maxBytes?: number;
|
|
71
75
|
}
|
|
@@ -73,6 +77,10 @@ export interface CallbackBatcherOptions {
|
|
|
73
77
|
export interface CallbackBatcher {
|
|
74
78
|
enqueue(event: CallbackBatchEvent): boolean;
|
|
75
79
|
flush(): Promise<boolean>;
|
|
80
|
+
/** Refresh the API wrapper after reload without losing handoff receipts. */
|
|
81
|
+
setHost(host: CallbackBatchHost): void;
|
|
82
|
+
/** Update the session predicate and schedule an asynchronous drain when available. */
|
|
83
|
+
setAvailability(isAvailable: () => boolean): void;
|
|
76
84
|
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
77
85
|
cancel(): void;
|
|
78
86
|
pendingCount(): number;
|
|
@@ -95,6 +103,7 @@ interface PendingEvent {
|
|
|
95
103
|
|
|
96
104
|
interface SharedCallbackBatcherState {
|
|
97
105
|
byHost: WeakMap<object, CallbackBatcher>;
|
|
106
|
+
bySession?: WeakMap<object, CallbackBatcher>;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
@@ -435,6 +444,19 @@ export function createCallbackBatcher(
|
|
|
435
444
|
let sequence = 0;
|
|
436
445
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
437
446
|
let flushPromise: Promise<boolean> | undefined;
|
|
447
|
+
let isAvailable = options.isAvailable ?? (() => true);
|
|
448
|
+
let gated = options.isAvailable !== undefined;
|
|
449
|
+
let awaitingRun = false;
|
|
450
|
+
|
|
451
|
+
const available = (): boolean => {
|
|
452
|
+
try {
|
|
453
|
+
if (!isAvailable()) {
|
|
454
|
+
awaitingRun = false;
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
return !awaitingRun;
|
|
458
|
+
} catch { return false; }
|
|
459
|
+
};
|
|
438
460
|
|
|
439
461
|
const cancelTimer = (): void => {
|
|
440
462
|
if (timer) clearTimeout(timer);
|
|
@@ -443,10 +465,12 @@ export function createCallbackBatcher(
|
|
|
443
465
|
|
|
444
466
|
const schedule = (delayMs: number): void => {
|
|
445
467
|
if (timer || pending.size === 0) return;
|
|
468
|
+
// Some idle transitions (manual compaction) have no agent_settled event.
|
|
469
|
+
const delay = available() ? delayMs : retryMs;
|
|
446
470
|
timer = setTimeout(() => {
|
|
447
471
|
timer = undefined;
|
|
448
472
|
void api.flush();
|
|
449
|
-
}, Math.max(
|
|
473
|
+
}, Math.max(1, delay));
|
|
450
474
|
timer.unref?.();
|
|
451
475
|
};
|
|
452
476
|
|
|
@@ -461,6 +485,10 @@ export function createCallbackBatcher(
|
|
|
461
485
|
|
|
462
486
|
const performFlush = async (): Promise<boolean> => {
|
|
463
487
|
cancelTimer();
|
|
488
|
+
if (!available()) {
|
|
489
|
+
schedule(retryMs);
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
464
492
|
const snapshot = [...pending.entries()]
|
|
465
493
|
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
466
494
|
pending.clear();
|
|
@@ -509,6 +537,9 @@ export function createCallbackBatcher(
|
|
|
509
537
|
const overflowItems = deliverable.filter(([, item]) => !representedSet.has(item.event));
|
|
510
538
|
|
|
511
539
|
try {
|
|
540
|
+
// Pi may defer the requested run while settled handlers finish, leaving
|
|
541
|
+
// isIdle() true. Permit no second handoff until a run starts.
|
|
542
|
+
awaitingRun = gated;
|
|
512
543
|
await host.sendMessage(
|
|
513
544
|
{
|
|
514
545
|
customType: "background-completion-batch",
|
|
@@ -518,6 +549,7 @@ export function createCallbackBatcher(
|
|
|
518
549
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
519
550
|
);
|
|
520
551
|
} catch {
|
|
552
|
+
awaitingRun = false;
|
|
521
553
|
for (const [key] of deliverable) inFlight.delete(key);
|
|
522
554
|
const retryItems = [...deliverable, ...pending.entries()]
|
|
523
555
|
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
@@ -596,10 +628,19 @@ export function createCallbackBatcher(
|
|
|
596
628
|
const api: CallbackBatcher = {
|
|
597
629
|
enqueue,
|
|
598
630
|
flush,
|
|
631
|
+
setHost(next) { host = next; },
|
|
632
|
+
setAvailability(check) {
|
|
633
|
+
gated = true;
|
|
634
|
+
isAvailable = check;
|
|
635
|
+
available();
|
|
636
|
+
cancelTimer();
|
|
637
|
+
schedule(windowMs);
|
|
638
|
+
},
|
|
599
639
|
deliverUrgent,
|
|
600
640
|
cancel() {
|
|
601
641
|
cancelTimer();
|
|
602
642
|
pending.clear();
|
|
643
|
+
awaitingRun = false;
|
|
603
644
|
},
|
|
604
645
|
pendingCount() {
|
|
605
646
|
return pending.size;
|
|
@@ -613,16 +654,38 @@ export function getCallbackBatcher(
|
|
|
613
654
|
options: CallbackBatcherOptions = {},
|
|
614
655
|
): CallbackBatcher {
|
|
615
656
|
const state = globalState();
|
|
616
|
-
const key = host
|
|
657
|
+
const key = host.events ?? host;
|
|
617
658
|
const existing = state.byHost.get(key);
|
|
618
|
-
if (existing)
|
|
659
|
+
if (existing) {
|
|
660
|
+
existing.setHost(host);
|
|
661
|
+
return existing;
|
|
662
|
+
}
|
|
619
663
|
const created = createCallbackBatcher(host, options);
|
|
620
664
|
state.byHost.set(key, created);
|
|
621
665
|
return created;
|
|
622
666
|
}
|
|
623
667
|
|
|
624
668
|
export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
625
|
-
globalState().byHost.get(host
|
|
669
|
+
const batcher = globalState().byHost.get(host.events ?? host);
|
|
670
|
+
batcher?.setAvailability(() => false);
|
|
671
|
+
batcher?.cancel();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Called on session_start, agent_start and agent_settled by both extensions. */
|
|
675
|
+
export function setCallbackBatchContext(
|
|
676
|
+
host: CallbackBatchHost,
|
|
677
|
+
ctx: { isIdle(): boolean; sessionManager?: object },
|
|
678
|
+
): void {
|
|
679
|
+
const state = globalState();
|
|
680
|
+
const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
|
|
681
|
+
const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
|
|
682
|
+
const batcher = shared ?? getCallbackBatcher(host);
|
|
683
|
+
if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
|
|
684
|
+
state.byHost.set(host.events ?? host, batcher);
|
|
685
|
+
batcher.setHost(host);
|
|
686
|
+
// Schedule rather than await delivery inside agent_settled: its handlers are
|
|
687
|
+
// still settling the previous run, and sendMessage may start a new one.
|
|
688
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
626
689
|
}
|
|
627
690
|
|
|
628
691
|
function globalState(): SharedCallbackBatcherState {
|