pi-subagents 0.52.1 → 0.54.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 +69 -0
- package/README.md +4 -0
- package/docs/configuration.md +12 -2
- package/docs/extension-api.md +3 -1
- package/docs/models.md +17 -3
- package/docs/workflows.md +2 -0
- package/index.ts +10 -1
- package/package.json +2 -1
- package/prompts/council.md +51 -0
- package/skills/council-mode/SKILL.md +231 -0
- package/skills/pi-subagents/SKILL.md +5 -1
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -0
- package/skills/pi-subagents/references/execution-controls.md +13 -0
- package/skills/pi-subagents/references/prompting-and-roles.md +7 -0
- package/src/agents/agent-management.ts +107 -8
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +96 -37
- package/src/agents/builtin-names.ts +9 -0
- package/src/agents/runtime-agent-registry.ts +418 -0
- package/src/api/agents.ts +7 -0
- package/src/api/preflight.ts +8 -3
- package/src/extension/config.ts +3 -0
- package/src/extension/doctor.ts +11 -0
- package/src/extension/fanout-child.ts +3 -2
- package/src/extension/index.ts +20 -4
- package/src/extension/public-execution.ts +1 -1
- package/src/extension/rpc.ts +41 -1
- package/src/extension/schemas.ts +6 -3
- package/src/extension/tool-description.ts +2 -2
- package/src/extension/tool-result.ts +19 -0
- package/src/inspectors/herdr/client.ts +3 -3
- package/src/runs/background/async-execution.ts +12 -6
- package/src/runs/background/async-job-tracker.ts +4 -3
- package/src/runs/background/async-resume.ts +2 -1
- package/src/runs/background/async-retention.ts +1 -1
- package/src/runs/background/async-status-snapshot.ts +14 -5
- package/src/runs/background/auto-drain.ts +1 -0
- package/src/runs/background/chain-root-attachment.ts +5 -0
- package/src/runs/background/result-watcher.ts +9 -0
- package/src/runs/background/stale-run-reconciler.ts +3 -0
- package/src/runs/background/subagent-runner.ts +32 -5
- package/src/runs/background/subagent-wait.ts +9 -5
- package/src/runs/background/terminal-run-index.ts +15 -6
- package/src/runs/background/wait-completions.ts +2 -0
- package/src/runs/background/wait-tool.ts +5 -3
- package/src/runs/foreground/execution.ts +34 -2
- package/src/runs/foreground/subagent-executor.ts +196 -52
- package/src/runs/foreground/workflow-detach-reconcile.ts +83 -15
- package/src/runs/shared/acceptance.ts +44 -1
- package/src/runs/shared/model-exclusions.ts +242 -0
- package/src/runs/shared/model-fallback.ts +72 -16
- package/src/runs/shared/model-scope.ts +106 -39
- package/src/runs/shared/pi-args.ts +34 -1
- package/src/runs/shared/subagent-control.ts +25 -3
- package/src/runs/shared/subagent-prompt-runtime.ts +36 -9
- package/src/shared/fork-context.ts +17 -1
- package/src/shared/model-info.ts +20 -0
- package/src/shared/settings.ts +2 -2
- package/src/shared/types.ts +47 -2
- package/src/slash/slash-commands.ts +20 -6
- package/src/slash/slash-live-state.ts +3 -3
- package/src/tui/fleet-status.ts +86 -1
- package/src/tui/fleet.ts +55 -2
- package/src/tui/render.ts +73 -3
- package/src/watchdog/permission-arbiter.ts +59 -51
- package/src/workflows/scripted-workflow.ts +100 -12
- package/src/workflows/workflow-receipt.ts +140 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
4
|
-
import { isStorageCapacityError } from "../../shared/file-system-retry.ts";
|
|
5
4
|
import { getSingleResultOutput, readStatus } from "../../shared/utils.ts";
|
|
6
5
|
import {
|
|
7
6
|
DIRS,
|
|
@@ -13,6 +12,8 @@ import {
|
|
|
13
12
|
} from "../../shared/types.ts";
|
|
14
13
|
import { updateActiveRunIndex } from "../background/active-run-index.ts";
|
|
15
14
|
import { resultFilePath, writeAsyncResultFile } from "../background/result-files.ts";
|
|
15
|
+
import { resolveAsyncResumeTarget } from "../background/async-resume.ts";
|
|
16
|
+
import { readWorkflowReceipt, workflowReceiptPath, writeWorkflowReceipt, type WorkflowReceipt } from "../../workflows/workflow-receipt.ts";
|
|
16
17
|
|
|
17
18
|
function cloneWorkflowStatus(status: AsyncStatus): AsyncStatus {
|
|
18
19
|
return {
|
|
@@ -97,7 +98,7 @@ function workflowResultChildren(status: AsyncStatus, childRunId: string, result:
|
|
|
97
98
|
}));
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string, existing?: Record<string, unknown
|
|
101
|
+
function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string, existing?: Record<string, unknown>, receipt?: WorkflowReceipt): Record<string, unknown> {
|
|
101
102
|
const sessionId = status.sessionId ?? (typeof existing?.sessionId === "string" ? existing.sessionId : undefined);
|
|
102
103
|
const summary = status.state === "complete"
|
|
103
104
|
? `Workflow completed after detached child ${childRunId} finished.`
|
|
@@ -118,6 +119,7 @@ function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result
|
|
|
118
119
|
timestamp: Date.now(),
|
|
119
120
|
results: workflowResultChildren(status, childRunId, result, existing?.results),
|
|
120
121
|
workflow: status.workflow,
|
|
122
|
+
...(receipt ? { workflowReceipt: { path: path.join(asyncDir, "workflow-receipt.json"), receipt } } : {}),
|
|
121
123
|
asyncDir,
|
|
122
124
|
cwd: status.cwd,
|
|
123
125
|
sessionId,
|
|
@@ -125,6 +127,60 @@ function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result
|
|
|
125
127
|
};
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
function reconcileWorkflowReceipt(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string): WorkflowReceipt | undefined {
|
|
131
|
+
const receiptPath = workflowReceiptPath(DIRS.async, status.runId);
|
|
132
|
+
if (!fs.existsSync(receiptPath)) return undefined;
|
|
133
|
+
const receipt = readWorkflowReceipt(DIRS.async, status.runId);
|
|
134
|
+
const step = status.steps?.find((candidate) => candidate.runId === childRunId);
|
|
135
|
+
const key = step?.workflowKey;
|
|
136
|
+
if (!key) throw new Error(`Workflow receipt '${status.runId}' cannot identify detached child '${childRunId}' by stable key.`);
|
|
137
|
+
const entry = receipt.entries[key];
|
|
138
|
+
if (!entry) throw new Error(`Workflow receipt '${status.runId}' has no detached child key '${key}'.`);
|
|
139
|
+
let resumability: typeof entry.resumability;
|
|
140
|
+
try {
|
|
141
|
+
const target = resolveAsyncResumeTarget({ id: childRunId, dir: path.join(DIRS.async, childRunId) }, {}, { requireSessionFile: true, sessionId: status.sessionId });
|
|
142
|
+
resumability = target.kind === "revive" ? { state: "resumable" } : { state: "not-resumable", reason: "child is still running" };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
resumability = { state: "not-resumable", reason: error instanceof Error ? error.message : String(error) };
|
|
145
|
+
}
|
|
146
|
+
const outputReference = result.savedOutputPath ?? result.outputReference?.path ?? entry.outputReference;
|
|
147
|
+
const updatedEntry: WorkflowReceipt["entries"][string] = resumability.state === "resumable"
|
|
148
|
+
? {
|
|
149
|
+
...entry,
|
|
150
|
+
...(step.agent ? { agent: step.agent } : {}),
|
|
151
|
+
...(step.context ? { resolvedContext: step.context } : {}),
|
|
152
|
+
latestRunId: entry.latestRunId ?? childRunId,
|
|
153
|
+
resumability,
|
|
154
|
+
...(outputReference ? { outputReference } : {}),
|
|
155
|
+
}
|
|
156
|
+
: {
|
|
157
|
+
...entry,
|
|
158
|
+
...(step.agent ? { agent: step.agent } : {}),
|
|
159
|
+
...(step.context ? { resolvedContext: step.context } : {}),
|
|
160
|
+
resumability,
|
|
161
|
+
...(outputReference ? { outputReference } : {}),
|
|
162
|
+
};
|
|
163
|
+
const next: WorkflowReceipt = {
|
|
164
|
+
...receipt,
|
|
165
|
+
state: status.state === "complete" ? "complete" : status.state === "stopped" ? "stopped" : status.state === "paused" ? "paused" : "failed",
|
|
166
|
+
entries: {
|
|
167
|
+
...receipt.entries,
|
|
168
|
+
[key]: updatedEntry,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
writeWorkflowReceipt(asyncDir, next);
|
|
172
|
+
return next;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function appendDetachedWorkflowEvent(asyncDir: string, event: Record<string, unknown>): void {
|
|
176
|
+
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
177
|
+
try {
|
|
178
|
+
fs.appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, "utf-8");
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error(`Failed to append detached workflow event '${eventsPath}':`, error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
128
184
|
export function reconcileDetachedWorkflowChildCompletion(input: {
|
|
129
185
|
state: SubagentState;
|
|
130
186
|
workflowRunId: string;
|
|
@@ -160,21 +216,33 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
|
|
|
160
216
|
} catch (error) {
|
|
161
217
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
162
218
|
}
|
|
163
|
-
|
|
219
|
+
let receipt: WorkflowReceipt | undefined;
|
|
220
|
+
let receiptError: string | undefined;
|
|
221
|
+
try {
|
|
222
|
+
receipt = reconcileWorkflowReceipt(next, input.childRunId, input.result, asyncDir);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
receiptError = `Failed to reconcile async workflow receipt: ${error instanceof Error ? error.message : String(error)}`;
|
|
225
|
+
}
|
|
226
|
+
const published = publishedWorkflowResult(next, input.childRunId, input.result, asyncDir, existing, receipt);
|
|
164
227
|
writeAsyncResultFile(resultPath, published);
|
|
228
|
+
if (receiptError) {
|
|
229
|
+
appendDetachedWorkflowEvent(asyncDir, {
|
|
230
|
+
ts: Date.now(),
|
|
231
|
+
runId: input.workflowRunId,
|
|
232
|
+
type: "subagent.workflow.receipt_write_failed",
|
|
233
|
+
error: receiptError,
|
|
234
|
+
reconciledFromDetachedChild: input.childRunId,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
165
237
|
if (next.state === "complete" || next.state === "failed") {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
})}\n`, "utf-8");
|
|
175
|
-
} catch (error) {
|
|
176
|
-
if (!isStorageCapacityError(error)) throw error;
|
|
177
|
-
}
|
|
238
|
+
appendDetachedWorkflowEvent(asyncDir, {
|
|
239
|
+
ts: Date.now(),
|
|
240
|
+
runId: input.workflowRunId,
|
|
241
|
+
type: "subagent.workflow.completed",
|
|
242
|
+
state: next.state,
|
|
243
|
+
...(next.error ? { error: next.error } : {}),
|
|
244
|
+
reconciledFromDetachedChild: input.childRunId,
|
|
245
|
+
});
|
|
178
246
|
input.events?.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
|
|
179
247
|
id: input.workflowRunId,
|
|
180
248
|
runId: input.workflowRunId,
|
|
@@ -1140,6 +1140,49 @@ async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaul
|
|
|
1140
1140
|
return evidenced;
|
|
1141
1141
|
}
|
|
1142
1142
|
|
|
1143
|
+
/**
|
|
1144
|
+
* On Windows with `shell: true`, cmd.exe parses the command line itself and an
|
|
1145
|
+
* unquoted executable path containing spaces (e.g. `C:\Program Files\...\tool.exe`)
|
|
1146
|
+
* is split at the first space, so cmd tries to run `C:\Program` and fails.
|
|
1147
|
+
*
|
|
1148
|
+
* The command line is ambiguous, so only an unquoted absolute drive path with
|
|
1149
|
+
* a space in a directory component is safe to identify as an executable. That
|
|
1150
|
+
* path is quoted; everything after it is preserved as arguments.
|
|
1151
|
+
*
|
|
1152
|
+
* Commands that already start with a quote, single-token commands, and commands
|
|
1153
|
+
* whose first token already ends in an executable extension are returned
|
|
1154
|
+
* unchanged. Non-Windows platforms pass the command through untouched.
|
|
1155
|
+
*/
|
|
1156
|
+
export function quoteExecutableForShell(command: string, platform: string = process.platform): string {
|
|
1157
|
+
if (platform !== "win32") return command;
|
|
1158
|
+
const trimmed = command.trimStart();
|
|
1159
|
+
if (trimmed.startsWith("\"")) return command;
|
|
1160
|
+
const firstToken = trimmed.match(/^\S+/)?.[0];
|
|
1161
|
+
if (/\.(?:exe|bat|cmd|com|ps1)$/i.test(firstToken ?? "")) return command;
|
|
1162
|
+
const match = trimmed.match(/^([A-Za-z]:\\[^"<>|&*?\r\n]*?\s[^"<>|&*?\r\n]*?\\[^"<>|&*?\r\n]*?\.(?:exe|bat|cmd|com|ps1))(?=\s|$)/i);
|
|
1163
|
+
const executable = match?.[1];
|
|
1164
|
+
if (executable && /\s/.test(executable) && !/[A-Za-z]:\\/.test(executable.slice(3))) {
|
|
1165
|
+
const rest = trimmed.slice(executable.length);
|
|
1166
|
+
const leading = command.slice(0, command.length - trimmed.length);
|
|
1167
|
+
return `${leading}"${executable}"${rest}`;
|
|
1168
|
+
}
|
|
1169
|
+
const extensionlessSpacedFilenameMatch = trimmed.match(/^([A-Za-z]:\\[^"<>|&*?\r\n]*?\s[^"<>|&*?\r\n]*?)(?=\s+--|$)/i);
|
|
1170
|
+
const extensionlessSpacedFilename = extensionlessSpacedFilenameMatch?.[1];
|
|
1171
|
+
if (extensionlessSpacedFilename && /\s/.test(extensionlessSpacedFilename.slice(0, extensionlessSpacedFilename.lastIndexOf("\\"))) && !/[A-Za-z]:\\/.test(extensionlessSpacedFilename.slice(3))) {
|
|
1172
|
+
const rest = trimmed.slice(extensionlessSpacedFilename.length);
|
|
1173
|
+
const leading = command.slice(0, command.length - trimmed.length);
|
|
1174
|
+
return `${leading}"${extensionlessSpacedFilename}"${rest}`;
|
|
1175
|
+
}
|
|
1176
|
+
const extensionlessMatch = trimmed.match(/^([A-Za-z]:\\[^"<>|&*?\r\n]*?\s[^"<>|&*?\r\n]*?\\[^"<>|&*?\s\r\n]+)(?=\s|$)/i);
|
|
1177
|
+
const extensionlessExecutable = extensionlessMatch?.[1];
|
|
1178
|
+
if (extensionlessExecutable && /\s/.test(extensionlessExecutable) && !/[A-Za-z]:\\/.test(extensionlessExecutable.slice(3)) && !/\s/.test(extensionlessExecutable.slice(extensionlessExecutable.lastIndexOf("\\") + 1))) {
|
|
1179
|
+
const rest = trimmed.slice(extensionlessExecutable.length);
|
|
1180
|
+
const leading = command.slice(0, command.length - trimmed.length);
|
|
1181
|
+
return `${leading}"${extensionlessExecutable}"${rest}`;
|
|
1182
|
+
}
|
|
1183
|
+
return command;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1143
1186
|
function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: { signal?: AbortSignal; abortMessage?: string } = {}): Promise<AcceptanceVerifyResult> {
|
|
1144
1187
|
return new Promise((resolve) => {
|
|
1145
1188
|
const startedAt = Date.now();
|
|
@@ -1149,7 +1192,7 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string,
|
|
|
1149
1192
|
let timedOut = false;
|
|
1150
1193
|
let settled = false;
|
|
1151
1194
|
let hardKill: NodeJS.Timeout | undefined;
|
|
1152
|
-
const child = spawn(command.command, {
|
|
1195
|
+
const child = spawn(quoteExecutableForShell(command.command), {
|
|
1153
1196
|
cwd,
|
|
1154
1197
|
env: effectiveVerifyEnv(command.env),
|
|
1155
1198
|
shell: true,
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { TEMP_ROOT_DIR } from "../../shared/types.ts";
|
|
4
|
+
|
|
5
|
+
export const EXCLUSIONS_PATH_ENV = "PI_MODEL_EXCLUSIONS_PATH";
|
|
6
|
+
|
|
7
|
+
type ModelExclusionTarget = { modelId: string; provider?: string } | { provider: string; modelId?: never };
|
|
8
|
+
|
|
9
|
+
export type ModelExclusion = ModelExclusionTarget & {
|
|
10
|
+
reason?: string;
|
|
11
|
+
recordedAt: number;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type RecordModelFailureOptions = ModelExclusionTarget & {
|
|
16
|
+
reason?: string;
|
|
17
|
+
ttlMs?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let exclusions: ModelExclusion[] = [];
|
|
21
|
+
let loaded = false;
|
|
22
|
+
let defaultTTLMs = 24 * 60 * 60_000; // 24 hours, overridable via setDefaultTTL
|
|
23
|
+
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
|
+
let persistSeq = 0;
|
|
25
|
+
|
|
26
|
+
/** Override the default exclusion TTL. */
|
|
27
|
+
export function setDefaultTTL(ms: number): void {
|
|
28
|
+
if (!Number.isFinite(ms) || ms <= 0) throw new Error("Default model exclusion TTL must be a finite positive number.");
|
|
29
|
+
defaultTTLMs = ms;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the persistence path. Honors PI_MODEL_EXCLUSIONS_PATH; defaults to
|
|
34
|
+
* <TEMP_ROOT_DIR>/model-exclusions.json. Resolved lazily so tests can point the
|
|
35
|
+
* store at an isolated location after module load.
|
|
36
|
+
*/
|
|
37
|
+
export function getExclusionsFilePath(): string {
|
|
38
|
+
const envPath = process.env[EXCLUSIONS_PATH_ENV];
|
|
39
|
+
if (typeof envPath === "string" && envPath.trim()) return envPath.trim();
|
|
40
|
+
return path.join(TEMP_ROOT_DIR, "model-exclusions.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Persist exclusions to disk immediately (atomic write via tmp + rename).
|
|
45
|
+
* The store otherwise debounces writes; call this when durability matters
|
|
46
|
+
* (and in tests).
|
|
47
|
+
*/
|
|
48
|
+
export function flushPersist(): void {
|
|
49
|
+
const file = getExclusionsFilePath();
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
52
|
+
const tmpPath = `${file}.${process.pid}.${persistSeq++}.tmp`;
|
|
53
|
+
fs.writeFileSync(tmpPath, JSON.stringify({
|
|
54
|
+
version: 1,
|
|
55
|
+
exclusions: deduplicate(exclusions),
|
|
56
|
+
}, null, 2), "utf-8");
|
|
57
|
+
fs.renameSync(tmpPath, file);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(`[model-exclusions] Failed to persist exclusions to ${file}:`, error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function schedulePersist(): void {
|
|
64
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
65
|
+
persistTimer = setTimeout(() => {
|
|
66
|
+
persistTimer = null;
|
|
67
|
+
flushPersist();
|
|
68
|
+
}, 5000);
|
|
69
|
+
// Never hold the process open just to flush exclusions.
|
|
70
|
+
persistTimer.unref?.();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ensureLoaded(): void {
|
|
74
|
+
if (loaded) return;
|
|
75
|
+
loaded = true;
|
|
76
|
+
try {
|
|
77
|
+
const raw = fs.readFileSync(getExclusionsFilePath(), "utf-8");
|
|
78
|
+
const data = JSON.parse(raw);
|
|
79
|
+
if (data.version === 1) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
exclusions = (data.exclusions ?? []).filter((e: ModelExclusion) => e.expiresAt > now);
|
|
82
|
+
exclusions = deduplicate(exclusions);
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
86
|
+
console.error(`[model-exclusions] Failed to load exclusions from ${getExclusionsFilePath()}:`, error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function dedupKey(entry: ModelExclusion): string {
|
|
92
|
+
return `${entry.provider ?? ""}|${entry.modelId ?? ""}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function deduplicate(items: ModelExclusion[]): ModelExclusion[] {
|
|
96
|
+
const map = new Map<string, ModelExclusion>();
|
|
97
|
+
for (const entry of items) {
|
|
98
|
+
const key = dedupKey(entry);
|
|
99
|
+
const existing = map.get(key);
|
|
100
|
+
if (!existing || entry.recordedAt > existing.recordedAt) {
|
|
101
|
+
map.set(key, entry);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return Array.from(map.values());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Record a model failure as a temporary exclusion. While the exclusion is
|
|
109
|
+
* active, {@link isExcluded} returns true for the model (or for every model of
|
|
110
|
+
* the provider when modelId is omitted), and {@link filterFallbackCandidates}
|
|
111
|
+
* removes matching candidates from fallback lists.
|
|
112
|
+
*/
|
|
113
|
+
export function recordModelFailure(options: RecordModelFailureOptions): void {
|
|
114
|
+
ensureLoaded();
|
|
115
|
+
const ttl = options.ttlMs ?? defaultTTLMs;
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const target: ModelExclusionTarget = options.modelId !== undefined
|
|
118
|
+
? { modelId: options.modelId, ...(options.provider ? { provider: options.provider } : {}) }
|
|
119
|
+
: { provider: options.provider };
|
|
120
|
+
const exclusion: ModelExclusion = {
|
|
121
|
+
...target,
|
|
122
|
+
reason: options.reason ?? "runtime-failure",
|
|
123
|
+
recordedAt: now,
|
|
124
|
+
expiresAt: now + ttl,
|
|
125
|
+
};
|
|
126
|
+
exclusions.unshift(exclusion);
|
|
127
|
+
exclusions = deduplicate(exclusions);
|
|
128
|
+
if (exclusions.length > 200) exclusions.length = 200;
|
|
129
|
+
flushPersist();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Drop all expired exclusions from memory and schedule a persist.
|
|
134
|
+
*/
|
|
135
|
+
export function clearExpiredExclusions(): void {
|
|
136
|
+
ensureLoaded();
|
|
137
|
+
prune(exclusions, Date.now());
|
|
138
|
+
schedulePersist();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Remove every exclusion (e.g. after the operator fixes credentials).
|
|
143
|
+
*/
|
|
144
|
+
export function clearExclusions(): void {
|
|
145
|
+
ensureLoaded();
|
|
146
|
+
exclusions.length = 0;
|
|
147
|
+
schedulePersist();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether an exclusion entry matches a candidate.
|
|
152
|
+
*
|
|
153
|
+
* Semantics:
|
|
154
|
+
* - Entry with modelId: model-specific exclusion. Matches only that modelId;
|
|
155
|
+
* when both the entry and the candidate carry a provider, the providers must
|
|
156
|
+
* also agree so `openai/gpt-4` does not exclude `github-copilot/gpt-4`.
|
|
157
|
+
* - Entry without modelId: provider-wide exclusion (e.g. quota or auth failure).
|
|
158
|
+
* Matches every model of that provider.
|
|
159
|
+
*/
|
|
160
|
+
function entryMatches(entry: ModelExclusion, candidateModelId: string, candidateProvider: string | undefined, now: number): boolean {
|
|
161
|
+
if (entry.expiresAt <= now) return false;
|
|
162
|
+
if (entry.modelId) {
|
|
163
|
+
if (entry.modelId !== candidateModelId) return false;
|
|
164
|
+
return !entry.provider || !candidateProvider || entry.provider === candidateProvider;
|
|
165
|
+
}
|
|
166
|
+
return Boolean(entry.provider) && entry.provider === candidateProvider;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Whether a model (or its provider) is currently excluded.
|
|
171
|
+
*/
|
|
172
|
+
export function isExcluded(modelId: string, provider: string): boolean {
|
|
173
|
+
ensureLoaded();
|
|
174
|
+
return exclusions.some((entry) => entryMatches(entry, modelId, provider, Date.now()));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Number of live (non-expired) exclusions.
|
|
179
|
+
*/
|
|
180
|
+
export function getExcludedCount(): number {
|
|
181
|
+
ensureLoaded();
|
|
182
|
+
clearExpiredExclusions();
|
|
183
|
+
return exclusions.length;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Split a candidate fullId into its provider + modelId components.
|
|
188
|
+
*
|
|
189
|
+
* A fullId may carry a thinking suffix (`provider/model:thinking`) which is
|
|
190
|
+
* stripped before parsing, and the modelId itself may contain slashes
|
|
191
|
+
* (e.g. `openrouter/google/gemini-flash`). The first `/`-segment is the
|
|
192
|
+
* provider; everything after is the modelId. This MUST stay in lock-step with
|
|
193
|
+
* the matching inside {@link isExcluded} so that a failure recorded via
|
|
194
|
+
* {@link recordModelFailure} is later recognised by the candidate filter.
|
|
195
|
+
*/
|
|
196
|
+
export function parseModelKey(fullId: string): { provider?: string; modelId: string } {
|
|
197
|
+
const base = fullId.includes(":") ? fullId.substring(0, fullId.lastIndexOf(":")) : fullId;
|
|
198
|
+
if (!base.includes("/")) return { modelId: base };
|
|
199
|
+
const slash = base.indexOf("/");
|
|
200
|
+
return { provider: base.slice(0, slash), modelId: base.slice(slash + 1) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Filter a list of candidate fullIds, removing excluded models/providers and
|
|
205
|
+
* duplicates while preserving order.
|
|
206
|
+
*/
|
|
207
|
+
export function filterFallbackCandidates(candidates: string[], opts?: { now?: number }): string[] {
|
|
208
|
+
ensureLoaded();
|
|
209
|
+
const timestamp = opts?.now ?? Date.now();
|
|
210
|
+
const seen = new Set<string>();
|
|
211
|
+
const filtered: string[] = [];
|
|
212
|
+
for (const raw of candidates) {
|
|
213
|
+
if (!raw || seen.has(raw)) continue;
|
|
214
|
+
const { provider: candidateProvider, modelId: candidateModelId } = parseModelKey(raw);
|
|
215
|
+
const excluded = exclusions.some((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
|
|
216
|
+
if (excluded) continue;
|
|
217
|
+
seen.add(raw);
|
|
218
|
+
filtered.push(raw);
|
|
219
|
+
}
|
|
220
|
+
return filtered;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Reload exclusions from disk (for tests and config hot-reload).
|
|
225
|
+
* Discards any in-memory-only exclusions that were not yet persisted.
|
|
226
|
+
*/
|
|
227
|
+
export function reloadFromDisk(): void {
|
|
228
|
+
loaded = false;
|
|
229
|
+
exclusions = [];
|
|
230
|
+
ensureLoaded();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function prune(items: ModelExclusion[], now: number): void {
|
|
234
|
+
let write = 0;
|
|
235
|
+
for (let i = 0; i < items.length; i++) {
|
|
236
|
+
const entry = items[i]!;
|
|
237
|
+
if (entry.expiresAt > now) {
|
|
238
|
+
items[write++] = entry;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
items.length = write;
|
|
242
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
|
|
2
2
|
import type { Usage } from "../../shared/types.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
|
|
4
|
+
import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
|
|
4
5
|
|
|
5
6
|
export type { AvailableModelInfo };
|
|
6
7
|
|
|
@@ -235,7 +236,7 @@ function resolveRequiredSubagentModelCandidate(
|
|
|
235
236
|
|
|
236
237
|
export interface ResolveSubagentModelOverrideOptions {
|
|
237
238
|
/** When set with `enforce: true`, out-of-scope models are rejected. */
|
|
238
|
-
scope?:
|
|
239
|
+
scope?: ModelScopeCheckRule | ModelScopeCheckRule[];
|
|
239
240
|
/** Origin of the requested model: explicit caller-supplied (hard error) vs inherited (warn). Defaults to `"inherited"`. */
|
|
240
241
|
source?: ModelSource;
|
|
241
242
|
/** Called for warn-severity violations instead of `console.warn`. */
|
|
@@ -246,6 +247,32 @@ function defaultScopeWarn(violation: ModelScopeViolation): void {
|
|
|
246
247
|
console.warn(`[pi-subagents] ${violation.message}`);
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
function configuredScopes(scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined): ModelScopeCheckRule[] {
|
|
251
|
+
return scope ? (Array.isArray(scope) ? scope : [scope]) : [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function throwForUnresolvedEnforcedInheritScope(scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined, includeMixed = false): void {
|
|
255
|
+
const unresolvedInheritScope = configuredScopes(scope)
|
|
256
|
+
.find((entry) => entry.enforce === true && (includeMixed ? entry.allow?.includes(INHERIT_MODEL) : entry.allow?.length === 1 && entry.allow[0] === INHERIT_MODEL));
|
|
257
|
+
if (!unresolvedInheritScope) return;
|
|
258
|
+
const origin = unresolvedInheritScope.origin ?? "modelScope";
|
|
259
|
+
throw new Error(`Cannot enforce subagent model scope (${origin}): 'inherit' requires a current parent session model.`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function enforceModelScopes(
|
|
263
|
+
model: string,
|
|
264
|
+
scope: ModelScopeCheckRule | ModelScopeCheckRule[] | undefined,
|
|
265
|
+
source: ModelSource,
|
|
266
|
+
onWarn: ((violation: ModelScopeViolation) => void) | undefined,
|
|
267
|
+
): void {
|
|
268
|
+
const violations = configuredScopes(scope)
|
|
269
|
+
.map((entry) => checkModelScope(model, entry, source))
|
|
270
|
+
.filter((violation): violation is ModelScopeViolation => violation !== undefined);
|
|
271
|
+
const error = violations.find((violation) => violation.severity === "error");
|
|
272
|
+
if (error) throw new Error(error.message);
|
|
273
|
+
for (const violation of violations) (onWarn ?? defaultScopeWarn)(violation);
|
|
274
|
+
}
|
|
275
|
+
|
|
249
276
|
/**
|
|
250
277
|
* Resolve the `--model` override passed to a spawned subagent.
|
|
251
278
|
*
|
|
@@ -273,19 +300,16 @@ export function resolveSubagentModelOverride(
|
|
|
273
300
|
): string | undefined {
|
|
274
301
|
const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
|
|
275
302
|
const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
|
|
303
|
+
if (!parentModel) throwForUnresolvedEnforcedInheritScope(options?.scope, explicit === undefined || options?.source === "inherited");
|
|
276
304
|
let resolved: string | undefined;
|
|
277
305
|
if (explicit === undefined) {
|
|
278
306
|
resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
|
|
279
307
|
} else {
|
|
280
308
|
resolved = resolveRequiredSubagentModelCandidate(explicit, availableModels, preferredProvider);
|
|
281
309
|
}
|
|
282
|
-
if (resolved && options?.scope
|
|
310
|
+
if (resolved && options?.scope) {
|
|
283
311
|
const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
|
|
284
|
-
|
|
285
|
-
if (violation) {
|
|
286
|
-
if (violation.severity === "error") throw new Error(violation.message);
|
|
287
|
-
(options.onWarn ?? defaultScopeWarn)(violation);
|
|
288
|
-
}
|
|
312
|
+
enforceModelScopes(resolved, options.scope, source, options.onWarn);
|
|
289
313
|
}
|
|
290
314
|
return resolved;
|
|
291
315
|
}
|
|
@@ -317,7 +341,7 @@ export function resolveEffectiveSubagentModel(
|
|
|
317
341
|
|
|
318
342
|
export interface BuildModelCandidatesOptions {
|
|
319
343
|
/** Fallback models warn by default and throw when strict scope enforcement is enabled. */
|
|
320
|
-
scope?:
|
|
344
|
+
scope?: ModelScopeCheckRule | ModelScopeCheckRule[];
|
|
321
345
|
onWarn?: (violation: ModelScopeViolation) => void;
|
|
322
346
|
/** The primary model came from the running parent session, not configuration. */
|
|
323
347
|
primaryModelFromParent?: boolean;
|
|
@@ -340,6 +364,7 @@ export function buildModelCandidates(
|
|
|
340
364
|
preferredProvider?: string,
|
|
341
365
|
options?: BuildModelCandidatesOptions,
|
|
342
366
|
): string[] {
|
|
367
|
+
if (!primaryModel) throwForUnresolvedEnforcedInheritScope(options?.scope, true);
|
|
343
368
|
const seen = new Set<string>();
|
|
344
369
|
const candidates: string[] = [];
|
|
345
370
|
const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
|
|
@@ -357,17 +382,14 @@ export function buildModelCandidates(
|
|
|
357
382
|
continue;
|
|
358
383
|
}
|
|
359
384
|
if (seen.has(normalized)) continue;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (violation.severity === "error") throw new Error(violation.message);
|
|
364
|
-
(options.onWarn ?? defaultScopeWarn)(violation);
|
|
365
|
-
}
|
|
385
|
+
const scopes = configuredScopes(options?.scope);
|
|
386
|
+
if (index > 0 || scopes.some((scope) => scope.enforce === true && scope.strict === true)) {
|
|
387
|
+
enforceModelScopes(normalized, scopes, "inherited", options?.onWarn);
|
|
366
388
|
}
|
|
367
389
|
seen.add(normalized);
|
|
368
390
|
candidates.push(normalized);
|
|
369
391
|
}
|
|
370
|
-
return candidates;
|
|
392
|
+
return filterFallbackCandidates(candidates);
|
|
371
393
|
}
|
|
372
394
|
|
|
373
395
|
const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
@@ -424,6 +446,40 @@ export function isRetryableModelFailure(error: string | undefined): boolean {
|
|
|
424
446
|
return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
|
|
425
447
|
}
|
|
426
448
|
|
|
449
|
+
export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
|
|
450
|
+
if (!model || !isRetryableModelFailure(error)) return;
|
|
451
|
+
const { provider, modelId } = parseModelKey(model);
|
|
452
|
+
recordModelFailure({ modelId, reason: error, ...(provider ? { provider } : {}) });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Context-overflow signals. These are deliberately NOT part of
|
|
457
|
+
* {@link RETRYABLE_MODEL_FAILURE_PATTERNS}: an overflow means the input was too
|
|
458
|
+
* large for the model's context window, so retrying the same input on another
|
|
459
|
+
* model (or the same model again) cannot succeed. Callers should treat overflow
|
|
460
|
+
* as a terminal, non-retryable failure and surface a clear "input too large"
|
|
461
|
+
* error instead of burning fallback attempts on a guaranteed failure.
|
|
462
|
+
*/
|
|
463
|
+
const CONTEXT_OVERFLOW_PATTERNS = [
|
|
464
|
+
/context(?: length| window| limit)? (?:exceed|overflow|too long)/i,
|
|
465
|
+
/maximum context length/i,
|
|
466
|
+
/too many tokens/i,
|
|
467
|
+
/token limit/i,
|
|
468
|
+
/context_length_exceeded/i,
|
|
469
|
+
/length_required/i,
|
|
470
|
+
/maximum.*tokens/i,
|
|
471
|
+
/prompt.*too long/i,
|
|
472
|
+
/input.*too long/i,
|
|
473
|
+
/exceeded.*context/i,
|
|
474
|
+
/context.*overflow/i,
|
|
475
|
+
];
|
|
476
|
+
|
|
477
|
+
export function isContextOverflow(error: string | undefined): boolean {
|
|
478
|
+
if (!error) return false;
|
|
479
|
+
if (TOOL_FAILURE_PREFIX.test(error.trim())) return false;
|
|
480
|
+
return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(error));
|
|
481
|
+
}
|
|
482
|
+
|
|
427
483
|
export function formatModelAttemptNote(attempt: ModelAttemptSummary, nextModel?: string): string {
|
|
428
484
|
const failure = attempt.error?.trim() || `exit ${attempt.exitCode ?? 1}`;
|
|
429
485
|
return nextModel
|