@cr1ms0n/pi-subagent 0.10.0 → 0.11.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/src/registry.ts CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import type { Message } from "@earendil-works/pi-ai";
4
4
  import type { SubagentConfig } from "./config.js";
5
5
  import type { PersistenceAdapter, PersistedResult } from "./persistence.js";
6
- import { normalizeTaskRouting, PersistenceLayer } from "./persistence.js";
6
+ import { normalizeAttemptedModels, normalizeModelAttempts, normalizeTaskRouting, PersistenceLayer } from "./persistence.js";
7
7
  import type { ProcessLockManager } from "./process-lock.js";
8
8
  import type { RunMode, RunSnapshot, RunState, TaskResult, TaskSpec } from "./types.js";
9
9
  import { emptyUsage } from "./types.js";
@@ -49,15 +49,16 @@ const terminalStates = new Set<RunState>(["completed", "partial", "failed", "can
49
49
  /** Trailing coalesce window for high-frequency "changed" events. */
50
50
  const EMIT_COALESCE_MS = 100;
51
51
 
52
- function finalText(messages: Message[], fallback?: string): string | undefined {
52
+ function finalText(messages: Message[], fallback?: string, latestOnly = false): string | undefined {
53
53
  for (let i = messages.length - 1; i >= 0; i--) {
54
54
  const message = messages[i];
55
- if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
55
+ if (message?.role !== "assistant") continue;
56
+ if (!Array.isArray(message.content)) { if (latestOnly) return undefined; continue; }
56
57
  const text = message.content
57
58
  .filter((part: any) => part?.type === "text" && typeof part.text === "string")
58
59
  .map((part: any) => part.text)
59
60
  .join("");
60
- if (text) return text;
61
+ if (text || latestOnly) return text || undefined;
61
62
  }
62
63
  return fallback;
63
64
  }
@@ -96,13 +97,17 @@ export function toPersistedResult(result: TaskResult): PersistedResult {
96
97
  sessionId: result.sessionId,
97
98
  process: result.process,
98
99
  ...(routing === undefined ? {} : { routing }),
99
- finalOutput: utf8Prefix(finalText(result.messages, result.liveText), 16_384),
100
+ finalOutput: utf8Prefix(finalText(result.messages, result.liveText, !!routing?.rankedModels), 16_384),
100
101
  transcript: utf8Prefix(result.transcript, 32_768),
101
102
  worktree: result.worktree,
102
103
  wrappedUp: result.wrappedUp,
103
104
  stalledSince: result.stalledSince,
104
105
  attempts: result.attempts,
105
- attemptedModels: result.attemptedModels,
106
+ attemptedModels: normalizeAttemptedModels(result.attemptedModels),
107
+ toolActivity: result.toolActivity,
108
+ // Clone/validate at the projection boundary as well as reload, preserving
109
+ // immutable snapshots and the same producer/decoder preview limits.
110
+ modelAttempts: normalizeModelAttempts(result.modelAttempts),
106
111
  structuredOutput: result.structuredOutput,
107
112
  structuredError: result.structuredError,
108
113
  };
@@ -132,6 +137,13 @@ function resultFingerprint(result: TaskResult): string {
132
137
  result.structuredOutput !== undefined ? 1 : 0,
133
138
  result.structuredError?.length ?? 0,
134
139
  (result as { routing?: { decisionId?: unknown } }).routing?.decisionId ?? "",
140
+ // Model/attempt revision: a stable decision ID with same-length text must
141
+ // not keep stale UI state when the actual model, activity latch or attempt
142
+ // history changed between attempts.
143
+ result.model ?? "",
144
+ result.toolActivity ?? "",
145
+ JSON.stringify(normalizeModelAttempts(result.modelAttempts)) ?? "",
146
+ JSON.stringify(normalizeAttemptedModels(result.attemptedModels)) ?? "",
135
147
  ].join("|");
136
148
  }
137
149
 
@@ -173,7 +185,21 @@ export function toCheckpointResult(result: TaskResult): PersistedResult {
173
185
  wrappedUp: result.wrappedUp,
174
186
  stalledSince: result.stalledSince,
175
187
  attempts: result.attempts,
176
- attemptedModels: result.attemptedModels,
188
+ attemptedModels: normalizeAttemptedModels(result.attemptedModels),
189
+ toolActivity: result.toolActivity,
190
+ // Checkpoints carry attempt metadata/pointers only — preview TEXT is
191
+ // persisted exactly once at terminal, so repeated checkpoints stay small.
192
+ modelAttempts: normalizeModelAttempts(result.modelAttempts)?.map((record) => ({
193
+ attempt: record.attempt,
194
+ rank: record.rank,
195
+ model: record.model,
196
+ probability: record.probability,
197
+ outcome: record.outcome,
198
+ ...(record.stopReason === undefined ? {} : { stopReason: record.stopReason }),
199
+ ...(record.failureCategory === undefined ? {} : { failureCategory: record.failureCategory }),
200
+ ...(record.toolActivity === undefined ? {} : { toolActivity: record.toolActivity }),
201
+ ...(record.sessionId === undefined ? {} : { sessionId: record.sessionId }),
202
+ })),
177
203
  ...(routing === undefined ? {} : { routing }),
178
204
  };
179
205
  }
@@ -251,7 +251,7 @@ function routingSummary(config: JevRoutingConfig): string[] {
251
251
  lines.push(`- …and ${config.models.length - listed.length} more configured candidate(s); every configured candidate is eligible.`);
252
252
  }
253
253
  lines.push(
254
- "The selector returns one execution model and an individual include/exclude decision per eligible tool. Unknown, unsafe or unavailable choices are rejected locally, and required Pi control-plane tools are added locally rather than chosen by the selector.",
254
+ "The selector returns probability-ranked model candidates and one task-based, model-independent include/exclude decision per eligible tool. Unknown, unsafe or unavailable choices are rejected locally, and required Pi control-plane tools are added locally. Before any tool starts, a recognized settled model-availability failure can advance through this ranking without another selector request, under the total max_retries extra-attempt budget (0 = initial attempt only; default 1). Started or uncertain tool activity, auth/quota/context/schema failures, cancellation and exhausted task budgets stop switching. Confidence is answer-level; priorities use option probabilities, with no threshold.",
255
255
  );
256
256
  return lines;
257
257
  }
