pi-subagents 0.55.0 → 0.56.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +28 -3
  2. package/docs/models.md +6 -0
  3. package/package.json +1 -1
  4. package/src/agents/agent-serializer.ts +2 -0
  5. package/src/agents/agents.ts +23 -1
  6. package/src/api/preflight.ts +20 -1
  7. package/src/extension/schemas.ts +5 -0
  8. package/src/runs/background/async-execution.ts +48 -18
  9. package/src/runs/background/async-resume.ts +3 -0
  10. package/src/runs/background/subagent-runner.ts +83 -11
  11. package/src/runs/foreground/execution.ts +35 -6
  12. package/src/runs/foreground/foreground-history.ts +3 -2
  13. package/src/runs/foreground/subagent-executor.ts +59 -8
  14. package/src/runs/foreground/workflow-detach-reconcile.ts +15 -6
  15. package/src/runs/shared/acceptance.ts +10 -5
  16. package/src/runs/shared/agent-contract.ts +1 -1
  17. package/src/runs/shared/completion-guard.ts +3 -2
  18. package/src/runs/shared/dynamic-fanout.ts +1 -1
  19. package/src/runs/shared/extension-bindings.ts +78 -0
  20. package/src/runs/shared/external-cli-runner.ts +2 -0
  21. package/src/runs/shared/fast-mode-extension.ts +10 -0
  22. package/src/runs/shared/model-exclusions.ts +2 -1
  23. package/src/runs/shared/model-fallback.ts +2 -7
  24. package/src/runs/shared/mutation-evidence.ts +145 -0
  25. package/src/runs/shared/parallel-utils.ts +3 -0
  26. package/src/runs/shared/pi-args.ts +47 -0
  27. package/src/runs/shared/structured-output.ts +18 -4
  28. package/src/runs/shared/subagent-prompt-runtime.ts +9 -5
  29. package/src/shared/launch-contract.ts +6 -0
  30. package/src/shared/settings.ts +6 -1
  31. package/src/shared/types.ts +48 -0
  32. package/src/workflows/scripted-workflow.ts +50 -3
@@ -73,6 +73,7 @@ import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
73
73
  import { usageBudgetExceededMessage, usageBudgetState, validateUsageBudgetConfig } from "../shared/usage-budget.ts";
74
74
  import { intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
75
75
  import { isAgentContractV1 } from "../shared/agent-contract.ts";
76
+ import { normalizeExtensionBindings, type ExtensionBindings } from "../shared/extension-bindings.ts";
76
77
  import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
77
78
  import { cleanupStructuredOutputRuntime, createStructuredOutputRuntime } from "../shared/structured-output.ts";
78
79
  import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, readStatus, resolveChildCwd, sumResultsCost, sumResultsUsage } from "../../shared/utils.ts";
@@ -88,7 +89,7 @@ import {
88
89
  resolveSubagentResultStatus,
89
90
  stripDetailsOutputsForIntercomReceipt,
90
91
  } from "../../intercom/result-intercom.ts";
91
- import { applySteeringRecoveryAgentConfig, asyncReviveRequiresRecoveryDescriptor, buildRevivedAsyncTask, resolveAsyncResumeTarget, resolveAsyncRunLocation } from "../background/async-resume.ts";
92
+ import { applySteeringRecoveryAgentConfig, asyncReviveRequiresRecoveryDescriptor, buildRevivedAsyncTask, readAsyncRecoveryDescriptor, resolveAsyncResumeTarget, resolveAsyncRunLocation } from "../background/async-resume.ts";
92
93
  import { deliverInterruptRequest, readRevivalBriefs, requestAsyncSteer, type SteerDeliveryMode } from "../background/control-channel.ts";
93
94
  import { updateSteeringTarget, waitForSteeringAction } from "../background/steering.ts";
94
95
  import { canQueueRetainedAsyncFollowUp, steerAsyncRun } from "./async-steering-action.ts";
