@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/CHANGELOG.md +8 -1
- package/README.md +11 -101
- package/README.zh-CN.md +42 -132
- package/docs/ARCHITECTURE.md +12 -24
- package/docs/COST-ACCOUNTING.md +6 -7
- package/docs/REFERENCE.md +25 -7
- package/docs/UX.md +8 -12
- package/package.json +1 -1
- package/skills/subagent/SKILL.md +9 -8
- package/src/backend.ts +16 -1
- package/src/extension.ts +33 -15
- package/src/format.ts +98 -3
- package/src/jev-router.ts +56 -16
- package/src/model-failover.ts +445 -0
- package/src/notifications.ts +2 -0
- package/src/orchestrator.ts +522 -303
- package/src/output.ts +9 -4
- package/src/persistence.ts +127 -5
- package/src/policy.ts +26 -3
- package/src/process-lock.ts +16 -0
- package/src/protocol.ts +208 -11
- package/src/registry.ts +33 -7
- package/src/routing-policy.ts +1 -1
- package/src/routing-types.ts +22 -0
- package/src/runner.ts +101 -15
- package/src/schema.ts +3 -3
- package/src/types.ts +88 -1
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"
|
|
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
|
}
|
package/src/routing-policy.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/routing-types.ts
CHANGED
|
@@ -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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
62
|
-
max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 5, description: "
|
|
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;
|
|
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
|
|
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
|
}>;
|