pi-better-background-tasks 0.2.20 → 0.4.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.
- package/README.md +46 -2
- package/package.json +1 -1
- package/src/failures.ts +101 -6
- package/src/index.ts +5 -0
- package/src/logs.ts +51 -7
- package/src/navigator-provider.ts +4 -3
- package/src/output.ts +783 -0
- package/src/process.ts +95 -8
- package/src/registry.ts +84 -7
- package/src/runtime.ts +228 -33
- package/src/sandbox.ts +6 -4
- package/src/shared-callback-batcher.ts +358 -29
- package/src/shared-failure-observations.ts +833 -23
- package/src/shared-log-utils.ts +1139 -2
- package/src/shared-navigator.ts +12 -10
- package/src/shared-sandbox-core.ts +660 -17
- package/src/tools.ts +201 -156
- package/src/types.ts +23 -0
|
@@ -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,25 @@ 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
|
|
64
|
-
const
|
|
65
|
-
|
|
103
|
+
const MAX_LABEL_BYTES = 160;
|
|
104
|
+
const MAX_ID_BYTES = 200;
|
|
105
|
+
/**
|
|
106
|
+
* Status field bound. Long statuses keep whole `; `-separated notes and say
|
|
107
|
+
* how many were left out, instead of cutting mid-word (#323).
|
|
108
|
+
*/
|
|
109
|
+
const MAX_STATUS_BYTES = 160;
|
|
110
|
+
const MAX_FAILURE_BYTES = 400;
|
|
111
|
+
const MAX_DECISION_BYTES = 400;
|
|
112
|
+
const encoder = new TextEncoder();
|
|
113
|
+
const decoder = new TextDecoder("utf-8");
|
|
114
|
+
|
|
115
|
+
/** OUTPUT-POLICY default: UTF-8 bytes of one model-facing callback batch. */
|
|
116
|
+
export const CALLBACK_BATCH_BUDGET_BYTES = 2 * 1024;
|
|
117
|
+
/** Documented hard cap. Explicit larger pages clamp here. */
|
|
118
|
+
export const CALLBACK_BATCH_MAX_BYTES = 8 * 1024;
|
|
119
|
+
|
|
120
|
+
const RETRIEVAL_FOOTER =
|
|
121
|
+
"Retrieve durable results/status with the listed tools using cursor/limit. Full results and logs are intentionally omitted.";
|
|
66
122
|
|
|
67
123
|
export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
|
|
68
124
|
export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
|
|
@@ -76,24 +132,293 @@ export function resolveCallbackBatchWindowMs(
|
|
|
76
132
|
return Math.max(0, Math.min(5_000, Math.floor(parsed)));
|
|
77
133
|
}
|
|
78
134
|
|
|
79
|
-
export function
|
|
80
|
-
|
|
135
|
+
export function utf8ByteLength(text: string): number {
|
|
136
|
+
return encoder.encode(text).byteLength;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function callbackBatchBudget(requested?: unknown): number {
|
|
140
|
+
const parsed = typeof requested === "number" ? requested
|
|
141
|
+
: typeof requested === "string" && requested.trim() !== "" ? Number(requested)
|
|
142
|
+
: Number.NaN;
|
|
143
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return CALLBACK_BATCH_BUDGET_BYTES;
|
|
144
|
+
return Math.min(Math.max(1, Math.floor(parsed)), CALLBACK_BATCH_MAX_BYTES);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function completeUtf8End(bytes: Uint8Array, to: number): number {
|
|
148
|
+
if (to <= 0) return 0;
|
|
149
|
+
if (to >= bytes.length) return bytes.length;
|
|
150
|
+
let seqStart = to - 1;
|
|
151
|
+
while (seqStart > 0 && (bytes[seqStart]! & 0xc0) === 0x80) seqStart -= 1;
|
|
152
|
+
if ((bytes[seqStart]! & 0xc0) === 0x80) return to;
|
|
153
|
+
const lead = bytes[seqStart]!;
|
|
154
|
+
const needed = lead <= 0x7f ? 1
|
|
155
|
+
: (lead & 0xe0) === 0xc0 ? 2
|
|
156
|
+
: (lead & 0xf0) === 0xe0 ? 3
|
|
157
|
+
: (lead & 0xf8) === 0xf0 ? 4
|
|
158
|
+
: 1;
|
|
159
|
+
return seqStart + needed > to ? seqStart : to;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function clipUtf8Prefix(text: string, maxBytes: number): string {
|
|
163
|
+
if (maxBytes <= 0) return "";
|
|
164
|
+
const bytes = encoder.encode(text);
|
|
165
|
+
if (bytes.byteLength <= maxBytes) return text;
|
|
166
|
+
return decoder.decode(bytes.subarray(0, completeUtf8End(bytes, Math.min(maxBytes, bytes.byteLength))));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function boundedField(value: unknown, maxBytes: number): string {
|
|
170
|
+
const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
171
|
+
if (utf8ByteLength(oneLine) <= maxBytes) return oneLine;
|
|
172
|
+
const ellipsis = "...";
|
|
173
|
+
return `${clipUtf8Prefix(oneLine, Math.max(0, maxBytes - utf8ByteLength(ellipsis)))}${ellipsis}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The status field under MAX_STATUS_BYTES without losing meaning: the leading
|
|
178
|
+
* lifecycle note is always kept, later `; `-separated notes are kept whole
|
|
179
|
+
* while they fit, and anything left out is named with a count and the inspect
|
|
180
|
+
* tool rather than an ellipsis.
|
|
181
|
+
*/
|
|
182
|
+
function boundedStatus(value: unknown): string {
|
|
183
|
+
const status = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
184
|
+
if (utf8ByteLength(status) <= MAX_STATUS_BYTES) return status;
|
|
185
|
+
const notes = status.split(/;\s+/).filter(Boolean);
|
|
186
|
+
const omission = (count: number) => ` (+${count} more status note${count === 1 ? "" : "s"}; see inspect)`;
|
|
187
|
+
const reserve = utf8ByteLength(omission(notes.length));
|
|
188
|
+
let kept = notes[0] ?? "";
|
|
189
|
+
if (utf8ByteLength(kept) + reserve > MAX_STATUS_BYTES) {
|
|
190
|
+
const suffix = " (clipped; see inspect)";
|
|
191
|
+
return `${clipUtf8Prefix(kept, MAX_STATUS_BYTES - utf8ByteLength(suffix))}${suffix}`;
|
|
192
|
+
}
|
|
193
|
+
let count = 1;
|
|
194
|
+
for (const note of notes.slice(1)) {
|
|
195
|
+
const next = `${kept}; ${note}`;
|
|
196
|
+
if (utf8ByteLength(next) + reserve > MAX_STATUS_BYTES) break;
|
|
197
|
+
kept = next;
|
|
198
|
+
count += 1;
|
|
199
|
+
}
|
|
200
|
+
return count < notes.length ? `${kept}${omission(notes.length - count)}` : kept;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function inspectFor(event: Pick<CallbackBatchEvent, "id" | "detailTool">): string {
|
|
204
|
+
const id = boundedField(event.id, MAX_ID_BYTES);
|
|
205
|
+
return event.detailTool === "bg_task_status"
|
|
206
|
+
? `bg_task_status id=${id}`
|
|
207
|
+
: `subagent_result id=${JSON.stringify(id)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface IncidentLines {
|
|
211
|
+
lines: string[];
|
|
212
|
+
shown: number;
|
|
213
|
+
total: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whole incident rows that fit `maxBytes`, one per line. A first row that does
|
|
218
|
+
* not fit is shown as a clipped prefix and is NOT counted as shown.
|
|
219
|
+
*/
|
|
220
|
+
function incidentLines(rows: readonly string[], total: number, maxBytes: number, indent: string): IncidentLines {
|
|
221
|
+
const lines: string[] = [];
|
|
222
|
+
let used = 0;
|
|
223
|
+
let shown = 0;
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
const line = `${indent}${String(row).replace(/\s+/g, " ").trim()}`;
|
|
226
|
+
const size = utf8ByteLength(line) + 1;
|
|
227
|
+
if (used + size <= maxBytes) {
|
|
228
|
+
lines.push(line);
|
|
229
|
+
used += size;
|
|
230
|
+
shown += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (lines.length === 0 && maxBytes - indent.length > 48) {
|
|
234
|
+
lines.push(`${indent}${boundedField(row, maxBytes - utf8ByteLength(indent) - 1)} (clipped)`);
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
return { lines, shown, total: Math.max(total, rows.length) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function countsLine(incidents: IncidentLines, inspect: string): string | undefined {
|
|
242
|
+
if (incidents.total <= 0) return undefined;
|
|
243
|
+
const omitted = Math.max(0, incidents.total - incidents.shown);
|
|
244
|
+
return ` incidents=${incidents.total} shown=${incidents.shown}` +
|
|
245
|
+
(omitted > 0 ? ` omittedIncidents=${omitted} retrieve: ${inspect} (incident pages via cursor)` : "");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function eventIncidents(event: CallbackBatchEvent, failureBytes: number): IncidentLines {
|
|
249
|
+
if (event.failureRows && event.failureRows.length) {
|
|
250
|
+
return incidentLines(event.failureRows, event.incidentCount ?? event.failureRows.length, failureBytes, " failure: ");
|
|
251
|
+
}
|
|
252
|
+
if (event.failure) {
|
|
253
|
+
const total = event.incidentCount ?? 1;
|
|
254
|
+
const whole = incidentLines([event.failure], 1, failureBytes, " failure: ");
|
|
255
|
+
const legacyShown = whole.shown ? Math.max(0, total - (event.omittedIncidents ?? 0)) : 0;
|
|
256
|
+
return { lines: whole.lines, shown: legacyShown, total };
|
|
257
|
+
}
|
|
258
|
+
const total = event.incidentCount ?? 0;
|
|
259
|
+
return { lines: [], shown: 0, total };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function formatRow(event: CallbackBatchEvent, detailBytes = MAX_FAILURE_BYTES + MAX_DECISION_BYTES): string {
|
|
263
|
+
const source = boundedField(event.source, 40);
|
|
264
|
+
const id = boundedField(event.id, MAX_ID_BYTES);
|
|
265
|
+
const label = boundedField(event.label, MAX_LABEL_BYTES);
|
|
266
|
+
const status = boundedStatus(event.status);
|
|
267
|
+
const inspect = inspectFor(event);
|
|
268
|
+
const lines = [
|
|
269
|
+
`- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${inspect}`,
|
|
270
|
+
];
|
|
271
|
+
if (event.outcome) {
|
|
272
|
+
const outcome = boundedField(event.outcome, 80);
|
|
273
|
+
if (outcome && outcome !== status) lines.push(` outcome=${outcome}`);
|
|
274
|
+
}
|
|
275
|
+
const decisionBytes = Math.min(MAX_DECISION_BYTES, Math.floor(detailBytes / 2));
|
|
276
|
+
if (event.decision && decisionBytes > 24) lines.push(` decision: ${boundedField(event.decision, decisionBytes)}`);
|
|
277
|
+
const incidents = eventIncidents(event, Math.max(0, Math.min(MAX_FAILURE_BYTES * 2, detailBytes - decisionBytes)));
|
|
278
|
+
lines.push(...incidents.lines);
|
|
279
|
+
const counts = countsLine(incidents, inspect);
|
|
280
|
+
if (counts) lines.push(counts);
|
|
281
|
+
return lines.join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function renderBatch(represented: readonly CallbackBatchEvent[], omitted: number, detailBytes?: number): string {
|
|
285
|
+
const count = represented.length;
|
|
81
286
|
const heading = `${count} background completion${count === 1 ? " is" : "s are"} ready:`;
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
287
|
+
const omittedLine = omitted > 0
|
|
288
|
+
? `${omitted} more completion${omitted === 1 ? "" : "s"} omitted from this batch (not receipted; still queued).`
|
|
289
|
+
: undefined;
|
|
290
|
+
return [heading, ...represented.map((event) => formatRow(event, detailBytes)), omittedLine, RETRIEVAL_FOOTER]
|
|
291
|
+
.filter((line): line is string => Boolean(line))
|
|
292
|
+
.join("\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function clipRendered(text: string, maxBytes: number): string {
|
|
296
|
+
if (utf8ByteLength(text) <= maxBytes) return text;
|
|
297
|
+
const suffix = "\n[clipped to callback budget]";
|
|
298
|
+
const budget = maxBytes - utf8ByteLength(suffix);
|
|
299
|
+
if (budget < 24) return clipUtf8Prefix(text, maxBytes);
|
|
300
|
+
return `${clipUtf8Prefix(text, budget)}${suffix}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function eventPriority(event: CallbackBatchEvent): number {
|
|
304
|
+
if (event.failure || event.failureRows?.length || (event.omittedIncidents ?? 0) > 0 || (event.incidentCount ?? 0) > 0) return 0;
|
|
305
|
+
if (event.decision) return 1;
|
|
306
|
+
const status = String(event.status ?? "").toLowerCase();
|
|
307
|
+
if (/(?:fail|orphan|lost|timed_out|timeout|unresolved|incomplete|observation incomplete)/.test(status)) return 0;
|
|
308
|
+
return 2;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Urgent health/failure callback under the total budget. Order: header,
|
|
313
|
+
* explanation, whole incident rows that fit, the incident count line (total,
|
|
314
|
+
* shown, omitted, retrieval), and the inspect line. The count and inspect
|
|
315
|
+
* lines are never dropped; unshown explanation bytes are counted.
|
|
316
|
+
*/
|
|
317
|
+
export function formatUrgentCallback(
|
|
318
|
+
event: UrgentCallbackEvent,
|
|
319
|
+
options: CallbackBatchFormatOptions = {},
|
|
320
|
+
): string {
|
|
321
|
+
const maxBytes = callbackBatchBudget(options.maxBytes);
|
|
322
|
+
const target = event.inspectId ?? event.id;
|
|
323
|
+
const id = boundedField(target, MAX_ID_BYTES);
|
|
324
|
+
const label = boundedField(event.label, MAX_LABEL_BYTES);
|
|
325
|
+
const status = boundedStatus(event.status);
|
|
326
|
+
const tool = event.detailTool
|
|
327
|
+
?? (event.source === "background-task" ? "bg_task_status" : "subagent_result");
|
|
328
|
+
const inspectTarget = tool === "bg_task_status" ? `bg_task_status id=${id}` : `subagent_result id=${JSON.stringify(id)}`;
|
|
329
|
+
const inspect = `Inspect: ${inspectTarget}`;
|
|
330
|
+
const header = `${boundedField(event.source, 40)} id=${id} label=${JSON.stringify(label)} status=${status}`;
|
|
331
|
+
const source = String(event.content ?? "").trim();
|
|
332
|
+
const rows = event.failureRows ?? [];
|
|
333
|
+
const total = rows.length ? Math.max(rows.length, event.incidentCount ?? 0) : event.incidentCount ?? 0;
|
|
334
|
+
const legacyOmitted = rows.length ? 0 : event.omittedIncidents ?? 0;
|
|
335
|
+
const counts = (shown: number): string | undefined => {
|
|
336
|
+
if (total <= 0) return undefined;
|
|
337
|
+
const omitted = rows.length ? total - shown : legacyOmitted;
|
|
338
|
+
const shownPart = rows.length ? ` shown=${shown}` : "";
|
|
339
|
+
return `incidents=${total}${shownPart}` +
|
|
340
|
+
(omitted > 0 ? ` omittedIncidents=${omitted} retrieve: ${inspectTarget} (incident pages via cursor)` : "");
|
|
341
|
+
};
|
|
342
|
+
const render = (body: string, note: string | undefined, shownRows: string[]): string =>
|
|
343
|
+
[header, body, note, ...shownRows, counts(shownRows.length), inspect]
|
|
344
|
+
.filter((part): part is string => Boolean(part && part.length > 0))
|
|
345
|
+
.join("\n");
|
|
346
|
+
const fixed = utf8ByteLength(render("", undefined, [])) + 16;
|
|
347
|
+
const room = Math.max(0, maxBytes - fixed);
|
|
348
|
+
// The explanation keeps at least half the room when incident rows compete.
|
|
349
|
+
const contentShare = rows.length ? Math.floor(room / 2) : room;
|
|
350
|
+
let body = source;
|
|
351
|
+
let note: string | undefined;
|
|
352
|
+
if (utf8ByteLength(source) > contentShare) {
|
|
353
|
+
const noteFor = (omitted: number) => `omittedBytes=${omitted} retrieve: ${inspectTarget}`;
|
|
354
|
+
const clipped = clipUtf8Prefix(source, Math.max(0, contentShare - utf8ByteLength(noteFor(utf8ByteLength(source))) - 1));
|
|
355
|
+
body = clipped;
|
|
356
|
+
note = noteFor(utf8ByteLength(source) - utf8ByteLength(clipped));
|
|
357
|
+
}
|
|
358
|
+
const shown: string[] = [];
|
|
359
|
+
for (const row of rows) {
|
|
360
|
+
const line = String(row).replace(/\s+/g, " ").trim();
|
|
361
|
+
if (utf8ByteLength(render(body, note, [...shown, line])) + 8 > maxBytes) break;
|
|
362
|
+
shown.push(line);
|
|
363
|
+
}
|
|
364
|
+
const rendered = render(body, note, shown);
|
|
365
|
+
if (utf8ByteLength(rendered) <= maxBytes) return rendered;
|
|
366
|
+
const minimal = render("", source ? `omittedBytes=${utf8ByteLength(source)} retrieve: ${inspectTarget}` : undefined, []);
|
|
367
|
+
return clipRendered(minimal, maxBytes);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function packCallbackBatch(
|
|
371
|
+
events: readonly CallbackBatchEvent[],
|
|
372
|
+
options: CallbackBatchFormatOptions = {},
|
|
373
|
+
): FormattedCallbackBatch {
|
|
374
|
+
const maxBytes = callbackBatchBudget(options.maxBytes);
|
|
375
|
+
if (events.length === 0) {
|
|
376
|
+
return { text: renderBatch([], 0), represented: [], omitted: 0 };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const ranked = events.map((event, index) => ({ event, index }))
|
|
380
|
+
.sort((a, b) => eventPriority(a.event) - eventPriority(b.event) || a.index - b.index);
|
|
381
|
+
|
|
382
|
+
const selected = new Set<number>();
|
|
383
|
+
const renderSelected = (): string => {
|
|
384
|
+
const represented = events.filter((_, index) => selected.has(index));
|
|
385
|
+
return renderBatch(represented, events.length - selected.size);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
for (const { index } of ranked) {
|
|
389
|
+
selected.add(index);
|
|
390
|
+
if (utf8ByteLength(renderSelected()) <= maxBytes) continue;
|
|
391
|
+
selected.delete(index);
|
|
392
|
+
if (selected.size === 0) {
|
|
393
|
+
// One row alone exceeds the budget: shrink its detail, never its counts.
|
|
394
|
+
const event = events[index]!;
|
|
395
|
+
for (const detail of [MAX_FAILURE_BYTES, 200, 0]) {
|
|
396
|
+
const text = renderBatch([event], events.length - 1, detail);
|
|
397
|
+
if (utf8ByteLength(text) <= maxBytes) {
|
|
398
|
+
return { text, represented: [event], omitted: events.length - 1 };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
text: clipRendered(renderBatch([event], events.length - 1, 0), maxBytes),
|
|
403
|
+
represented: [event],
|
|
404
|
+
omitted: events.length - 1,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const represented = events.filter((_, index) => selected.has(index));
|
|
410
|
+
return {
|
|
411
|
+
text: renderSelected(),
|
|
412
|
+
represented,
|
|
413
|
+
omitted: events.length - represented.length,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function formatCallbackBatch(
|
|
418
|
+
events: readonly CallbackBatchEvent[],
|
|
419
|
+
options: CallbackBatchFormatOptions = {},
|
|
420
|
+
): string {
|
|
421
|
+
return packCallbackBatch(events, options).text;
|
|
97
422
|
}
|
|
98
423
|
|
|
99
424
|
export function createCallbackBatcher(
|
|
@@ -102,6 +427,7 @@ export function createCallbackBatcher(
|
|
|
102
427
|
): CallbackBatcher {
|
|
103
428
|
const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
|
|
104
429
|
const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
|
|
430
|
+
const maxBytes = callbackBatchBudget(options.maxBytes);
|
|
105
431
|
const pending = new Map<string, PendingEvent>();
|
|
106
432
|
const inFlight = new Set<string>();
|
|
107
433
|
const urgentInFlight = new Set<string>();
|
|
@@ -177,11 +503,16 @@ export function createCallbackBatcher(
|
|
|
177
503
|
return !deferred;
|
|
178
504
|
}
|
|
179
505
|
|
|
506
|
+
const packed = packCallbackBatch(deliverable.map(([, item]) => item.event), { maxBytes });
|
|
507
|
+
const representedSet = new Set(packed.represented);
|
|
508
|
+
const representedItems = deliverable.filter(([, item]) => representedSet.has(item.event));
|
|
509
|
+
const overflowItems = deliverable.filter(([, item]) => !representedSet.has(item.event));
|
|
510
|
+
|
|
180
511
|
try {
|
|
181
512
|
await host.sendMessage(
|
|
182
513
|
{
|
|
183
514
|
customType: "background-completion-batch",
|
|
184
|
-
content:
|
|
515
|
+
content: packed.text,
|
|
185
516
|
display: true,
|
|
186
517
|
},
|
|
187
518
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
@@ -199,7 +530,7 @@ export function createCallbackBatcher(
|
|
|
199
530
|
}
|
|
200
531
|
|
|
201
532
|
const deliveredAt = Date.now();
|
|
202
|
-
for (const [key, item] of
|
|
533
|
+
for (const [key, item] of representedItems) {
|
|
203
534
|
handedOff.set(key, deliveredAt);
|
|
204
535
|
if (!invokeDelivered(item.event, deliveredAt)) {
|
|
205
536
|
deferred = true;
|
|
@@ -207,6 +538,10 @@ export function createCallbackBatcher(
|
|
|
207
538
|
}
|
|
208
539
|
inFlight.delete(key);
|
|
209
540
|
}
|
|
541
|
+
for (const [key, item] of overflowItems) {
|
|
542
|
+
pending.set(key, item);
|
|
543
|
+
inFlight.delete(key);
|
|
544
|
+
}
|
|
210
545
|
if (pending.size > 0) schedule(deferred ? retryMs : windowMs);
|
|
211
546
|
return !deferred;
|
|
212
547
|
};
|
|
@@ -240,7 +575,7 @@ export function createCallbackBatcher(
|
|
|
240
575
|
urgentInFlight.add(key);
|
|
241
576
|
try {
|
|
242
577
|
const handoff = host.sendMessage(
|
|
243
|
-
{ customType: event.customType, content: event
|
|
578
|
+
{ customType: event.customType, content: formatUrgentCallback(event, { maxBytes }), display: true },
|
|
244
579
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
245
580
|
);
|
|
246
581
|
if (isPromiseLike(handoff)) {
|
|
@@ -333,12 +668,6 @@ function invokeSuppressed(
|
|
|
333
668
|
try { event.onSuppressed?.(reason, at); } catch { /* best effort durable suppression */ }
|
|
334
669
|
}
|
|
335
670
|
|
|
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
671
|
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
343
672
|
return (typeof value === "object" || typeof value === "function")
|
|
344
673
|
&& value !== null
|