@@ -102,6 +102,20 @@ export interface RoutingModelCandidate {
102
102
  readonly thinking?: string;
103
103
  }
104
104
 
105
+ /**
106
+ * One probability-ranked candidate as returned by the model Choice answer.
107
+ * `probability` is the validated per-option value from TypeSafe's full
108
+ * distribution — not the answer-level `confidence` and not a measured
109
+ * availability or quality score. Entries are ordered by descending
110
+ * probability; the returned choice leads a tied maximum and remaining ties
111
+ * keep the configured candidate order. Zero and low probabilities remain
112
+ * valid candidates; no threshold is applied.
113
+ */
114
+ export interface RankedModelOption {
115
+ readonly model: string;
116
+ readonly probability: number;
117
+ }
118
+
105
119
  /**
106
120
  * A tool offered to the selector. The caller must have already removed mandatory local
107
121
  * additions (Pi control-plane tools) — those are never Jev questions.
@@ -181,6 +195,14 @@ export interface RoutingDecision {
181
195
  readonly selectedTools: readonly string[];
182
196
  /** Confidence of the model Choice. Diagnostic only; never a permission threshold. */
183
197
  readonly confidence?: number;
198
+ /**
199
+ * Full probability-ranked candidate list for automatic pre-tool availability
200
+ * failover. New router decisions always carry the complete ordered ranking.
201
+ * Optional only at legacy/persistence boundaries: a decoded decision without
202
+ * a ranking is display metadata and is never re-materialized into an
203
+ * executable attempt plan.
204
+ */
205
+ readonly rankedModels?: readonly RankedModelOption[];
184
206
  readonly selectorModel: string;
185
207
  /** Primary selector version: the version reported by the model response. */
186
208
  readonly selectorVersion?: string;
package/src/runner.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  UsageStats,
11
11
  } from "./types.js";
12
12
  import { emptyUsage } from "./types.js";
13
+ import { addUsage } from "./usage.js";
13
14
  import { ProtocolParser, type ProtocolUpdate } from "./protocol.js";
14
15
  import { Semaphore } from "./semaphore.js";
15
16
  import { defaultConfig } from "./config.js";
