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.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/failures.ts +25 -4
- package/src/logs.ts +51 -7
- package/src/navigator-provider.ts +2 -2
- package/src/output.ts +753 -0
- package/src/process.ts +95 -8
- package/src/registry.ts +84 -7
- package/src/runtime.ts +55 -16
- package/src/shared-callback-batcher.ts +327 -29
- package/src/shared-failure-observations.ts +501 -22
- package/src/shared-log-utils.ts +1025 -2
- package/src/shared-sandbox-core.ts +11 -8
- package/src/tools.ts +105 -135
- package/src/types.ts +16 -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,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
|
|
64
|
-
const
|
|
65
|
-
const
|
|
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
|
|
80
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|