pi-better-subagents 0.4.2 → 0.5.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/README.md +1 -1
- package/callback-fields.ts +92 -0
- package/child-incidents.ts +149 -0
- package/docs/failure-observations.md +50 -3
- package/failures.ts +42 -53
- package/finalization.ts +16 -25
- package/incident-model.ts +320 -0
- package/index.ts +31 -26
- package/lifecycle.ts +9 -8
- package/list.mjs +49 -26
- package/list.ts +1 -0
- package/output-payload.ts +842 -0
- package/package.json +3 -3
- package/parse.ts +14 -5
- package/registry.ts +86 -3
- package/shared-callback-batcher.ts +327 -29
- package/shared-failure-observations.ts +501 -22
- package/shared-log-utils.ts +1025 -2
- package/shared-task-sandbox.ts +3 -1
- package/task-guard.ts +15 -3
- package/tools.ts +144 -79
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"test": "node --import tsx --test tests/*.test.mjs",
|
|
37
37
|
"test:cross-session": "node --import tsx --test --test-name-pattern \"callback session isolation\" tests/extension_health_lifecycle.test.mjs",
|
|
38
38
|
"pretest:macos-sandbox": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
39
|
-
"test:macos-sandbox": "bash tests/test_sandbox_applied.sh && bash tests/test_sandbox_deny_outside.sh && bash tests/test_sandbox_wrapper_argv.sh && node --import tsx --test tests/sandbox_profile.test.mjs",
|
|
39
|
+
"test:macos-sandbox": "bash tests/test_sandbox_applied.sh && bash tests/test_sandbox_deny_outside.sh && bash tests/test_sandbox_wrapper_argv.sh && node --import tsx --test tests/sandbox_profile.test.mjs tests/child_incidents.test.mjs",
|
|
40
40
|
"pretest:linux-sandbox": "node ../../scripts/sync-shared-sandbox-core.mjs",
|
|
41
|
-
"test:linux-sandbox": "node --import tsx --test tests/linux_bubblewrap.integration.mjs",
|
|
41
|
+
"test:linux-sandbox": "node --import tsx --test tests/linux_bubblewrap.integration.mjs tests/child_incidents.test.mjs",
|
|
42
42
|
"verify": "npm run typecheck && npm test"
|
|
43
43
|
},
|
|
44
44
|
"files": [
|
package/parse.ts
CHANGED
|
@@ -97,13 +97,20 @@ export function tailLog(id: string, n: number, maxBytes = maxRawTailBytes()): st
|
|
|
97
97
|
return out === "" ? "(no output yet)" : out;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Join the text blocks of a message verbatim. Leading indentation and trailing
|
|
102
|
+
* newlines are part of the answer (Markdown, code); whitespace-only text is
|
|
103
|
+
* treated as no text.
|
|
104
|
+
*/
|
|
101
105
|
function messageText(msg: Msg | undefined): string {
|
|
102
106
|
if (!msg) return "";
|
|
103
107
|
const c = msg.content;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
const joined = typeof c === "string"
|
|
109
|
+
? c
|
|
110
|
+
: Array.isArray(c)
|
|
111
|
+
? c.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("")
|
|
112
|
+
: "";
|
|
113
|
+
return joined.trim() ? joined : "";
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
export interface UnmatchedToolCall {
|
|
@@ -1359,7 +1366,9 @@ export function formatSubagentOutputBody(
|
|
|
1359
1366
|
: `(no parsed output yet)\n\n--- raw log tail ---\n${rawTail}`;
|
|
1360
1367
|
}
|
|
1361
1368
|
const diag = diagnostics.length ? `\n[parser: ${diagnostics.join("; ")}]` : "";
|
|
1362
|
-
|
|
1369
|
+
// Ordinary ordered tool histories are omitted from default output (#312).
|
|
1370
|
+
void tools;
|
|
1371
|
+
return `${head}${diag}\n${body}`;
|
|
1363
1372
|
}
|
|
1364
1373
|
|
|
1365
1374
|
/** Build the human-readable body for subagent_result. Exported for unit testing. */
|
package/registry.ts
CHANGED
|
@@ -879,6 +879,89 @@ export function listMetas(): RunMeta[] {
|
|
|
879
879
|
.sort((a, b) => b.startedAt - a.startedAt);
|
|
880
880
|
}
|
|
881
881
|
|
|
882
|
+
export interface RunRecordIndex {
|
|
883
|
+
metas: RunMeta[];
|
|
884
|
+
/** Run directories whose metadata is missing, unreadable, or corrupt. */
|
|
885
|
+
unreadable: Array<{ id: string; detail: string }>;
|
|
886
|
+
/** The runs directory itself could not be read (not "no runs"). */
|
|
887
|
+
indexError?: string;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Why parsed metadata is not a usable run record, or undefined when it is. */
|
|
891
|
+
export function runMetaShapeProblem(meta: unknown, id: string): string | undefined {
|
|
892
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return "not an object";
|
|
893
|
+
const value = meta as Partial<RunMeta>;
|
|
894
|
+
if (value.id !== id) return `id ${JSON.stringify(value.id ?? null)} does not match ${JSON.stringify(id)}`;
|
|
895
|
+
if (typeof value.status !== "string" || !value.status) return "missing status";
|
|
896
|
+
if (typeof value.startedAt !== "number" || !Number.isFinite(value.startedAt)) return "missing startedAt";
|
|
897
|
+
return undefined;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Inspect one run's metadata without collapsing "missing" and "corrupt":
|
|
902
|
+
* `missing` only when neither metadata nor the run directory exists.
|
|
903
|
+
*/
|
|
904
|
+
export function inspectRunMeta(id: string): { kind: "ok"; meta: RunMeta } | { kind: "missing" } | { kind: "unreadable"; detail: string } {
|
|
905
|
+
let raw: string;
|
|
906
|
+
try {
|
|
907
|
+
registryIo.metadataFileReads += 1;
|
|
908
|
+
raw = readFileSync(metaPathFor(id), "utf-8");
|
|
909
|
+
} catch (error) {
|
|
910
|
+
metaCache.delete(id);
|
|
911
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
912
|
+
if (code === "ENOENT") {
|
|
913
|
+
try {
|
|
914
|
+
statSync(runDir(id));
|
|
915
|
+
return { kind: "unreadable", detail: "metadata file is missing; the run directory and its evidence are retained" };
|
|
916
|
+
} catch {
|
|
917
|
+
return { kind: "missing" };
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return { kind: "unreadable", detail: error instanceof Error ? error.message : String(error) };
|
|
921
|
+
}
|
|
922
|
+
let meta: RunMeta;
|
|
923
|
+
try {
|
|
924
|
+
meta = JSON.parse(raw) as RunMeta;
|
|
925
|
+
} catch (error) {
|
|
926
|
+
metaCache.delete(id);
|
|
927
|
+
return { kind: "unreadable", detail: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
928
|
+
}
|
|
929
|
+
const invalid = runMetaShapeProblem(meta, id);
|
|
930
|
+
if (invalid) {
|
|
931
|
+
metaCache.delete(id);
|
|
932
|
+
return { kind: "unreadable", detail: `invalid metadata: ${invalid}` };
|
|
933
|
+
}
|
|
934
|
+
metaCache.set(id, meta);
|
|
935
|
+
return { kind: "ok", meta };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Every run directory, counting unreadable records instead of dropping them. */
|
|
939
|
+
export function listRunRecords(): RunRecordIndex {
|
|
940
|
+
let ids: string[];
|
|
941
|
+
try {
|
|
942
|
+
registryIo.fullDirectoryReads += 1;
|
|
943
|
+
ids = readdirSync(join(baseDir(), "runs"));
|
|
944
|
+
} catch (error) {
|
|
945
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
946
|
+
if (code === "ENOENT") return { metas: [], unreadable: [] };
|
|
947
|
+
return { metas: [], unreadable: [], indexError: error instanceof Error ? error.message : String(error) };
|
|
948
|
+
}
|
|
949
|
+
const metas: RunMeta[] = [];
|
|
950
|
+
const unreadable: Array<{ id: string; detail: string }> = [];
|
|
951
|
+
for (const id of ids) {
|
|
952
|
+
const cached = metaCache.get(id);
|
|
953
|
+
if (cached && cached.status !== "running" && cached.status !== "orphaned") {
|
|
954
|
+
metas.push(cached);
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
const inspected = inspectRunMeta(id);
|
|
958
|
+
if (inspected.kind === "ok") metas.push(inspected.meta);
|
|
959
|
+
else if (inspected.kind === "unreadable") unreadable.push({ id, detail: inspected.detail });
|
|
960
|
+
}
|
|
961
|
+
metas.sort((a, b) => b.startedAt - a.startedAt);
|
|
962
|
+
return { metas, unreadable };
|
|
963
|
+
}
|
|
964
|
+
|
|
882
965
|
export function listMetasForParent(parentPid: number): RunMeta[] {
|
|
883
966
|
const directory = join(baseDir(), "by-parent", String(parentPid));
|
|
884
967
|
ensureIndex(directory, (meta) => meta.spawnPid === parentPid);
|
|
@@ -1015,18 +1098,18 @@ function ensureActiveParentIndex(directory: string, parentPid: number): void {
|
|
|
1015
1098
|
initializedIndexes.add(directory);
|
|
1016
1099
|
}
|
|
1017
1100
|
|
|
1018
|
-
function originOf(meta: RunMeta): RunCallbackOrigin {
|
|
1101
|
+
export function originOf(meta: RunMeta): RunCallbackOrigin {
|
|
1019
1102
|
return meta.callbackOrigin ?? { cwd: meta.cwd };
|
|
1020
1103
|
}
|
|
1021
1104
|
|
|
1022
|
-
function belongsToOrigin(meta: RunMeta, origin: RunCallbackOrigin): boolean {
|
|
1105
|
+
export function belongsToOrigin(meta: RunMeta, origin: RunCallbackOrigin): boolean {
|
|
1023
1106
|
const candidate = originOf(meta);
|
|
1024
1107
|
if (candidate.cwd !== origin.cwd) return false;
|
|
1025
1108
|
if (candidate.sessionId || origin.sessionId) return candidate.sessionId === origin.sessionId;
|
|
1026
1109
|
return true;
|
|
1027
1110
|
}
|
|
1028
1111
|
|
|
1029
|
-
function originKey(origin: RunCallbackOrigin): string {
|
|
1112
|
+
export function originKey(origin: RunCallbackOrigin): string {
|
|
1030
1113
|
return createHash("sha256")
|
|
1031
1114
|
.update(origin.cwd)
|
|
1032
1115
|
.update("\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
|