@@ -156,6 +157,7 @@ import {
156
157
  type ResolvedTurnBudget,
157
158
  type ResolvedToolBudget,
158
159
  type RunFanoutBudgetDescriptor,
160
+ type SteeringRecoveryDescriptor,
159
161
  type SingleResult,
160
162
  type SubagentChildStatusEvent,
161
163
  type ToolBudgetConfig,
@@ -264,6 +266,7 @@ interface TaskParam {
264
266
  reads?: string[] | boolean;
265
267
  progress?: boolean;
266
268
  model?: string;
269
+ fast?: boolean;
267
270
  skill?: string | string[] | boolean;
268
271
  outputSchema?: JsonSchemaObject;
269
272
  acceptance?: AcceptanceInput;
@@ -288,6 +291,7 @@ export interface SubagentParamsLike {
288
291
  type?: string;
289
292
  agent?: string;
290
293
  task?: string;
294
+ extensionBindings?: ExtensionBindings;
291
295
  /** Retained async child run id. Valid only on workflow runs.run items. */
292
296
  resume?: string;
293
297
  message?: string;
@@ -340,6 +344,7 @@ export interface SubagentParamsLike {
340
344
  artifacts?: boolean;
341
345
  includeProgress?: boolean;
342
346
  model?: string;
347
+ fast?: boolean;
343
348
  thinking?: string | false;
344
349
  scope?: string;
345
350
  target?: string;
@@ -644,7 +649,7 @@ function foregroundChildActivityFromProgress(progress: SingleResult["progress"]
644
649
  };
645
650
  }
646
651
 
647
- function rememberForegroundRun(state: SubagentState, input: { runId: string; mode: "single" | "parallel" | "chain"; cwd: string; sessionId: string | null; results: SingleResult[] }): void {
652
+ function rememberForegroundRun(state: SubagentState, input: { runId: string; mode: "single" | "parallel" | "chain"; cwd: string; sessionId: string | null; results: SingleResult[]; extensionBindings?: ExtensionBindings }): void {
648
653
  state.foregroundRuns ??= new Map();
649
654
  const previous = state.foregroundRuns.get(input.runId);
650
655
  const updatedAt = Date.now();
@@ -686,6 +691,7 @@ function rememberForegroundRun(state: SubagentState, input: { runId: string; mod
686
691
  ...(result.detachedReason ? { detachedReason: result.detachedReason } : {}),
687
692
  ...(result.acceptance ? { acceptance: result.acceptance } : {}),
688
693
  ...(result.launchContractDigest ? { launchContractDigest: result.launchContractDigest } : {}),
694
+ ...(input.extensionBindings ? { extensionBindings: input.extensionBindings } : {}),
689
695
  ...(result.launchResolvedExtensions ? { launchResolvedExtensions: result.launchResolvedExtensions } : {}),
690
696
  ...(result.runtimeAcknowledgedExtensions ? { runtimeAcknowledgedExtensions: result.runtimeAcknowledgedExtensions } : {}),
691
697
  ...(result.capabilityCeiling ? { capabilityCeiling: result.capabilityCeiling } : {}),
@@ -804,7 +810,7 @@ function updateRememberedForegroundChild(state: SubagentState, input: { runId: s
804
810
  });
805
811
  }
806
812
 
807
- function resolveForegroundResumeTarget(params: SubagentParamsLike, state: SubagentState, options: { exactOnly?: boolean } = {}): { runId: string; mode: SubagentRunMode; state: "complete"; agent: string; index: number; cwd: string; sessionFile: string; model?: string; thinking?: string; launchContractDigest?: string; capabilityCeiling?: ResolvedSubagentCapabilityCeiling } | undefined {
813
+ function resolveForegroundResumeTarget(params: SubagentParamsLike, state: SubagentState, options: { exactOnly?: boolean } = {}): { runId: string; mode: SubagentRunMode; state: "complete"; agent: string; index: number; cwd: string; sessionFile: string; model?: string; thinking?: string; launchContractDigest?: string; extensionBindings?: ExtensionBindings; capabilityCeiling?: ResolvedSubagentCapabilityCeiling } | undefined {
808
814
  const requested = (params.id ?? params.runId)?.trim();
809
815
  if (!requested || !state.foregroundRuns?.size || !state.currentSessionId) return undefined;
810
816
  const direct = state.foregroundRuns.get(requested);
@@ -835,6 +841,7 @@ function resolveForegroundResumeTarget(params: SubagentParamsLike, state: Subage
835
841
  ...(child.model ? { model: child.model } : {}),
836
842
  ...(child.thinking ? { thinking: child.thinking } : {}),
837
843
  ...(child.launchContractDigest ? { launchContractDigest: child.launchContractDigest } : {}),
844
+ ...(child.extensionBindings ? { extensionBindings: normalizeExtensionBindings(child.extensionBindings)!.value } : {}),
838
845
  ...(child.capabilityCeiling ? { capabilityCeiling: child.capabilityCeiling } : {}),
839
846
  };
840
847
  }
@@ -854,6 +861,7 @@ type NestedResumeSourceTarget = {
854
861
  thinking?: AgentConfig["thinking"];
855
862
  launchContractDigest?: string;
856
863
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
864
+ recoveryDescriptor?: SteeringRecoveryDescriptor;
857
865
  };
858
866
  type ResumeSourceTarget = AsyncResumeSourceTarget | ForegroundResumeSourceTarget | NestedResumeSourceTarget;
859
867
 
@@ -1339,6 +1347,14 @@ function validateNestedSessionFile(run: NestedRunSummary, trustedSessionRoots: s
1339
1347
  return realSessionFile;
1340
1348
  }
1341
1349
 
1350
+ export function readNestedRecoveryDescriptor(asyncDir: string | undefined, runId: string, agent: string): SteeringRecoveryDescriptor | undefined {
1351
+ const recoveryDescriptor = readAsyncRecoveryDescriptor(asyncDir);
1352
+ if (!recoveryDescriptor) return undefined;
1353
+ if (recoveryDescriptor.sourceRunId !== runId) throw new Error(`Nested run '${runId}' has a recovery descriptor for a different source run.`);
1354
+ if (recoveryDescriptor.agent !== agent) throw new Error(`Nested run '${runId}' has a recovery descriptor for a different agent.`);
1355
+ return recoveryDescriptor;
1356
+ }
1357
+
1342
1358
  function resolveNestedResumeTarget(match: ResolvedSubagentRunId & { kind: "nested" }, trustedSessionRoots: string[]): NestedResumeSourceTarget {
1343
1359
  const run = match.match.run;
1344
1360
  if (run.state === "running" || run.state === "queued") throw new Error(`Nested run '${run.id}' is live; route the follow-up to the owner process instead.`);
@@ -1347,6 +1363,7 @@ function resolveNestedResumeTarget(match: ResolvedSubagentRunId & { kind: "neste
1347
1363
  if (!agent) throw new Error(`Could not determine child agent for nested run '${run.id}'.`);
1348
1364
  const state = run.state === "complete" || run.state === "failed" || run.state === "paused" ? run.state : "failed";
1349
1365
  const asyncDir = resolveNestedAsyncDir(match.match.rootRunId, run);
1366
+ const recoveryDescriptor = readNestedRecoveryDescriptor(asyncDir, run.id, agent);
1350
1367
  return compactOptional<NestedResumeSourceTarget>({
1351
1368
  kind: "revive",
1352
1369
  source: "nested",
@@ -1357,6 +1374,7 @@ function resolveNestedResumeTarget(match: ResolvedSubagentRunId & { kind: "neste
1357
1374
  cwd: asyncDir ? path.dirname(asyncDir) : undefined,
1358
1375
  sessionFile: validateNestedSessionFile(run, trustedSessionRoots),
1359
1376
  ...(run.capabilityCeiling ? { capabilityCeiling: run.capabilityCeiling } : {}),
1377
+ ...(recoveryDescriptor ? { recoveryDescriptor } : {}),
1360
1378
  });
1361
1379
  }
1362
1380
 
@@ -1835,6 +1853,7 @@ async function resumeAsyncRun(input: {
1835
1853
  sessionRoot: input.deps.getSubagentSessionRoot(parentSessionFile),
1836
1854
  chainSkills: normalized === false ? [] : (normalized ?? []),
1837
1855
  agentContract: input.params.agentContract,
1856
+ fast: input.params.fast,
1838
1857
  dynamicFanoutMaxItems: input.deps.config.chain?.dynamicFanout?.maxItems,
1839
1858
  maxSubagentDepth: resolveCurrentMaxSubagentDepth(input.deps.config.maxSubagentDepth),
1840
1859
  waitToolEnabled: input.deps.waitToolEnabled,
@@ -1938,9 +1957,11 @@ async function resumeAsyncRun(input: {
1938
1957
  },
1939
1958
  context: recoveryContext,
1940
1959
  modelOverride: recoveryDescriptor?.model ?? target.model,
1960
+ fast: recoveryDescriptor?.fast,
1941
1961
  modelOverrideFromParent: recoveryDescriptor?.modelOverrideFromParent,
1942
1962
  thinkingOverride: recoveryDescriptor?.thinking ?? target.thinking,
1943
1963
  thinkingCeiling: recoveryDescriptor?.thinkingCeiling ?? ("thinkingCeiling" in target ? target.thinkingCeiling : undefined),
1964
+ extensionBindings: recoveryDescriptor?.extensionBindings ?? ("extensionBindings" in target ? target.extensionBindings : undefined),
1944
1965
  outputBaseDir: resolveSingleRunOutputBaseDir(input.deps, artifactsDir, runId),
1945
1966
  maxSubagentDepth: recoveryDescriptor?.maxSubagentDepth ?? resolveCurrentMaxSubagentDepth(input.deps.config.maxSubagentDepth),
1946
1967
  waitToolEnabled: input.deps.waitToolEnabled,
@@ -2223,6 +2244,15 @@ function canonicalizeExecutionParams(params: SubagentParamsLike, agents: AgentCo
2223
2244
  const result = resolve(params.agent);
2224
2245
  if (result.error) return { error: result.error };
2225
2246
  params = omitUndefinedProperties({ ...params, agent: result.name });
2247
+ const agent = agents.find((candidate) => candidate.name === result.name);
2248
+ if (params.extensionBindings !== undefined && (agent?.runner?.type === "external-cli" || agent?.runner?.type === "external-job")) return { error: `extensionBindings is not supported for runner.type='${agent.runner.type}'.` };
2249
+ }
2250
+ if (params.extensionBindings !== undefined) {
2251
+ try {
2252
+ params = { ...params, extensionBindings: normalizeExtensionBindings(params.extensionBindings)!.value };
2253
+ } catch (error) {
2254
+ return { error: error instanceof Error ? error.message : String(error) };
2255
+ }
2226
2256
  }
2227
2257
  if (params.tasks) {
2228
2258
  const tasks: TaskParam[] = [];
@@ -3119,6 +3149,9 @@ async function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
3119
3149
  const externalRunnerWithoutExplicitModel = (a.runner?.type === "external-cli" || a.runner?.type === "external-job")
3120
3150
  && params.model === undefined
3121
3151
  && (a.model === undefined || (a.modelSource?.type === "subagents.defaultModel" && a.model === a.modelSource.model));
3152
+ if ((a.runner?.type === "external-cli" || a.runner?.type === "external-job") && (params.fast ?? a.fast) === true) {
3153
+ return buildRequestedModeError(params, `Agent '${a.name}' uses runner.type='${a.runner.type}' and does not support fast mode.`);
3154
+ }
3122
3155
  const modelScopes = resolveModelScopesForAgent(data.modelScope, a.name, parentModel);
3123
3156
  const modelOverride = a.runner?.type === "external-cli" || a.runner?.type === "external-job"
3124
3157
  ? params.model ?? (externalRunnerWithoutExplicitModel ? undefined : a.model)
@@ -3147,6 +3180,7 @@ async function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
3147
3180
  ...(params.reads !== undefined ? { reads: params.reads } : {}),
3148
3181
  outputBaseDir: resolveSingleRunOutputBaseDir(deps, artifactsDir, id),
3149
3182
  modelOverride,
3183
+ fast: params.fast,
3150
3184
  modelOverrideFromParent,
3151
3185
  thinkingOverride: externalRunnerWithoutExplicitModel ? undefined : thinkingOverrideForTask(params.agent!, 0, modelOverride, modelOverrideFromParent),
3152
3186
  thinkingCeiling: a.maxThinking,
@@ -3163,6 +3197,7 @@ async function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
3163
3197
  nestedRoute,
3164
3198
  agentContract: params.agentContract,
3165
3199
  structuredOutputSchema: params.outputSchema,
3200
+ extensionBindings: params.extensionBindings,
3166
3201
  acceptance: params.acceptance,
3167
3202
  timeoutMs: data.timeoutMs,
3168
3203
  turnBudget: data.turnBudget,
@@ -3540,7 +3575,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3540
3575
  return { content: [{ type: "text", text: validationError }], isError: true, details: { mode: "single", results: [] } };
3541
3576
  }
3542
3577
  const structuredRuntime = params.outputSchema
3543
- ? createStructuredOutputRuntime(params.outputSchema, artifactConfig.enabled ? path.join(artifactsDir, "structured-output", runId) : undefined)
3578
+ ? createStructuredOutputRuntime(params.outputSchema, artifactConfig.enabled ? path.join(artifactsDir, "structured-output", runId) : undefined, { captureAcceptanceReport: params.acceptance !== false })
3544
3579
  : undefined;
3545
3580
  // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
3546
3581
  // absolute paths pass through; relative paths resolve against the child cwd.
@@ -3624,9 +3659,11 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3624
3659
  nestedRoute: foregroundControl?.nestedRoute,
3625
3660
  index: 0,
3626
3661
  modelOverride,
3662
+ fast: params.fast,
3627
3663
  modelOverrideFromParent,
3628
3664
  thinkingOverride: thinkingOverrideForTask(params.agent!, 0, modelOverride, modelOverrideFromParent),
3629
3665
  thinkingCeiling: agentConfig.maxThinking,
3666
+ extensionBindings: params.extensionBindings,
3630
3667
  availableModels,
3631
3668
  preferredModelProvider: currentProvider,
3632
3669
  modelScope: modelScopes,
@@ -3739,7 +3776,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3739
3776
  usageBudget: usageBudgetState(data.usageBudget, totalCost),
3740
3777
  ...(worktreeHandoff?.reference ? { parallelHandoff: worktreeHandoff.reference } : {}),
3741
3778
  }));
3742
- rememberForegroundRun(deps.state, { runId, mode: "single", cwd: singleCwd, sessionId: data.parentSessionId, results: details.results });
3779
+ rememberForegroundRun(deps.state, { runId, mode: "single", cwd: singleCwd, sessionId: data.parentSessionId, results: details.results, extensionBindings: params.extensionBindings });
3743
3780
 
3744
3781
  const suppressRoutineResultIntercom = shouldSuppressRoutineResultIntercom({ suppressRoutineResultIntercom: params.suppressRoutineResultIntercom, results: [r] });
3745
3782
  if (!r.detached && !r.interrupted && !suppressRoutineResultIntercom) {
@@ -3894,8 +3931,9 @@ function workflowChildResult(
3894
3931
  ? result.details.results[0].finalOutput
3895
3932
  : receiptOutput;
3896
3933
  const detached = result.details.results.some((child) => child.detached);
3934
+ const interrupted = result.details.results.some((child) => child.interrupted);
3897
3935
  const stopped = result.details.results.some((child) => child.stopped);
3898
- const ok = result.isError !== true && !detached && !stopped;
3936
+ const ok = result.isError !== true && !detached && !interrupted && !stopped;
3899
3937
  const artifactPaths = new Set<string>();
3900
3938
  if (result.details.asyncDir) artifactPaths.add(result.details.asyncDir);
3901
3939
  if (result.details.parallelHandoff?.path) artifactPaths.add(result.details.parallelHandoff.path);
@@ -3935,6 +3973,7 @@ function workflowChildResult(
3935
3973
  output,
3936
3974
  ...(!ok ? { error: receiptOutput || output || "Child run failed." } : {}),
3937
3975
  ...(detached ? { detached: true } : {}),
3976
+ ...(interrupted ? { interrupted: true } : {}),
3938
3977
  ...(stopped ? { stopped: true } : {}),
3939
3978
  ...(structured.length === 1 ? { structuredOutput: structured[0] } : structured.length > 1 ? { structuredOutput: structured } : {}),
3940
3979
  ...(requestedContext ? { requestedContext } : {}),
@@ -4071,6 +4110,9 @@ export function prepareWorkflowLaunchParams(
4071
4110
  ? undefined
4072
4111
  : Math.max(1, options.parentDeadlineAt - Date.now());
4073
4112
  if (typeof childParams.resume === "string") {
4113
+ if (childParams.extensionBindings !== undefined || workflowDefaults.extensionBindings !== undefined) {
4114
+ throw new Error("extensionBindings is not supported with retained resume; resume uses the original retained child binding.");
4115
+ }
4074
4116
  if (childParams.gate !== undefined || workflowDefaults.gate !== undefined) {
4075
4117
  throw new Error("gate is not supported with retained resume; resume uses the retained child contract.");
4076
4118
  }
@@ -4109,6 +4151,7 @@ export function prepareWorkflowLaunchParams(
4109
4151
  ...(options.suppressRoutineResultIntercom ? { suppressRoutineResultIntercom: true } : {}),
4110
4152
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
4111
4153
  } as SubagentParamsLike;
4154
+ if (launchParams.extensionBindings !== undefined) launchParams.extensionBindings = normalizeExtensionBindings(launchParams.extensionBindings)!.value;
4112
4155
  const normalizedGate = normalizeGateParams(launchParams);
4113
4156
  if (!normalizedGate.ok) throw new Error(normalizedGate.error);
4114
4157
  return normalizedGate.params;
@@ -4244,7 +4287,15 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4244
4287
  if (!normalizedGate.ok) return buildRequestedModeError(params, normalizedGate.error);
4245
4288
  const requestParams = normalizedGate.params;
4246
4289
  const normalizedAction = typeof requestParams.action === "string" ? requestParams.action.trim() : requestParams.action;
4290
+ if (normalizedAction === "resume" && requestParams.extensionBindings !== undefined) return buildRequestedModeError(requestParams, "extensionBindings is not supported with action='resume'; resume uses the original retained child binding.");
4247
4291
  if (requestParams.workflowScript !== undefined && normalizedAction === undefined) {
4292
+ if (requestParams.extensionBindings !== undefined) {
4293
+ try {
4294
+ requestParams.extensionBindings = normalizeExtensionBindings(requestParams.extensionBindings)!.value;
4295
+ } catch (error) {
4296
+ return buildRequestedModeError(requestParams, error instanceof Error ? error.message : String(error));
4297
+ }
4298
+ }
4248
4299
  const parentCwd = ctx.cwd;
4249
4300
  const timeout = requestParams.timeoutMs ?? requestParams.maxRuntimeMs ?? (requestParams.async === false ? resolveConfigDefaultTimeoutMs(deps.config.timeoutMs) ?? DEFAULT_FOREGROUND_TIMEOUT_MS : undefined);
4250
4301
  const workflowUsageBudget = validateUsageBudgetConfig(requestParams.usageBudget ?? deps.config.usageBudget, requestParams.usageBudget ? "usageBudget" : "config.usageBudget");
@@ -4740,7 +4791,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4740
4791
  } catch (receiptError) {
4741
4792
  appendWorkflowEvent({ type: "subagent.workflow.receipt_write_failed", error: `Failed to persist async workflow receipt: ${receiptError instanceof Error ? receiptError.message : String(receiptError)}` });
4742
4793
  }
4743
- if (!writeWorkflowResult({ id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: true, state: "complete", summary: resultSummary, output: resultSummary, results: workflow.children.map((child) => ({ workflowKey: child.key, ...(child.agent ? { agent: child.agent } : {}), ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.stopped ? { stopped: true } : {}), ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, ...(workflowReceipt ? { workflowReceipt } : {}), asyncDir, cwd: workflowCwd, sessionId: currentSessionId, completionOwnerId, ...(requestParams.scheduleOrigin ? { scheduleOrigin: requestParams.scheduleOrigin } : {}), timestamp: Date.now(), durationMs: Date.now() - startedAt })) return;
4794
+ if (!writeWorkflowResult({ id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: true, state: "complete", summary: resultSummary, output: resultSummary, results: workflow.children.map((child) => ({ workflowKey: child.key, ...(child.agent ? { agent: child.agent } : {}), ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.stopped ? { stopped: true } : {}), ...(child.interrupted ? { interrupted: true } : {}), ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, ...(workflowReceipt ? { workflowReceipt } : {}), asyncDir, cwd: workflowCwd, sessionId: currentSessionId, completionOwnerId, ...(requestParams.scheduleOrigin ? { scheduleOrigin: requestParams.scheduleOrigin } : {}), timestamp: Date.now(), durationMs: Date.now() - startedAt })) return;
4744
4795
  persist();
4745
4796
  persistClosed = true;
4746
4797
  appendWorkflowEvent({ type: "subagent.workflow.completed", state: status.state, ...(status.error ? { error: status.error } : {}) });
@@ -4780,7 +4831,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4780
4831
  } catch (receiptError) {
4781
4832
  appendWorkflowEvent({ type: "subagent.workflow.receipt_write_failed", error: `Failed to persist async workflow receipt: ${receiptError instanceof Error ? receiptError.message : String(receiptError)}` });
4782
4833
  }
4783
- if (!writeWorkflowResult({ id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: status.state === "complete", state: status.state, summary: resultSummary, error: status.state === "complete" ? undefined : status.error, stopped: status.stopped, activityState: status.activityState, results: partial.children.map((child) => ({ workflowKey: child.key, ...(child.agent ? { agent: child.agent } : {}), ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.stopped ? { stopped: true } : {}), ...(child.detached && status.state !== "complete" ? { detached: true } : {}), ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, ...(workflowReceipt ? { workflowReceipt } : {}), asyncDir, cwd: workflowCwd, sessionId: currentSessionId, completionOwnerId, ...(requestParams.scheduleOrigin ? { scheduleOrigin: requestParams.scheduleOrigin } : {}), timestamp: Date.now(), durationMs: Date.now() - startedAt })) return;
4834
+ if (!writeWorkflowResult({ id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: status.state === "complete", state: status.state, summary: resultSummary, error: status.state === "complete" ? undefined : status.error, stopped: status.stopped, activityState: status.activityState, results: partial.children.map((child) => ({ workflowKey: child.key, ...(child.agent ? { agent: child.agent } : {}), ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.stopped ? { stopped: true } : {}), ...(child.interrupted ? { interrupted: true } : {}), ...(child.detached && status.state !== "complete" ? { detached: true } : {}), ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, ...(workflowReceipt ? { workflowReceipt } : {}), asyncDir, cwd: workflowCwd, sessionId: currentSessionId, completionOwnerId, ...(requestParams.scheduleOrigin ? { scheduleOrigin: requestParams.scheduleOrigin } : {}), timestamp: Date.now(), durationMs: Date.now() - startedAt })) return;
4784
4835
  persist();
4785
4836
  persistClosed = true;
4786
4837
  appendWorkflowEvent({ type: "subagent.workflow.completed", state: status.state, ...(status.error ? { error: status.error } : {}), ...(status.activityState ? { activityState: status.activityState } : {}) });
@@ -23,13 +23,16 @@ function cloneWorkflowStatus(status: AsyncStatus): AsyncStatus {
23
23
  };
24
24
  }
25
25
 
26
- function childSucceeded(result: Pick<SingleResult, "exitCode" | "error">): boolean {
27
- return result.exitCode === 0 && !result.error;
26
+ function childSucceeded(result: Pick<SingleResult, "exitCode" | "error" | "interrupted">): boolean {
27
+ return result.exitCode === 0 && !result.error && !result.interrupted;
28
28
  }
29
29
 
30
+ const UNSUPPORTED_DETACHED_WORKFLOW_CONTINUATION = "unsupported-continuation: detached workflow child settled, but JavaScript workflow continuation was not persisted. Resume the workflow explicitly instead of treating the completed child as top-level workflow completion.";
31
+ const INTERRUPTED_DETACHED_CHILD = "Interrupted. Waiting for explicit next action.";
32
+
30
33
  export function applyDetachedChildToPausedWorkflow(
31
34
  status: AsyncStatus,
32
- input: { childRunId: string; result: Pick<SingleResult, "exitCode" | "error" | "sessionFile">; workflowKey?: string },
35
+ input: { childRunId: string; result: Pick<SingleResult, "exitCode" | "error" | "interrupted" | "sessionFile">; workflowKey?: string },
33
36
  ): AsyncStatus | undefined {
34
37
  if (status.mode !== "workflow" || status.state !== "paused") return undefined;
35
38
  const next = cloneWorkflowStatus(status);
@@ -37,6 +40,7 @@ export function applyDetachedChildToPausedWorkflow(
37
40
  ?? (input.workflowKey ? next.steps?.find((candidate) => candidate.workflowKey === input.workflowKey) : undefined);
38
41
  if (!step) return undefined;
39
42
  const succeeded = childSucceeded(input.result);
43
+ const failedSiblingError = next.steps?.find((candidate) => candidate !== step && candidate.status === "failed" && candidate.error)?.error;
40
44
  const updatedAt = Date.now();
41
45
  step.status = succeeded ? "completed" : "failed";
42
46
  step.endedAt = updatedAt;
@@ -45,10 +49,12 @@ export function applyDetachedChildToPausedWorkflow(
45
49
  delete step.currentToolStartedAt;
46
50
  if (input.result.sessionFile) step.sessionFile = input.result.sessionFile;
47
51
  if (succeeded) delete step.error;
52
+ else if (input.result.interrupted) step.error = input.result.error ?? INTERRUPTED_DETACHED_CHILD;
48
53
  else if (input.result.error) step.error = input.result.error;
49
54
  next.lastUpdate = updatedAt;
50
55
  const promoted = promotePausedWorkflowIfSettled(next);
51
- if (promoted?.state === "failed" && input.result.error) promoted.error = input.result.error;
56
+ if (promoted?.state === "failed" && input.result.interrupted && !failedSiblingError) promoted.error = input.result.error ?? INTERRUPTED_DETACHED_CHILD;
57
+ else if (promoted?.state === "failed" && input.result.error) promoted.error = input.result.error;
52
58
  return promoted ?? next;
53
59
  }
54
60
 
@@ -63,10 +69,11 @@ export function promotePausedWorkflowIfSettled(status: AsyncStatus): AsyncStatus
63
69
  const failed = next.steps.some((candidate) => candidate.status === "failed");
64
70
  const updatedAt = Date.now();
65
71
  next.lastUpdate = updatedAt;
66
- next.state = failed ? "failed" : "complete";
72
+ next.state = "failed";
67
73
  next.endedAt = updatedAt;
68
74
  delete next.activityState;
69
- if (!failed) delete next.error;
75
+ if (failed) return next;
76
+ next.error = UNSUPPORTED_DETACHED_WORKFLOW_CONTINUATION;
70
77
  return next;
71
78
  }
72
79
 
@@ -83,6 +90,7 @@ function workflowResultChildren(status: AsyncStatus, childRunId: string, result:
83
90
  output,
84
91
  outputState: output.trim() ? "present" : "absent",
85
92
  detached: undefined,
93
+ ...(result.interrupted ? { interrupted: true } : {}),
86
94
  ...(result.error ? { error: result.error } : {}),
87
95
  };
88
96
  });
@@ -94,6 +102,7 @@ function workflowResultChildren(status: AsyncStatus, childRunId: string, result:
94
102
  success: step.status === "completed" || step.status === "complete",
95
103
  output: step.runId === childRunId ? output : "",
96
104
  outputState: step.runId === childRunId && output.trim() ? "present" : "absent",
105
+ ...(step.runId === childRunId && result.interrupted ? { interrupted: true } : {}),
97
106
  ...(step.error ? { error: step.error } : {}),
98
107
  }));
99
108
  }
@@ -404,7 +404,7 @@ function acceptanceRequiresChildReport(acceptance: ResolvedAcceptanceConfig): bo
404
404
  return acceptance.criteria.length > 0 || acceptance.evidence.length > 0;
405
405
  }
406
406
 
407
- export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, options: { reportOptional?: boolean } = {}): string {
407
+ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, options: { reportOptional?: boolean; structuredOutput?: boolean } = {}): string {
408
408
  if (acceptance.level === "none") return "";
409
409
  if (options.reportOptional && !acceptanceRequiresChildReport(acceptance)) return "";
410
410
  const lines = [
@@ -431,13 +431,15 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, opt
431
431
  }
432
432
  lines.push(
433
433
  "",
434
- "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
434
+ options.structuredOutput
435
+ ? "Include an `acceptanceReport` object in your final `structured_output` tool call in this shape:"
436
+ : "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
435
437
  "Use empty arrays when no items apply; array fields contain strings unless object entries are shown.",
436
438
  "Empty-string entries (`[\"\"]`) are ignored; use `[]` when nothing applies.",
437
439
  "`criteriaSatisfied[].status` must be exactly one of: satisfied, not-satisfied, not-applicable.",
438
440
  "`commandsRun[].result` must be exactly one of: passed, failed, not-run.",
439
441
  "`manualNotes` and `notes` are optional strings; an empty string means no note and does not satisfy `manual-notes` evidence.",
440
- "```acceptance-report",
442
+ ...(options.structuredOutput ? [] : ["```acceptance-report"]),
441
443
  JSON.stringify({
442
444
  criteriaSatisfied: acceptance.criteria
443
445
  .filter((criterion) => criterion.severity !== "recommended")
@@ -452,7 +454,7 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, opt
452
454
  reviewFindings: ["blocker: file.ts:12 - issue found, or no blockers"],
453
455
  manualNotes: "anything else the parent should know",
454
456
  }, null, 2),
455
- "```",
457
+ ...(options.structuredOutput ? [] : ["```"]),
456
458
  );
457
459
  return lines.join("\n");
458
460
  }
@@ -1272,6 +1274,7 @@ export async function evaluateAcceptance(input: {
1272
1274
  */
1273
1275
  fileOutput?: { content: string; path: string; authoritative?: boolean };
1274
1276
  report?: AcceptanceReport;
1277
+ reportError?: string;
1275
1278
  reviewResult?: AcceptanceReviewResult;
1276
1279
  signal?: AbortSignal;
1277
1280
  abortMessage?: string;
@@ -1293,7 +1296,9 @@ export async function evaluateAcceptance(input: {
1293
1296
  };
1294
1297
  if (acceptance.level === "none") return ledger;
1295
1298
 
1296
- const parsed = input.report
1299
+ const parsed = input.reportError
1300
+ ? { error: input.reportError }
1301
+ : input.report
1297
1302
  ? (() => {
1298
1303
  const validation = validateAcceptanceReport(input.report);
1299
1304
  return validation.report
@@ -12,7 +12,7 @@ export function buildExecutionProjection(result: Pick<SingleResult, "exitCode" |
12
12
  return { status: "stopped", success: false, exitCode: result.exitCode, stopped: true, ...(result.error ? { error: result.error } : {}) };
13
13
  }
14
14
  if (result.interrupted) {
15
- return { status: "paused", success: true, exitCode: result.exitCode, interrupted: true, ...(result.error ? { error: result.error } : {}) };
15
+ return { status: "paused", success: false, exitCode: result.exitCode, interrupted: true, ...(result.error ? { error: result.error } : {}) };
16
16
  }
17
17
  const success = result.exitCode === 0 && !result.error && !result.timedOut;
18
18
  return {
@@ -1,5 +1,5 @@
1
1
  import type { Message } from "@earendil-works/pi-ai";
2
- import type { AcceptanceRole } from "../../shared/types.ts";
2
+ import type { AcceptanceRole, TrackedMutationEvidence } from "../../shared/types.ts";
3
3
  import { isMutatingTool } from "./long-running-guard.ts";
4
4
  import { classifyTaskMutationIntent, expectsImplementationMutation, taskMayMutate } from "./task-intent.ts";
5
5
 
@@ -48,6 +48,7 @@ interface CompletionMutationGuardInput {
48
48
  tools?: string[];
49
49
  mcpDirectTools?: string[];
50
50
  toolAvailabilityError?: string;
51
+ mutationEvidence?: TrackedMutationEvidence;
51
52
  }
52
53
 
53
54
  interface CompletionMutationGuardResult {
@@ -238,7 +239,7 @@ export function evaluateCompletionMutationGuard(input: CompletionMutationGuardIn
238
239
  const expectedMutation = hasMutationToolCapability(input.tools, input.mcpDirectTools)
239
240
  ? expectsImplementationMutation(input.agent, input.task)
240
241
  : false;
241
- const attemptedMutation = hasMutationToolCall(input.messages);
242
+ const attemptedMutation = hasMutationToolCall(input.messages) || input.mutationEvidence?.attemptedMutation === true;
242
243
  const noEditChallengeComplete = isImplementationChallengeTask(input.task)
243
244
  && reportsNoBetterChallengeChange(input.messages);
244
245
  return {
@@ -46,7 +46,7 @@ const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency
46
46
  const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "sessionFiles", "thinkingOverrides"]);
47
47
  const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
48
48
  const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
49
- const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "toolBudget", "acceptance", "agentContract", "gateOn"]);
49
+ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "fast", "toolBudget", "acceptance", "agentContract", "gateOn"]);
50
50
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
51
51
  ...DYNAMIC_PARALLEL_KEYS,
52
52
  "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "timeoutMs", "waitToolEnabled",
@@ -0,0 +1,78 @@
1
+ export const PI_SUBAGENT_EXTENSION_BINDINGS_ENV = "PI_SUBAGENT_EXTENSION_BINDINGS";
2
+ export const MAX_EXTENSION_BINDING_NAMESPACES = 16;
3
+ export const MAX_EXTENSION_BINDINGS_BYTES = 16 * 1024;
4
+ export const MAX_EXTENSION_BINDINGS_DEPTH = 16;
5
+ export const MAX_EXTENSION_BINDINGS_PROPERTIES = 256;
6
+
7
+ const EXTENSION_BINDING_NAMESPACE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62})\/[1-9][0-9]{0,8}$/;
8
+
9
+ export type ExtensionBindingJson = null | boolean | number | string | ReadonlyArray<ExtensionBindingJson> | { readonly [key: string]: ExtensionBindingJson };
10
+ export type ExtensionBindings = Readonly<Record<string, ExtensionBindingJson>>;
11
+
12
+ export interface NormalizedExtensionBindings {
13
+ value: ExtensionBindings;
14
+ json: string;
15
+ }
16
+
17
+ function canonicalizeJson(value: unknown, path: string, depth: number, seen: Set<object>, propertyCount: { value: number }): ExtensionBindingJson {
18
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
19
+ if (typeof value === "number") {
20
+ if (!Number.isFinite(value)) throw new Error(`${path} must contain only finite JSON numbers.`);
21
+ return value;
22
+ }
23
+ if (typeof value !== "object") throw new Error(`${path} must contain only plain JSON values.`);
24
+ if (depth > MAX_EXTENSION_BINDINGS_DEPTH) throw new Error(`${path} exceeds the maximum nesting depth of ${MAX_EXTENSION_BINDINGS_DEPTH}.`);
25
+ if (seen.has(value)) throw new Error(`${path} must not contain cycles.`);
26
+ seen.add(value);
27
+ try {
28
+ if (Array.isArray(value)) {
29
+ const output: ExtensionBindingJson[] = [];
30
+ for (let index = 0; index < value.length; index++) {
31
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw new Error(`${path} must not contain sparse arrays.`);
32
+ output.push(canonicalizeJson(value[index], `${path}[${index}]`, depth + 1, seen, propertyCount));
33
+ }
34
+ return Object.freeze(output);
35
+ }
36
+ const prototype = Object.getPrototypeOf(value);
37
+ if (prototype !== Object.prototype && prototype !== null) throw new Error(`${path} must contain only plain JSON objects.`);
38
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(`${path} must not contain symbol keys.`);
39
+ const descriptors = Object.getOwnPropertyDescriptors(value);
40
+ const output: Record<string, ExtensionBindingJson> = {};
41
+ for (const key of Object.keys(descriptors).sort()) {
42
+ const descriptor = descriptors[key]!;
43
+ if (!descriptor.enumerable || !("value" in descriptor)) throw new Error(`${path}.${key} must be an enumerable data property.`);
44
+ propertyCount.value += 1;
45
+ if (propertyCount.value > MAX_EXTENSION_BINDINGS_PROPERTIES) throw new Error(`extensionBindings exceeds ${MAX_EXTENSION_BINDINGS_PROPERTIES} total properties.`);
46
+ Object.defineProperty(output, key, { value: canonicalizeJson(descriptor.value, `${path}.${key}`, depth + 1, seen, propertyCount), enumerable: true, writable: false, configurable: false });
47
+ }
48
+ return Object.freeze(output);
49
+ } finally {
50
+ seen.delete(value);
51
+ }
52
+ }
53
+
54
+ export function normalizeExtensionBindings(input: unknown): NormalizedExtensionBindings | undefined {
55
+ if (input === undefined) return undefined;
56
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("extensionBindings must be a plain JSON object.");
57
+ const prototype = Object.getPrototypeOf(input);
58
+ if (prototype !== Object.prototype && prototype !== null) throw new Error("extensionBindings must be a plain JSON object.");
59
+ const keys = Object.keys(input);
60
+ if (keys.length > MAX_EXTENSION_BINDING_NAMESPACES) throw new Error(`extensionBindings supports at most ${MAX_EXTENSION_BINDING_NAMESPACES} namespaces.`);
61
+ for (const key of keys) {
62
+ if (!EXTENSION_BINDING_NAMESPACE.test(key)) throw new Error(`extensionBindings namespace '${key}' must use a package-like name followed by '/<positive-version>'.`);
63
+ }
64
+ const value = canonicalizeJson(input, "extensionBindings", 0, new Set(), { value: 0 }) as ExtensionBindings;
65
+ const json = JSON.stringify(value);
66
+ const bytes = Buffer.byteLength(json, "utf8");
67
+ if (bytes > MAX_EXTENSION_BINDINGS_BYTES) throw new Error(`extensionBindings canonical JSON is ${bytes} bytes; maximum is ${MAX_EXTENSION_BINDINGS_BYTES}.`);
68
+ return { value, json };
69
+ }
70
+
71
+ export function encodeExtensionBindings(input: ExtensionBindings | undefined): string | undefined {
72
+ return input === undefined ? undefined : normalizeExtensionBindings(input)!.json;
73
+ }
74
+
75
+ export function omitExtensionBindingsEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
76
+ const { [PI_SUBAGENT_EXTENSION_BINDINGS_ENV]: _extensionBindings, ...sanitized } = env;
77
+ return sanitized;
78
+ }
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { finished } from "node:stream/promises";
5
5
  import { trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
6
6
  import type { ExternalProcessStatus } from "../../shared/types.ts";
7
+ import { omitExtensionBindingsEnv } from "./extension-bindings.ts";
7
8
 
8
9
  const HARD_KILL_DELAY_MS = 2_000;
9
10
  const MAX_OUTPUT_TAIL_BYTES = 64 * 1024;
@@ -54,6 +55,7 @@ export function runExternalCli(input: {
54
55
  let hardKillTimer: NodeJS.Timeout | undefined;
55
56
  const child = spawn(input.command, input.args ?? [], {
56
57
  cwd: input.cwd,
58
+ env: omitExtensionBindingsEnv(process.env),
57
59
  stdio: ["pipe", "pipe", "pipe"],
58
60
  shell: false,
59
61
  windowsHide: true,
@@ -0,0 +1,10 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ function withPriorityServiceTier(payload: unknown): unknown {
4
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
5
+ return { ...payload, service_tier: "priority" };
6
+ }
7
+
8
+ export default function registerSubagentFastModeExtension(pi: ExtensionAPI): void {
9
+ pi.on("before_provider_request", (event) => ({ payload: withPriorityServiceTier(event.payload) }));
10
+ }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
3
4
  import { TEMP_ROOT_DIR } from "../../shared/types.ts";
4
5
 
5
6
  export const EXCLUSIONS_PATH_ENV = "PI_MODEL_EXCLUSIONS_PATH";
@@ -194,7 +195,7 @@ export function getExcludedCount(): number {
194
195
  * {@link recordModelFailure} is later recognised by the candidate filter.
195
196
  */
196
197
  export function parseModelKey(fullId: string): { provider?: string; modelId: string } {
197
- const base = fullId.includes(":") ? fullId.substring(0, fullId.lastIndexOf(":")) : fullId;
198
+ const base = splitKnownThinkingSuffix(fullId).baseModel;
198
199
  if (!base.includes("/")) return { modelId: base };
199
200
  const slash = base.indexOf("/");
200
201
  return { provider: base.slice(0, slash), modelId: base.slice(slash + 1) };
@@ -1,4 +1,4 @@
1
- import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
1
+ import { splitKnownThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
2
  import type { Usage } from "../../shared/types.ts";
3
3
  import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
4
4
  import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
@@ -14,12 +14,7 @@ interface ModelAttemptSummary {
14
14
  }
15
15
 
16
16
  export function splitThinkingSuffix(model: string): { baseModel: string; thinkingSuffix: string } {
17
- const colonIdx = model.lastIndexOf(":");
18
- if (colonIdx === -1) return { baseModel: model, thinkingSuffix: "" };
19
- return {
20
- baseModel: model.substring(0, colonIdx),
21
- thinkingSuffix: model.substring(colonIdx),
22
- };
17
+ return splitKnownThinkingSuffix(model);
23
18
  }
24
19
 
25
20
  export function formatSubagentModelVerificationError(expectedModel: string, observedModel: string, availableModels: AvailableModelInfo[] | undefined): string | undefined {