pi-better-background-tasks 0.2.19 → 0.3.0

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.
@@ -16,6 +16,24 @@ export interface CallbackBatchEvent {
16
16
  status: string;
17
17
  detailTool: CallbackDetailTool;
18
18
  callback?: boolean;
19
+ /** Lifecycle/work outcome; independent of semantic task correctness. */
20
+ outcome?: string;
21
+ /**
22
+ * Legacy single-string failure summary already reduced by the observation
23
+ * owner. Prefer `failureRows` so shown/omitted incidents are counted exactly.
24
+ */
25
+ failure?: string;
26
+ /** One row per active incident, priority order (failure-observations formatFailureLines). */
27
+ failureRows?: string[];
28
+ /** Matched-condition, stop-error, or observation-gap facts. */
29
+ decision?: string;
30
+ /** Active incidents for this row. Defaults to `failureRows.length`. */
31
+ incidentCount?: number;
32
+ /**
33
+ * Legacy: incidents the caller already knows are not in `failure`. Ignored
34
+ * when `failureRows` is given, because the batch then counts rows it shows.
35
+ */
36
+ omittedIncidents?: number;
19
37
  isDelivered?: () => boolean;
20
38
  getSuppressionReason?: () => string | undefined;
21
39
  onDelivered?: (at: number) => void;
@@ -24,11 +42,21 @@ export interface CallbackBatchEvent {
24
42
 
25
43
  export interface UrgentCallbackEvent {
26
44
  source: CallbackSource;
45
+ /** Notification identity (dedupe/receipts). */
27
46
  id: string;
47
+ /** Retrieval identity for the inspect tool when it differs from `id`. */
48
+ inspectId?: string;
28
49
  label: string;
29
50
  status: "orphaned" | "lost" | string;
30
51
  customType: string;
52
+ /** Explanation text (health transition, attention reason). */
31
53
  content: string;
54
+ detailTool?: CallbackDetailTool;
55
+ /** One row per active incident, priority order. */
56
+ failureRows?: string[];
57
+ incidentCount?: number;
58
+ /** Legacy: ignored when `failureRows` is given. */
59
+ omittedIncidents?: number;
32
60
  isDelivered?: () => boolean;
33
61
  getSuppressionReason?: () => string | undefined;
34
62
  onDelivered?: (at: number) => void;
@@ -38,6 +66,8 @@ export interface UrgentCallbackEvent {
38
66
  export interface CallbackBatcherOptions {
39
67
  windowMs?: number;
40
68
  retryMs?: number;
69
+ /** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
70
+ maxBytes?: number;
41
71
  }
42
72
 
43
73
  export interface CallbackBatcher {
@@ -48,6 +78,16 @@ export interface CallbackBatcher {
48
78
  pendingCount(): number;
49
79
  }
50
80
 
81
+ export interface CallbackBatchFormatOptions {
82
+ maxBytes?: number;
83
+ }
84
+
85
+ export interface FormattedCallbackBatch {
86
+ text: string;
87
+ represented: CallbackBatchEvent[];
88
+ omitted: number;
89
+ }
90
+
51
91
  interface PendingEvent {
52
92
  event: CallbackBatchEvent;
53
93
  sequence: number;
@@ -60,9 +100,21 @@ interface SharedCallbackBatcherState {
60
100
  const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
61
101
  const DEFAULT_WINDOW_MS = 100;
62
102
  const DEFAULT_RETRY_MS = 1_000;
63
- const MAX_LABEL_CHARS = 160;
64
- const MAX_ID_CHARS = 200;
65
- const MAX_STATUS_CHARS = 80;
103
+ const MAX_LABEL_BYTES = 160;
104
+ const MAX_ID_BYTES = 200;
105
+ const MAX_STATUS_BYTES = 80;
106
+ const MAX_FAILURE_BYTES = 400;
107
+ const MAX_DECISION_BYTES = 400;
108
+ const encoder = new TextEncoder();
109
+ const decoder = new TextDecoder("utf-8");
110
+
111
+ /** OUTPUT-POLICY default: UTF-8 bytes of one model-facing callback batch. */
112
+ export const CALLBACK_BATCH_BUDGET_BYTES = 2 * 1024;
113
+ /** Documented hard cap. Explicit larger pages clamp here. */
114
+ export const CALLBACK_BATCH_MAX_BYTES = 8 * 1024;
115
+
116
+ const RETRIEVAL_FOOTER =
117
+ "Retrieve durable results/status with the listed tools using cursor/limit. Full results and logs are intentionally omitted.";
66
118
 
67
119
  export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
68
120
  export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
@@ -76,24 +128,266 @@ export function resolveCallbackBatchWindowMs(
76
128
  return Math.max(0, Math.min(5_000, Math.floor(parsed)));
77
129
  }
78
130
 
79
- export function formatCallbackBatch(events: readonly CallbackBatchEvent[]): string {
80
- const count = events.length;
131
+ export function utf8ByteLength(text: string): number {
132
+ return encoder.encode(text).byteLength;
133
+ }
134
+
135
+ export function callbackBatchBudget(requested?: unknown): number {
136
+ const parsed = typeof requested === "number" ? requested
137
+ : typeof requested === "string" && requested.trim() !== "" ? Number(requested)
138
+ : Number.NaN;
139
+ if (!Number.isFinite(parsed) || parsed <= 0) return CALLBACK_BATCH_BUDGET_BYTES;
140
+ return Math.min(Math.max(1, Math.floor(parsed)), CALLBACK_BATCH_MAX_BYTES);
141
+ }
142
+
143
+ function completeUtf8End(bytes: Uint8Array, to: number): number {
144
+ if (to <= 0) return 0;
145
+ if (to >= bytes.length) return bytes.length;
146
+ let seqStart = to - 1;
147
+ while (seqStart > 0 && (bytes[seqStart]! & 0xc0) === 0x80) seqStart -= 1;
148
+ if ((bytes[seqStart]! & 0xc0) === 0x80) return to;
149
+ const lead = bytes[seqStart]!;
150
+ const needed = lead <= 0x7f ? 1
151
+ : (lead & 0xe0) === 0xc0 ? 2
152
+ : (lead & 0xf0) === 0xe0 ? 3
153
+ : (lead & 0xf8) === 0xf0 ? 4
154
+ : 1;
155
+ return seqStart + needed > to ? seqStart : to;
156
+ }
157
+
158
+ function clipUtf8Prefix(text: string, maxBytes: number): string {
159
+ if (maxBytes <= 0) return "";
160
+ const bytes = encoder.encode(text);
161
+ if (bytes.byteLength <= maxBytes) return text;
162
+ return decoder.decode(bytes.subarray(0, completeUtf8End(bytes, Math.min(maxBytes, bytes.byteLength))));
163
+ }
164
+
165
+ function boundedField(value: unknown, maxBytes: number): string {
166
+ const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
167
+ if (utf8ByteLength(oneLine) <= maxBytes) return oneLine;
168
+ const ellipsis = "...";
169
+ return `${clipUtf8Prefix(oneLine, Math.max(0, maxBytes - utf8ByteLength(ellipsis)))}${ellipsis}`;
170
+ }
171
+
172
+ function inspectFor(event: Pick<CallbackBatchEvent, "id" | "detailTool">): string {
173
+ const id = boundedField(event.id, MAX_ID_BYTES);
174
+ return event.detailTool === "bg_task_status"
175
+ ? `bg_task_status id=${id}`
176
+ : `subagent_result id=${JSON.stringify(id)}`;
177
+ }
178
+
179
+ interface IncidentLines {
180
+ lines: string[];
181
+ shown: number;
182
+ total: number;
183
+ }
184
+
185
+ /**
186
+ * Whole incident rows that fit `maxBytes`, one per line. A first row that does
187
+ * not fit is shown as a clipped prefix and is NOT counted as shown.
188
+ */
189
+ function incidentLines(rows: readonly string[], total: number, maxBytes: number, indent: string): IncidentLines {
190
+ const lines: string[] = [];
191
+ let used = 0;
192
+ let shown = 0;
193
+ for (const row of rows) {
194
+ const line = `${indent}${String(row).replace(/\s+/g, " ").trim()}`;
195
+ const size = utf8ByteLength(line) + 1;
196
+ if (used + size <= maxBytes) {
197
+ lines.push(line);
198
+ used += size;
199
+ shown += 1;
200
+ continue;
201
+ }
202
+ if (lines.length === 0 && maxBytes - indent.length > 48) {
203
+ lines.push(`${indent}${boundedField(row, maxBytes - utf8ByteLength(indent) - 1)} (clipped)`);
204
+ }
205
+ break;
206
+ }
207
+ return { lines, shown, total: Math.max(total, rows.length) };
208
+ }
209
+
210
+ function countsLine(incidents: IncidentLines, inspect: string): string | undefined {
211
+ if (incidents.total <= 0) return undefined;
212
+ const omitted = Math.max(0, incidents.total - incidents.shown);
213
+ return ` incidents=${incidents.total} shown=${incidents.shown}` +
214
+ (omitted > 0 ? ` omittedIncidents=${omitted} retrieve: ${inspect} (incident pages via cursor)` : "");
215
+ }
216
+
217
+ function eventIncidents(event: CallbackBatchEvent, failureBytes: number): IncidentLines {
218
+ if (event.failureRows && event.failureRows.length) {
219
+ return incidentLines(event.failureRows, event.incidentCount ?? event.failureRows.length, failureBytes, " failure: ");
220
+ }
221
+ if (event.failure) {
222
+ const total = event.incidentCount ?? 1;
223
+ const whole = incidentLines([event.failure], 1, failureBytes, " failure: ");
224
+ const legacyShown = whole.shown ? Math.max(0, total - (event.omittedIncidents ?? 0)) : 0;
225
+ return { lines: whole.lines, shown: legacyShown, total };
226
+ }
227
+ const total = event.incidentCount ?? 0;
228
+ return { lines: [], shown: 0, total };
229
+ }
230
+
231
+ function formatRow(event: CallbackBatchEvent, detailBytes = MAX_FAILURE_BYTES + MAX_DECISION_BYTES): string {
232
+ const source = boundedField(event.source, 40);
233
+ const id = boundedField(event.id, MAX_ID_BYTES);
234
+ const label = boundedField(event.label, MAX_LABEL_BYTES);
235
+ const status = boundedField(event.status, MAX_STATUS_BYTES);
236
+ const inspect = inspectFor(event);
237
+ const lines = [
238
+ `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${inspect}`,
239
+ ];
240
+ if (event.outcome) {
241
+ const outcome = boundedField(event.outcome, 80);
242
+ if (outcome && outcome !== status) lines.push(` outcome=${outcome}`);
243
+ }
244
+ const decisionBytes = Math.min(MAX_DECISION_BYTES, Math.floor(detailBytes / 2));
245
+ if (event.decision && decisionBytes > 24) lines.push(` decision: ${boundedField(event.decision, decisionBytes)}`);
246
+ const incidents = eventIncidents(event, Math.max(0, Math.min(MAX_FAILURE_BYTES * 2, detailBytes - decisionBytes)));
247
+ lines.push(...incidents.lines);
248
+ const counts = countsLine(incidents, inspect);
249
+ if (counts) lines.push(counts);
250
+ return lines.join("\n");
251
+ }
252
+
253
+ function renderBatch(represented: readonly CallbackBatchEvent[], omitted: number, detailBytes?: number): string {
254
+ const count = represented.length;
81
255
  const heading = `${count} background completion${count === 1 ? " is" : "s are"} ready:`;
82
- const rows = events.map((event) => {
83
- const source = boundedField(event.source, 40);
84
- const id = boundedField(event.id, MAX_ID_CHARS);
85
- const label = boundedField(event.label, MAX_LABEL_CHARS);
86
- const status = boundedField(event.status, MAX_STATUS_CHARS);
87
- const detail = event.detailTool === "bg_task_status"
88
- ? `bg_task_status id=${id}`
89
- : `subagent_result id=${JSON.stringify(id)}`;
90
- return `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${detail}`;
91
- });
92
- return [
93
- heading,
94
- ...rows,
95
- "Retrieve durable results/status with the listed tools. Full results and logs are intentionally omitted.",
96
- ].join("\n");
256
+ const omittedLine = omitted > 0
257
+ ? `${omitted} more completion${omitted === 1 ? "" : "s"} omitted from this batch (not receipted; still queued).`
258
+ : undefined;
259
+ return [heading, ...represented.map((event) => formatRow(event, detailBytes)), omittedLine, RETRIEVAL_FOOTER]
260
+ .filter((line): line is string => Boolean(line))
261
+ .join("\n");
262
+ }
263
+
264
+ function clipRendered(text: string, maxBytes: number): string {
265
+ if (utf8ByteLength(text) <= maxBytes) return text;
266
+ const suffix = "\n[clipped to callback budget]";
267
+ const budget = maxBytes - utf8ByteLength(suffix);
268
+ if (budget < 24) return clipUtf8Prefix(text, maxBytes);
269
+ return `${clipUtf8Prefix(text, budget)}${suffix}`;
270
+ }
271
+
272
+ function eventPriority(event: CallbackBatchEvent): number {
273
+ if (event.failure || event.failureRows?.length || (event.omittedIncidents ?? 0) > 0 || (event.incidentCount ?? 0) > 0) return 0;
274
+ if (event.decision) return 1;
275
+ const status = String(event.status ?? "").toLowerCase();
276
+ if (/(?:fail|orphan|lost|timed_out|timeout|unresolved|incomplete|observation incomplete)/.test(status)) return 0;
277
+ return 2;
278
+ }
279
+
280
+ /**
281
+ * Urgent health/failure callback under the total budget. Order: header,
282
+ * explanation, whole incident rows that fit, the incident count line (total,
283
+ * shown, omitted, retrieval), and the inspect line. The count and inspect
284
+ * lines are never dropped; unshown explanation bytes are counted.
285
+ */
286
+ export function formatUrgentCallback(
287
+ event: UrgentCallbackEvent,
288
+ options: CallbackBatchFormatOptions = {},
289
+ ): string {
290
+ const maxBytes = callbackBatchBudget(options.maxBytes);
291
+ const target = event.inspectId ?? event.id;
292
+ const id = boundedField(target, MAX_ID_BYTES);
293
+ const label = boundedField(event.label, MAX_LABEL_BYTES);
294
+ const status = boundedField(event.status, MAX_STATUS_BYTES);
295
+ const tool = event.detailTool
296
+ ?? (event.source === "background-task" ? "bg_task_status" : "subagent_result");
297
+ const inspectTarget = tool === "bg_task_status" ? `bg_task_status id=${id}` : `subagent_result id=${JSON.stringify(id)}`;
298
+ const inspect = `Inspect: ${inspectTarget}`;
299
+ const header = `${boundedField(event.source, 40)} id=${id} label=${JSON.stringify(label)} status=${status}`;
300
+ const source = String(event.content ?? "").trim();
301
+ const rows = event.failureRows ?? [];
302
+ const total = rows.length ? Math.max(rows.length, event.incidentCount ?? 0) : event.incidentCount ?? 0;
303
+ const legacyOmitted = rows.length ? 0 : event.omittedIncidents ?? 0;
304
+ const counts = (shown: number): string | undefined => {
305
+ if (total <= 0) return undefined;
306
+ const omitted = rows.length ? total - shown : legacyOmitted;
307
+ const shownPart = rows.length ? ` shown=${shown}` : "";
308
+ return `incidents=${total}${shownPart}` +
309
+ (omitted > 0 ? ` omittedIncidents=${omitted} retrieve: ${inspectTarget} (incident pages via cursor)` : "");
310
+ };
311
+ const render = (body: string, note: string | undefined, shownRows: string[]): string =>
312
+ [header, body, note, ...shownRows, counts(shownRows.length), inspect]
313
+ .filter((part): part is string => Boolean(part && part.length > 0))
314
+ .join("\n");
315
+ const fixed = utf8ByteLength(render("", undefined, [])) + 16;
316
+ const room = Math.max(0, maxBytes - fixed);
317
+ // The explanation keeps at least half the room when incident rows compete.
318
+ const contentShare = rows.length ? Math.floor(room / 2) : room;
319
+ let body = source;
320
+ let note: string | undefined;
321
+ if (utf8ByteLength(source) > contentShare) {
322
+ const noteFor = (omitted: number) => `omittedBytes=${omitted} retrieve: ${inspectTarget}`;
323
+ const clipped = clipUtf8Prefix(source, Math.max(0, contentShare - utf8ByteLength(noteFor(utf8ByteLength(source))) - 1));
324
+ body = clipped;
325
+ note = noteFor(utf8ByteLength(source) - utf8ByteLength(clipped));
326
+ }
327
+ const shown: string[] = [];
328
+ for (const row of rows) {
329
+ const line = String(row).replace(/\s+/g, " ").trim();
330
+ if (utf8ByteLength(render(body, note, [...shown, line])) + 8 > maxBytes) break;
331
+ shown.push(line);
332
+ }
333
+ const rendered = render(body, note, shown);
334
+ if (utf8ByteLength(rendered) <= maxBytes) return rendered;
335
+ const minimal = render("", source ? `omittedBytes=${utf8ByteLength(source)} retrieve: ${inspectTarget}` : undefined, []);
336
+ return clipRendered(minimal, maxBytes);
337
+ }
338
+
339
+ export function packCallbackBatch(
340
+ events: readonly CallbackBatchEvent[],
341
+ options: CallbackBatchFormatOptions = {},
342
+ ): FormattedCallbackBatch {
343
+ const maxBytes = callbackBatchBudget(options.maxBytes);
344
+ if (events.length === 0) {
345
+ return { text: renderBatch([], 0), represented: [], omitted: 0 };
346
+ }
347
+
348
+ const ranked = events.map((event, index) => ({ event, index }))
349
+ .sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.index - b.index);
350
+
351
+ const selected = new Set<number>();
352
+ const renderSelected = (): string => {
353
+ const represented = events.filter((_, index) => selected.has(index));
354
+ return renderBatch(represented, events.length - selected.size);
355
+ };
356
+
357
+ for (const { index } of ranked) {
358
+ selected.add(index);
359
+ if (utf8ByteLength(renderSelected()) <= maxBytes) continue;
360
+ selected.delete(index);
361
+ if (selected.size === 0) {
362
+ // One row alone exceeds the budget: shrink its detail, never its counts.
363
+ const event = events[index]!;
364
+ for (const detail of [MAX_FAILURE_BYTES, 200, 0]) {
365
+ const text = renderBatch([event], events.length - 1, detail);
366
+ if (utf8ByteLength(text) <= maxBytes) {
367
+ return { text, represented: [event], omitted: events.length - 1 };
368
+ }
369
+ }
370
+ return {
371
+ text: clipRendered(renderBatch([event], events.length - 1, 0), maxBytes),
372
+ represented: [event],
373
+ omitted: events.length - 1,
374
+ };
375
+ }
376
+ }
377
+
378
+ const represented = events.filter((_, index) => selected.has(index));
379
+ return {
380
+ text: renderSelected(),
381
+ represented,
382
+ omitted: events.length - represented.length,
383
+ };
384
+ }
385
+
386
+ export function formatCallbackBatch(
387
+ events: readonly CallbackBatchEvent[],
388
+ options: CallbackBatchFormatOptions = {},
389
+ ): string {
390
+ return packCallbackBatch(events, options).text;
97
391
  }
98
392
 
99
393
  export function createCallbackBatcher(
@@ -102,6 +396,7 @@ export function createCallbackBatcher(
102
396
  ): CallbackBatcher {
103
397
  const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
104
398
  const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
399
+ const maxBytes = callbackBatchBudget(options.maxBytes);
105
400
  const pending = new Map<string, PendingEvent>();
106
401
  const inFlight = new Set<string>();
107
402
  const urgentInFlight = new Set<string>();
@@ -177,11 +472,16 @@ export function createCallbackBatcher(
177
472
  return !deferred;
178
473
  }
179
474
 
475
+ const packed = packCallbackBatch(deliverable.map(([, item]) => item.event), { maxBytes });
476
+ const representedSet = new Set(packed.represented);
477
+ const representedItems = deliverable.filter(([, item]) => representedSet.has(item.event));
478
+ const overflowItems = deliverable.filter(([, item]) => !representedSet.has(item.event));
479
+
180
480
  try {
181
481
  await host.sendMessage(
182
482
  {
183
483
  customType: "background-completion-batch",
184
- content: formatCallbackBatch(deliverable.map(([, item]) => item.event)),
484
+ content: packed.text,
185
485
  display: true,
186
486
  },
187
487
  { deliverAs: "followUp", triggerTurn: true },
@@ -199,7 +499,7 @@ export function createCallbackBatcher(
199
499
  }
200
500
 
201
501
  const deliveredAt = Date.now();
202
- for (const [key, item] of deliverable) {
502
+ for (const [key, item] of representedItems) {
203
503
  handedOff.set(key, deliveredAt);
204
504
  if (!invokeDelivered(item.event, deliveredAt)) {
205
505
  deferred = true;
@@ -207,6 +507,10 @@ export function createCallbackBatcher(
207
507
  }
208
508
  inFlight.delete(key);
209
509
  }
510
+ for (const [key, item] of overflowItems) {
511
+ pending.set(key, item);
512
+ inFlight.delete(key);
513
+ }
210
514
  if (pending.size > 0) schedule(deferred ? retryMs : windowMs);
211
515
  return !deferred;
212
516
  };
@@ -240,7 +544,7 @@ export function createCallbackBatcher(
240
544
  urgentInFlight.add(key);
241
545
  try {
242
546
  const handoff = host.sendMessage(
243
- { customType: event.customType, content: event.content, display: true },
547
+ { customType: event.customType, content: formatUrgentCallback(event, { maxBytes }), display: true },
244
548
  { deliverAs: "followUp", triggerTurn: true },
245
549
  );
246
550
  if (isPromiseLike(handoff)) {
@@ -333,12 +637,6 @@ function invokeSuppressed(
333
637
  try { event.onSuppressed?.(reason, at); } catch { /* best effort durable suppression */ }
334
638
  }
335
639
 
336
- function boundedField(value: unknown, maxChars: number): string {
337
- const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
338
- if (oneLine.length <= maxChars) return oneLine;
339
- return `${oneLine.slice(0, Math.max(0, maxChars - 3))}...`;
340
- }
341
-
342
640
  function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
343
641
  return (typeof value === "object" || typeof value === "function")
344
642
  && value !== null