pi-better-subagents 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/child-incidents.ts +8 -6
- package/docs/failure-observations.md +3 -1
- package/failures.ts +94 -6
- package/incident-model.ts +18 -11
- package/index.ts +47 -18
- package/output-payload.ts +4 -13
- package/package.json +1 -1
- package/permission-policy.ts +9 -3
- package/registry.ts +19 -2
- package/shared-failure-observations.ts +70 -21
- package/shared-log-utils.ts +6 -12
- package/shared-navigator.ts +224 -125
- package/shared-sandbox-core.ts +194 -22
- package/shared-task-apply-patch.ts +409 -0
- package/shared-task-files.ts +69 -7
- package/shared-task-sandbox.ts +12 -0
- package/shared-task-tools.ts +111 -0
- package/subagent-tools.ts +95 -0
- package/task-guard.ts +42 -7
- package/task-policy.ts +29 -1
|
@@ -75,7 +75,8 @@ export function failureIdentity(...parts: unknown[]): string {
|
|
|
75
75
|
return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
|
|
76
76
|
}
|
|
77
77
|
function text(value: string | undefined, fallback: string): string {
|
|
78
|
-
|
|
78
|
+
// The 400-unit cut can split a surrogate pair; a lone half would render as U+FFFD (#332).
|
|
79
|
+
return dropLoneSurrogates((value || fallback).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400));
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/** Agent tool attempts: the agent handles its own tool errors, so one failure is not yet actionable. */
|
|
@@ -104,24 +105,60 @@ export interface CommandIntent {
|
|
|
104
105
|
export type CommandIntentField = keyof CommandIntent;
|
|
105
106
|
const INTENT_FIELDS: readonly CommandIntentField[] = ["operationId", "attemptId", "expectedExitCodes"];
|
|
106
107
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
108
|
+
* Pi's own argument coercion for the intent fields (pi-ai `validateToolArguments` against the confined
|
|
109
|
+
* bash schema, where each field is `anyOf: [field schema, null]`). Pi applies it before execute, but the
|
|
110
|
+
* process log and session record keep the raw arguments; mirroring it lets the parent's replay read
|
|
111
|
+
* the declaration the child executed with (#332).
|
|
112
|
+
* - ids: a number or boolean becomes its string; `""` fails the pattern and falls through to null.
|
|
113
|
+
* - exit codes: array items that read as integers become integers (null -> 0, true -> 1);
|
|
114
|
+
* a scalar `0`, `""`, or `false` falls through to null.
|
|
115
|
+
*/
|
|
116
|
+
function coerceInteger(value: unknown): unknown {
|
|
117
|
+
if (value === null) return 0;
|
|
118
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isInteger(Number(value))) return Number(value);
|
|
119
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
function normalizeIntentField(key: CommandIntentField, value: unknown): unknown {
|
|
123
|
+
if (value == null) return undefined;
|
|
124
|
+
if (key === "expectedExitCodes") {
|
|
125
|
+
if (value === 0 || value === "" || value === false) return undefined;
|
|
126
|
+
if (!Array.isArray(value)) return value;
|
|
127
|
+
// 0 is a no-op declaration: exit 0 is already success. A list of only zeros declares nothing.
|
|
128
|
+
const codes = value.map(coerceInteger).filter((code) => code !== 0);
|
|
129
|
+
if (codes.length === 0 && value.length > 0) return undefined;
|
|
130
|
+
return codes.length === value.length && codes.every((code, index) => code === value[index]) ? value : codes;
|
|
131
|
+
}
|
|
132
|
+
if (value === "") return undefined;
|
|
133
|
+
return typeof value === "number" || typeof value === "boolean" ? String(value) : value;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The one normalization of intent-bearing arguments. An explicit `null` intent field means "not
|
|
137
|
+
* declared", exactly like an omitted one: models routinely send optional fields as null, and Pi's
|
|
138
|
+
* argument validation drops or nulls them before a tool runs, while the process log and session
|
|
139
|
+
* record keep the raw arguments. Values Pi would coerce are coerced the same way, and `0` is dropped
|
|
140
|
+
* from `expectedExitCodes`. Every reader of intent, the executing tool and the replay alike, and the
|
|
141
|
+
* exact operation identity go through this, so all see the same declaration from the same input.
|
|
142
|
+
* Malformed values are kept as they are for the validator to refuse.
|
|
112
143
|
*/
|
|
113
144
|
export function withoutAbsentIntent<T>(args: T): T {
|
|
114
145
|
if (!args || typeof args !== "object" || Array.isArray(args)) return args;
|
|
115
146
|
const input = args as Record<string, unknown>;
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
147
|
+
let out: Record<string, unknown> | undefined;
|
|
148
|
+
for (const key of INTENT_FIELDS) {
|
|
149
|
+
if (!(key in input)) continue;
|
|
150
|
+
const value = normalizeIntentField(key, input[key]);
|
|
151
|
+
if (value === input[key]) continue;
|
|
152
|
+
out ??= { ...input };
|
|
153
|
+
if (value === undefined) delete out[key];
|
|
154
|
+
else out[key] = value;
|
|
155
|
+
}
|
|
156
|
+
return (out ?? args) as T;
|
|
120
157
|
}
|
|
121
158
|
/**
|
|
122
|
-
* Validate structured intent fields
|
|
159
|
+
* Validate structured intent fields after `withoutAbsentIntent`. Absent fields are fine; malformed
|
|
123
160
|
* ones are an error, and the caller must not run the command. `names` renames fields in the error
|
|
124
|
-
* (e.g. snake_case parameters).
|
|
161
|
+
* (e.g. snake_case parameters). `0` in `expectedExitCodes` is accepted and dropped (#332).
|
|
125
162
|
*/
|
|
126
163
|
export function readCommandIntent(args: unknown, names: Partial<Record<CommandIntentField, string>> = {}): { intent: CommandIntent; error?: string } {
|
|
127
164
|
const input = withoutAbsentIntent((args && typeof args === "object" ? args : {}) as Record<string, unknown>);
|
|
@@ -138,7 +175,7 @@ export function readCommandIntent(args: unknown, names: Partial<Record<CommandIn
|
|
|
138
175
|
if (codes !== undefined) {
|
|
139
176
|
if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
|
|
140
177
|
!codes.every((code) => Number.isInteger(code) && code >= 1 && code <= 255) || new Set(codes).size !== codes.length) {
|
|
141
|
-
return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255` };
|
|
178
|
+
return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255 (0 is allowed and ignored)` };
|
|
142
179
|
}
|
|
143
180
|
intent.expectedExitCodes = [...codes] as number[];
|
|
144
181
|
}
|
|
@@ -481,6 +518,8 @@ export interface TerminalFailureParts {
|
|
|
481
518
|
rows: string[];
|
|
482
519
|
/** Counts and separate facts: earlier-reported, unclassified, expected, closed history, correctness. */
|
|
483
520
|
notes: string[];
|
|
521
|
+
/** The unclassified-count note, when it is among `notes`: a byte budget keeps it longer than the others. */
|
|
522
|
+
unclassifiedNote?: string;
|
|
484
523
|
}
|
|
485
524
|
/**
|
|
486
525
|
* Terminal/completion facts: actionable and incomplete incidents from `incidents` as rows,
|
|
@@ -494,12 +533,14 @@ export function terminalFailureParts(state: FailureState, incidents: readonly st
|
|
|
494
533
|
const earlier = activeFailures(state).filter((x) => !wanted.has(x.id) && reportable(x)).length;
|
|
495
534
|
if (earlier) notes.push(`${earlier} actionable incident${earlier === 1 ? " was" : "s were"} reported earlier; not repeated here.`);
|
|
496
535
|
const counts = failureCounts(state);
|
|
497
|
-
|
|
536
|
+
const unclassifiedNote = counts.unclassified
|
|
537
|
+
? `${counts.unclassified} earlier tool failure${counts.unclassified === 1 ? "" : "s"} remain${counts.unclassified === 1 ? "s" : ""} unclassified.` : undefined;
|
|
538
|
+
if (unclassifiedNote) notes.push(unclassifiedNote);
|
|
498
539
|
if (counts.expected) notes.push(`${counts.expected} expected failure${counts.expected === 1 ? "" : "s"} recorded.`);
|
|
499
540
|
const history = closedHistoryLine(state);
|
|
500
541
|
if (history) notes.push(history);
|
|
501
542
|
if (counts.unclassified || counts.actionRequired) notes.push(CORRECTNESS_NOTE);
|
|
502
|
-
return { rows, notes };
|
|
543
|
+
return { rows, notes, ...(unclassifiedNote ? { unclassifiedNote } : {}) };
|
|
503
544
|
}
|
|
504
545
|
export function formatTerminalFailureFacts(state: FailureState, incidents: readonly string[] = []): string {
|
|
505
546
|
const { rows, notes } = terminalFailureParts(state, incidents);
|
|
@@ -607,7 +648,10 @@ export interface IncidentPageRequest extends IncidentRowOptions {
|
|
|
607
648
|
* Caller-owned incident pages. Whole rows are preferred; a row larger than the
|
|
608
649
|
* page is split at a code-point boundary and resumes at that byte, so pages
|
|
609
650
|
* reconstruct formatFailureLines() exactly (join with "\n" except after a page
|
|
610
|
-
* that `endsPartial`). A page never exceeds `maxBytes
|
|
651
|
+
* that `endsPartial`). A page never exceeds `maxBytes`, so one smaller than the
|
|
652
|
+
* next code point (up to 4 bytes) returns no text, `hasMore`, and a `nextCursor`
|
|
653
|
+
* equal to `cursor`: the caller retries with a larger page, as with
|
|
654
|
+
* pageVerbatimText. Consumers never ask for fewer than 4 bytes.
|
|
611
655
|
*/
|
|
612
656
|
export function pageFailureIncidents(state: FailureState, request: IncidentPageRequest = {}): FailureIncidentPage {
|
|
613
657
|
// A cursor carries its view: following a history cursor stays in history without the flag.
|
|
@@ -725,14 +769,19 @@ export function incidentVerbatimPage(state: FailureState, request: IncidentPageR
|
|
|
725
769
|
* it, so a cache keyed by it never serves stale incident counts.
|
|
726
770
|
*/
|
|
727
771
|
export function failureJournalFingerprint(path: string): string {
|
|
728
|
-
|
|
772
|
+
return `${fileChangeIdentity(path)}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* A file's identity and change stamp (device, inode, size, and nanosecond mtime/ctime), or its read
|
|
776
|
+
* error. Any append, rewrite, replacement, or deletion changes it; for cache keys, never for trust.
|
|
777
|
+
*/
|
|
778
|
+
export function fileChangeIdentity(path: string): string {
|
|
729
779
|
try {
|
|
730
780
|
const stats = statSync(path, { bigint: true });
|
|
731
|
-
|
|
781
|
+
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
|
|
732
782
|
} catch (error) {
|
|
733
|
-
|
|
783
|
+
return `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
|
|
734
784
|
}
|
|
735
|
-
return `${file}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
|
|
736
785
|
}
|
|
737
786
|
|
|
738
787
|
export function incidentCursorAt(state: FailureState, offset: number, resource?: string, options: IncidentRowOptions = {}): string {
|
|
@@ -867,7 +916,7 @@ export function formatTerminalIncidentSummary(state: FailureState, options: Inci
|
|
|
867
916
|
// cursor's retrieval hint, the unclassified count, the cursor, and the count line. The
|
|
868
917
|
// correctness note goes last, and only when it alone does not fit.
|
|
869
918
|
const correctness: string[] = notes.filter((x) => x === CORRECTNESS_NOTE);
|
|
870
|
-
const unclassified = notes.filter((x) => x
|
|
919
|
+
const unclassified = notes.filter((x) => x === parts.unclassifiedNote);
|
|
871
920
|
const low = notes.filter((x) => !correctness.includes(x) && !unclassified.includes(x));
|
|
872
921
|
const ladder: Array<{ mode: Header | undefined; notes: string[] }> = [];
|
|
873
922
|
for (let keep = low.length - 1; keep >= 0; keep -= 1) ladder.push({ mode: "full", notes: [...low.slice(0, keep), ...unclassified, ...correctness] });
|
package/shared-log-utils.ts
CHANGED
|
@@ -819,29 +819,22 @@ export interface OutputControls {
|
|
|
819
819
|
maxBytes?: unknown;
|
|
820
820
|
/** Raw requested line count (canonical `lines`, else deprecated `tail_lines`). */
|
|
821
821
|
lines?: unknown;
|
|
822
|
-
/** Deprecated alias names the caller used (whether or not the canonical name won). */
|
|
823
|
-
deprecated: string[];
|
|
824
822
|
}
|
|
825
823
|
|
|
826
824
|
/**
|
|
827
825
|
* Resolve output-control parameters. Both spellings work; when both are given
|
|
828
|
-
* the canonical name wins.
|
|
829
|
-
*
|
|
826
|
+
* the canonical name wins. An explicit `null` is "not given", so a null
|
|
827
|
+
* canonical value never hides an alias value (#332). Values are returned
|
|
828
|
+
* unvalidated so each surface keeps its own defaults and hard caps.
|
|
830
829
|
*/
|
|
831
830
|
export function readOutputControls(params: unknown): OutputControls {
|
|
832
831
|
const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
|
|
833
|
-
const
|
|
834
|
-
const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => {
|
|
835
|
-
const alias = OUTPUT_CONTROL_ALIASES[canonical];
|
|
836
|
-
if (p[alias] !== undefined) deprecated.push(alias);
|
|
837
|
-
return p[canonical] !== undefined ? p[canonical] : p[alias];
|
|
838
|
-
};
|
|
832
|
+
const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => p[canonical] ?? p[OUTPUT_CONTROL_ALIASES[canonical]] ?? undefined;
|
|
839
833
|
const maxBytes = pick("max_bytes");
|
|
840
834
|
const lines = pick("lines");
|
|
841
835
|
return {
|
|
842
836
|
...(maxBytes !== undefined ? { maxBytes } : {}),
|
|
843
837
|
...(lines !== undefined ? { lines } : {}),
|
|
844
|
-
deprecated,
|
|
845
838
|
};
|
|
846
839
|
}
|
|
847
840
|
|
|
@@ -851,7 +844,8 @@ export type OutputInclude = (typeof OUTPUT_INCLUDE_VALUES)[number];
|
|
|
851
844
|
|
|
852
845
|
/** Parse an `include` request into known values and unknown ones (both deduplicated). */
|
|
853
846
|
export function readOutputInclude(value: unknown): { include: Set<OutputInclude>; unknown: string[] } {
|
|
854
|
-
|
|
847
|
+
// Any other non-null value (a number, boolean, or object) is one unknown entry, never silently nothing (#332).
|
|
848
|
+
const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : value == null ? [] : [value];
|
|
855
849
|
const include = new Set<OutputInclude>();
|
|
856
850
|
const unknown: string[] = [];
|
|
857
851
|
for (const entry of raw) {
|