pi-subagents 0.50.0 → 0.52.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 +103 -0
- package/agents/oracle.md +3 -1
- package/agents/reviewer.md +1 -0
- package/agents/scout.md +2 -2
- package/agents/worker.md +2 -1
- package/async-retention-discovery-worker.mjs +180 -0
- package/docs/agents.md +40 -2
- package/docs/configuration.md +30 -12
- package/docs/extension-api.md +42 -1
- package/docs/models.md +2 -0
- package/docs/observability.md +41 -5
- package/docs/tool-reference.md +38 -39
- package/docs/workflows.md +171 -3
- package/package.json +4 -2
- package/skills/pi-subagents/SKILL.md +6 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
- package/skills/pi-subagents/references/execution-controls.md +29 -15
- package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
- package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
- package/src/agents/agent-management.ts +124 -351
- package/src/agents/agents.ts +157 -31
- package/src/agents/skills.ts +1 -1
- package/src/api/external-job-provider.ts +185 -0
- package/src/api/preflight.ts +36 -8
- package/src/api/shared-types.ts +2 -0
- package/src/extension/config.ts +3 -3
- package/src/extension/doctor.ts +3 -6
- package/src/extension/fanout-child.ts +2 -2
- package/src/extension/index.ts +166 -88
- package/src/extension/public-execution.ts +31 -2
- package/src/extension/schemas.ts +12 -35
- package/src/extension/tool-description.ts +35 -24
- package/src/inspectors/herdr/actions.ts +2 -2
- package/src/inspectors/herdr/inspector-runner.ts +2 -1
- package/src/inspectors/herdr/project-panes.ts +2 -2
- package/src/intercom/native-supervisor-channel.ts +32 -10
- package/src/missions/lifecycle.ts +6 -1
- package/src/missions/store.ts +4 -9
- package/src/missions/workflow-state.ts +2 -2
- package/src/profiles/profiles.ts +3 -1
- package/src/runs/background/active-run-index.ts +31 -8
- package/src/runs/background/async-execution.ts +68 -40
- package/src/runs/background/async-job-tracker.ts +20 -4
- package/src/runs/background/async-resume.ts +47 -15
- package/src/runs/background/async-retention.ts +886 -0
- package/src/runs/background/async-status.ts +39 -53
- package/src/runs/background/chain-append.ts +3 -33
- package/src/runs/background/completion-dedupe.ts +5 -1
- package/src/runs/background/completion-replay.ts +22 -12
- package/src/runs/background/control-channel.ts +14 -68
- package/src/runs/background/fleet-view.ts +68 -19
- package/src/runs/background/index-segment.ts +59 -0
- package/src/runs/background/inspect-rpc.ts +443 -0
- package/src/runs/background/notify.ts +31 -5
- package/src/runs/background/result-files.ts +158 -90
- package/src/runs/background/result-watcher.ts +116 -20
- package/src/runs/background/resume-guidance.ts +8 -5
- package/src/runs/background/retained-children.ts +13 -3
- package/src/runs/background/run-id-query.ts +7 -0
- package/src/runs/background/run-id-resolver.ts +11 -9
- package/src/runs/background/run-status.ts +27 -18
- package/src/runs/background/scheduled-runs.ts +24 -4
- package/src/runs/background/stale-run-reconciler.ts +6 -3
- package/src/runs/background/steering.ts +11 -1
- package/src/runs/background/subagent-runner.ts +253 -140
- package/src/runs/background/subagent-wait.ts +8 -8
- package/src/runs/background/terminal-run-index.ts +129 -0
- package/src/runs/background/wait-completions.ts +21 -4
- package/src/runs/background/wait-subscriptions.ts +81 -2
- package/src/runs/foreground/async-steering-action.ts +21 -14
- package/src/runs/foreground/execution.ts +3 -1
- package/src/runs/foreground/subagent-executor.ts +666 -1579
- package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
- package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
- package/src/runs/shared/acceptance.ts +4 -4
- package/src/runs/shared/chain-outputs.ts +1 -3
- package/src/runs/shared/external-job-bridge.ts +444 -0
- package/src/runs/shared/external-job-runner.ts +286 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
- package/src/runs/shared/model-fallback.ts +98 -38
- package/src/runs/shared/orca-progress-tabs.ts +84 -22
- package/src/runs/shared/parallel-handoff.ts +46 -4
- package/src/runs/shared/parallel-utils.ts +4 -15
- package/src/runs/shared/permissions.ts +5 -1
- package/src/runs/shared/pi-args.ts +8 -1
- package/src/runs/shared/session-lease.ts +0 -6
- package/src/runs/shared/subagent-control.ts +26 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
- package/src/runs/shared/workflow-graph.ts +1 -23
- package/src/runs/shared/worktree.ts +14 -2
- package/src/shared/atomic-json.ts +22 -2
- package/src/shared/capacity-resilient-json.ts +102 -0
- package/src/shared/completion-owner.ts +14 -0
- package/src/shared/file-system-retry.ts +49 -1
- package/src/shared/fork-context.ts +42 -0
- package/src/shared/prompt-resources.ts +0 -40
- package/src/shared/settings.ts +3 -27
- package/src/shared/types.ts +71 -26
- package/src/shared/utils.ts +8 -0
- package/src/shared/watch-strategy.ts +10 -0
- package/src/slash/slash-bridge.ts +2 -1
- package/src/slash/slash-commands.ts +29 -3
- package/src/tui/fleet.ts +63 -15
- package/src/watchdog/change-signature.ts +1 -1
- package/src/watchdog/lsp-diagnostics.ts +1 -0
- package/src/workflows/chat-progress.ts +2 -2
- package/src/workflows/scripted-workflow.ts +220 -67
- package/src/runs/foreground/chain-clarify.ts +0 -1354
- package/src/runs/foreground/chain-execution.ts +0 -1581
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { ExternalJobProviderError, type ExternalJobHandle, type ExternalJobResult, type ExternalJobState } from "../../api/external-job-provider.ts";
|
|
5
|
+
import type { ExternalJobStatus } from "../../shared/types.ts";
|
|
6
|
+
import { requestExternalJobOperation } from "./external-job-bridge.ts";
|
|
7
|
+
|
|
8
|
+
const STATUS_POLL_INTERVAL_MS = 1_000;
|
|
9
|
+
|
|
10
|
+
export interface ExternalJobRunResult {
|
|
11
|
+
output: string;
|
|
12
|
+
exitCode: number;
|
|
13
|
+
error?: string;
|
|
14
|
+
timedOut?: boolean;
|
|
15
|
+
stopped?: boolean;
|
|
16
|
+
externalJob: ExternalJobStatus;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function externalJobPromptDigest(prompt: string): string {
|
|
20
|
+
return createHash("sha256").update(prompt).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sleep(ms: number): Promise<void> {
|
|
24
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function terminal(state: ExternalJobState): boolean {
|
|
28
|
+
return state === "completed" || state === "failed" || state === "stopped" || state === "blocked";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatError(error: ExternalJobProviderError, provider: string): string {
|
|
32
|
+
const blocking = error.blockingJobId ? ` Blocking provider job: ${error.blockingJobId}.` : "";
|
|
33
|
+
return `External-job provider '${provider}' failed closed (${error.code}): ${error.message}.${blocking}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isNotFound(error: unknown): boolean {
|
|
37
|
+
return typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function malformedStatus(statusPath: string): ExternalJobProviderError {
|
|
45
|
+
return new ExternalJobProviderError(`Malformed external-job status '${statusPath}'. Refusing to redispatch the prompt.`, { code: "status-unreadable" });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseExternalJobStatus(value: unknown, statusPath: string): ExternalJobStatus {
|
|
49
|
+
if (!isRecord(value)) throw malformedStatus(statusPath);
|
|
50
|
+
const provider = value.provider;
|
|
51
|
+
const promptDigest = value.promptDigest;
|
|
52
|
+
const state = value.state;
|
|
53
|
+
if (typeof provider !== "string" || provider.length === 0) throw malformedStatus(statusPath);
|
|
54
|
+
if (typeof promptDigest !== "string" || promptDigest.length === 0) throw malformedStatus(statusPath);
|
|
55
|
+
if (state !== "queued" && state !== "running" && state !== "completed" && state !== "failed" && state !== "stopped" && state !== "blocked") throw malformedStatus(statusPath);
|
|
56
|
+
if (value.options !== undefined && !isRecord(value.options)) throw malformedStatus(statusPath);
|
|
57
|
+
const providerJobId = value.providerJobId;
|
|
58
|
+
const handleUrl = value.handleUrl;
|
|
59
|
+
const conversationUrl = value.conversationUrl;
|
|
60
|
+
const resultArtifactPath = value.resultArtifactPath;
|
|
61
|
+
const failureCode = value.failureCode;
|
|
62
|
+
const failureMessage = value.failureMessage;
|
|
63
|
+
const blockingJobId = value.blockingJobId;
|
|
64
|
+
const startedAt = value.startedAt;
|
|
65
|
+
const updatedAt = value.updatedAt;
|
|
66
|
+
if (providerJobId !== undefined && (typeof providerJobId !== "string" || providerJobId.length === 0)) throw malformedStatus(statusPath);
|
|
67
|
+
if (handleUrl !== undefined && typeof handleUrl !== "string") throw malformedStatus(statusPath);
|
|
68
|
+
if (conversationUrl !== undefined && typeof conversationUrl !== "string") throw malformedStatus(statusPath);
|
|
69
|
+
if (resultArtifactPath !== undefined && typeof resultArtifactPath !== "string") throw malformedStatus(statusPath);
|
|
70
|
+
if (failureCode !== undefined && typeof failureCode !== "string") throw malformedStatus(statusPath);
|
|
71
|
+
if (failureMessage !== undefined && typeof failureMessage !== "string") throw malformedStatus(statusPath);
|
|
72
|
+
if (blockingJobId !== undefined && typeof blockingJobId !== "string") throw malformedStatus(statusPath);
|
|
73
|
+
if (startedAt !== undefined && typeof startedAt !== "number") throw malformedStatus(statusPath);
|
|
74
|
+
if (updatedAt !== undefined && typeof updatedAt !== "number") throw malformedStatus(statusPath);
|
|
75
|
+
return {
|
|
76
|
+
provider,
|
|
77
|
+
promptDigest,
|
|
78
|
+
options: value.options ?? {},
|
|
79
|
+
state,
|
|
80
|
+
...(providerJobId ? { providerJobId } : {}),
|
|
81
|
+
...(handleUrl ? { handleUrl } : {}),
|
|
82
|
+
...(conversationUrl ? { conversationUrl } : {}),
|
|
83
|
+
...(resultArtifactPath ? { resultArtifactPath } : {}),
|
|
84
|
+
...(failureCode ? { failureCode } : {}),
|
|
85
|
+
...(failureMessage ? { failureMessage } : {}),
|
|
86
|
+
...(blockingJobId ? { blockingJobId } : {}),
|
|
87
|
+
...(startedAt !== undefined ? { startedAt } : {}),
|
|
88
|
+
...(updatedAt !== undefined ? { updatedAt } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readExistingExternalJob(asyncDir: string, stepIndex: number): ExternalJobStatus | undefined {
|
|
93
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
94
|
+
let raw: string;
|
|
95
|
+
try {
|
|
96
|
+
raw = fs.readFileSync(statusPath, "utf-8");
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (isNotFound(error)) return undefined;
|
|
99
|
+
throw new ExternalJobProviderError(`Unreadable external-job status '${statusPath}'. Refusing to redispatch the prompt.`, { code: "status-unreadable", cause: error });
|
|
100
|
+
}
|
|
101
|
+
let parsed: unknown;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new ExternalJobProviderError(`Malformed external-job status '${statusPath}'. Refusing to redispatch the prompt.`, { code: "status-unreadable", cause: error });
|
|
106
|
+
}
|
|
107
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.steps)) throw malformedStatus(statusPath);
|
|
108
|
+
const step = parsed.steps[stepIndex];
|
|
109
|
+
if (step === undefined || !isRecord(step)) throw malformedStatus(statusPath);
|
|
110
|
+
if (step.externalJob === undefined) return undefined;
|
|
111
|
+
return parseExternalJobStatus(step.externalJob, statusPath);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function statusFromHandle(input: {
|
|
115
|
+
provider: string;
|
|
116
|
+
promptDigest: string;
|
|
117
|
+
options: Record<string, unknown>;
|
|
118
|
+
startedAt?: number;
|
|
119
|
+
previous?: ExternalJobStatus;
|
|
120
|
+
handle: ExternalJobHandle;
|
|
121
|
+
resultArtifactPath?: string;
|
|
122
|
+
}): ExternalJobStatus {
|
|
123
|
+
return {
|
|
124
|
+
provider: input.provider,
|
|
125
|
+
providerJobId: input.handle.providerJobId,
|
|
126
|
+
promptDigest: input.promptDigest,
|
|
127
|
+
options: input.options,
|
|
128
|
+
handleUrl: input.handle.handleUrl ?? input.previous?.handleUrl,
|
|
129
|
+
conversationUrl: input.handle.conversationUrl ?? input.previous?.conversationUrl,
|
|
130
|
+
resultArtifactPath: input.resultArtifactPath ?? input.previous?.resultArtifactPath,
|
|
131
|
+
state: input.handle.state,
|
|
132
|
+
failureCode: input.handle.failureCode,
|
|
133
|
+
failureMessage: input.handle.failureMessage,
|
|
134
|
+
blockingJobId: input.handle.blockingJobId,
|
|
135
|
+
startedAt: input.previous?.startedAt ?? input.startedAt,
|
|
136
|
+
updatedAt: Date.now(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function failureStatus(input: {
|
|
141
|
+
provider: string;
|
|
142
|
+
promptDigest: string;
|
|
143
|
+
options: Record<string, unknown>;
|
|
144
|
+
previous?: ExternalJobStatus;
|
|
145
|
+
code: string;
|
|
146
|
+
message: string;
|
|
147
|
+
blockingJobId?: string;
|
|
148
|
+
}): ExternalJobStatus {
|
|
149
|
+
return {
|
|
150
|
+
provider: input.provider,
|
|
151
|
+
providerJobId: input.previous?.providerJobId,
|
|
152
|
+
promptDigest: input.promptDigest,
|
|
153
|
+
options: input.options,
|
|
154
|
+
handleUrl: input.previous?.handleUrl,
|
|
155
|
+
conversationUrl: input.previous?.conversationUrl,
|
|
156
|
+
resultArtifactPath: input.previous?.resultArtifactPath,
|
|
157
|
+
state: input.blockingJobId ? "blocked" : "failed",
|
|
158
|
+
failureCode: input.code,
|
|
159
|
+
failureMessage: input.message,
|
|
160
|
+
blockingJobId: input.blockingJobId,
|
|
161
|
+
startedAt: input.previous?.startedAt,
|
|
162
|
+
updatedAt: Date.now(),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function resultOutput(result: ExternalJobResult, artifactPath: string | undefined): string {
|
|
167
|
+
if (result.output?.trim()) return result.output.trim();
|
|
168
|
+
if (artifactPath) return `External job finished. Result artifact: ${artifactPath}`;
|
|
169
|
+
return "External job finished without text output.";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function blocksStartRedispatch(status: ExternalJobStatus, provider: string, promptDigest: string): boolean {
|
|
173
|
+
return status.provider === provider
|
|
174
|
+
&& status.promptDigest === promptDigest
|
|
175
|
+
&& !status.providerJobId
|
|
176
|
+
&& status.failureCode !== "provider-unavailable";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function runExternalJob(input: {
|
|
180
|
+
provider: string;
|
|
181
|
+
options?: Record<string, unknown>;
|
|
182
|
+
cwd: string;
|
|
183
|
+
prompt: string;
|
|
184
|
+
asyncDir: string;
|
|
185
|
+
stepIndex: number;
|
|
186
|
+
runId: string;
|
|
187
|
+
agent: string;
|
|
188
|
+
sessionId?: string;
|
|
189
|
+
registerTimeout?: (stop: (() => void) | undefined) => void;
|
|
190
|
+
registerStop?: (stop: (() => void) | undefined) => void;
|
|
191
|
+
timeoutMessage?: string;
|
|
192
|
+
stopMessage?: string;
|
|
193
|
+
onExternalJob?: (status: ExternalJobStatus) => void;
|
|
194
|
+
}): Promise<ExternalJobRunResult> {
|
|
195
|
+
const provider = input.provider;
|
|
196
|
+
const options = input.options ?? {};
|
|
197
|
+
const promptDigest = externalJobPromptDigest(input.prompt);
|
|
198
|
+
const startedAt = Date.now();
|
|
199
|
+
let timedOut = false;
|
|
200
|
+
let stopped = false;
|
|
201
|
+
let current: ExternalJobStatus | undefined;
|
|
202
|
+
const timeout = () => { timedOut = true; };
|
|
203
|
+
const stop = () => { stopped = true; };
|
|
204
|
+
input.registerTimeout?.(timeout);
|
|
205
|
+
input.registerStop?.(stop);
|
|
206
|
+
const publish = (status: ExternalJobStatus) => {
|
|
207
|
+
current = status;
|
|
208
|
+
input.onExternalJob?.(status);
|
|
209
|
+
};
|
|
210
|
+
const localCancellation = () => {
|
|
211
|
+
if (!timedOut && !stopped) return undefined;
|
|
212
|
+
const message = stopped
|
|
213
|
+
? input.stopMessage ?? `Subagent stopped locally before external provider '${provider}' returned a job id. The provider start may still be running.`
|
|
214
|
+
: input.timeoutMessage ?? `Subagent timed out locally before external provider '${provider}' returned a job id. The provider start may still be running.`;
|
|
215
|
+
return new ExternalJobProviderError(message, { code: stopped ? "local-stop" : "local-timeout" });
|
|
216
|
+
};
|
|
217
|
+
try {
|
|
218
|
+
current = readExistingExternalJob(input.asyncDir, input.stepIndex);
|
|
219
|
+
let handle: ExternalJobHandle;
|
|
220
|
+
if (current?.providerJobId) {
|
|
221
|
+
if (current.provider !== provider || current.promptDigest !== promptDigest) {
|
|
222
|
+
const message = `Existing external job '${current.providerJobId}' does not match provider or prompt digest. Refusing to redispatch prompt.`;
|
|
223
|
+
const status = failureStatus({ provider, promptDigest, options, previous: current, code: "recovery-mismatch", message });
|
|
224
|
+
publish(status);
|
|
225
|
+
return { output: message, exitCode: 1, error: message, externalJob: status };
|
|
226
|
+
}
|
|
227
|
+
handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, { operation: "reattach", provider, providerJobId: current.providerJobId });
|
|
228
|
+
} else {
|
|
229
|
+
if (current && blocksStartRedispatch(current, provider, promptDigest)) {
|
|
230
|
+
const message = `External-job start for provider '${provider}' previously ended without a durable provider job id. Refusing to redispatch the prompt automatically.`;
|
|
231
|
+
const status = failureStatus({ provider, promptDigest, options, previous: current, code: "start-redispatch-blocked", message });
|
|
232
|
+
publish(status);
|
|
233
|
+
return { output: message, exitCode: 1, error: message, externalJob: status };
|
|
234
|
+
}
|
|
235
|
+
publish({ provider, promptDigest, options, state: "queued", startedAt, updatedAt: Date.now() });
|
|
236
|
+
handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, {
|
|
237
|
+
operation: "start",
|
|
238
|
+
provider,
|
|
239
|
+
start: {
|
|
240
|
+
prompt: input.prompt,
|
|
241
|
+
promptDigest,
|
|
242
|
+
cwd: input.cwd,
|
|
243
|
+
runId: input.runId,
|
|
244
|
+
stepIndex: input.stepIndex,
|
|
245
|
+
agent: input.agent,
|
|
246
|
+
options,
|
|
247
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
248
|
+
},
|
|
249
|
+
}, undefined, localCancellation);
|
|
250
|
+
}
|
|
251
|
+
publish(statusFromHandle({ provider, promptDigest, options, startedAt, previous: current, handle }));
|
|
252
|
+
while (!terminal(handle.state)) {
|
|
253
|
+
if (timedOut || stopped) {
|
|
254
|
+
const message = stopped
|
|
255
|
+
? input.stopMessage ?? `Subagent stopped locally; external provider job '${handle.providerJobId}' may still be running.`
|
|
256
|
+
: input.timeoutMessage ?? `Subagent timed out locally; external provider job '${handle.providerJobId}' may still be running.`;
|
|
257
|
+
return { output: message, exitCode: 1, error: message, ...(timedOut ? { timedOut: true } : {}), ...(stopped ? { stopped: true } : {}), externalJob: current! };
|
|
258
|
+
}
|
|
259
|
+
await sleep(STATUS_POLL_INTERVAL_MS);
|
|
260
|
+
handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, { operation: "status", provider, providerJobId: handle.providerJobId });
|
|
261
|
+
publish(statusFromHandle({ provider, promptDigest, options, previous: current, handle }));
|
|
262
|
+
}
|
|
263
|
+
const result = await requestExternalJobOperation<ExternalJobResult>(input.asyncDir, { operation: "result", provider, providerJobId: handle.providerJobId });
|
|
264
|
+
let artifactPath = result.artifactPath;
|
|
265
|
+
if (!artifactPath && result.output !== undefined) {
|
|
266
|
+
artifactPath = path.join(input.asyncDir, `external-job-${input.stepIndex}.result.md`);
|
|
267
|
+
fs.writeFileSync(artifactPath, result.output, "utf-8");
|
|
268
|
+
}
|
|
269
|
+
const finalStatus = statusFromHandle({ provider, promptDigest, options, previous: current, handle: result, resultArtifactPath: artifactPath });
|
|
270
|
+
publish(finalStatus);
|
|
271
|
+
const output = resultOutput(result, artifactPath);
|
|
272
|
+
const error = result.state === "completed" ? undefined : result.failureMessage ?? `External job ${result.state}.`;
|
|
273
|
+
return { output, exitCode: result.state === "completed" ? 0 : 1, ...(error ? { error } : {}), externalJob: finalStatus };
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const providerError = error instanceof ExternalJobProviderError
|
|
276
|
+
? error
|
|
277
|
+
: new ExternalJobProviderError(error instanceof Error ? error.message : String(error), { code: "provider-error", cause: error });
|
|
278
|
+
const message = formatError(providerError, provider);
|
|
279
|
+
const status = failureStatus({ provider, promptDigest, options, previous: current, code: providerError.code, message, ...(providerError.blockingJobId ? { blockingJobId: providerError.blockingJobId } : {}) });
|
|
280
|
+
publish(status);
|
|
281
|
+
return { output: message, exitCode: 1, error: message, ...(timedOut ? { timedOut: true } : {}), ...(stopped ? { stopped: true } : {}), externalJob: status };
|
|
282
|
+
} finally {
|
|
283
|
+
input.registerTimeout?.(undefined);
|
|
284
|
+
input.registerStop?.(undefined);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -32,6 +32,12 @@ interface ServerEntry {
|
|
|
32
32
|
cwd?: string;
|
|
33
33
|
url?: string;
|
|
34
34
|
headers?: Record<string, string>;
|
|
35
|
+
requestHeadersCommand?: {
|
|
36
|
+
command: string;
|
|
37
|
+
args?: string[];
|
|
38
|
+
env?: Record<string, string>;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
};
|
|
35
41
|
auth?: "oauth" | "bearer" | false;
|
|
36
42
|
bearerToken?: string;
|
|
37
43
|
bearerTokenEnv?: string;
|
|
@@ -279,6 +285,14 @@ export function computeMcpServerHash(definition: ServerEntry): string {
|
|
|
279
285
|
cwd: resolveConfigPath(definition.cwd),
|
|
280
286
|
url: resolveServerUrl(definition),
|
|
281
287
|
headers: interpolateEnvRecord(definition.headers),
|
|
288
|
+
requestHeadersCommand: definition.requestHeadersCommand
|
|
289
|
+
? {
|
|
290
|
+
command: interpolateEnvVars(definition.requestHeadersCommand.command),
|
|
291
|
+
args: definition.requestHeadersCommand.args?.map(interpolateEnvVars),
|
|
292
|
+
env: interpolateEnvRecord(definition.requestHeadersCommand.env),
|
|
293
|
+
timeoutMs: definition.requestHeadersCommand.timeoutMs,
|
|
294
|
+
}
|
|
295
|
+
: undefined,
|
|
282
296
|
auth: definition.auth,
|
|
283
297
|
bearerToken: resolveBearerToken(definition),
|
|
284
298
|
bearerTokenEnv: definition.bearerTokenEnv,
|
|
@@ -41,7 +41,7 @@ export function normalizeParentModel(model: unknown): ParentModel | undefined {
|
|
|
41
41
|
/**
|
|
42
42
|
* Normalize a model id or provider segment for fuzzy comparison: case-fold,
|
|
43
43
|
* treat dots/underscores as dashes (so `4.5` matches `4-5`), and collapse
|
|
44
|
-
* repeated separators.
|
|
44
|
+
* repeated separators.
|
|
45
45
|
*/
|
|
46
46
|
export function normalizeModelSegment(segment: string): string {
|
|
47
47
|
return segment
|
|
@@ -58,7 +58,7 @@ function isPlausibleDateStamp(year: string, month: string, day: string): boolean
|
|
|
58
58
|
return yyyy >= 1900 && yyyy <= 2099 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match.
|
|
61
|
+
/** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match. */
|
|
62
62
|
function stripTrailingDateStamp(segment: string): string {
|
|
63
63
|
const dashed = /^(.*)-(\d{4})-(\d{2})-(\d{2})$/.exec(segment);
|
|
64
64
|
if (dashed && isPlausibleDateStamp(dashed[2]!, dashed[3]!, dashed[4]!)) return dashed[1]!;
|
|
@@ -67,21 +67,65 @@ function stripTrailingDateStamp(segment: string): string {
|
|
|
67
67
|
return segment;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
function isRegisteredProvider(provider: string, availableModels: AvailableModelInfo[]): boolean {
|
|
71
|
+
const normalized = normalizeModelSegment(provider);
|
|
72
|
+
return availableModels.some((entry) => normalizeModelSegment(entry.provider) === normalized);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split `provider/id` only when the first path segment is a registered provider.
|
|
77
|
+
* Hugging Face-style `owner/name` ids therefore stay intact unless `owner` is
|
|
78
|
+
* itself a provider in the active registry. `:` and `.` keep the same rule.
|
|
79
|
+
*/
|
|
80
|
+
function splitQualifiedModelQuery(
|
|
81
|
+
baseModel: string,
|
|
82
|
+
availableModels: AvailableModelInfo[],
|
|
83
|
+
): { queryProvider?: string; queryIdRaw: string } {
|
|
84
|
+
const slashIdx = baseModel.indexOf("/");
|
|
85
|
+
if (slashIdx !== -1) {
|
|
86
|
+
const providerPart = baseModel.slice(0, slashIdx);
|
|
87
|
+
if (isRegisteredProvider(providerPart, availableModels)) {
|
|
88
|
+
return { queryProvider: normalizeModelSegment(providerPart), queryIdRaw: baseModel.slice(slashIdx + 1) };
|
|
89
|
+
}
|
|
90
|
+
return { queryIdRaw: baseModel };
|
|
91
|
+
}
|
|
92
|
+
const providerSeparators = [":", "."];
|
|
93
|
+
for (const separator of providerSeparators) {
|
|
94
|
+
const separatorIdx = baseModel.indexOf(separator);
|
|
95
|
+
if (separatorIdx <= 0) continue;
|
|
96
|
+
const providerPart = baseModel.slice(0, separatorIdx);
|
|
97
|
+
if (!isRegisteredProvider(providerPart, availableModels)) continue;
|
|
98
|
+
return { queryProvider: normalizeModelSegment(providerPart), queryIdRaw: baseModel.slice(separatorIdx + 1) };
|
|
99
|
+
}
|
|
100
|
+
return { queryIdRaw: baseModel };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveExactIdMatches(
|
|
104
|
+
baseModel: string,
|
|
105
|
+
availableModels: AvailableModelInfo[],
|
|
106
|
+
preferredProvider?: string,
|
|
107
|
+
): string | undefined {
|
|
108
|
+
const exactMatches = availableModels.filter((entry) => entry.id === baseModel);
|
|
109
|
+
if (preferredProvider) {
|
|
110
|
+
const preferredMatch = exactMatches.find((entry) => entry.provider === preferredProvider);
|
|
111
|
+
if (preferredMatch) return preferredMatch.fullId;
|
|
112
|
+
}
|
|
113
|
+
if (exactMatches.length === 1) return exactMatches[0]!.fullId;
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
70
117
|
function resolveBaseModelCandidate(
|
|
71
118
|
baseModel: string,
|
|
72
119
|
availableModels: AvailableModelInfo[],
|
|
73
120
|
preferredProvider?: string,
|
|
74
121
|
): string | undefined {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (preferredMatch) return preferredMatch.fullId;
|
|
83
|
-
}
|
|
84
|
-
if (exactMatches.length === 1) return exactMatches[0]!.fullId;
|
|
122
|
+
const exact = availableModels.find((entry) => entry.fullId === baseModel);
|
|
123
|
+
if (exact) return exact.fullId;
|
|
124
|
+
|
|
125
|
+
const { queryProvider } = splitQualifiedModelQuery(baseModel, availableModels);
|
|
126
|
+
if (queryProvider === undefined) {
|
|
127
|
+
const exactId = resolveExactIdMatches(baseModel, availableModels, preferredProvider);
|
|
128
|
+
if (exactId) return exactId;
|
|
85
129
|
}
|
|
86
130
|
|
|
87
131
|
return fuzzyResolveModel(baseModel, availableModels, preferredProvider);
|
|
@@ -90,35 +134,20 @@ function resolveBaseModelCandidate(
|
|
|
90
134
|
/**
|
|
91
135
|
* Fuzzy-resolve a base model id (thinking suffix already stripped) against the
|
|
92
136
|
* registry, tolerating separator, case, and optional date-stamp differences so
|
|
93
|
-
* users do not have to spell provider/model exactly. A
|
|
137
|
+
* users do not have to spell provider/model exactly. A slash is a provider
|
|
138
|
+
* prefix only when that prefix is a registered provider; otherwise the whole
|
|
139
|
+
* string is the model id (Hugging Face `owner/name`). A qualified provider
|
|
94
140
|
* query only matches within the named provider — this never silently switches
|
|
95
141
|
* providers for security/cost-sensitive configs. Returns the matched `fullId`,
|
|
96
142
|
* or `undefined` when there is no match or the match is ambiguous across
|
|
97
|
-
* providers (and no `preferredProvider` disambiguates).
|
|
143
|
+
* providers (and no `preferredProvider` disambiguates).
|
|
98
144
|
*/
|
|
99
145
|
export function fuzzyResolveModel(
|
|
100
146
|
baseModel: string,
|
|
101
147
|
availableModels: AvailableModelInfo[],
|
|
102
148
|
preferredProvider?: string,
|
|
103
149
|
): string | undefined {
|
|
104
|
-
|
|
105
|
-
let queryIdRaw = baseModel;
|
|
106
|
-
const slashIdx = baseModel.indexOf("/");
|
|
107
|
-
if (slashIdx !== -1) {
|
|
108
|
-
queryProvider = normalizeModelSegment(baseModel.slice(0, slashIdx));
|
|
109
|
-
queryIdRaw = baseModel.slice(slashIdx + 1);
|
|
110
|
-
} else {
|
|
111
|
-
const providerSeparators = [":", "."];
|
|
112
|
-
for (const separator of providerSeparators) {
|
|
113
|
-
const separatorIdx = baseModel.indexOf(separator);
|
|
114
|
-
if (separatorIdx <= 0) continue;
|
|
115
|
-
const providerPart = normalizeModelSegment(baseModel.slice(0, separatorIdx));
|
|
116
|
-
if (!availableModels.some((entry) => normalizeModelSegment(entry.provider) === providerPart)) continue;
|
|
117
|
-
queryProvider = providerPart;
|
|
118
|
-
queryIdRaw = baseModel.slice(separatorIdx + 1);
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
150
|
+
const { queryProvider, queryIdRaw } = splitQualifiedModelQuery(baseModel, availableModels);
|
|
122
151
|
const queryId = normalizeModelSegment(queryIdRaw);
|
|
123
152
|
const queryIdNoDate = stripTrailingDateStamp(queryId);
|
|
124
153
|
|
|
@@ -143,7 +172,7 @@ export function fuzzyResolveModel(
|
|
|
143
172
|
* thinking suffix). Exact registry matches win; fuzzy normalization
|
|
144
173
|
* (separator/case/date-stamp via {@link fuzzyResolveModel}) is a fallback so
|
|
145
174
|
* spelling differences still resolve. Never switches providers for a qualified
|
|
146
|
-
* query.
|
|
175
|
+
* query.
|
|
147
176
|
*/
|
|
148
177
|
export function resolveModelCandidate(
|
|
149
178
|
model: string | undefined,
|
|
@@ -163,17 +192,26 @@ export function resolveModelCandidate(
|
|
|
163
192
|
return model;
|
|
164
193
|
}
|
|
165
194
|
|
|
166
|
-
function
|
|
195
|
+
function resolveSubagentModelCandidate(
|
|
167
196
|
model: string,
|
|
168
197
|
availableModels: AvailableModelInfo[] | undefined,
|
|
169
198
|
preferredProvider?: string,
|
|
170
|
-
): string {
|
|
199
|
+
): string | undefined {
|
|
171
200
|
if (!availableModels || availableModels.length === 0) return model;
|
|
172
201
|
const resolvedWhole = resolveBaseModelCandidate(model, availableModels, preferredProvider);
|
|
173
202
|
if (resolvedWhole) return resolvedWhole;
|
|
174
203
|
const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
|
|
175
204
|
const resolvedBase = thinkingSuffix ? resolveBaseModelCandidate(baseModel, availableModels, preferredProvider) : undefined;
|
|
176
|
-
|
|
205
|
+
return resolvedBase ? `${resolvedBase}${thinkingSuffix}` : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveRequiredSubagentModelCandidate(
|
|
209
|
+
model: string,
|
|
210
|
+
availableModels: AvailableModelInfo[] | undefined,
|
|
211
|
+
preferredProvider?: string,
|
|
212
|
+
): string {
|
|
213
|
+
const resolved = resolveSubagentModelCandidate(model, availableModels, preferredProvider);
|
|
214
|
+
if (resolved) return resolved;
|
|
177
215
|
throw new Error(`Unknown subagent model '${model}' in the active Pi model registry.`);
|
|
178
216
|
}
|
|
179
217
|
|
|
@@ -263,6 +301,18 @@ export interface BuildModelCandidatesOptions {
|
|
|
263
301
|
/** Fallback models warn by default and throw when strict scope enforcement is enabled. */
|
|
264
302
|
scope?: ModelScopeConfig;
|
|
265
303
|
onWarn?: (violation: ModelScopeViolation) => void;
|
|
304
|
+
/** The primary model came from the running parent session, not configuration. */
|
|
305
|
+
primaryModelFromParent?: boolean;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function inheritsParentModel(
|
|
309
|
+
explicitModel: string | boolean | undefined,
|
|
310
|
+
agentModel: string | boolean | undefined,
|
|
311
|
+
parentModel: ParentModel | undefined,
|
|
312
|
+
): boolean {
|
|
313
|
+
const requestedModel = explicitModel ?? agentModel;
|
|
314
|
+
const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
|
|
315
|
+
return Boolean(parentModel && (!trimmed || trimmed === INHERIT_MODEL));
|
|
266
316
|
}
|
|
267
317
|
|
|
268
318
|
export function buildModelCandidates(
|
|
@@ -278,8 +328,17 @@ export function buildModelCandidates(
|
|
|
278
328
|
for (let index = 0; index < rawCandidates.length; index++) {
|
|
279
329
|
const raw = rawCandidates[index];
|
|
280
330
|
if (!raw) continue;
|
|
281
|
-
const
|
|
282
|
-
|
|
331
|
+
const model = raw.trim();
|
|
332
|
+
const normalized = index === 0
|
|
333
|
+
? options?.primaryModelFromParent
|
|
334
|
+
? model
|
|
335
|
+
: resolveRequiredSubagentModelCandidate(model, availableModels, preferredProvider)
|
|
336
|
+
: resolveSubagentModelCandidate(model, availableModels, preferredProvider);
|
|
337
|
+
if (!normalized) {
|
|
338
|
+
console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (seen.has(normalized)) continue;
|
|
283
342
|
if ((index > 0 || options?.scope?.strict === true) && options?.scope?.enforce) {
|
|
284
343
|
const violation = checkModelScope(normalized, options.scope, "inherited");
|
|
285
344
|
if (violation) {
|
|
@@ -295,6 +354,7 @@ export function buildModelCandidates(
|
|
|
295
354
|
|
|
296
355
|
const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
297
356
|
/rate\s*limit/i,
|
|
357
|
+
/usage\s*limit/i,
|
|
298
358
|
/too many requests/i,
|
|
299
359
|
/\b429\b/,
|
|
300
360
|
/quota/i,
|