pi-better-subagents 0.2.0 → 0.4.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 +2 -2
- package/docs/failure-observations.md +11 -0
- package/failures.ts +188 -0
- package/finalization.ts +14 -0
- package/index.ts +130 -40
- package/list.mjs +4 -1
- package/package.json +7 -6
- package/permission-policy.ts +104 -0
- package/registry.ts +17 -3
- package/sandbox.ts +26 -5
- package/shared-callback-batcher.ts +42 -16
- package/shared-failure-observations.ts +205 -0
- package/shared-sandbox-core.ts +309 -14
- package/shared-task-files.ts +246 -0
- package/shared-task-sandbox.ts +189 -0
- package/task-guard.ts +47 -0
- package/task-policy.ts +97 -0
- package/task-runtime.mjs +39 -0
- package/tools.ts +9 -4
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
|
|
17
|
+
- Durable logs, result retrieval, and [failure observations](docs/failure-observations.md) independent of lifecycle status.
|
|
18
18
|
- Live background-work navigator for active runs.
|
|
19
19
|
|
|
20
20
|
## Install
|
|
@@ -29,7 +29,7 @@ Try it for one run:
|
|
|
29
29
|
pi -e npm:pi-better-subagents
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
Linux
|
|
32
|
+
Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or newer. `/sandbox` controls the independent Subagents profile, which each launch freezes. Pi handles its own startup, authentication, and provider connection; task tools obey the selected file, command, and network permissions. Outside project defaults to Read. Currently confined children admit `read`, `write`, `edit`, and `bash`; unsupported requested tools are reported as unavailable. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox) for the runtime boundary and supported configurations.
|
|
33
33
|
|
|
34
34
|
## When To Use
|
|
35
35
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Failure observations
|
|
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.
|
|
4
|
+
|
|
5
|
+
Each run retains a `failures.jsonl` journal. A later successful tool attempt clears an incident only when tool name, arguments, and working directory match and the retry began after the failure. Parallel or unrelated successes cannot clear it. Expected failures require explicit `expected: true` event/result metadata; prose such as “this test should fail” is not interpreted as metadata.
|
|
6
|
+
|
|
7
|
+
Running failures become eligible for attention after 60 seconds. Observation gaps are eligible immediately, and terminal runs use the existing completion callback. Delivery receipts are independent of recovery and are written after handoff; `callback:false` suppresses notifications without hiding inspection evidence.
|
|
8
|
+
|
|
9
|
+
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
|
+
|
|
11
|
+
A `.observed` companion marker makes missing journals detectable after restart. Failed writes retain evidence and receipts in memory and retry on subsequent reads, with an observation-incomplete warning until storage recovers. This pending memory cannot survive process loss while persistence is unavailable. Journals and markers follow existing run retention and explicit cleanup.
|
package/failures.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { logPathFor, runDir } from "./registry.ts";
|
|
4
|
+
import { failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
|
|
5
|
+
observeFailures, pendingFailureAttention, readFailureState, type FailureState } from "./shared-failure-observations.ts";
|
|
6
|
+
|
|
7
|
+
export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered };
|
|
8
|
+
export const failurePath = (id: string) => join(runDir(id), "failures.jsonl");
|
|
9
|
+
export const readRunFailures = (id: string): FailureState => readFailureState(failurePath(id));
|
|
10
|
+
interface Attempt { operation: string; sequence: number }
|
|
11
|
+
interface Scan { offset: number; head: string; identity: string; attempts: Map<string, Attempt>; failed: Map<string, { id: string; sequence: number }>; sequence: number; retry?: number }
|
|
12
|
+
const scans = new Map<string, Scan>();
|
|
13
|
+
/** Drop an in-memory scan cursor (e.g. on reload); the journal remains authoritative. */
|
|
14
|
+
export function resetFailureScanCursor(id?: string): void {
|
|
15
|
+
if (id === undefined) scans.clear();
|
|
16
|
+
else scans.delete(id);
|
|
17
|
+
}
|
|
18
|
+
const CHUNK = 64 * 1024;
|
|
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
|
+
function time(event: any): number | undefined {
|
|
40
|
+
return typeof event.at === "number" && Number.isFinite(event.at) ? event.at :
|
|
41
|
+
typeof event.message?.timestamp === "number" && Number.isFinite(event.message.timestamp) ? event.message.timestamp : undefined;
|
|
42
|
+
}
|
|
43
|
+
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
|
+
const path = failurePath(id);
|
|
47
|
+
if (row.type === "tool_execution_start") {
|
|
48
|
+
scan.attempts.set(eventId, { operation: toolOperation(String(row.toolName ?? "unknown"), row.args, cwd), sequence: seq });
|
|
49
|
+
} else if (row.type === "tool_execution_end") {
|
|
50
|
+
const attempt = scan.attempts.get(eventId);
|
|
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
|
+
}
|
|
67
|
+
} else if (row.type === "message_end" && row.message?.role === "assistant" &&
|
|
68
|
+
(row.message.stopReason === "error" || typeof row.message.errorMessage === "string")) {
|
|
69
|
+
const message = evidence(row.message.errorMessage) ?? "Model response failed";
|
|
70
|
+
const failureId = `model:${offset}`;
|
|
71
|
+
const state = observeFailures(path, [{ id: failureId, operation: "model-call", kind: "failure", at: time(row), category: "model", summary: message }]);
|
|
72
|
+
scan.failed.set("model-call", { id: state.observations[failureIdentity("model-call")]?.id ?? failureId, sequence: seq });
|
|
73
|
+
} else if (row.type === "auto_retry_start") {
|
|
74
|
+
if (typeof row.errorMessage === "string") {
|
|
75
|
+
const failureId = `model-retry:${offset}`;
|
|
76
|
+
const state = observeFailures(path, [{ id: failureId, operation: "model-call", kind: "failure", at: time(row), category: "model", summary: row.errorMessage }]);
|
|
77
|
+
scan.failed.set("model-call", { id: state.observations[failureIdentity("model-call")]?.id ?? failureId, sequence: seq - 1 });
|
|
78
|
+
}
|
|
79
|
+
scan.retry = seq;
|
|
80
|
+
} else if (row.type === "auto_retry_end" && row.success === false) {
|
|
81
|
+
const failureId = `model-exhausted:${offset}`;
|
|
82
|
+
const state = observeFailures(path, [{ id: failureId, operation: "model-call", kind: "failure", at: time(row), category: "model",
|
|
83
|
+
summary: evidence(row.finalError) ?? "Model retry exhausted" }]);
|
|
84
|
+
scan.failed.set("model-call", { id: state.observations[failureIdentity("model-call")]?.id ?? failureId, sequence: seq });
|
|
85
|
+
scan.retry = undefined;
|
|
86
|
+
} else if (row.type === "auto_retry_end" && row.success === true && scan.retry !== undefined) {
|
|
87
|
+
const failed = scan.failed.get("model-call");
|
|
88
|
+
if (failed && scan.retry > failed.sequence) {
|
|
89
|
+
observeFailures(path, [{ id: `model-recovered:${offset}`, operation: "model-call", kind: "recovered", at: time(row), incidents: [failed.id] }]);
|
|
90
|
+
scan.failed.delete("model-call");
|
|
91
|
+
}
|
|
92
|
+
scan.retry = undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Scan complete source records, independent of the finite progress/transcript tail. */
|
|
96
|
+
export function collectRunFailures(id: string, cwd: string, terminal = false): FailureState {
|
|
97
|
+
const path = failurePath(id);
|
|
98
|
+
let fd: number;
|
|
99
|
+
try { fd = openSync(logPathFor(id), "r"); }
|
|
100
|
+
catch (error) {
|
|
101
|
+
const prior = readRunFailures(id);
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && !terminal && !scans.has(id) && prior.seen.length === 0) return prior;
|
|
103
|
+
const access = prior.observations[failureIdentity("child-log-access")];
|
|
104
|
+
if (access && access.status !== "resolved") return prior;
|
|
105
|
+
return observeFailures(path, [{ id: failureIdentity("source-unreadable", access?.id ?? "initial"), operation: "child-log-access", kind: "incomplete",
|
|
106
|
+
summary: "Child log is unavailable; observations may be incomplete" }]);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const stat = fstatSync(fd);
|
|
110
|
+
const size = stat.size;
|
|
111
|
+
const identity = `${stat.dev}:${stat.ino}`;
|
|
112
|
+
const prefix = Buffer.alloc(Math.min(256, size));
|
|
113
|
+
readSync(fd, prefix, 0, prefix.length, 0);
|
|
114
|
+
const head = prefix.toString("latin1");
|
|
115
|
+
let scan = scans.get(id);
|
|
116
|
+
const shared = scan ? Math.min(head.length, scan.head.length) : 0;
|
|
117
|
+
if (scan && (identity !== scan.identity || size < scan.offset || head.slice(0, shared) !== scan.head.slice(0, shared))) {
|
|
118
|
+
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
|
+
scan = undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!scan) scan = { offset: 0, head, identity, attempts: new Map(), failed: new Map(), sequence: 0 };
|
|
122
|
+
scan.head = head;
|
|
123
|
+
let position = scan.offset;
|
|
124
|
+
let start = position;
|
|
125
|
+
let fragments: Buffer[] = [];
|
|
126
|
+
let length = 0;
|
|
127
|
+
let oversized = false;
|
|
128
|
+
const buffer = Buffer.alloc(CHUNK);
|
|
129
|
+
while (position < size) {
|
|
130
|
+
const count = readSync(fd, buffer, 0, Math.min(CHUNK, size - position), position);
|
|
131
|
+
if (!count) break;
|
|
132
|
+
let from = 0;
|
|
133
|
+
for (let i = 0; i < count; i++) {
|
|
134
|
+
if (buffer[i] !== 10) continue;
|
|
135
|
+
const part = buffer.subarray(from, i);
|
|
136
|
+
length += part.length;
|
|
137
|
+
if (!oversized && length <= MAX_LINE) {
|
|
138
|
+
const line = Buffer.concat([...fragments, part]).toString("utf8").trim();
|
|
139
|
+
if (line.startsWith("{")) {
|
|
140
|
+
try {
|
|
141
|
+
const row = JSON.parse(line);
|
|
142
|
+
if (["tool_execution_start", "tool_execution_end", "message_end", "auto_retry_start", "auto_retry_end"].includes(row?.type)) {
|
|
143
|
+
if ((row.type.startsWith("tool_execution_") &&
|
|
144
|
+
(typeof row.toolCallId !== "string" || !row.toolCallId || typeof row.toolName !== "string")) ||
|
|
145
|
+
(row.type === "tool_execution_end" && typeof row.isError !== "boolean" && typeof row.result?.isError !== "boolean" && typeof row.result?.exitCode !== "number") ||
|
|
146
|
+
(row.type === "message_end" && (!row.message || typeof row.message.role !== "string")) ||
|
|
147
|
+
(row.type === "auto_retry_end" && typeof row.success !== "boolean")) throw new Error("invalid structured event");
|
|
148
|
+
fold(id, scan, row, start, cwd);
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
observeFailures(path, [{ id: `malformed:${start}`, operation: "child-log", kind: "incomplete", summary: "Child log contains malformed structured events; observations may be incomplete" }]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
observeFailures(path, [{ id: `oversized:${start}`, operation: "child-log", kind: "incomplete", summary: "Child log contains an oversized event; observations may be incomplete" }]);
|
|
156
|
+
}
|
|
157
|
+
start = position + i + 1;
|
|
158
|
+
from = i + 1;
|
|
159
|
+
fragments = []; length = 0; oversized = false;
|
|
160
|
+
}
|
|
161
|
+
const rest = buffer.subarray(from, count);
|
|
162
|
+
length += rest.length;
|
|
163
|
+
if (length > MAX_LINE) oversized = true;
|
|
164
|
+
if (!oversized && rest.length) fragments.push(Buffer.from(rest));
|
|
165
|
+
position += count;
|
|
166
|
+
}
|
|
167
|
+
scan.offset = start;
|
|
168
|
+
scans.delete(id);
|
|
169
|
+
scans.set(id, scan);
|
|
170
|
+
while (scans.size > 64) scans.delete(scans.keys().next().value!);
|
|
171
|
+
const access = readRunFailures(id).observations[failureIdentity("child-log-access")];
|
|
172
|
+
if (access && access.status !== "resolved") observeFailures(path, [{ id: `source-readable:${access.id}:${identity}:${size}`,
|
|
173
|
+
operation: "child-log-access", kind: "recovered", incidents: [access.id] }]);
|
|
174
|
+
if (terminal && position > start) observeFailures(path, [{ id: `partial:${start}`, operation: "child-log", kind: "incomplete", summary: "Child log ends with a partial event; observations may be incomplete" }]);
|
|
175
|
+
} catch {
|
|
176
|
+
observeFailures(path, [{ id: "log-read-error", operation: "child-log", kind: "incomplete", summary: "Child log could not be fully scanned; observations may be incomplete" }]);
|
|
177
|
+
} finally { closeSync(fd); }
|
|
178
|
+
return readRunFailures(id);
|
|
179
|
+
}
|
|
180
|
+
export function failureSummary(id: string, cwd: string, terminal = false): string {
|
|
181
|
+
return formatFailureSummary(collectRunFailures(id, cwd, terminal));
|
|
182
|
+
}
|
|
183
|
+
export function prependFailureSummary(body: string, summary: string): string {
|
|
184
|
+
if (!summary) return body;
|
|
185
|
+
// Keep the established status header first; failure evidence still precedes assistant progress.
|
|
186
|
+
const header = /^(\[[^\n]+\])\n/.exec(body);
|
|
187
|
+
return header ? `${header[1]}\n${summary}\n${body.slice(header[0].length)}` : `${summary}\n${body}`;
|
|
188
|
+
}
|
package/finalization.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { buildCompletionDelivery } from "./completion.ts";
|
|
9
|
+
import { collectRunFailures, failurePath } from "./failures.ts";
|
|
10
|
+
import { observeFailures } from "./shared-failure-observations.ts";
|
|
9
11
|
import {
|
|
10
12
|
classifyChildExit,
|
|
11
13
|
formatSubagentResult,
|
|
@@ -57,6 +59,18 @@ export function finalizeRun(
|
|
|
57
59
|
// Lifecycle authority streams the complete NDJSON log; result text stays bounded.
|
|
58
60
|
const r = parseRunForLifecycle(id);
|
|
59
61
|
const outcome = classifyChildExit(code, r);
|
|
62
|
+
collectRunFailures(id, meta.cwd, true);
|
|
63
|
+
if (!outcome.incomplete && code !== null) {
|
|
64
|
+
observeFailures(failurePath(id), ["orphaned", "lost"].map((status) => ({
|
|
65
|
+
id: `supervision:${status}:exit-recovered`, operation: `supervision:${status}`,
|
|
66
|
+
kind: "recovered" as const, incidents: [`supervision:${status}`],
|
|
67
|
+
})));
|
|
68
|
+
}
|
|
69
|
+
if (code !== 0 || outcome.incomplete) {
|
|
70
|
+
observeFailures(failurePath(id), [{ id: `exit:${code ?? "unknown"}:${outcome.classification}`,
|
|
71
|
+
operation: "child-exit", kind: outcome.incomplete ? "incomplete" : "failure",
|
|
72
|
+
category: "exit", summary: outcome.incomplete ? "Child exit evidence is incomplete" : `Child exited with code ${code}` }]);
|
|
73
|
+
}
|
|
60
74
|
meta.status = outcome.status;
|
|
61
75
|
meta.lifecycleClassification = outcome.classification;
|
|
62
76
|
if (outcome.incomplete) meta.failureReason = "incomplete-stream";
|