pi-better-subagents 0.4.1 → 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-sandbox-core.ts +11 -8
- package/shared-task-sandbox.ts +3 -1
- package/task-guard.ts +15 -3
- package/tools.ts +144 -79
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
|
|
|
14
14
|
- Non-blocking subagent launches, with an optional role and named-agent catalog (`docs/agent-catalog.md`, `docs/agent-catalog-lifecycle.md`).
|
|
15
15
|
- Default OS write sandboxing on macOS and Linux.
|
|
16
16
|
- Explicit tool allowlists for child sessions.
|
|
17
|
-
- Durable logs, result retrieval, and [failure observations](docs/failure-observations.md) independent of lifecycle status.
|
|
17
|
+
- Durable logs, result retrieval, and [failure observations](docs/failure-observations.md) independent of lifecycle status. A live child handles its own tool errors; the parent is woken only for actionable incidents, and children can classify handled failures with `failure_disposition`.
|
|
18
18
|
- Live background-work navigator for active runs.
|
|
19
19
|
|
|
20
20
|
## Install
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing content fields for subagent callbacks (#312).
|
|
3
|
+
*
|
|
4
|
+
* index.ts spreads these into the shared callback batcher together with its
|
|
5
|
+
* delivery/receipt closures, so the payload builders here are the production
|
|
6
|
+
* ones. The measurement harness feeds the same fields through the real
|
|
7
|
+
* batcher to capture actual delivered content.
|
|
8
|
+
*/
|
|
9
|
+
import type { CallbackBatchEvent, UrgentCallbackEvent } from "./shared-callback-batcher.ts";
|
|
10
|
+
import { activeFailures, pendingAttentionNote, pendingAttentionRows, requiresAction, terminalFailureParts,
|
|
11
|
+
type FailureState } from "./shared-failure-observations.ts";
|
|
12
|
+
import type { RunMeta } from "./registry.ts";
|
|
13
|
+
|
|
14
|
+
export function runLabel(meta: Pick<RunMeta, "id" | "name">): string {
|
|
15
|
+
return meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type CompletionFields = Pick<CallbackBatchEvent,
|
|
19
|
+
"source" | "id" | "label" | "status" | "detailTool" | "outcome" | "failureRows" | "decision" | "incidentCount">;
|
|
20
|
+
|
|
21
|
+
type UrgentFields = Pick<UrgentCallbackEvent,
|
|
22
|
+
"source" | "id" | "label" | "status" | "customType" | "content" | "detailTool" | "failureRows" | "incidentCount">;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Lifecycle, current actionability, and retained history are separate facts (#315). Rows are the
|
|
26
|
+
* actionable/incomplete incidents among `incidents` (the terminal pending set), counted exactly;
|
|
27
|
+
* unclassified tool failures, expected failures, closed history, and earlier deliveries are counts.
|
|
28
|
+
*/
|
|
29
|
+
function terminalFields(state: FailureState, incidents: readonly string[]) {
|
|
30
|
+
const { rows, notes } = terminalFailureParts(state, incidents);
|
|
31
|
+
return { failureRows: rows.length ? rows : undefined, incidentCount: rows.length || undefined, notes };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function observationStatus(state: FailureState): string | undefined {
|
|
35
|
+
const active = activeFailures(state);
|
|
36
|
+
return [
|
|
37
|
+
active.some((observation) => requiresAction(observation)) ? "action required" : "",
|
|
38
|
+
active.some((observation) => observation.category === "observation-incomplete") ? "observation incomplete" : "",
|
|
39
|
+
].filter(Boolean).join("; ") || undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ordinary terminal completion: outcome, pending actionable rows, and history as counts. */
|
|
43
|
+
export function completionCallbackFields(meta: RunMeta, state: FailureState, incidents: readonly string[] = []): CompletionFields {
|
|
44
|
+
const status = observationStatus(state);
|
|
45
|
+
const { failureRows, incidentCount, notes } = terminalFields(state, incidents);
|
|
46
|
+
return {
|
|
47
|
+
source: "subagent",
|
|
48
|
+
id: meta.id,
|
|
49
|
+
label: runLabel(meta),
|
|
50
|
+
status: status ? `${meta.status}; ${status}` : meta.status,
|
|
51
|
+
detailTool: "subagent_result",
|
|
52
|
+
outcome: meta.status,
|
|
53
|
+
failureRows,
|
|
54
|
+
...(notes.length ? { decision: notes.join(" ") } : {}),
|
|
55
|
+
incidentCount,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Orphaned/lost health transition: the ATTENTION explanation, pending actionable rows, history as counts. */
|
|
60
|
+
export function healthCallbackFields(meta: RunMeta, status: "orphaned" | "lost", state: FailureState, content: string,
|
|
61
|
+
incidents: readonly string[] = []): UrgentFields {
|
|
62
|
+
const { failureRows, incidentCount, notes } = terminalFields(state, incidents);
|
|
63
|
+
return {
|
|
64
|
+
source: "subagent",
|
|
65
|
+
id: meta.id,
|
|
66
|
+
label: runLabel(meta),
|
|
67
|
+
status,
|
|
68
|
+
customType: "subagent-health",
|
|
69
|
+
content: notes.length ? `${content}\n${notes.join(" ")}` : content,
|
|
70
|
+
detailTool: "subagent_result",
|
|
71
|
+
failureRows,
|
|
72
|
+
incidentCount,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Running/orphaned failure attention: exactly the pending incidents; earlier ones are counted, never repeated. */
|
|
77
|
+
export function failureAttentionFields(meta: RunMeta, state: FailureState, pending: { key: string; incidents: string[] }): UrgentFields {
|
|
78
|
+
const rows = pendingAttentionRows(state, pending.incidents);
|
|
79
|
+
const note = pendingAttentionNote(state, pending.incidents);
|
|
80
|
+
const due = rows.length;
|
|
81
|
+
return {
|
|
82
|
+
source: "subagent",
|
|
83
|
+
id: meta.id,
|
|
84
|
+
label: runLabel(meta),
|
|
85
|
+
status: `failure:${pending.key}`,
|
|
86
|
+
customType: "subagent-failure",
|
|
87
|
+
content: `Subagent ${meta.id} (${meta.status}) has ${due} failure observation${due === 1 ? "" : "s"} that need attention.${note ? ` ${note}` : ""}`,
|
|
88
|
+
detailTool: "subagent_result",
|
|
89
|
+
failureRows: rows,
|
|
90
|
+
incidentCount: due || undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-side structured failure intent (#315), installed by the trusted task runtime.
|
|
3
|
+
*
|
|
4
|
+
* - `bash` accepts optional `operationId`, `attemptId`, and `expectedExitCodes`. They are
|
|
5
|
+
* validated before the command runs. A declared exit code returns a non-error result
|
|
6
|
+
* whose details carry the structured exit code; every other non-zero exit, timeout, or
|
|
7
|
+
* abort stays an ordinary tool error.
|
|
8
|
+
* - `failure_disposition` lets the agent that owns an incident classify it explicitly.
|
|
9
|
+
* It validates against the same incident model the parent scanner replays, so the
|
|
10
|
+
* parent journals exactly what the child accepted and nothing else.
|
|
11
|
+
*/
|
|
12
|
+
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { BashOperations, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { emptyFailureState, reduceFailure, type FailureEvent, type FailureState } from "./shared-failure-observations.ts";
|
|
15
|
+
import { DISPOSITION_TOOL, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, describeOpenTargets, foldToolEnd, foldToolStart,
|
|
16
|
+
newIncidentModel, readCommandIntent, readDispositionRequest, resolveDisposition, type IncidentModel, type IncidentSink } from "./incident-model.ts";
|
|
17
|
+
|
|
18
|
+
const { createBashToolDefinition } = PiCodingAgent;
|
|
19
|
+
|
|
20
|
+
const intentProperties = {
|
|
21
|
+
operationId: { type: "string", pattern: INTENT_ID_PATTERN,
|
|
22
|
+
description: "Optional stable id for one logical operation. Reuse it on a modified retry (changed scope, timeout, or flags) so a later success recovers the earlier failure." },
|
|
23
|
+
attemptId: { type: "string", pattern: INTENT_ID_PATTERN,
|
|
24
|
+
description: "Optional unique id for this execution, for use as evidence in failure_disposition." },
|
|
25
|
+
expectedExitCodes: { type: "array", items: { type: "integer", minimum: 1, maximum: 255 }, minItems: 1, maxItems: MAX_EXPECTED_EXIT_CODES, uniqueItems: true,
|
|
26
|
+
description: "Optional non-zero exit codes that are intentional for this command (for example [1] for an rg/grep no-match or git diff --exit-code probe). Declared before the command runs; the final shell exit code is what is classified." },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function sessionToolCalls(ctx: ExtensionContext | undefined): Array<{ type: string; [key: string]: unknown }> {
|
|
30
|
+
const branch = (ctx?.sessionManager?.getBranch?.() ?? []) as any[];
|
|
31
|
+
const rows: Array<{ type: string; [key: string]: unknown }> = [];
|
|
32
|
+
let pendingEnds: any[] = [];
|
|
33
|
+
const flush = () => { rows.push(...pendingEnds); pendingEnds = []; };
|
|
34
|
+
for (const entry of branch) {
|
|
35
|
+
const message = entry?.type === "message" ? entry.message : undefined;
|
|
36
|
+
if (!message) continue;
|
|
37
|
+
if (message.role === "assistant") {
|
|
38
|
+
flush();
|
|
39
|
+
for (const part of Array.isArray(message.content) ? message.content : []) {
|
|
40
|
+
if (part?.type === "toolCall" && typeof part.id === "string") {
|
|
41
|
+
rows.push({ type: "tool_execution_start", toolCallId: part.id, toolName: part.name, args: part.arguments });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} else if (message.role === "toolResult" && typeof message.toolCallId === "string") {
|
|
45
|
+
pendingEnds.push({ type: "tool_execution_end", toolCallId: message.toolCallId, toolName: message.toolName,
|
|
46
|
+
isError: message.isError === true, result: { content: message.content, details: message.details } });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
flush();
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Rebuild the child's view of its own incidents from the durable session branch. */
|
|
54
|
+
export function childIncidentView(ctx: ExtensionContext | undefined, cwd: string): { model: IncidentModel; state: FailureState } {
|
|
55
|
+
let state = emptyFailureState();
|
|
56
|
+
let at = 0;
|
|
57
|
+
const sink: IncidentSink = {
|
|
58
|
+
observe: (events: FailureEvent[]) => { for (const event of events) state = reduceFailure(state, event, ++at); return state; },
|
|
59
|
+
dispose: (event: FailureEvent) => { state = reduceFailure(state, event, ++at); },
|
|
60
|
+
state: () => state,
|
|
61
|
+
};
|
|
62
|
+
const model = newIncidentModel(true);
|
|
63
|
+
for (const row of sessionToolCalls(ctx)) {
|
|
64
|
+
if (row.type === "tool_execution_start") foldToolStart(model, row, cwd, sink);
|
|
65
|
+
else foldToolEnd(model, row, cwd, "session", sink);
|
|
66
|
+
}
|
|
67
|
+
return { model, state };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Bash with structured intent, delegating execution to the given operations. */
|
|
71
|
+
export function intentBashDefinition(cwd: string, operations: BashOperations) {
|
|
72
|
+
const base = createBashToolDefinition(cwd, { operations });
|
|
73
|
+
const parameters = { ...base.parameters, properties: { ...(base.parameters as any).properties, ...intentProperties } };
|
|
74
|
+
return {
|
|
75
|
+
...base,
|
|
76
|
+
parameters,
|
|
77
|
+
async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: ExtensionContext) {
|
|
78
|
+
const { intent, error } = readCommandIntent(params);
|
|
79
|
+
if (error) throw new Error(`Invalid command intent: ${error}. The command was not run.`);
|
|
80
|
+
if (intent.attemptId) {
|
|
81
|
+
const reused = sessionToolCalls(ctx).some((row) => row.type === "tool_execution_start" && row.toolName === "bash" &&
|
|
82
|
+
row.toolCallId !== toolCallId && (row.args as any)?.attemptId === intent.attemptId);
|
|
83
|
+
if (reused) throw new Error(`Invalid command intent: attemptId ${intent.attemptId} was already used. The command was not run.`);
|
|
84
|
+
}
|
|
85
|
+
const { operationId: _o, attemptId: _a, expectedExitCodes: _e, ...command } = params ?? {};
|
|
86
|
+
let exitCode: number | null | undefined;
|
|
87
|
+
const observed: BashOperations = { ...operations, exec: async (...args: Parameters<BashOperations["exec"]>) => {
|
|
88
|
+
const result = await operations.exec(...args);
|
|
89
|
+
exitCode = result.exitCode;
|
|
90
|
+
return result;
|
|
91
|
+
} };
|
|
92
|
+
const run = createBashToolDefinition(cwd, { operations: observed });
|
|
93
|
+
try {
|
|
94
|
+
return await run.execute(toolCallId, command, signal, onUpdate, ctx);
|
|
95
|
+
} catch (failure) {
|
|
96
|
+
if (typeof exitCode === "number" && intent.expectedExitCodes?.includes(exitCode) && !signal?.aborted) {
|
|
97
|
+
const message = failure instanceof Error ? failure.message : String(failure);
|
|
98
|
+
return { content: [{ type: "text" as const, text: `${message}\n(Exit code ${exitCode} was declared expected.)` }],
|
|
99
|
+
details: { exitCode, expectedExit: true, ...(intent.operationId ? { operationId: intent.operationId } : {}),
|
|
100
|
+
...(intent.attemptId ? { attemptId: intent.attemptId } : {}) } as any };
|
|
101
|
+
}
|
|
102
|
+
throw failure;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function failureDispositionTool(cwd: () => string) {
|
|
109
|
+
return {
|
|
110
|
+
name: DISPOSITION_TOOL,
|
|
111
|
+
label: "Failure disposition",
|
|
112
|
+
description: [
|
|
113
|
+
"Classify your own earlier tool failures so the parent is not told about problems you already handled.",
|
|
114
|
+
"recovered: the same operation later passed (evidence = that attempt). superseded: a different verification or remediation established the outcome (evidence = the successful attempt).",
|
|
115
|
+
"expected: the failure was intentional. open: it still needs the parent's action (this asks for attention).",
|
|
116
|
+
"targets: incident ids, the attemptId/operationId you declared on bash, or tool call ids. Unknown or already-disposed incidents are rejected.",
|
|
117
|
+
"Never claim recovery without a successful later attempt as evidence.",
|
|
118
|
+
].join(" "),
|
|
119
|
+
promptSnippet: "Classify handled tool failures (recovered, superseded, expected) or flag one as open for the parent.",
|
|
120
|
+
parameters: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
disposition: { type: "string", enum: ["recovered", "superseded", "expected", "open"] },
|
|
124
|
+
targets: { type: "array", items: { type: "string", minLength: 1, maxLength: 200 }, minItems: 1, maxItems: 20 },
|
|
125
|
+
reason: { type: "string", minLength: 1, maxLength: 400 },
|
|
126
|
+
evidence: { type: "string", minLength: 1, maxLength: 200, description: "attemptId or tool call id of the successful later attempt; required for recovered and superseded." },
|
|
127
|
+
},
|
|
128
|
+
required: ["disposition", "targets", "reason"],
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
},
|
|
131
|
+
async execute(toolCallId: string, params: unknown, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
132
|
+
const parsed = readDispositionRequest(params);
|
|
133
|
+
if (!parsed.request) throw new Error(`Disposition rejected: ${parsed.error}.`);
|
|
134
|
+
const { model, state } = childIncidentView(ctx, cwd());
|
|
135
|
+
const outcome = resolveDisposition(model, state, parsed.request, toolCallId);
|
|
136
|
+
if (!outcome.event) {
|
|
137
|
+
const open = describeOpenTargets(model, state);
|
|
138
|
+
throw new Error(`Disposition rejected: ${outcome.error}.` + (open.length ? `\nOpen incidents:\n${open.join("\n")}` : "\nNo open incidents."));
|
|
139
|
+
}
|
|
140
|
+
const incidents = outcome.event.incidents ?? [];
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text" as const, text: `Recorded ${parsed.request.disposition} for ${incidents.length} incident${incidents.length === 1 ? "" : "s"}: ${incidents.join(", ")}.` }],
|
|
143
|
+
details: { disposition: parsed.request.disposition, incidents },
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -1,10 +1,57 @@
|
|
|
1
1
|
# Failure observations
|
|
2
2
|
|
|
3
|
-
Structured tool, model, exit, and supervision failures are collected separately from lifecycle status. A child may still be `running`, or finish its agent loop, while checks remain unresolved. List, output, result, navigator, and completion notifications expose these observations before assistant progress can suggest that the work is healthy.
|
|
3
|
+
Structured tool, model, exit, and supervision failures are collected separately from lifecycle status. A child may still be `running`, or finish its agent loop, while checks remain unresolved. List, output, result, navigator, and completion notifications expose these observations before assistant progress can suggest that the work is healthy. All of them read the same reduced incident state.
|
|
4
4
|
|
|
5
|
-
Each run retains
|
|
5
|
+
Each run retains an append-only `failures.jsonl` journal. Nothing is deleted from it: recovered and superseded incidents leave the active summaries but stay in history.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## What each label means
|
|
8
|
+
|
|
9
|
+
| Label | Meaning |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `Action required` | An incident that needs someone other than the child: a non-tool failure (child exit, model, supervision), the same tool operation failing three times with no recovery, or an incident the child explicitly marked `open`. |
|
|
12
|
+
| `Unclassified failure observation` | A tool error the child has not disposed of. Children normally handle their own tool errors, so this is retained evidence, not the run's outcome. |
|
|
13
|
+
| `Expected failure` | A non-success the child declared intentional before running the command, or disposed as `expected`. |
|
|
14
|
+
| `Observation incomplete` | Missing, corrupt, truncated, or unreadable evidence. |
|
|
15
|
+
| `Recovered` / `Superseded` | Closed. Counted in history, never shown as active. |
|
|
16
|
+
|
|
17
|
+
A completed run reports lifecycle and incidents as separate facts, for example:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
status=completed
|
|
21
|
+
8 earlier tool failures remain unclassified.
|
|
22
|
+
Work correctness was not inferred from lifecycle alone.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## When the parent is notified
|
|
26
|
+
|
|
27
|
+
- **While the child runs**, a single tool error does not wake the parent; the child owns it. The parent is woken, after a 60-second grace period, only for non-tool failures, for an operation that failed three times with no recovery, and for incidents the child marked `open`. Each incident is delivered once. Observation gaps (for example an oversized log record) stay visible on every surface and are delivered with the completion or health callback, since the parent cannot act on them mid-run.
|
|
28
|
+
- **At completion**, every still-unresolved incident is reported once in the ordinary completion callback: actionable incidents in full, unclassified tool failures as a count. Incidents a running notification already delivered are counted, not repeated.
|
|
29
|
+
- **Orphaned and lost** health callbacks carry the same facts once.
|
|
30
|
+
|
|
31
|
+
Every notification renders only its pending incidents. Delivery receipts are independent of recovery and are written after handoff; `callback:false` suppresses notifications without hiding inspection evidence.
|
|
32
|
+
|
|
33
|
+
## Recovery and disposition
|
|
34
|
+
|
|
35
|
+
Automatic recovery still requires an exact retry: the same tool name, arguments, and working directory, started after the failure. Parallel or unrelated successes cannot clear an incident, and neither can exit 0 of another command or anything an assistant writes.
|
|
36
|
+
|
|
37
|
+
Sandboxed children (the task runtime) get two structured additions. The parent honours them only for runs whose metadata it wrote as launched on the task runtime; for any other run, intent-looking arguments are ordinary arguments and `failure_disposition` calls are ignored.
|
|
38
|
+
|
|
39
|
+
- `bash` accepts optional `operationId`, `attemptId`, and `expectedExitCodes`, validated before the command runs.
|
|
40
|
+
- `operationId` names one logical operation across modified retries. A later success with the same `operationId` (a changed scope, timeout, or flag) recovers the earlier failure automatically. Without it, the exact rule applies. `attemptId` names one execution for use as evidence and never changes operation identity.
|
|
41
|
+
- `expectedExitCodes` (distinct integers 1–255) declares intentional non-zero exits, such as `[1]` for an `rg`/`grep` no-match or a `git diff --exit-code` probe. Only the final shell exit code is classified, taken from the tool's structured result, never from output text. Timeouts, aborts, and undeclared codes stay ordinary failures.
|
|
42
|
+
- `failure_disposition({ disposition, targets, reason, evidence? })` records an explicit classification of the child's own incidents:
|
|
43
|
+
- `recovered`: the same operation later passed. Evidence must be a successful attempt of that operation that started after the failure.
|
|
44
|
+
- `superseded`: a different verification or remediation established the outcome, such as resolving a merge conflict and continuing. Evidence must be a successful attempt that started after the failure.
|
|
45
|
+
- `expected`: the failure was intentional. A later successful retry keeps the classification.
|
|
46
|
+
- `open`: the incident still needs the parent. This makes it `Action required`.
|
|
47
|
+
|
|
48
|
+
A `bash` call whose intent is rejected (malformed fields, or a reused `attemptId`) never runs. It is recorded as its own unclassified observation, not as a failure of the command it named, so it cannot make that operation look stuck.
|
|
49
|
+
|
|
50
|
+
Targets may be incident ids, the `attemptId` or `operationId` the child declared, or tool call ids. Unknown, already-disposed, evidence-free, or partially invalid requests are rejected whole, with the open incidents listed, and nothing is written. The parent replays the same validation from the child's log before it appends the disposition to the journal, so dispositions survive reload and replay exactly once.
|
|
51
|
+
|
|
52
|
+
Unconfined children keep the exact-retry rule and do not yet have these additions.
|
|
53
|
+
|
|
54
|
+
## Evidence health
|
|
8
55
|
|
|
9
56
|
The collector scans complete structured records independently of the finite progress tail. Missing terminal logs, unreadable records, and detected truncation are shown as **observation incomplete**. Failure classification uses structured error and exit fields; output text and domain-specific status codes are not failure signals.
|
|
10
57
|
|
package/failures.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { logPathFor, runDir } from "./registry.ts";
|
|
4
|
-
import { failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
|
|
3
|
+
import { logPathFor, readMeta, runDir } from "./registry.ts";
|
|
4
|
+
import { activeFailures, disposeIncidents, failureIdentity, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
|
|
5
5
|
observeFailures, pendingFailureAttention, readFailureState, type FailureState } from "./shared-failure-observations.ts";
|
|
6
|
+
import { evidenceText, foldToolEnd, foldToolStart, newIncidentModel, toolOperation, type IncidentModel, type IncidentSink } from "./incident-model.ts";
|
|
6
7
|
|
|
7
|
-
export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered };
|
|
8
|
+
export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered, toolOperation };
|
|
8
9
|
export const failurePath = (id: string) => join(runDir(id), "failures.jsonl");
|
|
9
10
|
export const readRunFailures = (id: string): FailureState => readFailureState(failurePath(id));
|
|
10
|
-
interface
|
|
11
|
-
interface Scan { offset: number; head: string; identity: string; attempts: Map<string, Attempt>; failed: Map<string, { id: string; sequence: number }>; sequence: number; retry?: number }
|
|
11
|
+
interface Scan { offset: number; head: string; identity: string; model: IncidentModel; retry?: number }
|
|
12
12
|
const scans = new Map<string, Scan>();
|
|
13
13
|
/** Drop an in-memory scan cursor (e.g. on reload); the journal remains authoritative. */
|
|
14
14
|
export function resetFailureScanCursor(id?: string): void {
|
|
@@ -17,79 +17,57 @@ export function resetFailureScanCursor(id?: string): void {
|
|
|
17
17
|
}
|
|
18
18
|
const CHUNK = 64 * 1024;
|
|
19
19
|
const MAX_LINE = 2 * 1024 * 1024;
|
|
20
|
-
function stable(value: unknown): unknown {
|
|
21
|
-
if (Array.isArray(value)) return value.map(stable);
|
|
22
|
-
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, stable(v)]));
|
|
23
|
-
return value;
|
|
24
|
-
}
|
|
25
|
-
export function toolOperation(name: string, args: unknown, cwd: string): string {
|
|
26
|
-
return failureIdentity("tool", name, stable(args ?? {}), cwd);
|
|
27
|
-
}
|
|
28
|
-
function evidence(value: unknown): string | undefined {
|
|
29
|
-
if (value == null) return undefined;
|
|
30
|
-
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
|
31
|
-
return raw?.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 300);
|
|
32
|
-
}
|
|
33
|
-
function resultError(result: any): string | undefined {
|
|
34
|
-
if (result?.isError === true || (typeof result?.exitCode === "number" && result.exitCode !== 0)) {
|
|
35
|
-
return evidence(result?.content?.find?.((x: any) => x?.type === "text")?.text ?? result?.stderr ?? result?.error ?? result);
|
|
36
|
-
}
|
|
37
|
-
return undefined;
|
|
38
|
-
}
|
|
39
20
|
function time(event: any): number | undefined {
|
|
40
21
|
return typeof event.at === "number" && Number.isFinite(event.at) ? event.at :
|
|
41
22
|
typeof event.message?.timestamp === "number" && Number.isFinite(event.message.timestamp) ? event.message.timestamp : undefined;
|
|
42
23
|
}
|
|
24
|
+
function sinkFor(id: string): IncidentSink {
|
|
25
|
+
const path = failurePath(id);
|
|
26
|
+
return {
|
|
27
|
+
observe: (events) => observeFailures(path, events),
|
|
28
|
+
dispose: (event) => { disposeIncidents(path, event); },
|
|
29
|
+
state: () => readFailureState(path),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
43
32
|
function fold(id: string, scan: Scan, row: any, offset: number, cwd: string): void {
|
|
44
|
-
const eventId = typeof row.toolCallId === "string" ? row.toolCallId : `offset:${offset}`;
|
|
45
|
-
const seq = ++scan.sequence;
|
|
46
33
|
const path = failurePath(id);
|
|
34
|
+
const model = scan.model;
|
|
47
35
|
if (row.type === "tool_execution_start") {
|
|
48
|
-
|
|
36
|
+
foldToolStart(model, row, cwd, sinkFor(id));
|
|
49
37
|
} else if (row.type === "tool_execution_end") {
|
|
50
|
-
|
|
51
|
-
if (attempt) scan.attempts.delete(eventId);
|
|
52
|
-
const operation = attempt?.operation ?? toolOperation(String(row.toolName ?? "unknown"), row.args, cwd);
|
|
53
|
-
const error = row.isError === true ? evidence(row.result) ?? "Tool returned an error" : resultError(row.result);
|
|
54
|
-
if (error) {
|
|
55
|
-
const failureId = `tool:${eventId}`;
|
|
56
|
-
const state = observeFailures(path, [{ id: failureId, operation, kind: "failure", at: time(row), category: "tool",
|
|
57
|
-
summary: `${row.toolName ?? "Tool"} failed: ${error.slice(0, 180)}`, evidence: `${logPathFor(id)}#byte=${offset}`,
|
|
58
|
-
expected: row.expected === true || row.result?.expected === true }]);
|
|
59
|
-
scan.failed.set(operation, { id: state.observations[failureIdentity(operation)]?.id ?? failureId, sequence: seq });
|
|
60
|
-
} else if (attempt) {
|
|
61
|
-
const failed = scan.failed.get(operation);
|
|
62
|
-
if (failed && attempt.sequence > failed.sequence) {
|
|
63
|
-
observeFailures(path, [{ id: `recovered:${eventId}`, operation, kind: "recovered", at: time(row), incidents: [failed.id] }]);
|
|
64
|
-
scan.failed.delete(operation);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
38
|
+
foldToolEnd(model, row, cwd, `${logPathFor(id)}#byte=${offset}`, sinkFor(id));
|
|
67
39
|
} else if (row.type === "message_end" && row.message?.role === "assistant" &&
|
|
68
40
|
(row.message.stopReason === "error" || typeof row.message.errorMessage === "string")) {
|
|
69
|
-
const
|
|
41
|
+
const seq = ++model.sequence;
|
|
42
|
+
const message = evidenceText(row.message.errorMessage) ?? "Model response failed";
|
|
70
43
|
const failureId = `model:${offset}`;
|
|
71
44
|
const state = observeFailures(path, [{ id: failureId, operation: "model-call", kind: "failure", at: time(row), category: "model", summary: message }]);
|
|
72
|
-
|
|
45
|
+
model.failed.set("model-call", { id: state.observations[failureIdentity("model-call")]?.id ?? failureId, sequence: seq });
|
|
73
46
|
} else if (row.type === "auto_retry_start") {
|
|
47
|
+
const seq = ++model.sequence;
|
|
74
48
|
if (typeof row.errorMessage === "string") {
|
|
75
49
|
const failureId = `model-retry:${offset}`;
|
|
76
50
|
const state = observeFailures(path, [{ id: failureId, operation: "model-call", kind: "failure", at: time(row), category: "model", summary: row.errorMessage }]);
|
|
77
|
-
|
|
51
|
+
model.failed.set("model-call", { id: state.observations[failureIdentity("model-call")]?.id ?? failureId, sequence: seq - 1 });
|
|
78
52
|
}
|
|
79
53
|
scan.retry = seq;
|
|
80
54
|
} else if (row.type === "auto_retry_end" && row.success === false) {
|
|
55
|
+
const seq = ++model.sequence;
|
|
81
56
|
const failureId = `model-exhausted:${offset}`;
|
|
82
57
|
const state = observeFailures(path, [{ id: failureId, operation: "model-call", kind: "failure", at: time(row), category: "model",
|
|
83
|
-
summary:
|
|
84
|
-
|
|
58
|
+
summary: evidenceText(row.finalError) ?? "Model retry exhausted" }]);
|
|
59
|
+
model.failed.set("model-call", { id: state.observations[failureIdentity("model-call")]?.id ?? failureId, sequence: seq });
|
|
85
60
|
scan.retry = undefined;
|
|
86
61
|
} else if (row.type === "auto_retry_end" && row.success === true && scan.retry !== undefined) {
|
|
87
|
-
|
|
62
|
+
++model.sequence;
|
|
63
|
+
const failed = model.failed.get("model-call");
|
|
88
64
|
if (failed && scan.retry > failed.sequence) {
|
|
89
65
|
observeFailures(path, [{ id: `model-recovered:${offset}`, operation: "model-call", kind: "recovered", at: time(row), incidents: [failed.id] }]);
|
|
90
|
-
|
|
66
|
+
model.failed.delete("model-call");
|
|
91
67
|
}
|
|
92
68
|
scan.retry = undefined;
|
|
69
|
+
} else {
|
|
70
|
+
++model.sequence;
|
|
93
71
|
}
|
|
94
72
|
}
|
|
95
73
|
/** Scan complete source records, independent of the finite progress/transcript tail. */
|
|
@@ -118,7 +96,9 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
118
96
|
observeFailures(path, [{ id: failureIdentity("log-rewritten", scan.head, scan.offset), operation: "child-log", kind: "incomplete", summary: "Child log was truncated or rewritten; observations may be incomplete" }]);
|
|
119
97
|
scan = undefined;
|
|
120
98
|
}
|
|
121
|
-
|
|
99
|
+
// Structured intent and dispositions are honoured only for runs launched on the trusted task
|
|
100
|
+
// runtime, whose bash and failure_disposition tools are the guarded ones (parent-written metadata).
|
|
101
|
+
if (!scan) scan = { offset: 0, head, identity, model: newIncidentModel(readMeta(id)?.taskRuntime === true) };
|
|
122
102
|
scan.head = head;
|
|
123
103
|
let position = scan.offset;
|
|
124
104
|
let start = position;
|
|
@@ -177,8 +157,17 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
177
157
|
} finally { closeSync(fd); }
|
|
178
158
|
return readRunFailures(id);
|
|
179
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* The one presentation of a run's reduced incident state for the navigator. Running: prioritized
|
|
162
|
+
* active rows. Terminal: actionable and incomplete rows, with unclassified agent tool failures
|
|
163
|
+
* counted rather than presented as the run's outcome (#315).
|
|
164
|
+
*/
|
|
165
|
+
export function runFailureFacts(state: FailureState, terminal: boolean): string {
|
|
166
|
+
if (!terminal) return formatFailureSummary(state);
|
|
167
|
+
return formatTerminalFailureFacts(state, activeFailures(state).map((x) => x.id));
|
|
168
|
+
}
|
|
180
169
|
export function failureSummary(id: string, cwd: string, terminal = false): string {
|
|
181
|
-
return
|
|
170
|
+
return runFailureFacts(collectRunFailures(id, cwd, terminal), terminal);
|
|
182
171
|
}
|
|
183
172
|
export function prependFailureSummary(body: string, summary: string): string {
|
|
184
173
|
if (!summary) return body;
|
package/finalization.ts
CHANGED
|
@@ -10,11 +10,10 @@ import { collectRunFailures, failurePath } from "./failures.ts";
|
|
|
10
10
|
import { observeFailures } from "./shared-failure-observations.ts";
|
|
11
11
|
import {
|
|
12
12
|
classifyChildExit,
|
|
13
|
-
formatSubagentResult,
|
|
14
|
-
resolveLifecycle,
|
|
15
13
|
type ChildExitOutcome,
|
|
16
14
|
} from "./lifecycle.ts";
|
|
17
|
-
import {
|
|
15
|
+
import { assembleSubagentResult } from "./output-payload.ts";
|
|
16
|
+
import { parseRunForLifecycle } from "./parse.ts";
|
|
18
17
|
import {
|
|
19
18
|
canExitFinalize,
|
|
20
19
|
effectiveStatus,
|
|
@@ -89,7 +88,8 @@ export function finalizeRun(
|
|
|
89
88
|
const verdict = outcome.verdict;
|
|
90
89
|
const el = fmtElapsed(meta.endedAt - meta.startedAt);
|
|
91
90
|
const spend = fmtSpend(r.usage);
|
|
92
|
-
const
|
|
91
|
+
const humanStat = `${el}${spend ? ` · ${spend}` : ""}`;
|
|
92
|
+
const stat = el;
|
|
93
93
|
|
|
94
94
|
// A finished run is no longer in the widget; redraw (and stop the ticker if
|
|
95
95
|
// it was the last one).
|
|
@@ -98,7 +98,7 @@ export function finalizeRun(
|
|
|
98
98
|
// Best-effort human toast. ctx may be stale by now; never let it throw.
|
|
99
99
|
try {
|
|
100
100
|
hooks.notify?.(
|
|
101
|
-
`Subagent ${label} ${verdict} · ${
|
|
101
|
+
`Subagent ${label} ${verdict} · ${humanStat}`,
|
|
102
102
|
meta.status === "completed" ? "info" : "warning",
|
|
103
103
|
);
|
|
104
104
|
} catch {
|
|
@@ -138,28 +138,19 @@ export function finalizeRun(
|
|
|
138
138
|
* Returns null when the run is still live so the tool can emit its running message.
|
|
139
139
|
*/
|
|
140
140
|
export function buildSubagentResultText(id: string): string | null {
|
|
141
|
+
return buildSubagentResultPayload(id);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Optional paging/raw evidence for the registered result tool. */
|
|
145
|
+
export function buildSubagentResultPayload(
|
|
146
|
+
id: string,
|
|
147
|
+
request?: { cursor?: string; maxBytes?: unknown; mode?: unknown; all?: unknown },
|
|
148
|
+
healthLine = "",
|
|
149
|
+
scopeKey = `parent:${process.pid}`,
|
|
150
|
+
): string | null {
|
|
141
151
|
const meta = readMeta(id);
|
|
142
152
|
if (!meta) throw new Error(`Unknown run id: ${id}`);
|
|
143
153
|
const st = effectiveStatus(meta);
|
|
144
|
-
// Non-final statuses (running / orphaned) have no final result body.
|
|
145
154
|
if (!isFinalResultStatus(st)) return null;
|
|
146
|
-
|
|
147
|
-
const exit = meta.exitCode === undefined ? "?" : String(meta.exitCode);
|
|
148
|
-
// Re-derive lifecycle diagnostics from the complete stream even when meta is stale.
|
|
149
|
-
const r = parseRunForLifecycle(id);
|
|
150
|
-
const el = fmtElapsed((meta.endedAt ?? Date.now()) - meta.startedAt);
|
|
151
|
-
const spend = fmtSpend(r.usage);
|
|
152
|
-
const statSeg = ` · ${el}${spend ? ` · ${spend}` : ""}`;
|
|
153
|
-
const tools = r.toolCalls.length ? ` · tools: ${r.toolCalls.join(", ")}` : "";
|
|
154
|
-
const lifecycle = resolveLifecycle(meta, r);
|
|
155
|
-
return formatSubagentResult({
|
|
156
|
-
id,
|
|
157
|
-
status: st,
|
|
158
|
-
exitCode: exit,
|
|
159
|
-
statSeg,
|
|
160
|
-
toolsSeg: tools,
|
|
161
|
-
run: r,
|
|
162
|
-
rawLogTail: tailLog(id, 40),
|
|
163
|
-
lifecycle,
|
|
164
|
-
});
|
|
155
|
+
return assembleSubagentResult(id, meta, request ?? {}, healthLine, scopeKey);
|
|
165
156
|
}
|