pi-subagents 0.55.0 → 0.57.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/CHANGELOG.md +81 -3
- package/agents/claude-code-writer.md +15 -0
- package/agents/claude-code.md +15 -0
- package/agents/codex-exec-writer.md +15 -0
- package/agents/codex-exec.md +15 -0
- package/agents/cursor-agent-writer.md +14 -0
- package/agents/cursor-agent.md +14 -0
- package/docs/agents.md +111 -15
- package/docs/configuration.md +37 -0
- package/docs/extension-api.md +2 -2
- package/docs/models.md +6 -0
- package/docs/observability.md +8 -7
- package/docs/tool-reference.md +11 -1
- package/docs/workflows.md +20 -0
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +1 -1
- package/src/agents/agent-management.ts +13 -5
- package/src/agents/agent-refinements.ts +4 -4
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +242 -49
- package/src/agents/builtin-names.ts +6 -0
- package/src/agents/runtime-agent-registry.ts +8 -2
- package/src/api/preflight.ts +22 -3
- package/src/extension/config.ts +70 -0
- package/src/extension/doctor.ts +3 -3
- package/src/extension/index.ts +18 -4
- package/src/extension/public-execution.ts +29 -13
- package/src/extension/rpc.ts +20 -5
- package/src/extension/schemas.ts +9 -3
- package/src/extension/tool-description.ts +11 -9
- package/src/inspectors/herdr/actions.ts +2 -1
- package/src/inspectors/herdr/inspector-runner.ts +2 -10
- package/src/inspectors/herdr/session-roots-codec.ts +42 -0
- package/src/integrations/herdr-status.ts +46 -3
- package/src/runs/background/active-async-capacity.ts +77 -10
- package/src/runs/background/async-execution.ts +148 -27
- package/src/runs/background/async-job-tracker.ts +5 -0
- package/src/runs/background/async-resume.ts +3 -0
- package/src/runs/background/async-retention.ts +20 -3
- package/src/runs/background/async-status.ts +5 -0
- package/src/runs/background/chain-root-attachment.ts +15 -1
- package/src/runs/background/fleet-view.ts +16 -10
- package/src/runs/background/inspect-rpc.ts +8 -8
- package/src/runs/background/result-files.ts +27 -14
- package/src/runs/background/result-watcher.ts +9 -5
- package/src/runs/background/run-status.ts +33 -6
- package/src/runs/background/scheduled-runs.ts +7 -1
- package/src/runs/background/subagent-runner.ts +254 -49
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/foreground/execution.ts +107 -16
- package/src/runs/foreground/foreground-control.ts +6 -0
- package/src/runs/foreground/foreground-history.ts +25 -3
- package/src/runs/foreground/subagent-executor.ts +324 -84
- package/src/runs/foreground/workflow-detach-reconcile.ts +31 -7
- package/src/runs/shared/acceptance.ts +10 -5
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/child-protocol.ts +21 -7
- package/src/runs/shared/claude-code-adapter.ts +129 -0
- package/src/runs/shared/codex-exec-adapter.ts +129 -0
- package/src/runs/shared/completion-guard.ts +3 -2
- package/src/runs/shared/cursor-agent-adapter.ts +114 -0
- package/src/runs/shared/dynamic-fanout.ts +1 -1
- package/src/runs/shared/extension-bindings.ts +78 -0
- package/src/runs/shared/external-cli-contract.ts +167 -0
- package/src/runs/shared/external-cli-preflight.ts +122 -0
- package/src/runs/shared/external-cli-runner.ts +349 -54
- package/src/runs/shared/fast-mode-extension.ts +10 -0
- package/src/runs/shared/model-exclusions.ts +59 -8
- package/src/runs/shared/model-fallback.ts +25 -8
- package/src/runs/shared/mutation-evidence.ts +150 -0
- package/src/runs/shared/nested-events.ts +3 -1
- package/src/runs/shared/nested-render.ts +2 -2
- package/src/runs/shared/parallel-utils.ts +3 -0
- package/src/runs/shared/pi-args.ts +47 -0
- package/src/runs/shared/process-signal.ts +13 -0
- package/src/runs/shared/run-history.ts +21 -1
- package/src/runs/shared/structured-output.ts +18 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +9 -5
- package/src/shared/fork-context.ts +21 -0
- package/src/shared/formatters.ts +7 -1
- package/src/shared/launch-contract.ts +6 -0
- package/src/shared/pruned-fork.ts +450 -0
- package/src/shared/session-file-trust.ts +19 -0
- package/src/shared/session-tokens.ts +14 -3
- package/src/shared/settings.ts +16 -3
- package/src/shared/shortcuts.ts +17 -0
- package/src/shared/types.ts +166 -9
- package/src/shared/workflow-child-permit.ts +116 -0
- package/src/slash/delegation-adapters.ts +0 -1
- package/src/slash/slash-commands.ts +8 -6
- package/src/tui/fleet-status.ts +27 -10
- package/src/tui/fleet-transcript.ts +11 -5
- package/src/tui/fleet.ts +28 -13
- package/src/tui/render.ts +47 -21
- package/src/workflows/scripted-workflow.ts +298 -30
- package/src/workflows/workflow-child-summary.ts +117 -0
- package/src/workflows/workflow-receipt.ts +119 -4
|
@@ -1,18 +1,64 @@
|
|
|
1
|
-
import
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { finished } from "node:stream/promises";
|
|
5
|
-
import { trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
|
|
6
6
|
import type { ExternalProcessStatus } from "../../shared/types.ts";
|
|
7
|
+
import { createOwnedProcessTreeController, type OwnedProcessTreeController } from "../background/owned-process-tree.ts";
|
|
8
|
+
import { omitExtensionBindingsEnv } from "./extension-bindings.ts";
|
|
9
|
+
import {
|
|
10
|
+
invalidateExternalCliPreflight,
|
|
11
|
+
preflightExternalCli,
|
|
12
|
+
type ExternalCliPreflightResult,
|
|
13
|
+
type ExternalCliPreflightSpec,
|
|
14
|
+
} from "./external-cli-preflight.ts";
|
|
7
15
|
|
|
8
|
-
const HARD_KILL_DELAY_MS = 2_000;
|
|
9
16
|
const MAX_OUTPUT_TAIL_BYTES = 64 * 1024;
|
|
10
17
|
const MAX_ERROR_TAIL_BYTES = 64 * 1024;
|
|
18
|
+
const MAX_RAW_LOG_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
const MAX_PARSER_LINE_BYTES = 256 * 1024;
|
|
20
|
+
const MAX_PARSER_STREAM_BYTES = 32 * 1024 * 1024;
|
|
21
|
+
const MAX_PARSER_OUTPUT_BYTES = 1024 * 1024;
|
|
22
|
+
const MAX_OVERSIZED_LINE_PREFIX_BYTES = 512;
|
|
23
|
+
const MAX_SKIPPABLE_LINE_BYTES = 1024 * 1024;
|
|
24
|
+
const PARSER_PROGRESS_INTERVAL_MS = 100;
|
|
11
25
|
|
|
12
26
|
export function buildExternalCliPrompt(systemInstructions: string, task: string): string {
|
|
13
27
|
return `<System instructions>\n${systemInstructions.trim()}\n\n<Task>\n${task}`;
|
|
14
28
|
}
|
|
15
29
|
|
|
30
|
+
export interface ExternalCliParserProgress {
|
|
31
|
+
phase: string;
|
|
32
|
+
eventCount: number;
|
|
33
|
+
message?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExternalCliParserTerminal {
|
|
37
|
+
state: "completed" | "failed";
|
|
38
|
+
output?: string;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ExternalCliParser {
|
|
43
|
+
parseLine(line: string): ExternalCliParserProgress | undefined;
|
|
44
|
+
/** Inspect only a bounded prefix when a non-terminal event exceeds the normal line cap. */
|
|
45
|
+
skipOversizedLine?(prefix: string, byteLength: number): ExternalCliParserProgress | undefined;
|
|
46
|
+
finish(): ExternalCliParserTerminal | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseExternalCliJsonlEvent(line: string, label: string, maxTypeLength: number): Record<string, unknown> {
|
|
50
|
+
let value: unknown;
|
|
51
|
+
try {
|
|
52
|
+
value = JSON.parse(line) as unknown;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(`${label} emitted malformed JSONL: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} emitted a JSONL event that is not an object.`);
|
|
57
|
+
const event = value as Record<string, unknown>;
|
|
58
|
+
if (typeof event.type !== "string" || !event.type || event.type.length > maxTypeLength) throw new Error(`${label} emitted a JSONL event with an invalid type.`);
|
|
59
|
+
return event;
|
|
60
|
+
}
|
|
61
|
+
|
|
16
62
|
export interface ExternalCliRunResult {
|
|
17
63
|
output: string;
|
|
18
64
|
exitCode: number | null;
|
|
@@ -21,6 +67,87 @@ export interface ExternalCliRunResult {
|
|
|
21
67
|
stopped?: boolean;
|
|
22
68
|
processSignal?: string | null;
|
|
23
69
|
externalProcess: ExternalProcessStatus;
|
|
70
|
+
parserTerminal?: ExternalCliParserTerminal;
|
|
71
|
+
preflight?: ExternalCliPreflightResult;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface StreamLimits {
|
|
75
|
+
stdoutLogBytes?: number;
|
|
76
|
+
stderrLogBytes?: number;
|
|
77
|
+
parserLineBytes?: number;
|
|
78
|
+
parserStreamBytes?: number;
|
|
79
|
+
parserOutputBytes?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function narrowLimit(value: number | undefined, ceiling: number, label: string): number {
|
|
83
|
+
if (value === undefined) return ceiling;
|
|
84
|
+
assert(Number.isInteger(value) && value > 0 && value <= ceiling, `${label} may only narrow the code-owned ${ceiling}-byte limit.`);
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function externalEnvironment(allowlist: readonly string[] | undefined, values: Readonly<Record<string, string>> | undefined): NodeJS.ProcessEnv {
|
|
89
|
+
if (!allowlist) return omitExtensionBindingsEnv(process.env);
|
|
90
|
+
const allowed = new Set(allowlist);
|
|
91
|
+
const env: NodeJS.ProcessEnv = {};
|
|
92
|
+
for (const key of allowed) {
|
|
93
|
+
if (!key || key.includes("=") || key.includes("\0")) throw new Error(`Invalid external CLI environment key: ${JSON.stringify(key)}.`);
|
|
94
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
95
|
+
}
|
|
96
|
+
for (const [key, value] of Object.entries(values ?? {})) {
|
|
97
|
+
if (!allowed.has(key)) throw new Error(`External CLI environment value '${key}' is not in the adapter allowlist.`);
|
|
98
|
+
env[key] = value;
|
|
99
|
+
}
|
|
100
|
+
return omitExtensionBindingsEnv(env);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createByteTail(maxBytes: number): { push(chunk: Buffer): void; text(): string } {
|
|
104
|
+
const chunks: Buffer[] = [];
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
return {
|
|
107
|
+
push(chunk) {
|
|
108
|
+
chunks.push(chunk);
|
|
109
|
+
bytes += chunk.length;
|
|
110
|
+
while (bytes > maxBytes && chunks.length > 0) {
|
|
111
|
+
const first = chunks[0]!;
|
|
112
|
+
const excess = bytes - maxBytes;
|
|
113
|
+
if (first.length <= excess) {
|
|
114
|
+
chunks.shift();
|
|
115
|
+
bytes -= first.length;
|
|
116
|
+
} else {
|
|
117
|
+
chunks[0] = first.subarray(excess);
|
|
118
|
+
bytes -= excess;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
text: () => Buffer.concat(chunks, bytes).toString("utf-8"),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeBoundedLog(source: NodeJS.ReadableStream, stream: fs.WriteStream, chunk: Buffer, state: { bytes: number; total: number }, limit: number): void {
|
|
127
|
+
state.total += chunk.length;
|
|
128
|
+
const remaining = limit - state.bytes;
|
|
129
|
+
if (remaining <= 0) return;
|
|
130
|
+
const bytes = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
131
|
+
state.bytes += bytes.length;
|
|
132
|
+
if (!stream.write(bytes)) {
|
|
133
|
+
source.pause();
|
|
134
|
+
stream.once("drain", () => source.resume());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function classifyInvalidation(error: string): "auth" | "permission" | "launch" {
|
|
139
|
+
if (/auth|unauthori[sz]ed|credential|login/i.test(error)) return "auth";
|
|
140
|
+
if (/permission|forbidden|denied|read.?only/i.test(error)) return "permission";
|
|
141
|
+
return "launch";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function terminateExternalProcessTree(pid: number, controller: OwnedProcessTreeController): Promise<unknown> {
|
|
145
|
+
if (process.platform !== "win32") return controller.terminate();
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const cleanup = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
148
|
+
cleanup.once("error", () => { void controller.terminate().then(resolve); });
|
|
149
|
+
cleanup.once("close", () => { void controller.terminate().then(resolve); });
|
|
150
|
+
});
|
|
24
151
|
}
|
|
25
152
|
|
|
26
153
|
export function runExternalCli(input: {
|
|
@@ -30,14 +157,29 @@ export function runExternalCli(input: {
|
|
|
30
157
|
prompt: string;
|
|
31
158
|
asyncDir: string;
|
|
32
159
|
stepIndex: number;
|
|
160
|
+
environment?: { allowlist: readonly string[]; values?: Readonly<Record<string, string>> };
|
|
161
|
+
preflight?: ExternalCliPreflightSpec;
|
|
162
|
+
parser?: ExternalCliParser;
|
|
163
|
+
finalOutputPath?: string;
|
|
164
|
+
promptFilePath?: string;
|
|
165
|
+
temporaryDirectories?: readonly string[];
|
|
166
|
+
limits?: StreamLimits;
|
|
33
167
|
registerTimeout?: (stop: (() => void) | undefined) => void;
|
|
34
168
|
registerStop?: (stop: (() => void) | undefined) => void;
|
|
35
169
|
timeoutMessage?: string;
|
|
36
170
|
stopMessage?: string;
|
|
37
171
|
onProcess?: (process: ExternalProcessStatus) => void;
|
|
172
|
+
onParserProgress?: (progress: ExternalCliParserProgress) => void;
|
|
38
173
|
onStdout?: (chunk: Buffer) => void;
|
|
39
174
|
onStderr?: (chunk: Buffer) => void;
|
|
40
175
|
}): Promise<ExternalCliRunResult> {
|
|
176
|
+
const limits = {
|
|
177
|
+
stdoutLogBytes: narrowLimit(input.limits?.stdoutLogBytes, MAX_RAW_LOG_BYTES, "stdoutLogBytes"),
|
|
178
|
+
stderrLogBytes: narrowLimit(input.limits?.stderrLogBytes, MAX_RAW_LOG_BYTES, "stderrLogBytes"),
|
|
179
|
+
parserLineBytes: narrowLimit(input.limits?.parserLineBytes, MAX_PARSER_LINE_BYTES, "parserLineBytes"),
|
|
180
|
+
parserStreamBytes: narrowLimit(input.limits?.parserStreamBytes, MAX_PARSER_STREAM_BYTES, "parserStreamBytes"),
|
|
181
|
+
parserOutputBytes: narrowLimit(input.limits?.parserOutputBytes, MAX_PARSER_OUTPUT_BYTES, "parserOutputBytes"),
|
|
182
|
+
};
|
|
41
183
|
return new Promise((resolve, reject) => {
|
|
42
184
|
const startedAt = Date.now();
|
|
43
185
|
const stdoutPath = path.join(input.asyncDir, `external-${input.stepIndex}.stdout.log`);
|
|
@@ -46,89 +188,242 @@ export function runExternalCli(input: {
|
|
|
46
188
|
const stdoutStream = fs.createWriteStream(stdoutPath, { flags: "w" });
|
|
47
189
|
const stderrStream = fs.createWriteStream(stderrPath, { flags: "w" });
|
|
48
190
|
const streamsFinished = Promise.allSettled([finished(stdoutStream), finished(stderrStream)]);
|
|
49
|
-
|
|
50
|
-
let
|
|
191
|
+
const createdDirectories: string[] = [];
|
|
192
|
+
let promptFileCreated = false;
|
|
193
|
+
const cleanupTemporaryPaths = () => {
|
|
194
|
+
if (input.promptFilePath && promptFileCreated) fs.rmSync(input.promptFilePath, { force: true });
|
|
195
|
+
for (const directory of createdDirectories.reverse()) fs.rmSync(directory, { recursive: true, force: true });
|
|
196
|
+
};
|
|
197
|
+
const env = externalEnvironment(input.environment?.allowlist, input.environment?.values);
|
|
198
|
+
let preflight: ExternalCliPreflightResult | undefined;
|
|
199
|
+
try {
|
|
200
|
+
for (const directory of input.temporaryDirectories ?? []) {
|
|
201
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
202
|
+
createdDirectories.push(directory);
|
|
203
|
+
}
|
|
204
|
+
if (input.promptFilePath) {
|
|
205
|
+
const promptDescriptor = fs.openSync(input.promptFilePath, "wx", 0o600);
|
|
206
|
+
promptFileCreated = true;
|
|
207
|
+
try { fs.writeFileSync(promptDescriptor, input.prompt, { encoding: "utf-8" }); }
|
|
208
|
+
finally { fs.closeSync(promptDescriptor); }
|
|
209
|
+
}
|
|
210
|
+
if (input.preflight) preflight = preflightExternalCli(input.command, input.preflight, env, input.cwd);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const endedAt = Date.now();
|
|
213
|
+
const externalProcess = { startedAt, endedAt, durationMs: endedAt - startedAt, exitCode: 1, processSignal: null, stdoutPath, stderrPath, ...(input.finalOutputPath ? { finalOutputPath: input.finalOutputPath } : {}) } satisfies ExternalProcessStatus;
|
|
214
|
+
stdoutStream.end();
|
|
215
|
+
stderrStream.end();
|
|
216
|
+
void streamsFinished.then((streamResults) => {
|
|
217
|
+
try { cleanupTemporaryPaths(); }
|
|
218
|
+
catch (cleanupError) { reject(cleanupError); return; }
|
|
219
|
+
const streamFailure = streamResults.find((streamResult) => streamResult.status === "rejected");
|
|
220
|
+
if (streamFailure?.status === "rejected") reject(streamFailure.reason);
|
|
221
|
+
else resolve({ output: "", exitCode: 1, error: error instanceof Error ? error.message : String(error), processSignal: null, externalProcess });
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const stdoutTail = createByteTail(MAX_OUTPUT_TAIL_BYTES);
|
|
226
|
+
const stderrTail = createByteTail(MAX_ERROR_TAIL_BYTES);
|
|
227
|
+
const stdoutLog = { bytes: 0, total: 0 };
|
|
228
|
+
const stderrLog = { bytes: 0, total: 0 };
|
|
229
|
+
let parserBytes = 0;
|
|
230
|
+
let pendingLine = Buffer.alloc(0);
|
|
231
|
+
let pendingLineBytes = 0;
|
|
232
|
+
let pendingLineOversizedAccepted = false;
|
|
233
|
+
let parserError: Error | undefined;
|
|
234
|
+
let parserTerminal: ExternalCliParserTerminal | undefined;
|
|
235
|
+
let latestProgress: ExternalCliParserProgress | undefined;
|
|
236
|
+
let progressTimer: NodeJS.Timeout | undefined;
|
|
51
237
|
let timedOut = false;
|
|
52
238
|
let stopped = false;
|
|
53
239
|
let settled = false;
|
|
54
|
-
let
|
|
55
|
-
|
|
240
|
+
let processTree: OwnedProcessTreeController | undefined;
|
|
241
|
+
let processPid: number | undefined;
|
|
242
|
+
let termination: Promise<unknown> | undefined;
|
|
243
|
+
const flushProgress = () => {
|
|
244
|
+
if (!latestProgress) return;
|
|
245
|
+
input.onParserProgress?.(latestProgress);
|
|
246
|
+
latestProgress = undefined;
|
|
247
|
+
};
|
|
248
|
+
const reportProgress = (progress: ExternalCliParserProgress) => {
|
|
249
|
+
if (!progress.phase || progress.phase.length > 64 || !Number.isSafeInteger(progress.eventCount) || progress.eventCount < 0) {
|
|
250
|
+
failParser(new Error("External CLI parser returned invalid progress metadata."));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
latestProgress = { ...progress, ...(progress.message ? { message: progress.message.slice(0, 512) } : {}) };
|
|
254
|
+
if (progressTimer) return;
|
|
255
|
+
progressTimer = setTimeout(() => {
|
|
256
|
+
progressTimer = undefined;
|
|
257
|
+
flushProgress();
|
|
258
|
+
}, PARSER_PROGRESS_INTERVAL_MS);
|
|
259
|
+
progressTimer.unref?.();
|
|
260
|
+
};
|
|
261
|
+
const terminate = (reason: "timeout" | "stop") => {
|
|
262
|
+
if (settled || timedOut || stopped || parserError) return;
|
|
263
|
+
timedOut = reason === "timeout";
|
|
264
|
+
stopped = reason === "stop";
|
|
265
|
+
if (processTree && processPid !== undefined) termination = terminateExternalProcessTree(processPid, processTree);
|
|
266
|
+
};
|
|
267
|
+
const failParser = (error: unknown) => {
|
|
268
|
+
if (parserError) return;
|
|
269
|
+
parserError = error instanceof Error ? error : new Error(String(error));
|
|
270
|
+
if (input.preflight) invalidateExternalCliPreflight(input.command, input.preflight, "parser");
|
|
271
|
+
if (processTree && processPid !== undefined) termination = terminateExternalProcessTree(processPid, processTree);
|
|
272
|
+
};
|
|
273
|
+
const parseLine = (line: Buffer, byteLength = line.length): boolean => {
|
|
274
|
+
if (!input.parser || parserError) return false;
|
|
275
|
+
if (byteLength > limits.parserLineBytes) {
|
|
276
|
+
const progress = input.limits?.parserLineBytes === undefined
|
|
277
|
+
? input.parser.skipOversizedLine?.(line.subarray(0, MAX_OVERSIZED_LINE_PREFIX_BYTES).toString("utf-8"), byteLength)
|
|
278
|
+
: undefined;
|
|
279
|
+
if (progress) { reportProgress(progress); return true; }
|
|
280
|
+
failParser(new Error("External CLI parser line exceeded its byte limit."));
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const progress = input.parser.parseLine(line.toString("utf-8"));
|
|
285
|
+
if (progress) reportProgress(progress);
|
|
286
|
+
} catch (error) { failParser(error); }
|
|
287
|
+
return false;
|
|
288
|
+
};
|
|
289
|
+
const appendPendingLine = (chunk: Buffer) => {
|
|
290
|
+
pendingLineBytes += chunk.length;
|
|
291
|
+
if (pendingLineOversizedAccepted) {
|
|
292
|
+
if (pendingLineBytes > MAX_SKIPPABLE_LINE_BYTES) failParser(new Error("External CLI parser line exceeded its byte limit."));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (pendingLineBytes <= limits.parserLineBytes) {
|
|
296
|
+
pendingLine = Buffer.concat([pendingLine, chunk]);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (pendingLineBytes > MAX_SKIPPABLE_LINE_BYTES) {
|
|
300
|
+
failParser(new Error("External CLI parser line exceeded its byte limit."));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (pendingLine.length > MAX_OVERSIZED_LINE_PREFIX_BYTES) pendingLine = pendingLine.subarray(0, MAX_OVERSIZED_LINE_PREFIX_BYTES);
|
|
304
|
+
const remainingPrefixBytes = MAX_OVERSIZED_LINE_PREFIX_BYTES - pendingLine.length;
|
|
305
|
+
if (remainingPrefixBytes > 0) pendingLine = Buffer.concat([pendingLine, chunk.subarray(0, remainingPrefixBytes)]);
|
|
306
|
+
pendingLineOversizedAccepted = parseLine(pendingLine, pendingLineBytes);
|
|
307
|
+
};
|
|
308
|
+
const finishPendingLine = () => {
|
|
309
|
+
if (!pendingLineOversizedAccepted) parseLine(pendingLine, pendingLineBytes);
|
|
310
|
+
pendingLine = Buffer.alloc(0);
|
|
311
|
+
pendingLineBytes = 0;
|
|
312
|
+
pendingLineOversizedAccepted = false;
|
|
313
|
+
};
|
|
314
|
+
const parseChunk = (chunk: Buffer) => {
|
|
315
|
+
if (!input.parser || parserError) return;
|
|
316
|
+
parserBytes += chunk.length;
|
|
317
|
+
if (parserBytes > limits.parserStreamBytes) {
|
|
318
|
+
failParser(new Error("External CLI parser stream exceeded its byte limit."));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
let start = 0;
|
|
322
|
+
for (let index = 0; index < chunk.length; index++) {
|
|
323
|
+
if (chunk[index] !== 0x0a) continue;
|
|
324
|
+
appendPendingLine(chunk.subarray(start, index));
|
|
325
|
+
finishPendingLine();
|
|
326
|
+
start = index + 1;
|
|
327
|
+
}
|
|
328
|
+
appendPendingLine(chunk.subarray(start));
|
|
329
|
+
};
|
|
330
|
+
const child = spawn(preflight?.binaryPath ?? input.command, input.args ?? [], {
|
|
56
331
|
cwd: input.cwd,
|
|
332
|
+
env,
|
|
57
333
|
stdio: ["pipe", "pipe", "pipe"],
|
|
58
334
|
shell: false,
|
|
59
335
|
windowsHide: true,
|
|
60
|
-
|
|
336
|
+
detached: process.platform !== "win32",
|
|
337
|
+
}) as ChildProcessWithoutNullStreams;
|
|
338
|
+
if (typeof child.pid === "number") {
|
|
339
|
+
processPid = child.pid;
|
|
340
|
+
processTree = createOwnedProcessTreeController(child.pid, { termGraceMs: 2_000 });
|
|
341
|
+
}
|
|
61
342
|
const initialProcess: ExternalProcessStatus = {
|
|
62
343
|
...(typeof child.pid === "number" ? { pid: child.pid } : {}),
|
|
63
344
|
startedAt,
|
|
64
345
|
stdoutPath,
|
|
65
346
|
stderrPath,
|
|
347
|
+
...(input.finalOutputPath ? { finalOutputPath: input.finalOutputPath } : {}),
|
|
66
348
|
};
|
|
67
349
|
input.onProcess?.(initialProcess);
|
|
68
350
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
69
|
-
|
|
351
|
+
parseChunk(chunk);
|
|
352
|
+
writeBoundedLog(child.stdout, stdoutStream, chunk, stdoutLog, limits.stdoutLogBytes);
|
|
70
353
|
input.onStdout?.(chunk);
|
|
71
|
-
stdoutTail
|
|
72
|
-
if (stdoutTail.length > MAX_OUTPUT_TAIL_BYTES) stdoutTail = stdoutTail.subarray(stdoutTail.length - MAX_OUTPUT_TAIL_BYTES);
|
|
354
|
+
stdoutTail.push(chunk);
|
|
73
355
|
});
|
|
74
356
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
75
|
-
stderrStream.
|
|
357
|
+
writeBoundedLog(child.stderr, stderrStream, chunk, stderrLog, limits.stderrLogBytes);
|
|
76
358
|
input.onStderr?.(chunk);
|
|
77
|
-
stderrTail
|
|
78
|
-
if (stderrTail.length > MAX_ERROR_TAIL_BYTES) stderrTail = stderrTail.subarray(stderrTail.length - MAX_ERROR_TAIL_BYTES);
|
|
359
|
+
stderrTail.push(chunk);
|
|
79
360
|
});
|
|
80
|
-
const terminate = (reason: "timeout" | "stop") => {
|
|
81
|
-
if (settled || timedOut || stopped) return;
|
|
82
|
-
timedOut = reason === "timeout";
|
|
83
|
-
stopped = reason === "stop";
|
|
84
|
-
trySignalChild(child, "SIGTERM");
|
|
85
|
-
hardKillTimer = setTimeout(() => {
|
|
86
|
-
if (!settled) trySignalChild(child, "SIGKILL");
|
|
87
|
-
}, HARD_KILL_DELAY_MS);
|
|
88
|
-
hardKillTimer.unref?.();
|
|
89
|
-
};
|
|
90
361
|
input.registerTimeout?.(() => terminate("timeout"));
|
|
91
362
|
input.registerStop?.(() => terminate("stop"));
|
|
92
363
|
child.stdin.on("error", () => {});
|
|
93
|
-
child.stdin.end(input.prompt);
|
|
364
|
+
child.stdin.end(input.promptFilePath ? undefined : input.prompt);
|
|
94
365
|
let spawnError: Error | undefined;
|
|
95
366
|
child.once("error", (error) => { spawnError = error; });
|
|
367
|
+
child.stdout.once("end", () => {
|
|
368
|
+
if (!input.parser || parserError) return;
|
|
369
|
+
if (pendingLineBytes > 0) finishPendingLine();
|
|
370
|
+
try {
|
|
371
|
+
parserTerminal = input.parser.finish();
|
|
372
|
+
if (!parserTerminal) failParser(new Error("External CLI parser did not produce a terminal state."));
|
|
373
|
+
else if (Buffer.byteLength(parserTerminal.output ?? "", "utf-8") > limits.parserOutputBytes) failParser(new Error("External CLI parser terminal output exceeded its byte limit."));
|
|
374
|
+
else if (Buffer.byteLength(parserTerminal.error ?? "", "utf-8") > 4 * 1024) failParser(new Error("External CLI parser terminal error exceeded its byte limit."));
|
|
375
|
+
} catch (error) { failParser(error); }
|
|
376
|
+
});
|
|
96
377
|
child.once("close", (exitCode, signal) => {
|
|
97
378
|
settled = true;
|
|
98
|
-
if (
|
|
379
|
+
if (progressTimer) clearTimeout(progressTimer);
|
|
380
|
+
flushProgress();
|
|
99
381
|
input.registerTimeout?.(undefined);
|
|
100
382
|
input.registerStop?.(undefined);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
endedAt
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
383
|
+
void (async () => {
|
|
384
|
+
if (termination) await termination;
|
|
385
|
+
else if (processTree) await processTree.finishAfterWriterClose();
|
|
386
|
+
const endedAt = Date.now();
|
|
387
|
+
const externalProcess: ExternalProcessStatus = {
|
|
388
|
+
...initialProcess,
|
|
389
|
+
endedAt,
|
|
390
|
+
durationMs: endedAt - startedAt,
|
|
391
|
+
exitCode,
|
|
392
|
+
processSignal: signal,
|
|
393
|
+
stdoutBytes: stdoutLog.total,
|
|
394
|
+
stderrBytes: stderrLog.total,
|
|
395
|
+
...(stdoutLog.total > stdoutLog.bytes ? { stdoutTruncated: true } : {}),
|
|
396
|
+
...(stderrLog.total > stderrLog.bytes ? { stderrTruncated: true } : {}),
|
|
397
|
+
};
|
|
398
|
+
input.onProcess?.(externalProcess);
|
|
399
|
+
const stderr = stderrTail.text().trim();
|
|
400
|
+
const parserFailure = parserError?.message ?? (parserTerminal?.state === "failed" ? parserTerminal.error ?? "External CLI parser reported terminal failure." : undefined);
|
|
401
|
+
const error = stopped
|
|
402
|
+
? input.stopMessage ?? "Subagent stopped by user."
|
|
403
|
+
: timedOut
|
|
404
|
+
? input.timeoutMessage ?? "Subagent timed out."
|
|
405
|
+
: spawnError?.message ?? parserFailure ?? (exitCode === 0 ? undefined : stderr || `External CLI exited with code ${exitCode}.`);
|
|
406
|
+
if (error && input.preflight && !parserError) invalidateExternalCliPreflight(input.command, input.preflight, classifyInvalidation(error));
|
|
407
|
+
const result: ExternalCliRunResult = {
|
|
408
|
+
output: (!parserError && parserTerminal?.state === "completed" ? parserTerminal.output ?? "" : stdoutTail.text()).trim(),
|
|
409
|
+
exitCode: timedOut || stopped || spawnError || parserFailure ? 1 : exitCode,
|
|
410
|
+
...(error ? { error } : {}),
|
|
411
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
412
|
+
...(stopped ? { stopped: true } : {}),
|
|
413
|
+
processSignal: signal,
|
|
414
|
+
externalProcess,
|
|
415
|
+
...(parserTerminal ? { parserTerminal } : {}),
|
|
416
|
+
...(preflight ? { preflight } : {}),
|
|
417
|
+
};
|
|
418
|
+
stdoutStream.end();
|
|
419
|
+
stderrStream.end();
|
|
420
|
+
const streamResults = await streamsFinished;
|
|
128
421
|
const streamFailure = streamResults.find((streamResult) => streamResult.status === "rejected");
|
|
422
|
+
try { cleanupTemporaryPaths(); }
|
|
423
|
+
catch (cleanupError) { reject(cleanupError); return; }
|
|
129
424
|
if (streamFailure?.status === "rejected") reject(streamFailure.reason);
|
|
130
425
|
else resolve(result);
|
|
131
|
-
});
|
|
426
|
+
})();
|
|
132
427
|
});
|
|
133
428
|
});
|
|
134
429
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
function withPriorityServiceTier(payload: unknown): unknown {
|
|
4
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
|
|
5
|
+
return { ...payload, service_tier: "priority" };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function registerSubagentFastModeExtension(pi: ExtensionAPI): void {
|
|
9
|
+
pi.on("before_provider_request", (event) => ({ payload: withPriorityServiceTier(event.payload) }));
|
|
10
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
|
|
3
4
|
import { TEMP_ROOT_DIR } from "../../shared/types.ts";
|
|
4
5
|
|
|
5
6
|
export const EXCLUSIONS_PATH_ENV = "PI_MODEL_EXCLUSIONS_PATH";
|
|
@@ -19,14 +20,30 @@ type RecordModelFailureOptions = ModelExclusionTarget & {
|
|
|
19
20
|
|
|
20
21
|
let exclusions: ModelExclusion[] = [];
|
|
21
22
|
let loaded = false;
|
|
22
|
-
|
|
23
|
+
/** Default duration for a new model exclusion when no per-record TTL is supplied. */
|
|
24
|
+
export const DEFAULT_MODEL_EXCLUSION_TTL_MS = 24 * 60 * 60_000;
|
|
25
|
+
/** Keeps a new expiry safely below JavaScript's maximum Date timestamp. */
|
|
26
|
+
export const MAX_MODEL_EXCLUSION_TTL_MS = 8_000_000_000_000_000;
|
|
27
|
+
const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000;
|
|
28
|
+
let defaultTTLMs = DEFAULT_MODEL_EXCLUSION_TTL_MS;
|
|
29
|
+
let loadedTTLCeilingMs: number | undefined;
|
|
23
30
|
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
31
|
let persistSeq = 0;
|
|
25
32
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Override the default TTL applied to newly recorded model exclusions.
|
|
35
|
+
*
|
|
36
|
+
* @param ms Duration in milliseconds. Must be finite and positive.
|
|
37
|
+
* @returns Nothing.
|
|
38
|
+
*/
|
|
39
|
+
// TEST:test/unit/model-exclusions.test.ts[model exclusions — TTL expiry]
|
|
40
|
+
export function setDefaultTTL(ms: number, options?: { shortenExisting?: boolean }): void {
|
|
41
|
+
if (!Number.isFinite(ms) || ms <= 0 || ms > MAX_MODEL_EXCLUSION_TTL_MS) {
|
|
42
|
+
throw new Error(`Default model exclusion TTL must be a finite positive number no greater than ${MAX_MODEL_EXCLUSION_TTL_MS}.`);
|
|
43
|
+
}
|
|
29
44
|
defaultTTLMs = ms;
|
|
45
|
+
loadedTTLCeilingMs = options?.shortenExisting ? ms : undefined;
|
|
46
|
+
if (loaded && loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, Date.now())) schedulePersist();
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
/**
|
|
@@ -77,9 +94,23 @@ function ensureLoaded(): void {
|
|
|
77
94
|
const raw = fs.readFileSync(getExclusionsFilePath(), "utf-8");
|
|
78
95
|
const data = JSON.parse(raw);
|
|
79
96
|
if (data.version === 1) {
|
|
97
|
+
if (!Array.isArray(data.exclusions)) throw new Error("Model exclusion store version 1 must contain an exclusions array.");
|
|
98
|
+
for (let index = 0; index < data.exclusions.length; index++) {
|
|
99
|
+
const entry = data.exclusions[index];
|
|
100
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`Model exclusion store entry ${index} must be an object.`);
|
|
101
|
+
if (entry.reason !== undefined && typeof entry.reason !== "string") throw new Error(`Model exclusion store entry ${index} has an invalid reason.`);
|
|
102
|
+
for (const field of ["recordedAt", "expiresAt"] as const) {
|
|
103
|
+
const timestamp = entry[field];
|
|
104
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0 || timestamp > MAX_DATE_TIMESTAMP_MS) {
|
|
105
|
+
throw new Error(`Model exclusion store entry ${index} has an invalid ${field}.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
80
109
|
const now = Date.now();
|
|
81
110
|
exclusions = (data.exclusions ?? []).filter((e: ModelExclusion) => e.expiresAt > now);
|
|
111
|
+
const shortened = loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, now);
|
|
82
112
|
exclusions = deduplicate(exclusions);
|
|
113
|
+
if (shortened) schedulePersist();
|
|
83
114
|
}
|
|
84
115
|
} catch (error) {
|
|
85
116
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
@@ -194,7 +225,7 @@ export function getExcludedCount(): number {
|
|
|
194
225
|
* {@link recordModelFailure} is later recognised by the candidate filter.
|
|
195
226
|
*/
|
|
196
227
|
export function parseModelKey(fullId: string): { provider?: string; modelId: string } {
|
|
197
|
-
const base = fullId
|
|
228
|
+
const base = splitKnownThinkingSuffix(fullId).baseModel;
|
|
198
229
|
if (!base.includes("/")) return { modelId: base };
|
|
199
230
|
const slash = base.indexOf("/");
|
|
200
231
|
return { provider: base.slice(0, slash), modelId: base.slice(slash + 1) };
|
|
@@ -204,7 +235,10 @@ export function parseModelKey(fullId: string): { provider?: string; modelId: str
|
|
|
204
235
|
* Filter a list of candidate fullIds, removing excluded models/providers and
|
|
205
236
|
* duplicates while preserving order.
|
|
206
237
|
*/
|
|
207
|
-
export function filterFallbackCandidates(candidates: string[], opts?: {
|
|
238
|
+
export function filterFallbackCandidates(candidates: string[], opts?: {
|
|
239
|
+
now?: number;
|
|
240
|
+
onExcluded?: (candidate: string, exclusion: Readonly<ModelExclusion>) => void;
|
|
241
|
+
}): string[] {
|
|
208
242
|
ensureLoaded();
|
|
209
243
|
const timestamp = opts?.now ?? Date.now();
|
|
210
244
|
const seen = new Set<string>();
|
|
@@ -212,8 +246,11 @@ export function filterFallbackCandidates(candidates: string[], opts?: { now?: nu
|
|
|
212
246
|
for (const raw of candidates) {
|
|
213
247
|
if (!raw || seen.has(raw)) continue;
|
|
214
248
|
const { provider: candidateProvider, modelId: candidateModelId } = parseModelKey(raw);
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
249
|
+
const exclusion = exclusions.find((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
|
|
250
|
+
if (exclusion) {
|
|
251
|
+
opts?.onExcluded?.(raw, exclusion);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
217
254
|
seen.add(raw);
|
|
218
255
|
filtered.push(raw);
|
|
219
256
|
}
|
|
@@ -240,3 +277,17 @@ function prune(items: ModelExclusion[], now: number): void {
|
|
|
240
277
|
}
|
|
241
278
|
items.length = write;
|
|
242
279
|
}
|
|
280
|
+
|
|
281
|
+
function shortenExclusionsToTTL(items: ModelExclusion[], ttlMs: number, now: number): boolean {
|
|
282
|
+
let changed = false;
|
|
283
|
+
for (const entry of items) {
|
|
284
|
+
const configuredExpiry = entry.recordedAt + ttlMs;
|
|
285
|
+
if (entry.expiresAt > configuredExpiry) {
|
|
286
|
+
entry.expiresAt = configuredExpiry;
|
|
287
|
+
changed = true;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const previousLength = items.length;
|
|
291
|
+
prune(items, now);
|
|
292
|
+
return changed || items.length !== previousLength;
|
|
293
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { splitKnownThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
|
|
2
2
|
import type { Usage } from "../../shared/types.ts";
|
|
3
3
|
import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
|
|
4
4
|
import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
|
|
5
|
+
import { redactSecretValues } from "./permissions.ts";
|
|
5
6
|
|
|
6
7
|
export type { AvailableModelInfo };
|
|
7
8
|
|
|
@@ -14,12 +15,7 @@ interface ModelAttemptSummary {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function splitThinkingSuffix(model: string): { baseModel: string; thinkingSuffix: string } {
|
|
17
|
-
|
|
18
|
-
if (colonIdx === -1) return { baseModel: model, thinkingSuffix: "" };
|
|
19
|
-
return {
|
|
20
|
-
baseModel: model.substring(0, colonIdx),
|
|
21
|
-
thinkingSuffix: model.substring(colonIdx),
|
|
22
|
-
};
|
|
18
|
+
return splitKnownThinkingSuffix(model);
|
|
23
19
|
}
|
|
24
20
|
|
|
25
21
|
export function formatSubagentModelVerificationError(expectedModel: string, observedModel: string, availableModels: AvailableModelInfo[] | undefined): string | undefined {
|
|
@@ -399,7 +395,12 @@ export function buildModelCandidates(
|
|
|
399
395
|
seen.add(normalized);
|
|
400
396
|
candidates.push(normalized);
|
|
401
397
|
}
|
|
402
|
-
return filterFallbackCandidates(candidates
|
|
398
|
+
return filterFallbackCandidates(candidates, {
|
|
399
|
+
onExcluded(candidate, exclusion) {
|
|
400
|
+
const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
|
|
401
|
+
console.warn(`[pi-subagents] Skipping model '${candidate}' due to a cached exclusion (reason: ${reason}; expires: ${new Date(exclusion.expiresAt).toISOString()}).`);
|
|
402
|
+
},
|
|
403
|
+
});
|
|
403
404
|
}
|
|
404
405
|
|
|
405
406
|
const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
@@ -424,6 +425,7 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
|
424
425
|
/overloaded/i,
|
|
425
426
|
/service unavailable/i,
|
|
426
427
|
/temporar(?:ily)? unavailable/i,
|
|
428
|
+
/connection\s+(?:error|reset|closed|aborted)/i,
|
|
427
429
|
/connection refused/i,
|
|
428
430
|
/fetch failed/i,
|
|
429
431
|
/network error/i,
|
|
@@ -456,6 +458,21 @@ export function isRetryableModelFailure(error: string | undefined): boolean {
|
|
|
456
458
|
return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
|
|
457
459
|
}
|
|
458
460
|
|
|
461
|
+
function messageError(message: unknown): string | undefined {
|
|
462
|
+
if (!message || typeof message !== "object") return undefined;
|
|
463
|
+
const value = (message as { errorMessage?: unknown }).errorMessage;
|
|
464
|
+
return typeof value === "string" ? value : undefined;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function isRetryableModelFailureAttempt(input: { error: string | undefined; messages?: readonly unknown[]; toolCount?: number }): boolean {
|
|
468
|
+
if (!isRetryableModelFailure(input.error)) return false;
|
|
469
|
+
if ((input.toolCount ?? 0) > 0) return false;
|
|
470
|
+
if (input.error === "Subagent produced no output (possible model cold-start or empty response).") return true;
|
|
471
|
+
if ((input.toolCount ?? 0) === 0 && (input.messages?.length ?? 0) === 0) return true;
|
|
472
|
+
const error = input.error?.trim();
|
|
473
|
+
return Boolean(error && input.messages?.some((message) => messageError(message)?.trim() === error));
|
|
474
|
+
}
|
|
475
|
+
|
|
459
476
|
export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
|
|
460
477
|
if (!model || !isRetryableModelFailure(error)) return;
|
|
461
478
|
const { provider, modelId } = parseModelKey(model);
|