@@ -85,6 +86,15 @@ export interface RunnerOptions {
85
86
  startupTimeoutMs?: number;
86
87
  /** Backend adapter override (defaults to the spec's backend, then pi). */
87
88
  backend?: BackendAdapter;
89
+ /**
90
+ * Usage already billed by prior ranked attempts of the same task. Affects
91
+ * in-attempt budget COMPARISONS only (so `max_cost`/`max_turns` never reset
92
+ * per model); the runner still reports only this attempt's own usage. The
93
+ * orchestrator alone produces the cumulative figure for checkpoints/results.
94
+ */
95
+ priorUsage?: UsageStats;
96
+ /** Internal ranked-task ownership: orchestrator terminalizes after all attempts/cleanup. */
97
+ deferRunTerminal?: boolean;
88
98
  }
89
99
 
90
100
  type StopReason =
@@ -149,6 +159,9 @@ export class ChildRunner {
149
159
  private readonly stallKillAfterMs: number;
150
160
  private readonly startupTimeoutMs: number;
151
161
  private readonly backendOverride?: BackendAdapter;
162
+ /** Prior-attempt usage included in budget comparisons (never in returned usage). */
163
+ private readonly budgetOffset?: UsageStats;
164
+ private readonly deferRunTerminal: boolean;
152
165
  /** Backend for the in-flight run; set at spawn so steer() uses the right dialect. */
153
166
  private backend: BackendAdapter = resolveBackend("pi");
154
167
 
@@ -167,10 +180,12 @@ export class ChildRunner {
167
180
  private readonly maxTaskBytes = DEFAULT_MAX_TASK_BYTES,
168
181
  options: Pick<
169
182
  RunnerOptions,
170
- "graceTurns" | "stallAfterMs" | "stallKillAfterMs" | "startupTimeoutMs" | "backend"
183
+ "graceTurns" | "stallAfterMs" | "stallKillAfterMs" | "startupTimeoutMs" | "backend" | "priorUsage" | "deferRunTerminal"
171
184
  > = {},
172
185
  ) {
173
186
  this.backendOverride = options.backend;
187
+ this.budgetOffset = options.priorUsage;
188
+ this.deferRunTerminal = options.deferRunTerminal === true;
174
189
  this.graceTurns = options.graceTurns ?? defaultConfig.graceTurns;
175
190
  this.stallAfterMs = options.stallAfterMs ?? defaultConfig.stallAfterMs;
176
191
  this.stallKillAfterMs =
@@ -222,6 +237,10 @@ export class ChildRunner {
222
237
  };
223
238
 
224
239
  let processHandle: ChildProcess | undefined;
240
+ const failedBeforeSpawn = (error: unknown): boolean => {
241
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
242
+ return !processHandle?.pid && (code === "ENOENT" || code === "EPERM" || code === "EACCES");
243
+ };
225
244
  let slotHeld = false;
226
245
  let globalSlot: SlotToken | undefined;
227
246
  let forceKillTimer: NodeJS.Timeout | undefined;
@@ -256,6 +275,12 @@ export class ChildRunner {
256
275
  // `spec.routing` is added by the extension only for Jev-routed dispatches; the
257
276
  // trusted low-level SDK never sets it, so unrouted runs keep the old lifecycle.
258
277
  const routed = spec.routing !== undefined;
278
+ // Ranked extension runs carry a locally finalized probability plan. They get
279
+ // the stricter structured-output contract (no repair prompt and no final
280
+ // structuredOutput publication after a terminal provider error/abort or a
281
+ // failed/cancelled/timed-out settle). Trusted unranked SDK semantics stay
282
+ // exactly as before.
283
+ const ranked = routed && Array.isArray(spec.modelAttemptPlan) && spec.modelAttemptPlan.length > 0;
259
284
  let taskPromptSent = false;
260
285
  const absoluteDeadline =
261
286
  typeof spec.deadline === "number" && Number.isFinite(spec.deadline) ? spec.deadline : undefined;
@@ -473,6 +498,9 @@ export class ChildRunner {
473
498
  ...result,
474
499
  liveText: parser.getLiveText(),
475
500
  };
501
+ // Live sticky tool activity when the parser observes it (Pi adapter).
502
+ const liveActivity = parser.getToolActivity?.();
503
+ if (liveActivity !== undefined) checkpoint.toolActivity = liveActivity;
476
504
  if (withTranscript) checkpoint.transcript = parser.getTranscript();
477
505
  else delete checkpoint.transcript;
478
506
  this.onCheckpoint?.(checkpoint);
@@ -563,8 +591,16 @@ export class ChildRunner {
563
591
  // Structured-output gate: validate before letting the child exit.
564
592
  // Invalid → one steer-based repair round (a fresh prompt keeps the
565
593
  // RPC child alive and produces a new settle when it finishes).
566
- if (spec.outputSchema && !requestedStop && !pendingBudgetStop) {
567
- const extracted = extractStructuredResult(parser.getLiveText());
594
+ // Ranked exception: a settled assistant provider error/abort must NOT
595
+ // receive an extra same-model repair prompt — that prompt would add
596
+ // unintended work ahead of the ranked failover decision. A ranked
597
+ // parser without this observation capability is also not proof of a
598
+ // clean settle (fail closed); the unranked path is untouched.
599
+ const settledAssistantStop = parser.getAssistantStopReason?.();
600
+ const providerTerminated = settledAssistantStop === "error" || settledAssistantStop === "aborted"
601
+ || (ranked && settledAssistantStop === undefined);
602
+ if (spec.outputSchema && !requestedStop && !pendingBudgetStop && !(ranked && providerTerminated)) {
603
+ const extracted = extractStructuredResult(ranked ? parser.getAssistantText?.() : parser.getLiveText());
568
604
  const check =
569
605
  extracted.value !== undefined
570
606
  ? checkAgainstSchema(extracted.value, spec.outputSchema)
@@ -608,6 +644,9 @@ export class ChildRunner {
608
644
  state: "timeout",
609
645
  stopReason: "timeout",
610
646
  timeoutPhase: phase,
647
+ // A "queued" phase is runner-owned proof the slot was never held, so no
648
+ // child could have begun work; other phases are not pre-work conclusive.
649
+ preWorkInfraFailure: phase === "queued" ? true : base.preWorkInfraFailure,
611
650
  errorMessage:
612
651
  phase === "queued"
613
652
  ? "Timed out waiting for a process slot (never started)"
@@ -623,6 +662,7 @@ export class ChildRunner {
623
662
  result.stopReason = requestedStop ?? "cancelled";
624
663
  result.timeoutPhase =
625
664
  requestedStop === "timeout" ? (timeoutPhase ?? "queued") : undefined;
665
+ result.preWorkInfraFailure = result.timeoutPhase === "queued" ? true : undefined;
626
666
  result.exitCode = 1;
627
667
  result.endedAt = Date.now();
628
668
  if (result.state === "timeout" && !result.errorMessage) {
@@ -675,6 +715,10 @@ export class ChildRunner {
675
715
  result.errorMessage = error?.message ?? String(error);
676
716
  result.exitCode = 1;
677
717
  result.endedAt = Date.now();
718
+ // Admission rejection happens before any process exists: conclusive
719
+ // runner-owned proof that no child/task work began.
720
+ result.preWorkInfraFailure = true;
721
+ result.toolActivity = "none";
678
722
  return result;
679
723
  }
680
724
  }
@@ -770,6 +814,7 @@ export class ChildRunner {
770
814
  runId: this.runId,
771
815
  parentSessionKey: this.parentSessionKey ?? "",
772
816
  childSessionId: result.sessionId,
817
+ ...(this.deferRunTerminal ? { childSessionIds: [] } : {}),
773
818
  // Worktree-isolated runs record their checkout so concurrent Pi
774
819
  // processes' machine-wide GC sweeps can shield it while we live.
775
820
  worktreeCwd: spec.isolation === "worktree" ? spec.cwd : undefined,
@@ -1097,12 +1142,17 @@ export class ChildRunner {
1097
1142
  // Capability mismatch is not transient: never compensate by broadening tools,
1098
1143
  // choosing another model or retrying into an unverified launch.
1099
1144
  await stopChildForStartupFailure();
1100
- throw startupFailure(startupOutcome.code, startupOutcome.detail);
1145
+ // If the OS never created a process, no capability check could run.
1146
+ // Preserve this positive pre-work spawn proof for the same-model retry
1147
+ // path; an actually launched child's mismatch still refuses outright.
1148
+ if (!(ranked && failedBeforeSpawn(childExited?.error))) {
1149
+ throw startupFailure(startupOutcome.code, startupOutcome.detail);
1150
+ }
1101
1151
  }
1102
1152
  if (startupOutcome.kind === "cancelled") {
1103
1153
  // Cancelled/timed out during startup: never send the real task prompt.
1104
1154
  if (!requestedStop) requestStop("cancelled");
1105
- } else {
1155
+ } else if (startupOutcome.kind === "ok") {
1106
1156
  this.sendCommand = send;
1107
1157
  taskPromptSent = send({ type: "prompt", message: spec.task });
1108
1158
  // RPC mode has no session header line; get_state supplies the session id.
@@ -1131,7 +1181,11 @@ export class ChildRunner {
1131
1181
  thinking: spec.thinking,
1132
1182
  profile: spec.profile,
1133
1183
  backend: spec.backend ?? "pi",
1134
- model: finalized.model ?? result.model ?? spec.model,
1184
+ // Routed children were startup-verified against the exact `provider/model`
1185
+ // identity; provider message payloads may echo a bare ID, which must never
1186
+ // become the recorded actual model of an attempt.
1187
+ model: routed && spec.model ? spec.model : (finalized.model ?? result.model ?? spec.model),
1188
+ liveText: ranked ? parser.getAssistantText?.() || undefined : finalized.liveText,
1135
1189
  canWrite: spec.canWrite,
1136
1190
  process: result.process,
1137
1191
  startedAt,
@@ -1143,6 +1197,15 @@ export class ChildRunner {
1143
1197
  result.state = "failed";
1144
1198
  result.stopReason = "spawn_error";
1145
1199
  result.errorMessage = closed.error.message;
1200
+ // Only spawn-stage failures where the OS never produced a process are
1201
+ // conclusive pre-work proof. Any other child error leaves uncertainty:
1202
+ // a ranked attempt must not restart on it, so the activity latch rises
1203
+ // to `unknown` instead of staying `none`.
1204
+ const neverStarted = failedBeforeSpawn(closed.error);
1205
+ result.preWorkInfraFailure = neverStarted;
1206
+ if (!neverStarted && ranked && result.toolActivity !== "started") {
1207
+ result.toolActivity = "unknown";
1208
+ }
1146
1209
  } else if (requestedStop) {
1147
1210
  if (requestedStop === "timeout") {
1148
1211
  Object.assign(result, applyTimeoutSemantics(result));
@@ -1163,12 +1226,19 @@ export class ChildRunner {
1163
1226
  result.stopReason = requestedStop;
1164
1227
  result.exitCode = closed.code;
1165
1228
  result.errorMessage = `Stopped by ${requestedStop.replace("_", " ")} budget after the wrap-up grace period; partial output preserved`;
1166
- } else {
1229
+ } else if (requestedStop === "fatal") {
1230
+ // A fatal RPC rejection supersedes any earlier assistant error: the
1231
+ // final settled outcome is a protocol failure, not provider evidence.
1232
+ // Stale evidence must never authorize cross-model advancement.
1233
+ result.providerError = undefined;
1167
1234
  result.state = "failed";
1168
- result.stopReason =
1169
- requestedStop === "fatal" ? "error" : requestedStop;
1235
+ result.stopReason = "error";
1170
1236
  result.exitCode = closed.code ?? 1;
1171
1237
  if (fatalError) result.errorMessage = fatalError;
1238
+ } else {
1239
+ result.state = "failed";
1240
+ result.stopReason = requestedStop;
1241
+ result.exitCode = closed.code ?? 1;
1172
1242
  }
1173
1243
  } else if (
1174
1244
  pendingBudgetStop &&
@@ -1185,7 +1255,17 @@ export class ChildRunner {
1185
1255
  // Structured-output verdict: validate the final text once, after any
1186
1256
  // repair round. Failure downgrades completed → partial (paid work is
1187
1257
  // still delivered; the parent sees why it is not machine-readable).
1188
- if (spec.outputSchema) {
1258
+ // Ranked exception: a failed/cancelled/timed-out terminal attempt, or one
1259
+ // whose latest completed assistant message ended in a provider
1260
+ // error/abort, must never publish structuredOutput even when its text
1261
+ // contains a valid, schema-matching json:result block — that text stays
1262
+ // ordinary failed-attempt output/preview. Successful and legitimate
1263
+ // budget-limited "partial" attempts keep the existing validation.
1264
+ const settledAssistantStop = parser.getAssistantStopReason?.();
1265
+ const rankedAssistantTerminated = ranked && (settledAssistantStop === "error"
1266
+ || settledAssistantStop === "aborted"
1267
+ || settledAssistantStop === undefined);
1268
+ if (spec.outputSchema && !(ranked && (["failed", "cancelled", "timeout", "lost"].includes(result.state as TaskResult["state"]) || rankedAssistantTerminated))) {
1189
1269
  const extracted = extractStructuredResult(result.liveText);
1190
1270
  const check =
1191
1271
  extracted.value !== undefined
@@ -1210,7 +1290,7 @@ export class ChildRunner {
1210
1290
  }
1211
1291
  }
1212
1292
 
1213
- if (this.locks && this.runId) {
1293
+ if (!this.deferRunTerminal && this.locks && this.runId) {
1214
1294
  this.locks.markRunTerminal(this.runId, result.state);
1215
1295
  }
1216
1296
  return result;
@@ -1220,7 +1300,7 @@ export class ChildRunner {
1220
1300
  const startupFailureInfo = readStartupFailure(error);
1221
1301
  if (startupFailureInfo && requestedStop !== "timeout" && !abortSignal?.aborted) {
1222
1302
  markStartupFailure(result, startupFailureInfo.code, startupFailureInfo.detail);
1223
- if (this.locks && this.runId)
1303
+ if (!this.deferRunTerminal && this.locks && this.runId)
1224
1304
  this.locks.markRunTerminal(this.runId, result.state);
1225
1305
  return result;
1226
1306
  }
@@ -1232,6 +1312,7 @@ export class ChildRunner {
1232
1312
  result.stopReason = "timeout";
1233
1313
  result.timeoutPhase =
1234
1314
  timeoutPhase ?? (!slotHeld ? "queued" : "running");
1315
+ result.preWorkInfraFailure = result.timeoutPhase === "queued" ? true : undefined;
1235
1316
  result.errorMessage =
1236
1317
  result.timeoutPhase === "queued"
1237
1318
  ? "Timed out waiting for a process slot (never started)"
@@ -1245,7 +1326,7 @@ export class ChildRunner {
1245
1326
  }
1246
1327
  result.exitCode ??= 1;
1247
1328
  result.endedAt = Date.now();
1248
- if (this.locks && this.runId)
1329
+ if (!this.deferRunTerminal && this.locks && this.runId)
1249
1330
  this.locks.markRunTerminal(this.runId, result.state);
1250
1331
  return result;
1251
1332
  } finally {
@@ -1258,9 +1339,12 @@ export class ChildRunner {
1258
1339
  usage: UsageStats,
1259
1340
  ): "max_turns" | "max_cost" | undefined {
1260
1341
  // Stop only after a completed turn has pushed usage beyond the configured ceiling.
1261
- if (spec.maxTurns !== undefined && usage.turns > spec.maxTurns)
1342
+ // Prior-attempt usage is included in this comparison so a replacement model does
1343
+ // not reset max_cost/max_turns, while returned usage stays attempt-local.
1344
+ const compared = this.budgetOffset ? addUsage(this.budgetOffset, usage) : usage;
1345
+ if (spec.maxTurns !== undefined && compared.turns > spec.maxTurns)
1262
1346
  return "max_turns";
1263
- if (spec.maxCost !== undefined && usage.cost > spec.maxCost)
1347
+ if (spec.maxCost !== undefined && compared.cost > spec.maxCost)
1264
1348
  return "max_cost";
1265
1349
  return undefined;
1266
1350
  }
@@ -1294,6 +1378,8 @@ export function runSubagent(
1294
1378
  stallKillAfterMs: options.stallKillAfterMs,
1295
1379
  startupTimeoutMs: options.startupTimeoutMs,
1296
1380
  backend: options.backend,
1381
+ priorUsage: options.priorUsage,
1382
+ deferRunTerminal: options.deferRunTerminal,
1297
1383
  },
1298
1384
  ).run(spec, options.signal);
1299
1385
  }
package/src/schema.ts CHANGED
@@ -58,8 +58,8 @@ export const TaskFields = {
58
58
  max_turns: Type.Optional(Type.Number({ minimum: 1, maximum: 500, description: "Budget: at this many turns the child is steered to wrap up and given grace turns for a final answer; ends as 'partial' with output preserved." })),
59
59
  max_cost: Type.Optional(Type.Number({ minimum: 0, description: "Soft provider-reported execution cost ceiling in dollars, checked after each turn. TypeSafe routing currency is unreported and NOT capped by max_cost." })),
60
60
  grace_turns: Type.Optional(Type.Number({ minimum: 0, maximum: 20, description: "Wrap-up turns allowed after a budget breach before hard stop. 0 = immediate stop. Default from config (2)." })),
61
- fallback_models: Type.Optional(Type.Array(Type.String(), { maxItems: 5, description: "Legacy field: omit on new work, including an empty list. Jev routing has no emergency or fallback model." })),
62
- max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 5, description: "Extra same-model attempts on transient child failures; never reselects Jev or broadens tools. Defaults to config maxRetries (1). Task-quality failures never retry." })),
61
+ fallback_models: Type.Optional(Type.Array(Type.String(), { maxItems: 5, description: "Legacy field: omit on new work, including an empty list. Ranked alternatives come only from Jev; manual fallback and emergency models are not accepted." })),
62
+ max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 5, description: "Total extra child attempts (0 = initial attempt only; default 1). Before any tool execution, a recognized availability failure advances to the next Jev probability-ranked model with the same tools and no new selection. Auth/quota/context/task-quality failures never switch." })),
63
63
  context: Type.Optional(
64
64
  Type.Union([Type.Literal("fresh"), Type.Literal("fork")], {
65
65
  description: "fork starts the child from a branched copy of the parent conversation (needs a persisted parent session); fresh (default) starts clean. Fork is single-task only.",
@@ -70,7 +70,7 @@ export const TaskFields = {
70
70
  Type.Unsafe<Record<string, unknown>>(
71
71
  Type.Object({}, {
72
72
  additionalProperties: true,
73
- description: "JSON Schema the child's final result must satisfy. The child ends with a fenced json:result block; validation failures get one steer-based repair round, then end 'partial' with the errors reported.",
73
+ description: "JSON Schema the child's final result must satisfy. The child ends with a fenced json:result block; successful but invalid answers get one repair round, then end 'partial' with the errors reported. Provider errors/aborts are not repaired and failed attempts cannot publish structured output.",
74
74
  }),
75
75
  ),
76
76
  ),
package/src/types.ts CHANGED
@@ -41,6 +41,66 @@ export interface UsageStats {
41
41
 
42
42
  export type BackendName = "pi" | "codex" | "claude";
43
43
 
44
+ /**
45
+ * Sticky current-invocation tool activity for the pre-tool switch boundary. `started` latches on tool_execution_start, a newly observed
46
+ * completed assistant toolCall or a toolResult; `unknown` marks malformed,
47
+ * truncated or absent evidence. Both are sticky: neither can be erased by a
48
+ * later complete event, and only a conclusive `none` authorizes a new child.
49
+ */
50
+ export type ToolActivity = "none" | "started" | "unknown";
51
+
52
+ /**
53
+ * Conservative classification of the latest completed assistant provider error
54
+ * (see `model-failover.ts`). Only the availability categories advance the
55
+ * ranked path; everything else stops and is reported without switching.
56
+ */
57
+ export type ModelFailureCategory =
58
+ | "model_unavailable"
59
+ | "rate_limited"
60
+ | "service_overload"
61
+ | "transport"
62
+ | "auth"
63
+ | "quota"
64
+ | "invalid_request"
65
+ | "context_overflow"
66
+ | "refusal"
67
+ | "unknown";
68
+
69
+ /**
70
+ * One locally finalized ranked execution candidate. Internal contract built by
71
+ * `policy.ts` from the validated ranking and the original model catalog; never
72
+ * a tool-request/config field and never decoded from a persisted snapshot into
73
+ * an executable plan. Thinking follows explicit > agent > profile > candidate >
74
+ * parent per entry; tools/writer classification are shared across all entries.
75
+ */
76
+ export interface ModelAttemptSpec {
77
+ readonly model: string;
78
+ readonly probability: number;
79
+ readonly thinking?: ThinkingLevel;
80
+ }
81
+
82
+ /**
83
+ * Bounded descriptive history for one ranked/legacy attempt. Records are not
84
+ * a second usage ledger (TaskResult.usage stays cumulative) and never become
85
+ * executable: they carry the reason for a switch, the session pointer for
86
+ * discoverable partial work, and a 1 KiB output preview (16 KiB per task).
87
+ */
88
+ export interface ModelAttemptRecord {
89
+ /** 1-based launch count for this task. */
90
+ attempt: number;
91
+ /** 0-based ranking position of the candidate that ran. */
92
+ rank: number;
93
+ model: string;
94
+ probability: number;
95
+ outcome: RunState;
96
+ stopReason?: string;
97
+ failureCategory?: ModelFailureCategory;
98
+ toolActivity?: ToolActivity;
99
+ sessionId?: string;
100
+ /** Bounded output preview; metadata/session pointer survive when text is trimmed. */
101
+ outputPreview?: string;
102
+ }
103
+
44
104
  export interface TaskSpec {
45
105
  /** Agent CLI powering this child. Defaults to "pi". */
46
106
  backend?: BackendName;
@@ -75,8 +135,15 @@ export interface TaskSpec {
75
135
  graceTurns?: number;
76
136
  /** Ordered backup models tried on transient provider failures. */
77
137
  fallbackModels?: string[];
78
- /** Extra attempts on transient failures (queue timeout, stall, provider error). */
138
+ /** Extra launches: ranked pre-tool recovery, or the legacy SDK transient loop. */
79
139
  maxRetries?: number;
140
+ /**
141
+ * Locally finalized probability-ranked candidate plan for extension-managed
142
+ * tasks (internal; built only by `policy.finalizeRoutedTasks`). Its presence
143
+ * selects the ranked attempt loop; the legacy `fallbackModels` loop stays
144
+ * untouched for trusted unranked SDK tasks.
145
+ */
146
+ modelAttemptPlan?: readonly ModelAttemptSpec[];
80
147
  /** Fork the parent conversation into the child (real branched session). */
81
148
  contextFork?: boolean;
82
149
  /** Parent session file used for contextFork. */
@@ -129,6 +196,22 @@ export interface TaskResult {
129
196
  attempts?: number;
130
197
  /** Models tried across attempts, in order. */
131
198
  attemptedModels?: string[];
199
+ /** Sticky current-invocation tool activity across this task's attempts. */
200
+ toolActivity?: ToolActivity;
201
+ /**
202
+ * Bounded errorMessage + primitive diagnostics.error.code from the latest
203
+ * completed assistant provider error. Set only for stopReason "error";
204
+ * generic runner/RPC errors and diagnostic bodies never fill it.
205
+ */
206
+ providerError?: string;
207
+ /**
208
+ * Runner-owned positive proof that no child/task work could have begun
209
+ * (queue/admission timeout or a spawn that never produced a process). Only
210
+ * this conclusive evidence permits a same-model infrastructure retry.
211
+ */
212
+ preWorkInfraFailure?: boolean;
213
+ /** Bounded per-attempt history for ranked runs (descriptive, never executable). */
214
+ modelAttempts?: ModelAttemptRecord[];
132
215
  /** Parsed structured result when output_schema was requested and validated. */
133
216
  structuredOutput?: unknown;
134
217
  /** Validation errors when output_schema was requested but the result failed. */
@@ -182,6 +265,10 @@ export interface RunSnapshot {
182
265
  stalledSince?: number;
183
266
  attempts?: number;
184
267
  attemptedModels?: string[];
268
+ /** Sticky tool-activity boundary state across the task's attempts. */
269
+ toolActivity?: ToolActivity;
270
+ /** Bounded ranked attempt history (descriptive; previews capped). */
271
+ modelAttempts?: ModelAttemptRecord[];
185
272
  structuredOutput?: unknown;
186
273
  structuredError?: string;
187
274
  }>;