pi-subagents 0.52.0 → 0.53.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 +58 -0
- package/README.md +4 -0
- package/docs/configuration.md +11 -1
- package/docs/extension-api.md +3 -1
- package/docs/workflows.md +2 -0
- package/package.json +2 -1
- package/prompts/council.md +48 -0
- package/skills/council-mode/SKILL.md +230 -0
- package/skills/pi-subagents/SKILL.md +2 -0
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -0
- package/skills/pi-subagents/references/execution-controls.md +11 -0
- package/skills/pi-subagents/references/multi-lane-orchestration.md +39 -0
- package/src/agents/agent-management.ts +22 -3
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +29 -13
- 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/external-job-provider.ts +3 -2
- package/src/api/preflight.ts +1 -1
- package/src/extension/config.ts +3 -0
- package/src/extension/doctor.ts +1 -0
- package/src/extension/index.ts +17 -2
- package/src/extension/rpc.ts +41 -1
- package/src/extension/schemas.ts +7 -4
- package/src/extension/tool-description.ts +2 -2
- package/src/runs/background/async-execution.ts +2 -1
- package/src/runs/background/async-job-tracker.ts +4 -3
- package/src/runs/background/async-resume.ts +2 -1
- package/src/runs/background/async-status-snapshot.ts +14 -5
- package/src/runs/background/auto-drain.ts +1 -0
- package/src/runs/background/result-watcher.ts +8 -0
- package/src/runs/background/subagent-runner.ts +7 -4
- package/src/runs/background/subagent-wait.ts +9 -5
- package/src/runs/background/terminal-run-index.ts +15 -6
- package/src/runs/background/wait-tool.ts +1 -0
- package/src/runs/foreground/execution.ts +5 -1
- package/src/runs/foreground/subagent-executor.ts +182 -46
- 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 +55 -2
- package/src/runs/shared/subagent-control.ts +25 -3
- 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 +35 -0
- 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/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,5 +1,6 @@
|
|
|
1
1
|
import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
|
|
2
2
|
import type { Usage } from "../../shared/types.ts";
|
|
3
|
+
import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
|
|
3
4
|
import { checkModelScope, type ModelScopeConfig, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
|
|
4
5
|
|
|
5
6
|
export type { AvailableModelInfo };
|
|
@@ -205,6 +206,21 @@ function resolveSubagentModelCandidate(
|
|
|
205
206
|
return resolvedBase ? `${resolvedBase}${thinkingSuffix}` : undefined;
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
function suggestAlternateProviderModel(
|
|
210
|
+
model: string,
|
|
211
|
+
availableModels: AvailableModelInfo[] | undefined,
|
|
212
|
+
): string | undefined {
|
|
213
|
+
if (!availableModels || availableModels.length === 0) return undefined;
|
|
214
|
+
const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
|
|
215
|
+
const { queryProvider, queryIdRaw } = splitQualifiedModelQuery(baseModel, availableModels);
|
|
216
|
+
if (queryProvider === undefined) return undefined;
|
|
217
|
+
const suggestion = resolveBaseModelCandidate(queryIdRaw, availableModels);
|
|
218
|
+
if (!suggestion) return undefined;
|
|
219
|
+
const matched = availableModels.find((entry) => entry.fullId === suggestion);
|
|
220
|
+
if (!matched || normalizeModelSegment(matched.provider) === queryProvider) return undefined;
|
|
221
|
+
return `${suggestion}${thinkingSuffix}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
208
224
|
function resolveRequiredSubagentModelCandidate(
|
|
209
225
|
model: string,
|
|
210
226
|
availableModels: AvailableModelInfo[] | undefined,
|
|
@@ -212,7 +228,10 @@ function resolveRequiredSubagentModelCandidate(
|
|
|
212
228
|
): string {
|
|
213
229
|
const resolved = resolveSubagentModelCandidate(model, availableModels, preferredProvider);
|
|
214
230
|
if (resolved) return resolved;
|
|
215
|
-
|
|
231
|
+
const suggestion = suggestAlternateProviderModel(model, availableModels);
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Unknown subagent model '${model}' in the active Pi model registry.${suggestion ? ` Did you mean '${suggestion}'?` : ""}`,
|
|
234
|
+
);
|
|
216
235
|
}
|
|
217
236
|
|
|
218
237
|
export interface ResolveSubagentModelOverrideOptions {
|
|
@@ -349,7 +368,7 @@ export function buildModelCandidates(
|
|
|
349
368
|
seen.add(normalized);
|
|
350
369
|
candidates.push(normalized);
|
|
351
370
|
}
|
|
352
|
-
return candidates;
|
|
371
|
+
return filterFallbackCandidates(candidates);
|
|
353
372
|
}
|
|
354
373
|
|
|
355
374
|
const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
@@ -406,6 +425,40 @@ export function isRetryableModelFailure(error: string | undefined): boolean {
|
|
|
406
425
|
return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
|
|
407
426
|
}
|
|
408
427
|
|
|
428
|
+
export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
|
|
429
|
+
if (!model || !isRetryableModelFailure(error)) return;
|
|
430
|
+
const { provider, modelId } = parseModelKey(model);
|
|
431
|
+
recordModelFailure({ modelId, reason: error, ...(provider ? { provider } : {}) });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Context-overflow signals. These are deliberately NOT part of
|
|
436
|
+
* {@link RETRYABLE_MODEL_FAILURE_PATTERNS}: an overflow means the input was too
|
|
437
|
+
* large for the model's context window, so retrying the same input on another
|
|
438
|
+
* model (or the same model again) cannot succeed. Callers should treat overflow
|
|
439
|
+
* as a terminal, non-retryable failure and surface a clear "input too large"
|
|
440
|
+
* error instead of burning fallback attempts on a guaranteed failure.
|
|
441
|
+
*/
|
|
442
|
+
const CONTEXT_OVERFLOW_PATTERNS = [
|
|
443
|
+
/context(?: length| window| limit)? (?:exceed|overflow|too long)/i,
|
|
444
|
+
/maximum context length/i,
|
|
445
|
+
/too many tokens/i,
|
|
446
|
+
/token limit/i,
|
|
447
|
+
/context_length_exceeded/i,
|
|
448
|
+
/length_required/i,
|
|
449
|
+
/maximum.*tokens/i,
|
|
450
|
+
/prompt.*too long/i,
|
|
451
|
+
/input.*too long/i,
|
|
452
|
+
/exceeded.*context/i,
|
|
453
|
+
/context.*overflow/i,
|
|
454
|
+
];
|
|
455
|
+
|
|
456
|
+
export function isContextOverflow(error: string | undefined): boolean {
|
|
457
|
+
if (!error) return false;
|
|
458
|
+
if (TOOL_FAILURE_PREFIX.test(error.trim())) return false;
|
|
459
|
+
return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(error));
|
|
460
|
+
}
|
|
461
|
+
|
|
409
462
|
export function formatModelAttemptNote(attempt: ModelAttemptSummary, nextModel?: string): string {
|
|
410
463
|
const failure = attempt.error?.trim() || `exit ${attempt.exitCode ?? 1}`;
|
|
411
464
|
return nextModel
|
|
@@ -17,6 +17,7 @@ const DEFAULT_NOTIFY_ON: ControlEventType[] = ["active_long_running", "needs_att
|
|
|
17
17
|
export const DEFAULT_CONTROL_CONFIG: ResolvedControlConfig = {
|
|
18
18
|
enabled: true,
|
|
19
19
|
needsAttentionAfterMs: 60_000,
|
|
20
|
+
needsAttentionAfterMsIsExplicit: false,
|
|
20
21
|
activeNoticeAfterMs: 240_000,
|
|
21
22
|
failedToolAttemptsBeforeAttention: 3,
|
|
22
23
|
notifyOn: DEFAULT_NOTIFY_ON,
|
|
@@ -42,9 +43,13 @@ export function resolveControlConfig(
|
|
|
42
43
|
override?: ControlConfig,
|
|
43
44
|
): ResolvedControlConfig {
|
|
44
45
|
const enabled = override?.enabled ?? globalConfig?.enabled ?? DEFAULT_CONTROL_CONFIG.enabled;
|
|
45
|
-
const
|
|
46
|
-
|
|
46
|
+
const overrideNeedsAttentionAfterMs = parsePositiveInt(override?.needsAttentionAfterMs);
|
|
47
|
+
const globalNeedsAttentionAfterMs = parsePositiveInt(globalConfig?.needsAttentionAfterMs);
|
|
48
|
+
const needsAttentionAfterMs = overrideNeedsAttentionAfterMs
|
|
49
|
+
?? globalNeedsAttentionAfterMs
|
|
47
50
|
?? DEFAULT_CONTROL_CONFIG.needsAttentionAfterMs;
|
|
51
|
+
const needsAttentionAfterMsIsExplicit = overrideNeedsAttentionAfterMs !== undefined
|
|
52
|
+
|| globalNeedsAttentionAfterMs !== undefined;
|
|
48
53
|
const activeNoticeAfterMs = parsePositiveInt(override?.activeNoticeAfterMs)
|
|
49
54
|
?? parsePositiveInt(globalConfig?.activeNoticeAfterMs)
|
|
50
55
|
?? DEFAULT_CONTROL_CONFIG.activeNoticeAfterMs;
|
|
@@ -64,6 +69,7 @@ export function resolveControlConfig(
|
|
|
64
69
|
return {
|
|
65
70
|
enabled,
|
|
66
71
|
needsAttentionAfterMs,
|
|
72
|
+
needsAttentionAfterMsIsExplicit,
|
|
67
73
|
activeNoticeAfterMs,
|
|
68
74
|
activeNoticeAfterTurns,
|
|
69
75
|
activeNoticeAfterTokens,
|
|
@@ -73,18 +79,34 @@ export function resolveControlConfig(
|
|
|
73
79
|
};
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
function scaledNeedsAttentionAfterMs(config: ResolvedControlConfig, thinking?: string | false): number {
|
|
83
|
+
if (config.needsAttentionAfterMsIsExplicit !== false) return config.needsAttentionAfterMs;
|
|
84
|
+
switch (thinking) {
|
|
85
|
+
case "medium":
|
|
86
|
+
return config.needsAttentionAfterMs * 2;
|
|
87
|
+
case "high":
|
|
88
|
+
return config.needsAttentionAfterMs * 5;
|
|
89
|
+
case "xhigh":
|
|
90
|
+
case "max":
|
|
91
|
+
return config.needsAttentionAfterMs * 10;
|
|
92
|
+
default:
|
|
93
|
+
return config.needsAttentionAfterMs;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
export function deriveActivityState(input: {
|
|
77
98
|
config: ResolvedControlConfig;
|
|
78
99
|
startedAt: number;
|
|
79
100
|
lastActivityAt?: number;
|
|
80
101
|
currentTool?: string;
|
|
102
|
+
thinking?: string | false;
|
|
81
103
|
now?: number;
|
|
82
104
|
}): ActivityState | undefined {
|
|
83
105
|
if (!input.config.enabled || input.currentTool) return undefined;
|
|
84
106
|
const now = input.now ?? Date.now();
|
|
85
107
|
const lastActivity = input.lastActivityAt ?? input.startedAt;
|
|
86
108
|
const ageMs = Math.max(0, now - lastActivity);
|
|
87
|
-
return ageMs > input.config.
|
|
109
|
+
return ageMs > scaledNeedsAttentionAfterMs(input.config, input.thinking) ? "needs_attention" : undefined;
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
export function shouldEmitOpenToolAttention(input: {
|
|
@@ -208,7 +208,23 @@ export function createForkContextResolver(
|
|
|
208
208
|
const openSession = options.openSession
|
|
209
209
|
?? sessionManager.openSession
|
|
210
210
|
?? ((file: string, dir?: string) => SessionManager.open(file, dir));
|
|
211
|
-
|
|
211
|
+
// Fork files must not land in the parent's top-level session directory.
|
|
212
|
+
// Pi's recent-session discovery (`pi -c` → findMostRecentSession) is
|
|
213
|
+
// non-recursive and picks the largest-mtime *.jsonl in that directory, so
|
|
214
|
+
// a still-running forked subagent — which keeps appending after the parent
|
|
215
|
+
// went idle — would hijack the next `pi -c` away from the conversation the
|
|
216
|
+
// user actually left. Nesting fork sessions in a per-parent directory keeps
|
|
217
|
+
// them invisible to that discovery; the `parentSession` header still
|
|
218
|
+
// records the tree relationship. The directory mirrors
|
|
219
|
+
// getSubagentSessionRoot() plus a "forks" level so fork files never sit
|
|
220
|
+
// loose next to run-N/ result directories. Derived from the file path
|
|
221
|
+
// rather than getSessionDir() so it also works when the manager cannot
|
|
222
|
+
// report its directory.
|
|
223
|
+
const sessionDir = path.join(
|
|
224
|
+
path.dirname(parentSessionFile),
|
|
225
|
+
path.basename(parentSessionFile, ".jsonl"),
|
|
226
|
+
"forks",
|
|
227
|
+
);
|
|
212
228
|
const cachedResolutions = new Map<number, ForkContextResolution>();
|
|
213
229
|
|
|
214
230
|
const resolveFork = (index = 0): ForkContextResolution => {
|
package/src/shared/model-info.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ModelCost } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
1
3
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
2
4
|
export type ThinkingLevel = typeof THINKING_LEVELS[number];
|
|
3
5
|
export type ThinkingLevelMap = Partial<Record<ThinkingLevel, string | null>>;
|
|
@@ -9,6 +11,14 @@ export interface ModelInfo {
|
|
|
9
11
|
api?: string;
|
|
10
12
|
reasoning?: boolean;
|
|
11
13
|
thinkingLevelMap?: ThinkingLevelMap;
|
|
14
|
+
/** Context window in tokens, when the host model registry reports one. */
|
|
15
|
+
contextWindow?: number;
|
|
16
|
+
/** Maximum output tokens, when the host model registry reports one. */
|
|
17
|
+
maxTokens?: number;
|
|
18
|
+
/** Input modalities reported by the registry, e.g. ["text", "image"]. */
|
|
19
|
+
input?: string[];
|
|
20
|
+
/** Per-token pricing from the registry (USD per 1M tokens). */
|
|
21
|
+
cost?: ModelCost;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
interface RegistryModelLike {
|
|
@@ -17,6 +27,10 @@ interface RegistryModelLike {
|
|
|
17
27
|
api?: string;
|
|
18
28
|
reasoning?: boolean;
|
|
19
29
|
thinkingLevelMap?: ThinkingLevelMap;
|
|
30
|
+
contextWindow?: number;
|
|
31
|
+
maxTokens?: number;
|
|
32
|
+
input?: string[];
|
|
33
|
+
cost?: ModelCost;
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
export function toModelInfo(model: RegistryModelLike): ModelInfo {
|
|
@@ -27,6 +41,12 @@ export function toModelInfo(model: RegistryModelLike): ModelInfo {
|
|
|
27
41
|
api: model.api,
|
|
28
42
|
reasoning: model.reasoning,
|
|
29
43
|
thinkingLevelMap: model.thinkingLevelMap,
|
|
44
|
+
...(typeof model.contextWindow === "number" && Number.isFinite(model.contextWindow) && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
|
|
45
|
+
...(typeof model.maxTokens === "number" && Number.isFinite(model.maxTokens) && model.maxTokens > 0 ? { maxTokens: model.maxTokens } : {}),
|
|
46
|
+
...(Array.isArray(model.input) && model.input.length > 0 ? { input: [...model.input] } : {}),
|
|
47
|
+
...(model.cost && Number.isFinite(model.cost.input) && Number.isFinite(model.cost.output)
|
|
48
|
+
? { cost: { ...model.cost, ...(model.cost.tiers ? { tiers: model.cost.tiers.map((tier) => ({ ...tier })) } : {}) } }
|
|
49
|
+
: {}),
|
|
30
50
|
};
|
|
31
51
|
}
|
|
32
52
|
|