pi-better-subagents 0.5.1 → 0.7.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 +13 -1
- package/agent-commands.ts +26 -7
- package/agent-inspection.ts +4 -1
- package/batch.mjs +7 -0
- package/callback-fields.ts +4 -1
- package/catalog-ui.ts +167 -0
- package/child-incidents.ts +20 -6
- package/child-steer.ts +102 -0
- package/cleanup.ts +18 -2
- package/config.json +1 -0
- package/config.ts +5 -1
- package/delegation.ts +22 -0
- package/docs/agent-catalog-operations.md +2 -0
- package/docs/agent-catalog.md +9 -0
- package/docs/failure-observations.md +18 -5
- package/failures.ts +83 -8
- package/incident-model.ts +73 -55
- package/index.ts +352 -25
- package/list.mjs +4 -1
- package/output-payload.ts +152 -58
- package/package.json +4 -4
- package/parse.ts +6 -0
- package/permission-policy.ts +15 -7
- package/registry.ts +79 -1
- package/roles/role.architect.md +3 -3
- package/roles/role.developer.md +3 -3
- package/roles/role.explorer.md +3 -4
- package/roles/role.product-manager.md +3 -4
- package/roles/role.researcher.md +3 -4
- package/roles/role.reviewer.md +3 -3
- package/shared-callback-batcher.ts +34 -3
- package/shared-failure-observations.ts +414 -47
- package/shared-log-utils.ts +108 -0
- package/shared-navigator.ts +180 -134
- package/shared-sandbox-core.ts +724 -17
- package/shared-task-apply-patch.ts +409 -0
- package/shared-task-files.ts +69 -7
- package/shared-task-sandbox.ts +21 -5
- package/shared-task-tools.ts +111 -0
- package/spawn.ts +3 -1
- package/subagent-tools.ts +95 -0
- package/task-guard.ts +42 -7
- package/task-policy.ts +45 -5
- package/timing.ts +425 -0
- package/tools.ts +52 -14
|
@@ -14,7 +14,7 @@ Each run retains an append-only `failures.jsonl` journal. Nothing is deleted fro
|
|
|
14
14
|
| `Observation incomplete` | Missing, corrupt, truncated, or unreadable evidence. |
|
|
15
15
|
| `Recovered` / `Superseded` | Closed. Counted in history, never shown as active. |
|
|
16
16
|
|
|
17
|
-
A completed run reports lifecycle and incidents as separate facts,
|
|
17
|
+
A completed run reports lifecycle and incidents as separate facts. When a page is small, incident rows are dropped before notes, and the correctness note is kept whole whenever it fits. For example:
|
|
18
18
|
|
|
19
19
|
```text
|
|
20
20
|
status=completed
|
|
@@ -22,11 +22,22 @@ status=completed
|
|
|
22
22
|
Work correctness was not inferred from lifecycle alone.
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
## What the parent sees by default
|
|
26
|
+
|
|
27
|
+
Only incidents that need action are listed: `Action required` and `Observation incomplete`. Unclassified and expected failures are counted as history, never listed or paged by default. A completed run with nothing to act on says so in one line, with no incident cursor:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
No failures need action · 8 unclassified tool errors · 2 expected (history)
|
|
31
|
+
Work correctness was not inferred from lifecycle alone.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Pass `history: true` to `subagent_result` or `subagent_output` to page every incident, including recovered and superseded ones. Rows are compact: a tool's result JSON is shown as its text, the excerpt is capped at 120 bytes, and evidence reads `output.log#byte=N`. `mode: "raw"` keeps the whole excerpt and the full log path.
|
|
35
|
+
|
|
25
36
|
## When the parent is notified
|
|
26
37
|
|
|
27
38
|
- **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
39
|
- **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.
|
|
40
|
+
- **Orphaned and lost** health callbacks carry the same facts once. After an orphaned or lost run's health callback, observation gaps found later are delivered promptly, once, since no further callback may follow.
|
|
30
41
|
|
|
31
42
|
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
43
|
|
|
@@ -34,11 +45,11 @@ Every notification renders only its pending incidents. Delivery receipts are ind
|
|
|
34
45
|
|
|
35
46
|
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
47
|
|
|
37
|
-
Sandboxed children (the task runtime) get two structured additions. The parent honours them only for runs
|
|
48
|
+
Sandboxed children (the task runtime) get two structured additions. The parent honours them only for runs it launched on the task runtime: `meta.taskRuntime` and a parent-authored provenance record written before the child starts, outside the run directory, where the confined child cannot write. For any other run, intent-looking arguments are ordinary arguments and `failure_disposition` calls are ignored. If the metadata cannot be read for a moment, the parent waits and reads it again rather than falling back to the exact rule for the rest of the run. The wait is bounded: when the run ends, or after 30 seconds, the log is scanned under the exact-retry rule so real failures stay visible, with an `Observation incomplete` note that the metadata could not be read.
|
|
38
49
|
|
|
39
50
|
- `bash` accepts optional `operationId`, `attemptId`, and `expectedExitCodes`, validated before the command runs.
|
|
40
51
|
- `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.
|
|
52
|
+
- `expectedExitCodes` (distinct integers 1–255; a `0` is ignored, since exit 0 is already success) 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
53
|
- `failure_disposition({ disposition, targets, reason, evidence? })` records an explicit classification of the child's own incidents:
|
|
43
54
|
- `recovered`: the same operation later passed. Evidence must be a successful attempt of that operation that started after the failure.
|
|
44
55
|
- `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.
|
|
@@ -47,9 +58,11 @@ Sandboxed children (the task runtime) get two structured additions. The parent h
|
|
|
47
58
|
|
|
48
59
|
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
60
|
|
|
61
|
+
Reuse is checked against the child's whole session record, every branch, which is also what the parent scans (the whole log). A reuse the parent sees is always one the child refused, so a real failed run is never filed as a rejected intent.
|
|
62
|
+
|
|
50
63
|
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
64
|
|
|
52
|
-
Unconfined children keep the exact-retry rule and
|
|
65
|
+
Unconfined children keep the exact-retry rule, and this is intentional: an unconfined child can rewrite its own log and metadata, so the parent cannot trust intent or dispositions it reports. Background tasks, which the parent agent launches itself, accept `operation_id` and `expected_exit_codes` directly (see the background-tasks README).
|
|
53
66
|
|
|
54
67
|
## Evidence health
|
|
55
68
|
|
package/failures.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { logPathFor,
|
|
4
|
-
import { activeFailures, disposeIncidents, failureIdentity, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
|
|
3
|
+
import { logPathFor, runDir, taskRuntimeTrust } from "./registry.ts";
|
|
4
|
+
import { activeFailures, actionableFailures, disposeIncidents, failureIdentity, findIncident, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
|
|
5
5
|
observeFailures, pendingFailureAttention, readFailureState, type FailureState } from "./shared-failure-observations.ts";
|
|
6
6
|
import { evidenceText, foldToolEnd, foldToolStart, newIncidentModel, toolOperation, type IncidentModel, type IncidentSink } from "./incident-model.ts";
|
|
7
7
|
|
|
8
8
|
export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered, toolOperation };
|
|
9
9
|
export const failurePath = (id: string) => join(runDir(id), "failures.jsonl");
|
|
10
10
|
export const readRunFailures = (id: string): FailureState => readFailureState(failurePath(id));
|
|
11
|
-
interface Scan {
|
|
11
|
+
interface Scan {
|
|
12
|
+
offset: number; head: string; identity: string; model: IncidentModel; retry?: number;
|
|
13
|
+
/**
|
|
14
|
+
* A "run metadata could not be read" gap this trusted rescan inherited, with the tool incidents
|
|
15
|
+
* that were unresolved when it began (the earlier exact-rule scan's). Re-checked after every scan.
|
|
16
|
+
*/
|
|
17
|
+
gap?: { id: string; before: Set<string> };
|
|
18
|
+
}
|
|
12
19
|
const scans = new Map<string, Scan>();
|
|
20
|
+
/** When each run's trust first could not be read, for the bounded wait (#325). */
|
|
21
|
+
const trustUnknownSince = new Map<string, number>();
|
|
22
|
+
/** How long a running scan waits for unreadable trust before scanning under the exact-retry rule. */
|
|
23
|
+
export const TRUST_WAIT_MS = 30_000;
|
|
13
24
|
/** Drop an in-memory scan cursor (e.g. on reload); the journal remains authoritative. */
|
|
14
25
|
export function resetFailureScanCursor(id?: string): void {
|
|
15
|
-
if (id === undefined) scans.clear();
|
|
16
|
-
else scans.delete(id);
|
|
26
|
+
if (id === undefined) { scans.clear(); trustUnknownSince.clear(); }
|
|
27
|
+
else { scans.delete(id); trustUnknownSince.delete(id); }
|
|
17
28
|
}
|
|
18
29
|
const CHUNK = 64 * 1024;
|
|
19
30
|
const MAX_LINE = 2 * 1024 * 1024;
|
|
@@ -70,6 +81,27 @@ function fold(id: string, scan: Scan, row: any, offset: number, cwd: string): vo
|
|
|
70
81
|
++model.sequence;
|
|
71
82
|
}
|
|
72
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Close an inherited metadata gap unless an exact-rule leftover still needs it. A leftover counts only
|
|
86
|
+
* when its attempt declared intent (operationId, attemptId, or expectedExitCodes): its event id is
|
|
87
|
+
* already consumed under the exact rule, so this trusted scan cannot re-key it, and a declared retry
|
|
88
|
+
* or expected exit cannot resolve it. A plain failure reads the same under either rule and never
|
|
89
|
+
* holds the gap. Once the leftover is resolved (an exact retry or a disposition), the gap closes.
|
|
90
|
+
*/
|
|
91
|
+
function settleMetadataGap(id: string, scan: Scan): void {
|
|
92
|
+
const gap = scan.gap!;
|
|
93
|
+
const state = readRunFailures(id);
|
|
94
|
+
const current = state.observations[failureIdentity("run-metadata")];
|
|
95
|
+
if (!current || current.id !== gap.id || current.status !== "unresolved") { scan.gap = undefined; return; }
|
|
96
|
+
const intentBearing = new Set<string>();
|
|
97
|
+
for (const attempt of scan.model.finished.values()) {
|
|
98
|
+
if (attempt.incident && gap.before.has(attempt.incident) && Object.keys(attempt.intent).length) intentBearing.add(attempt.incident);
|
|
99
|
+
}
|
|
100
|
+
const held = [...intentBearing].some((incident) => findIncident(state, incident)?.status === "unresolved");
|
|
101
|
+
if (held) return;
|
|
102
|
+
observeFailures(failurePath(id), [{ id: `run-metadata-readable:${gap.id}`, operation: "run-metadata", kind: "recovered", incidents: [gap.id] }]);
|
|
103
|
+
scan.gap = undefined;
|
|
104
|
+
}
|
|
73
105
|
/** Scan complete source records, independent of the finite progress/transcript tail. */
|
|
74
106
|
export function collectRunFailures(id: string, cwd: string, terminal = false): FailureState {
|
|
75
107
|
const path = failurePath(id);
|
|
@@ -97,8 +129,41 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
97
129
|
scan = undefined;
|
|
98
130
|
}
|
|
99
131
|
// 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
|
|
101
|
-
|
|
132
|
+
// runtime, whose bash and failure_disposition tools are the guarded ones. Trust needs the
|
|
133
|
+
// parent-authored provenance record as well as `meta.taskRuntime` (#325). It is decided once
|
|
134
|
+
// per scan model, and only from a definite answer: while metadata or provenance cannot be read
|
|
135
|
+
// the scan waits (nothing is folded, so a brief read failure pins nothing to the exact rule).
|
|
136
|
+
// The wait is bounded: at a terminal read, or once trust has been unreadable for
|
|
137
|
+
// TRUST_WAIT_MS, the log is scanned under the exact-retry rule (untrusted) so real failures
|
|
138
|
+
// stay visible, and an observation gap says the metadata could not be read.
|
|
139
|
+
if (!scan) {
|
|
140
|
+
const trust = taskRuntimeTrust(id);
|
|
141
|
+
if (trust === "unknown") {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const since = trustUnknownSince.get(id) ?? now;
|
|
144
|
+
trustUnknownSince.set(id, since);
|
|
145
|
+
while (trustUnknownSince.size > 256) trustUnknownSince.delete(trustUnknownSince.keys().next().value!);
|
|
146
|
+
const prior = readRunFailures(id);
|
|
147
|
+
const gap = prior.observations[failureIdentity("run-metadata")];
|
|
148
|
+
const waited = terminal || now - since >= TRUST_WAIT_MS;
|
|
149
|
+
if (waited && !(gap && gap.status === "unresolved")) {
|
|
150
|
+
observeFailures(path, [{ id: failureIdentity("run-metadata-unreadable", gap?.id ?? "initial"), operation: "run-metadata", kind: "incomplete",
|
|
151
|
+
summary: "Run metadata could not be read; tool failures are scanned under the exact-retry rule" }]);
|
|
152
|
+
}
|
|
153
|
+
if (!waited) return prior;
|
|
154
|
+
scan = { offset: 0, head, identity, model: newIncidentModel(false) };
|
|
155
|
+
} else {
|
|
156
|
+
trustUnknownSince.delete(id);
|
|
157
|
+
scan = { offset: 0, head, identity, model: newIncidentModel(trust === "trusted") };
|
|
158
|
+
// The gap is settled after this scan has folded the log (settleMetadataGap), and again after
|
|
159
|
+
// every later scan, once no leftover of the exact-rule scan that trust would have read
|
|
160
|
+
// differently is still unresolved (#332).
|
|
161
|
+
const prior = readRunFailures(id);
|
|
162
|
+
const deferred = prior.observations[failureIdentity("run-metadata")];
|
|
163
|
+
if (deferred && deferred.status === "unresolved") scan.gap = { id: deferred.id,
|
|
164
|
+
before: new Set(Object.values(prior.observations).filter((x) => x.category === "tool" && x.status === "unresolved").map((x) => x.id)) };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
102
167
|
scan.head = head;
|
|
103
168
|
let position = scan.offset;
|
|
104
169
|
let start = position;
|
|
@@ -145,6 +210,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
145
210
|
position += count;
|
|
146
211
|
}
|
|
147
212
|
scan.offset = start;
|
|
213
|
+
if (scan.gap) settleMetadataGap(id, scan);
|
|
148
214
|
scans.delete(id);
|
|
149
215
|
scans.set(id, scan);
|
|
150
216
|
while (scans.size > 64) scans.delete(scans.keys().next().value!);
|
|
@@ -167,7 +233,16 @@ export function runFailureFacts(state: FailureState, terminal: boolean): string
|
|
|
167
233
|
return formatTerminalFailureFacts(state, activeFailures(state).map((x) => x.id));
|
|
168
234
|
}
|
|
169
235
|
export function failureSummary(id: string, cwd: string, terminal = false): string {
|
|
170
|
-
return
|
|
236
|
+
return failureView(id, cwd, terminal).text;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The failure facts plus whether anything needs action. A navigator row leads with failure text only
|
|
240
|
+
* when something does; a quiet "No failures need action · … (history)" line never displaces the
|
|
241
|
+
* row's model, tool, and spend columns (#332).
|
|
242
|
+
*/
|
|
243
|
+
export function failureView(id: string, cwd: string, terminal = false): { text: string; actionable: boolean } {
|
|
244
|
+
const state = collectRunFailures(id, cwd, terminal);
|
|
245
|
+
return { text: runFailureFacts(state, terminal), actionable: actionableFailures(state).length > 0 };
|
|
171
246
|
}
|
|
172
247
|
export function prependFailureSummary(body: string, summary: string): string {
|
|
173
248
|
if (!summary) return body;
|
package/incident-model.ts
CHANGED
|
@@ -8,46 +8,12 @@
|
|
|
8
8
|
* `operationId` or the exact tool/arguments/cwd hash; expected exits require a code
|
|
9
9
|
* declared on the attempt before it ran and a structured exit code from the tool.
|
|
10
10
|
*/
|
|
11
|
-
import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS,
|
|
12
|
-
type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
|
|
11
|
+
import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES,
|
|
12
|
+
REJECTED_INTENT_CATEGORY, readCommandIntent, withoutAbsentIntent, type CommandIntent, type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
|
|
13
13
|
|
|
14
14
|
export const DISPOSITION_TOOL = "failure_disposition";
|
|
15
|
-
|
|
16
|
-
export
|
|
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
|
-
}
|
|
15
|
+
// The intent validator is shared with background tasks through the vendored failure-observations module (#325).
|
|
16
|
+
export { INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent, type CommandIntent };
|
|
51
17
|
|
|
52
18
|
function stable(value: unknown): unknown {
|
|
53
19
|
if (Array.isArray(value)) return value.map(stable);
|
|
@@ -61,6 +27,8 @@ export function toolOperation(name: string, args: unknown, cwd: string): string
|
|
|
61
27
|
/** Declared identity when an operationId is present, otherwise the exact rule. attemptId never affects identity. */
|
|
62
28
|
export function attemptOperation(name: string, args: unknown, cwd: string, intent: CommandIntent): string {
|
|
63
29
|
if (intent.operationId) return failureIdentity("operation", name, intent.operationId, cwd);
|
|
30
|
+
// An explicit-null intent field is an undeclared one: `{expectedExitCodes: null}` and `{}` are the same command.
|
|
31
|
+
args = withoutAbsentIntent(args);
|
|
64
32
|
if (args && typeof args === "object" && !Array.isArray(args) && "attemptId" in args) {
|
|
65
33
|
const { attemptId: _attemptId, ...rest } = args as Record<string, unknown>;
|
|
66
34
|
return toolOperation(name, rest, cwd);
|
|
@@ -73,10 +41,12 @@ export function evidenceText(value: unknown): string | undefined {
|
|
|
73
41
|
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
|
74
42
|
return raw?.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 300);
|
|
75
43
|
}
|
|
44
|
+
/** The text a tool result carries (its first text part, stderr, or error), not its JSON wrapper. */
|
|
45
|
+
function resultText(result: any): string | undefined {
|
|
46
|
+
return evidenceText(result?.content?.find?.((x: any) => x?.type === "text")?.text ?? result?.stderr ?? result?.error ?? result);
|
|
47
|
+
}
|
|
76
48
|
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
|
-
}
|
|
49
|
+
if (result?.isError === true || (typeof result?.exitCode === "number" && result.exitCode !== 0)) return resultText(result);
|
|
80
50
|
return undefined;
|
|
81
51
|
}
|
|
82
52
|
function time(row: any): number | undefined {
|
|
@@ -90,8 +60,13 @@ interface Attempt {
|
|
|
90
60
|
operation: string;
|
|
91
61
|
intent: CommandIntent;
|
|
92
62
|
startSequence: number;
|
|
93
|
-
/**
|
|
94
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Why the confined intent bash is expected to refuse this attempt. A prediction only: the
|
|
65
|
+
* attempt is filed as a rejected intent when its end row carries the child's own refusal.
|
|
66
|
+
*/
|
|
67
|
+
predictedRejection?: string;
|
|
68
|
+
/** Intent names this start registered, undone if the child refused it after all. */
|
|
69
|
+
registered?: { attemptId?: string; operationId?: string };
|
|
95
70
|
}
|
|
96
71
|
interface Finished extends Attempt {
|
|
97
72
|
ok: boolean;
|
|
@@ -227,7 +202,9 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
|
|
|
227
202
|
const seq = ++model.sequence;
|
|
228
203
|
if (!model.structuredIntent) {
|
|
229
204
|
// Unconfined: intent-looking fields are ordinary arguments; exact identity only.
|
|
230
|
-
|
|
205
|
+
// An explicit-null intent-named field is still normalized away, as on the confined path, so
|
|
206
|
+
// `{command, expectedExitCodes: null}` and `{command}` are one command (#332).
|
|
207
|
+
model.open.set(toolCallId, { toolCallId, toolName, operation: toolOperation(toolName, withoutAbsentIntent(row.args), cwd), intent: {}, startSequence: seq });
|
|
231
208
|
return;
|
|
232
209
|
}
|
|
233
210
|
const parsed = toolName === "bash" ? readCommandIntent(row.args) : { intent: {} as CommandIntent };
|
|
@@ -236,13 +213,23 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
|
|
|
236
213
|
const rawAttemptId = toolName === "bash" && typeof row.args?.attemptId === "string" ? row.args.attemptId as string : undefined;
|
|
237
214
|
const reused = rawAttemptId !== undefined && model.namedAttempts.has(rawAttemptId);
|
|
238
215
|
if (rawAttemptId !== undefined) model.namedAttempts.add(rawAttemptId);
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
216
|
+
const predictedRejection = parsed.error ?? (reused ? `attemptId ${intent.attemptId} was already used` : undefined);
|
|
217
|
+
// A predicted refusal falls back to exact identity: if the child ran the command after all, it is
|
|
218
|
+
// an ordinary failure of exactly that command, never a failure of the operation it named.
|
|
219
|
+
const effective = predictedRejection ? {} : intent;
|
|
220
|
+
const operation = attemptOperation(toolName, row.args, cwd, effective);
|
|
221
|
+
const attempt: Attempt = { toolCallId, toolName, operation, intent: effective, startSequence: seq,
|
|
222
|
+
...(predictedRejection ? { predictedRejection } : {}) };
|
|
223
|
+
model.open.set(toolCallId, attempt);
|
|
224
|
+
if (predictedRejection) return;
|
|
225
|
+
if (intent.attemptId) {
|
|
226
|
+
model.attemptIds.set(intent.attemptId, toolCallId);
|
|
227
|
+
attempt.registered = { attemptId: intent.attemptId };
|
|
228
|
+
}
|
|
229
|
+
if (intent.operationId && !model.operationIds.has(intent.operationId)) {
|
|
230
|
+
model.operationIds.set(intent.operationId, operation);
|
|
231
|
+
attempt.registered = { ...attempt.registered, operationId: intent.operationId };
|
|
232
|
+
}
|
|
246
233
|
if (toolName === DISPOSITION_TOOL) {
|
|
247
234
|
const parsed = readDispositionRequest(row.args);
|
|
248
235
|
model.pendingDispositions.set(toolCallId, parsed.request
|
|
@@ -251,6 +238,33 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
|
|
|
251
238
|
}
|
|
252
239
|
}
|
|
253
240
|
|
|
241
|
+
/** The confined intent bash's own pre-run refusal (child-incidents.ts), matched as the whole result text. */
|
|
242
|
+
const INTENT_REFUSAL = /^Invalid command intent: ([\s\S]+)\. The command was not run\.$/;
|
|
243
|
+
/**
|
|
244
|
+
* Pi's pre-execution schema rejection of the bash call (pi-ai `validateToolArguments`), matched as its
|
|
245
|
+
* whole shape: the header, one or more ` - path: message` lines, and the received arguments as
|
|
246
|
+
* pretty-printed JSON ending the text, so a command that ran (and ends "Command exited with code N")
|
|
247
|
+
* can never match. Nothing ran.
|
|
248
|
+
*/
|
|
249
|
+
const SCHEMA_REFUSAL = /^Validation failed for tool "bash":\n(?: - [^\n]*\n)+\nReceived arguments:\n(?:\{\}|\{(?:\n {2,}[^\n]*)*\n\})$/;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Why the child refused this bash call before running it, read from the end row the child
|
|
253
|
+
* produced, never re-derived from the arguments. A command that ran and failed returns undefined.
|
|
254
|
+
*/
|
|
255
|
+
function preRunRefusal(model: IncidentModel, attempt: Attempt | undefined, toolName: string, row: any): string | undefined {
|
|
256
|
+
if (!model.structuredIntent || toolName !== "bash" || row.isError !== true) return undefined;
|
|
257
|
+
const content = row.result?.content;
|
|
258
|
+
const text = Array.isArray(content) && content.length === 1 && typeof content[0]?.text === "string" ? content[0].text.trim() : undefined;
|
|
259
|
+
if (text === undefined) return undefined;
|
|
260
|
+
const refused = INTENT_REFUSAL.exec(text);
|
|
261
|
+
if (refused) return refused[1];
|
|
262
|
+
// Pi validates arguments against the intent schema before execute; that refusal is ours only
|
|
263
|
+
// when the intent fields themselves are what the shared validator rejects.
|
|
264
|
+
if (attempt?.predictedRejection && !attempt.predictedRejection.includes("already used") && SCHEMA_REFUSAL.test(text)) return attempt.predictedRejection;
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
254
268
|
/** Fold one tool execution end: failures, declared expected exits, exact retry recovery, dispositions. */
|
|
255
269
|
export function foldToolEnd(model: IncidentModel, row: any, cwd: string, evidence: string, sink: IncidentSink): void {
|
|
256
270
|
const toolCallId = String(row.toolCallId);
|
|
@@ -259,19 +273,23 @@ export function foldToolEnd(model: IncidentModel, row: any, cwd: string, evidenc
|
|
|
259
273
|
const attempt = model.open.get(toolCallId);
|
|
260
274
|
if (attempt) model.open.delete(toolCallId);
|
|
261
275
|
const intent = attempt?.intent ?? {};
|
|
262
|
-
const operation = attempt?.operation ?? toolOperation(toolName, row.args, cwd);
|
|
276
|
+
const operation = attempt?.operation ?? toolOperation(toolName, withoutAbsentIntent(row.args), cwd);
|
|
263
277
|
const details = row.result?.details;
|
|
264
278
|
const declaredExit = row.isError === false && details?.expectedExit === true && typeof details.exitCode === "number" &&
|
|
265
279
|
(intent.expectedExitCodes ?? []).includes(details.exitCode) ? details.exitCode as number : undefined;
|
|
266
|
-
const error = row.isError === true ?
|
|
280
|
+
const error = row.isError === true ? resultText(row.result) ?? "Tool returned an error" : resultError(row.result);
|
|
267
281
|
const finished: Finished = { ...(attempt ?? { toolCallId, toolName, operation, intent, startSequence: seq }),
|
|
268
282
|
ok: !error && declaredExit === undefined, endSequence: seq, evidence };
|
|
269
283
|
model.finished.set(toolCallId, finished);
|
|
270
|
-
|
|
284
|
+
const refusal = preRunRefusal(model, attempt, toolName, row);
|
|
285
|
+
if (refusal !== undefined) {
|
|
271
286
|
// Nothing ran: visible, but never grouped with (or escalating) the operation it named.
|
|
287
|
+
if (attempt?.registered?.attemptId && model.attemptIds.get(attempt.registered.attemptId) === toolCallId) model.attemptIds.delete(attempt.registered.attemptId);
|
|
288
|
+
if (attempt?.registered?.operationId) model.operationIds.delete(attempt.registered.operationId);
|
|
289
|
+
finished.intent = {};
|
|
272
290
|
const failureId = `tool:${toolCallId}`;
|
|
273
291
|
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 (${
|
|
292
|
+
category: REJECTED_INTENT_CATEGORY, evidence, summary: `${toolName} not run: invalid command intent (${refusal})` }]);
|
|
275
293
|
finished.incident = failureId;
|
|
276
294
|
return;
|
|
277
295
|
}
|