pi-better-subagents 0.4.2 → 0.5.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 +1 -1
- package/callback-fields.ts +92 -0
- package/child-incidents.ts +147 -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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure incident model shared by the parent's log scanner (failures.ts) and the child's
|
|
3
|
+
* `failure_disposition` tool (child-incidents.ts). Both fold the same structured tool
|
|
4
|
+
* records in the same order, so a disposition the child accepts is re-validated
|
|
5
|
+
* identically by the parent before it is journaled (#315).
|
|
6
|
+
*
|
|
7
|
+
* Nothing here reads prose. Operation identity is either an explicit caller-declared
|
|
8
|
+
* `operationId` or the exact tool/arguments/cwd hash; expected exits require a code
|
|
9
|
+
* declared on the attempt before it ran and a structured exit code from the tool.
|
|
10
|
+
*/
|
|
11
|
+
import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS, REJECTED_INTENT_CATEGORY,
|
|
12
|
+
type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
|
|
13
|
+
|
|
14
|
+
export const DISPOSITION_TOOL = "failure_disposition";
|
|
15
|
+
/** Caller-chosen identifiers: short, printable, no whitespace. */
|
|
16
|
+
export const INTENT_ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,63}$";
|
|
17
|
+
const INTENT_ID = new RegExp(INTENT_ID_PATTERN);
|
|
18
|
+
export const MAX_EXPECTED_EXIT_CODES = 16;
|
|
19
|
+
|
|
20
|
+
export interface CommandIntent {
|
|
21
|
+
/** Stable across caller-declared modified retries of one logical operation. */
|
|
22
|
+
operationId?: string;
|
|
23
|
+
/** Names one concrete execution; evidence identity, never an operation match. */
|
|
24
|
+
attemptId?: string;
|
|
25
|
+
/** Non-zero exit codes declared intentional before execution. */
|
|
26
|
+
expectedExitCodes?: number[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Validate structured intent fields. Absent fields are fine; malformed ones are an error. */
|
|
30
|
+
export function readCommandIntent(args: unknown): { intent: CommandIntent; error?: string } {
|
|
31
|
+
const input = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
|
|
32
|
+
const intent: CommandIntent = {};
|
|
33
|
+
for (const key of ["operationId", "attemptId"] as const) {
|
|
34
|
+
const value = input[key];
|
|
35
|
+
if (value === undefined) continue;
|
|
36
|
+
if (typeof value !== "string" || !INTENT_ID.test(value)) {
|
|
37
|
+
return { intent: {}, error: `${key} must match ${INTENT_ID_PATTERN}` };
|
|
38
|
+
}
|
|
39
|
+
intent[key] = value;
|
|
40
|
+
}
|
|
41
|
+
const codes = input.expectedExitCodes;
|
|
42
|
+
if (codes !== undefined) {
|
|
43
|
+
if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
|
|
44
|
+
!codes.every((code) => Number.isInteger(code) && code >= 1 && code <= 255) || new Set(codes).size !== codes.length) {
|
|
45
|
+
return { intent: {}, error: `expectedExitCodes must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255` };
|
|
46
|
+
}
|
|
47
|
+
intent.expectedExitCodes = [...codes] as number[];
|
|
48
|
+
}
|
|
49
|
+
return { intent };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stable(value: unknown): unknown {
|
|
53
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
54
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, stable(v)]));
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
/** Exact identity: tool name, full arguments, and working directory. */
|
|
58
|
+
export function toolOperation(name: string, args: unknown, cwd: string): string {
|
|
59
|
+
return failureIdentity("tool", name, stable(args ?? {}), cwd);
|
|
60
|
+
}
|
|
61
|
+
/** Declared identity when an operationId is present, otherwise the exact rule. attemptId never affects identity. */
|
|
62
|
+
export function attemptOperation(name: string, args: unknown, cwd: string, intent: CommandIntent): string {
|
|
63
|
+
if (intent.operationId) return failureIdentity("operation", name, intent.operationId, cwd);
|
|
64
|
+
if (args && typeof args === "object" && !Array.isArray(args) && "attemptId" in args) {
|
|
65
|
+
const { attemptId: _attemptId, ...rest } = args as Record<string, unknown>;
|
|
66
|
+
return toolOperation(name, rest, cwd);
|
|
67
|
+
}
|
|
68
|
+
return toolOperation(name, args, cwd);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function evidenceText(value: unknown): string | undefined {
|
|
72
|
+
if (value == null) return undefined;
|
|
73
|
+
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
|
74
|
+
return raw?.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 300);
|
|
75
|
+
}
|
|
76
|
+
function resultError(result: any): string | undefined {
|
|
77
|
+
if (result?.isError === true || (typeof result?.exitCode === "number" && result.exitCode !== 0)) {
|
|
78
|
+
return evidenceText(result?.content?.find?.((x: any) => x?.type === "text")?.text ?? result?.stderr ?? result?.error ?? result);
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
function time(row: any): number | undefined {
|
|
83
|
+
return typeof row.at === "number" && Number.isFinite(row.at) ? row.at :
|
|
84
|
+
typeof row.message?.timestamp === "number" && Number.isFinite(row.message.timestamp) ? row.message.timestamp : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Attempt {
|
|
88
|
+
toolCallId: string;
|
|
89
|
+
toolName: string;
|
|
90
|
+
operation: string;
|
|
91
|
+
intent: CommandIntent;
|
|
92
|
+
startSequence: number;
|
|
93
|
+
/** The confined intent bash refused this attempt before running anything. */
|
|
94
|
+
rejectedIntent?: string;
|
|
95
|
+
}
|
|
96
|
+
interface Finished extends Attempt {
|
|
97
|
+
ok: boolean;
|
|
98
|
+
endSequence: number;
|
|
99
|
+
evidence: string;
|
|
100
|
+
incident?: string;
|
|
101
|
+
}
|
|
102
|
+
export interface IncidentModel {
|
|
103
|
+
/** Honour operationId / attemptId / expectedExitCodes / dispositions (confined runtime only). */
|
|
104
|
+
structuredIntent: boolean;
|
|
105
|
+
sequence: number;
|
|
106
|
+
open: Map<string, Attempt>;
|
|
107
|
+
finished: Map<string, Finished>;
|
|
108
|
+
/** attemptId → toolCallId of the accepted declaration. */
|
|
109
|
+
attemptIds: Map<string, string>;
|
|
110
|
+
/** Every attemptId any bash start has named, accepted or rejected. */
|
|
111
|
+
namedAttempts: Set<string>;
|
|
112
|
+
/** operationId → operation identity. */
|
|
113
|
+
operationIds: Map<string, string>;
|
|
114
|
+
/** Current unresolved incident per operation, for exact retry recovery. */
|
|
115
|
+
failed: Map<string, { id: string; sequence: number }>;
|
|
116
|
+
/** Incident → last failure sequence, for ordering evidence. */
|
|
117
|
+
incidentSequence: Map<string, number>;
|
|
118
|
+
/** Disposition requests resolved at their start position (parent) or execute time (child). */
|
|
119
|
+
pendingDispositions: Map<string, { event?: FailureEvent; error?: string }>;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* `structuredIntent` is true only for runs whose tools are the trusted task runtime's
|
|
123
|
+
* (intent bash + failure_disposition). Anywhere else the intent fields and disposition
|
|
124
|
+
* requests are ignored and the exact-retry identity rule applies.
|
|
125
|
+
*/
|
|
126
|
+
export function newIncidentModel(structuredIntent = false): IncidentModel {
|
|
127
|
+
return { structuredIntent, sequence: 0, open: new Map(), finished: new Map(), attemptIds: new Map(), namedAttempts: new Set(), operationIds: new Map(),
|
|
128
|
+
failed: new Map(), incidentSequence: new Map(), pendingDispositions: new Map() };
|
|
129
|
+
}
|
|
130
|
+
export interface IncidentSink {
|
|
131
|
+
/** Reduce/journal ordinary observations; returns the resulting state. */
|
|
132
|
+
observe(events: FailureEvent[]): FailureState;
|
|
133
|
+
/** Journal an already validated disposition. */
|
|
134
|
+
dispose(event: FailureEvent): void;
|
|
135
|
+
/** Current state for validation. */
|
|
136
|
+
state(): FailureState;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface DispositionRequest {
|
|
140
|
+
disposition: IncidentDisposition;
|
|
141
|
+
targets: string[];
|
|
142
|
+
reason: string;
|
|
143
|
+
evidence?: string;
|
|
144
|
+
}
|
|
145
|
+
export function readDispositionRequest(args: unknown): { request?: DispositionRequest; error?: string } {
|
|
146
|
+
const input = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
|
|
147
|
+
const disposition = input.disposition;
|
|
148
|
+
if (typeof disposition !== "string" || !INCIDENT_DISPOSITIONS.includes(disposition as IncidentDisposition)) {
|
|
149
|
+
return { error: `disposition must be one of ${INCIDENT_DISPOSITIONS.join(", ")}` };
|
|
150
|
+
}
|
|
151
|
+
const targets = input.targets;
|
|
152
|
+
if (!Array.isArray(targets) || targets.length === 0 || targets.length > 20 || !targets.every((x) => typeof x === "string" && x.length > 0 && x.length <= 200)) {
|
|
153
|
+
return { error: "targets must name 1-20 incidents, attempt ids, or operation ids" };
|
|
154
|
+
}
|
|
155
|
+
if (typeof input.reason !== "string" || !input.reason.trim()) return { error: "reason is required" };
|
|
156
|
+
if (input.evidence !== undefined && (typeof input.evidence !== "string" || !input.evidence.trim())) return { error: "evidence must be an attempt id" };
|
|
157
|
+
return { request: { disposition: disposition as IncidentDisposition, targets: [...targets] as string[], reason: input.reason.trim(),
|
|
158
|
+
...(typeof input.evidence === "string" ? { evidence: input.evidence.trim() } : {}) } };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function attemptRef(model: IncidentModel, ref: string): Finished | undefined {
|
|
162
|
+
const toolCallId = model.attemptIds.get(ref) ?? ref;
|
|
163
|
+
return model.finished.get(toolCallId);
|
|
164
|
+
}
|
|
165
|
+
function attemptLabel(attempt: Finished): string {
|
|
166
|
+
return attempt.intent.attemptId ? `attempt ${attempt.intent.attemptId}` : `tool call ${attempt.toolCallId}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve a disposition request against the model and reduced state. Targets may be incident ids,
|
|
171
|
+
* failed attempt ids / tool call ids, or declared operation ids with a current incident. Recovery
|
|
172
|
+
* and supersession need evidence naming a successful attempt that started after the failures;
|
|
173
|
+
* recovery additionally needs the same operation. Everything else fails closed.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveDisposition(model: IncidentModel, state: FailureState, request: DispositionRequest, requestId: string): { event?: FailureEvent; error?: string } {
|
|
176
|
+
const incidents: string[] = [];
|
|
177
|
+
for (const target of request.targets) {
|
|
178
|
+
let id: string | undefined;
|
|
179
|
+
if (findIncident(state, target) || Object.hasOwn(state.history ?? {}, target)) id = target;
|
|
180
|
+
else {
|
|
181
|
+
const attempt = attemptRef(model, target);
|
|
182
|
+
if (attempt) {
|
|
183
|
+
if (!attempt.incident) return { error: `${attemptLabel(attempt)} did not fail; it has no incident` };
|
|
184
|
+
id = attempt.incident;
|
|
185
|
+
} else if (model.operationIds.has(target)) {
|
|
186
|
+
const current = state.observations[failureIdentity(model.operationIds.get(target)!)];
|
|
187
|
+
if (!current) return { error: `Operation ${target} has no incident` };
|
|
188
|
+
id = current.id;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!id) return { error: `Unknown incident, attempt, or operation "${target}"` };
|
|
192
|
+
if (!incidents.includes(id)) incidents.push(id);
|
|
193
|
+
}
|
|
194
|
+
let evidence: string | undefined;
|
|
195
|
+
if (request.disposition === "recovered" || request.disposition === "superseded") {
|
|
196
|
+
if (!request.evidence) return { error: `A ${request.disposition} disposition requires evidence: the attempt id (or tool call id) of a successful later attempt` };
|
|
197
|
+
const proof = attemptRef(model, request.evidence);
|
|
198
|
+
if (!proof) return { error: `Unknown evidence attempt "${request.evidence}"` };
|
|
199
|
+
if (!proof.ok) return { error: `Evidence ${attemptLabel(proof)} did not succeed` };
|
|
200
|
+
for (const id of incidents) {
|
|
201
|
+
const failedAt = model.incidentSequence.get(id);
|
|
202
|
+
if (failedAt === undefined || proof.startSequence <= failedAt) {
|
|
203
|
+
return { error: `Evidence ${attemptLabel(proof)} did not start after incident ${id} failed` };
|
|
204
|
+
}
|
|
205
|
+
if (request.disposition === "recovered") {
|
|
206
|
+
const incident = findIncident(state, id);
|
|
207
|
+
if (!incident || incident.operation !== proof.operation) {
|
|
208
|
+
return { error: `Evidence ${attemptLabel(proof)} is a different operation; use superseded for a different verification` };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
evidence = `${attemptLabel(proof)} succeeded · ${proof.evidence}`;
|
|
213
|
+
} else if (request.evidence) {
|
|
214
|
+
const proof = attemptRef(model, request.evidence);
|
|
215
|
+
evidence = proof ? `${attemptLabel(proof)} · ${proof.evidence}` : request.evidence.slice(0, 200);
|
|
216
|
+
}
|
|
217
|
+
const event: FailureEvent = { id: `disposition:${requestId}`, operation: "incident-disposition", kind: "disposition",
|
|
218
|
+
disposition: request.disposition, incidents, reason: request.reason, ...(evidence ? { evidence } : {}) };
|
|
219
|
+
const error = validateDisposition(state, event);
|
|
220
|
+
return error ? { error } : { event };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Fold one tool execution start. Disposition requests are resolved here, at their start position. */
|
|
224
|
+
export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink: IncidentSink): void {
|
|
225
|
+
const toolCallId = String(row.toolCallId);
|
|
226
|
+
const toolName = String(row.toolName ?? "unknown");
|
|
227
|
+
const seq = ++model.sequence;
|
|
228
|
+
if (!model.structuredIntent) {
|
|
229
|
+
// Unconfined: intent-looking fields are ordinary arguments; exact identity only.
|
|
230
|
+
model.open.set(toolCallId, { toolCallId, toolName, operation: toolOperation(toolName, row.args, cwd), intent: {}, startSequence: seq });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const parsed = toolName === "bash" ? readCommandIntent(row.args) : { intent: {} as CommandIntent };
|
|
234
|
+
const intent = parsed.intent;
|
|
235
|
+
// Mirrors the child's check: any earlier bash start that named this attemptId, valid or not.
|
|
236
|
+
const rawAttemptId = toolName === "bash" && typeof row.args?.attemptId === "string" ? row.args.attemptId as string : undefined;
|
|
237
|
+
const reused = rawAttemptId !== undefined && model.namedAttempts.has(rawAttemptId);
|
|
238
|
+
if (rawAttemptId !== undefined) model.namedAttempts.add(rawAttemptId);
|
|
239
|
+
const rejectedIntent = parsed.error ?? (reused ? `attemptId ${intent.attemptId} was already used` : undefined);
|
|
240
|
+
const operation = attemptOperation(toolName, row.args, cwd, intent);
|
|
241
|
+
model.open.set(toolCallId, { toolCallId, toolName, operation, intent: rejectedIntent ? {} : intent, startSequence: seq,
|
|
242
|
+
...(rejectedIntent ? { rejectedIntent } : {}) });
|
|
243
|
+
if (rejectedIntent) return;
|
|
244
|
+
if (intent.attemptId) model.attemptIds.set(intent.attemptId, toolCallId);
|
|
245
|
+
if (intent.operationId && !model.operationIds.has(intent.operationId)) model.operationIds.set(intent.operationId, operation);
|
|
246
|
+
if (toolName === DISPOSITION_TOOL) {
|
|
247
|
+
const parsed = readDispositionRequest(row.args);
|
|
248
|
+
model.pendingDispositions.set(toolCallId, parsed.request
|
|
249
|
+
? resolveDisposition(model, sink.state(), parsed.request, toolCallId)
|
|
250
|
+
: { error: parsed.error });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Fold one tool execution end: failures, declared expected exits, exact retry recovery, dispositions. */
|
|
255
|
+
export function foldToolEnd(model: IncidentModel, row: any, cwd: string, evidence: string, sink: IncidentSink): void {
|
|
256
|
+
const toolCallId = String(row.toolCallId);
|
|
257
|
+
const toolName = String(row.toolName ?? "unknown");
|
|
258
|
+
const seq = ++model.sequence;
|
|
259
|
+
const attempt = model.open.get(toolCallId);
|
|
260
|
+
if (attempt) model.open.delete(toolCallId);
|
|
261
|
+
const intent = attempt?.intent ?? {};
|
|
262
|
+
const operation = attempt?.operation ?? toolOperation(toolName, row.args, cwd);
|
|
263
|
+
const details = row.result?.details;
|
|
264
|
+
const declaredExit = row.isError === false && details?.expectedExit === true && typeof details.exitCode === "number" &&
|
|
265
|
+
(intent.expectedExitCodes ?? []).includes(details.exitCode) ? details.exitCode as number : undefined;
|
|
266
|
+
const error = row.isError === true ? evidenceText(row.result) ?? "Tool returned an error" : resultError(row.result);
|
|
267
|
+
const finished: Finished = { ...(attempt ?? { toolCallId, toolName, operation, intent, startSequence: seq }),
|
|
268
|
+
ok: !error && declaredExit === undefined, endSequence: seq, evidence };
|
|
269
|
+
model.finished.set(toolCallId, finished);
|
|
270
|
+
if (attempt?.rejectedIntent && (row.isError === true || error)) {
|
|
271
|
+
// Nothing ran: visible, but never grouped with (or escalating) the operation it named.
|
|
272
|
+
const failureId = `tool:${toolCallId}`;
|
|
273
|
+
sink.observe([{ id: failureId, operation: failureIdentity("rejected-intent", toolCallId), kind: "failure", at: time(row),
|
|
274
|
+
category: REJECTED_INTENT_CATEGORY, evidence, summary: `${toolName} not run: invalid command intent (${attempt.rejectedIntent})` }]);
|
|
275
|
+
finished.incident = failureId;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (toolName === DISPOSITION_TOOL && model.structuredIntent) {
|
|
279
|
+
const pending = model.pendingDispositions.get(toolCallId);
|
|
280
|
+
model.pendingDispositions.delete(toolCallId);
|
|
281
|
+
if (!error && pending?.event && !validateDisposition(sink.state(), pending.event)) sink.dispose(pending.event);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (declaredExit !== undefined || error) {
|
|
285
|
+
const failureId = `tool:${toolCallId}`;
|
|
286
|
+
const event: FailureEvent = declaredExit !== undefined
|
|
287
|
+
? { id: failureId, operation, kind: "failure", at: time(row), category: "tool", expected: true, evidence,
|
|
288
|
+
summary: `${toolName} exited with declared expected code ${declaredExit}` }
|
|
289
|
+
: { id: failureId, operation, kind: "failure", at: time(row), category: "tool", evidence,
|
|
290
|
+
summary: `${toolName} failed: ${error!.slice(0, 180)}`, expected: row.expected === true || row.result?.expected === true };
|
|
291
|
+
const state = sink.observe([event]);
|
|
292
|
+
const incident = state.observations[failureIdentity(operation)]?.id ?? failureId;
|
|
293
|
+
finished.incident = incident;
|
|
294
|
+
model.incidentSequence.set(incident, seq);
|
|
295
|
+
model.failed.set(operation, { id: incident, sequence: seq });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (attempt) {
|
|
299
|
+
const failed = model.failed.get(operation);
|
|
300
|
+
if (failed && attempt.startSequence > failed.sequence) {
|
|
301
|
+
sink.observe([{ id: `recovered:${toolCallId}`, operation, kind: "recovered", at: time(row), incidents: [failed.id] }]);
|
|
302
|
+
model.failed.delete(operation);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Open incidents a child can name, for actionable rejection messages. */
|
|
308
|
+
export function describeOpenTargets(model: IncidentModel, state: FailureState, limit = 10): string[] {
|
|
309
|
+
const rows: string[] = [];
|
|
310
|
+
for (const attempt of model.finished.values()) {
|
|
311
|
+
if (!attempt.incident) continue;
|
|
312
|
+
const incident = findIncident(state, attempt.incident);
|
|
313
|
+
if (!incident || incident.status !== "unresolved" || incident.category === "observation-incomplete") continue;
|
|
314
|
+
const names = [incident.id, attempt.intent.attemptId, attempt.intent.operationId].filter(Boolean).join(" / ");
|
|
315
|
+
const row = `${names} · ${incident.summary.slice(0, 120)}`;
|
|
316
|
+
if (!rows.some((x) => x.startsWith(incident.id))) rows.push(row);
|
|
317
|
+
if (rows.length >= limit) break;
|
|
318
|
+
}
|
|
319
|
+
return rows;
|
|
320
|
+
}
|
package/index.ts
CHANGED
|
@@ -95,7 +95,8 @@ import {
|
|
|
95
95
|
} from "./capacity.mjs";
|
|
96
96
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
97
97
|
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
98
|
-
import {
|
|
98
|
+
import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
|
|
99
|
+
import { collectRunFailures, failurePath, failureSummary, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
|
|
99
100
|
import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
|
|
100
101
|
import {
|
|
101
102
|
text,
|
|
@@ -262,6 +263,17 @@ function callbackOriginFromContext(ctx: ExtensionContext): RunCallbackOrigin {
|
|
|
262
263
|
return { cwd: ctx.cwd, sessionId };
|
|
263
264
|
}
|
|
264
265
|
|
|
266
|
+
/** Origin with a readable session id, or undefined when identity is unavailable. */
|
|
267
|
+
function verifiedOriginFromContext(ctx: ExtensionContext | undefined): RunCallbackOrigin | undefined {
|
|
268
|
+
if (!ctx?.sessionManager || typeof ctx.cwd !== "string") return undefined;
|
|
269
|
+
try {
|
|
270
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
271
|
+
return sessionId ? { cwd: ctx.cwd, sessionId } : undefined;
|
|
272
|
+
} catch {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
265
277
|
function callbackSuppressionReason(meta: RunMeta, active: RunCallbackOrigin | undefined = activeCallbackOrigin): string | undefined {
|
|
266
278
|
const origin = meta.callbackOrigin;
|
|
267
279
|
if (origin) {
|
|
@@ -339,17 +351,11 @@ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
|
|
|
339
351
|
|| meta.completionCallbackPendingAt === undefined
|
|
340
352
|
|| meta.completionCallbackSentAt !== undefined
|
|
341
353
|
|| meta.completionCallbackSuppressedAt !== undefined) return;
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
const observationStatus = unresolved.some((observation) => observation.category === "observation-incomplete")
|
|
346
|
-
? "observation incomplete" : unresolved.length ? "unresolved failure observations" : undefined;
|
|
354
|
+
const state = collectRunFailures(id, meta.cwd, true);
|
|
355
|
+
// Lifecycle, current actionability, and retained history are separate facts (#315).
|
|
356
|
+
const due = pendingFailureAttention(state, Date.now(), { terminal: true });
|
|
347
357
|
getCallbackBatcher(pi).enqueue({
|
|
348
|
-
|
|
349
|
-
id,
|
|
350
|
-
label,
|
|
351
|
-
status: observationStatus ? `${meta.status}; ${observationStatus}` : meta.status,
|
|
352
|
-
detailTool: "subagent_result",
|
|
358
|
+
...completionCallbackFields(meta, state, due?.incidents ?? []),
|
|
353
359
|
callback: true,
|
|
354
360
|
isDelivered: () => {
|
|
355
361
|
const current = readMeta(id);
|
|
@@ -386,13 +392,13 @@ function deliverFailureAttention(pi: ExtensionAPI | undefined, meta: RunMeta, no
|
|
|
386
392
|
if (!pi || meta.callback === false || callbackSuppressionReason(meta)) return;
|
|
387
393
|
if ((meta.status === "orphaned" || meta.status === "lost") && !isHealthCallbackHandled(meta, meta.status)) return;
|
|
388
394
|
const state = collectRunFailures(meta.id, meta.cwd, meta.status !== "running" && meta.status !== "orphaned");
|
|
389
|
-
|
|
395
|
+
// Evidence gaps (oversized or malformed log records) are not something the parent can act on
|
|
396
|
+
// while the child runs; they ride the completion or health callback instead (#315).
|
|
397
|
+
const pending = pendingFailureAttention(state, now, { deferObservationGaps: true });
|
|
390
398
|
if (!pending || (meta.status !== "running" && meta.status !== "orphaned" && meta.completionCallbackPendingAt !== undefined)) return;
|
|
391
|
-
|
|
399
|
+
// Only the pending incidents are rendered; earlier deliveries are counted, not repeated (#315).
|
|
392
400
|
void getCallbackBatcher(pi).deliverUrgent({
|
|
393
|
-
|
|
394
|
-
customType: "subagent-failure",
|
|
395
|
-
content: `${formatFailureSummary(state)}\nInspect: subagent_output id=${JSON.stringify(meta.id)}`,
|
|
401
|
+
...failureAttentionFields(meta, state, pending),
|
|
396
402
|
isDelivered: () => failureAttentionHandled(collectRunFailures(meta.id, meta.cwd), pending.incidents),
|
|
397
403
|
getSuppressionReason: () => {
|
|
398
404
|
const current = readMeta(meta.id);
|
|
@@ -572,7 +578,6 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
572
578
|
const callback = meta.callback !== false;
|
|
573
579
|
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
574
580
|
const failureState = collectRunFailures(meta.id, meta.cwd, status === "lost");
|
|
575
|
-
const failureText = formatFailureSummary(failureState);
|
|
576
581
|
const attention = pendingFailureAttention(failureState, now, { terminal: status === "lost" });
|
|
577
582
|
const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
|
|
578
583
|
if (!delivery) {
|
|
@@ -584,12 +589,7 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
584
589
|
return;
|
|
585
590
|
}
|
|
586
591
|
void getCallbackBatcher(pi).deliverUrgent({
|
|
587
|
-
|
|
588
|
-
id: meta.id,
|
|
589
|
-
label,
|
|
590
|
-
status,
|
|
591
|
-
customType: "subagent-health",
|
|
592
|
-
content: prependFailureSummary(delivery.content, failureText),
|
|
592
|
+
...healthCallbackFields(meta, status, failureState, delivery.content, attention?.incidents ?? []),
|
|
593
593
|
isDelivered: () => {
|
|
594
594
|
const current = readMeta(meta.id);
|
|
595
595
|
if (!current) throw new Error("Subagent metadata is unavailable; defer health notification");
|
|
@@ -1785,9 +1785,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1785
1785
|
// the factories return, so tests invoke the same execute handlers the
|
|
1786
1786
|
// model reaches (no drift-prone second copy). Stop's only UI side effect
|
|
1787
1787
|
// (widget redraw after a kill) is injected as onStopped.
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
const
|
|
1788
|
+
// The foreground origin from session_start; otherwise the calling context's
|
|
1789
|
+
// own session. An unreadable session id stays unavailable (never cwd-wide).
|
|
1790
|
+
const toolSession = {
|
|
1791
|
+
getActiveOrigin: (ctx?: unknown) => activeCallbackOrigin ?? verifiedOriginFromContext(ctx as ExtensionContext | undefined),
|
|
1792
|
+
};
|
|
1793
|
+
pi.registerTool(subagentListTool(Type, toolSession));
|
|
1794
|
+
pi.registerTool(subagentOutputTool(Type, toolSession));
|
|
1795
|
+
const acceptanceResultTool = subagentResultTool(Type, toolSession);
|
|
1791
1796
|
pi.registerTool(acceptanceResultTool);
|
|
1792
1797
|
publishAcceptanceHooks(acceptanceResultTool);
|
|
1793
1798
|
const acceptanceStopTool = subagentStopTool(Type, { onStopped: renderWidget });
|
package/lifecycle.ts
CHANGED
|
@@ -299,7 +299,8 @@ export interface FormatSubagentResultInput {
|
|
|
299
299
|
status: string;
|
|
300
300
|
exitCode: string | number;
|
|
301
301
|
statSeg: string;
|
|
302
|
-
|
|
302
|
+
/** @deprecated Ordinary tool-name sequences are omitted from result heads (#312). */
|
|
303
|
+
toolsSeg?: string;
|
|
303
304
|
run: ParsedRun;
|
|
304
305
|
rawLogTail: string;
|
|
305
306
|
lifecycle: LifecycleValidation;
|
|
@@ -312,8 +313,9 @@ export interface FormatSubagentResultInput {
|
|
|
312
313
|
* consistent with the registered tool path.
|
|
313
314
|
*/
|
|
314
315
|
export function formatSubagentResult(input: FormatSubagentResultInput): string {
|
|
315
|
-
const { id, status, exitCode, statSeg,
|
|
316
|
-
|
|
316
|
+
const { id, status, exitCode, statSeg, run, rawLogTail, lifecycle } = input;
|
|
317
|
+
// Ordinary ordered tool histories are omitted from the head (#312).
|
|
318
|
+
const head = `[${id} · ${status} · exit ${exitCode}${statSeg} · lifecycle ${lifecycle.classification}]`;
|
|
317
319
|
if (lifecycle.incomplete) {
|
|
318
320
|
return `${head}\n${formatIncompleteResult(run, rawLogTail, lifecycle)}`;
|
|
319
321
|
}
|
|
@@ -325,9 +327,8 @@ export function formatSubagentResult(input: FormatSubagentResultInput): string {
|
|
|
325
327
|
|| (run.lastActivity
|
|
326
328
|
? `(no final answer parsed; latest activity)\n${run.lastActivity}`
|
|
327
329
|
: `(no final answer parsed)\n\n--- raw log tail ---\n${rawLogTail}`);
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
formatLifecycleDiagnostics(lifecycle),
|
|
331
|
-
body
|
|
332
|
-
].join("\n");
|
|
330
|
+
const exceptional = lifecycle.classification !== "complete" || lifecycle.incomplete;
|
|
331
|
+
return exceptional
|
|
332
|
+
? [head, formatLifecycleDiagnostics(lifecycle), body].join("\n")
|
|
333
|
+
: [head, body].join("\n");
|
|
333
334
|
}
|
package/list.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { fmtElapsed
|
|
1
|
+
import { fmtElapsed } from "./widget.mjs";
|
|
2
2
|
import { formatListHealthSuffix } from "./health-surface.mjs";
|
|
3
3
|
|
|
4
|
-
export const SUBAGENT_LIST_DEFAULT_LIMIT =
|
|
4
|
+
export const SUBAGENT_LIST_DEFAULT_LIMIT = 10;
|
|
5
5
|
export const SUBAGENT_LIST_MAX_LIMIT = 100;
|
|
6
6
|
/** Effective + durable supervision statuses accepted by subagent_list filters. */
|
|
7
7
|
export const SUBAGENT_LIST_STATUSES = [
|
|
@@ -65,60 +65,83 @@ export function normalizeSubagentListOptions(params = {}) {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function bounded(value, max) {
|
|
69
|
+
const single = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
70
|
+
return single.length <= max ? single : `${single.slice(0, Math.max(0, max - 1))}…`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One compact row. Free text (name, model, batch) is bounded so the id and
|
|
75
|
+
* status always sit within the row's first ~80 characters and survive clipping.
|
|
76
|
+
*/
|
|
68
77
|
export function formatSubagentListRow(meta, p) {
|
|
69
78
|
const status = p.status;
|
|
70
79
|
const now = p.now ?? Date.now();
|
|
71
|
-
const usage = p.usage;
|
|
72
80
|
const elapsed = fmtElapsed((meta.endedAt ?? now) - meta.startedAt);
|
|
73
|
-
const
|
|
74
|
-
const name = meta.name ? `${meta.name} ` : "";
|
|
75
|
-
const stat = `${elapsed}${spend ? ` · ${spend}` : ""}`;
|
|
81
|
+
const name = meta.name ? `${bounded(meta.name, 60)} ` : "";
|
|
76
82
|
const health = formatListHealthSuffix(p.health);
|
|
77
|
-
const failure = p.failure ?
|
|
83
|
+
const failure = p.failure ? ` · ${bounded(p.failure, 60)}` : "";
|
|
78
84
|
const batch = meta.batchId
|
|
79
|
-
? ` [batch: ${meta.batchName ? `${meta.batchName} ` : ""}${meta.batchId}]`
|
|
85
|
+
? ` [batch: ${meta.batchName ? `${bounded(meta.batchName, 40)} ` : ""}${bounded(meta.batchId, 60)}]`
|
|
80
86
|
: "";
|
|
81
|
-
return `• ${name}${meta.id} [${status}] ${meta.model ?? "?"} ${
|
|
87
|
+
return `• ${name}${meta.id} [${status}] ${bounded(meta.model ?? "?", 60)} ${elapsed}${health}${batch}${failure}\n ${promptPreview(meta)}`;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
|
-
export function
|
|
90
|
+
export function collectSubagentList(p) {
|
|
85
91
|
const options = normalizeSubagentListOptions(p.params ?? {});
|
|
86
92
|
const now = p.now ?? Date.now();
|
|
87
93
|
const parentPid = p.parentPid ?? process.pid;
|
|
88
94
|
const statusOf = p.statusOf ?? ((meta) => meta.status);
|
|
89
|
-
const usageById = p.usageById ?? (() => undefined);
|
|
90
95
|
const healthById = p.healthById ?? (() => undefined);
|
|
91
96
|
const failureById = p.failureById ?? (() => "");
|
|
97
|
+
const inScope = p.inScope ?? ((meta) => options.all || meta.spawnPid === parentPid);
|
|
98
|
+
const offset = Math.max(0, Math.floor(Number(p.offset) || 0));
|
|
92
99
|
|
|
93
100
|
const scoped = (p.metas ?? [])
|
|
94
|
-
.filter((meta) =>
|
|
101
|
+
.filter((meta) => inScope(meta, options))
|
|
95
102
|
.sort((a, b) => b.startedAt - a.startedAt)
|
|
96
103
|
.map((meta) => ({ meta, status: statusOf(meta) }));
|
|
97
104
|
|
|
98
|
-
const
|
|
105
|
+
const items = options.statuses === null
|
|
99
106
|
? scoped
|
|
100
107
|
: scoped.filter((row) => options.statuses.has(row.status));
|
|
101
108
|
|
|
102
|
-
const displayed =
|
|
103
|
-
const
|
|
109
|
+
const displayed = items.slice(offset, offset + options.limit);
|
|
110
|
+
const rows = displayed.map((row) => formatSubagentListRow(row.meta, {
|
|
111
|
+
status: row.status,
|
|
112
|
+
now,
|
|
113
|
+
health: healthById(row.meta.id),
|
|
114
|
+
failure: failureById(row.meta.id),
|
|
115
|
+
}));
|
|
104
116
|
|
|
105
|
-
|
|
117
|
+
return {
|
|
118
|
+
warnings: options.warnings,
|
|
119
|
+
items,
|
|
120
|
+
rows,
|
|
121
|
+
matching: items.length,
|
|
122
|
+
displayed: displayed.length,
|
|
123
|
+
offset,
|
|
124
|
+
limit: options.limit,
|
|
125
|
+
all: options.all,
|
|
126
|
+
empty: items.length === 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function buildSubagentList(p) {
|
|
131
|
+
const collected = collectSubagentList(p);
|
|
132
|
+
const lines = [...collected.warnings];
|
|
133
|
+
|
|
134
|
+
if (collected.empty) {
|
|
106
135
|
lines.push("No subagent runs match filters.");
|
|
107
136
|
return lines.join("\n");
|
|
108
137
|
}
|
|
109
138
|
|
|
110
|
-
lines.push(...
|
|
111
|
-
status: row.status,
|
|
112
|
-
now,
|
|
113
|
-
usage: usageById(row.meta.id),
|
|
114
|
-
health: healthById(row.meta.id),
|
|
115
|
-
failure: failureById(row.meta.id),
|
|
116
|
-
})));
|
|
139
|
+
lines.push(...collected.rows);
|
|
117
140
|
|
|
118
|
-
if (matching
|
|
141
|
+
if (collected.matching > collected.displayed + collected.offset) {
|
|
119
142
|
lines.push(
|
|
120
|
-
`Showing ${displayed
|
|
121
|
-
`(limit ${
|
|
143
|
+
`Showing ${collected.displayed} of ${collected.matching} matching subagent runs ` +
|
|
144
|
+
`(limit ${collected.limit}). Increase limit up to ${SUBAGENT_LIST_MAX_LIMIT} to see more.`,
|
|
122
145
|
);
|
|
123
146
|
}
|
|
124
147
|
|