@cr1ms0n/pi-subagent 0.9.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 +23 -2
- package/README.md +40 -672
- package/README.zh-CN.md +42 -0
- package/docs/ARCHITECTURE.md +12 -24
- package/docs/COST-ACCOUNTING.md +6 -7
- package/docs/DEVELOPMENT.md +124 -0
- package/docs/PLAN.md +2 -0
- package/docs/REFERENCE.md +454 -0
- package/docs/RELEASING.md +151 -32
- package/docs/ROADMAP.md +2 -0
- package/docs/SECURITY.md +17 -18
- package/docs/UX.md +8 -12
- package/package.json +9 -1
- package/skills/subagent/SKILL.md +17 -12
- package/src/backend.ts +16 -1
- package/src/config.ts +1 -1
- package/src/extension.ts +33 -15
- package/src/format.ts +98 -3
- package/src/jev-router.ts +63 -27
- 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 +27 -19
- package/src/routing-types.ts +26 -4
- package/src/runner.ts +101 -15
- package/src/schema.ts +3 -3
- package/src/types.ts +88 -1
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranked Jev model failover: internal pure decision rules.
|
|
3
|
+
*
|
|
4
|
+
* No I/O, no process, no HTTP. Owners: `jev-router` (ranking construction),
|
|
5
|
+
* `policy` (attempt-plan finalization), `protocol` (evidence latches),
|
|
6
|
+
* `runner`/`orchestrator` (attempt decisions), `persistence` (shared caps).
|
|
7
|
+
*
|
|
8
|
+
* Execution contract: cross-model advance requires a recognized
|
|
9
|
+
* settled provider-availability error, conclusively `none` tool activity for
|
|
10
|
+
* the current invocation, remaining candidate + retry budget. Missing or
|
|
11
|
+
* malformed evidence is never a green light. `max_retries` is the total
|
|
12
|
+
* extension-level extra-attempt budget; availability failures advance directly
|
|
13
|
+
* (no extra same-model attempt, no wrap). The host's broad transient regex is
|
|
14
|
+
* deliberately not reused: recognition is negative-before-positive over
|
|
15
|
+
* explicit bounded forms; unknown text never switches.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Buffer } from "node:buffer";
|
|
19
|
+
import { MAX_ROUTING_MODELS, MAX_ROUTING_MODEL_ID_LENGTH, PROBABILITY_SUM_TOLERANCE, type RankedModelOption } from "./routing-types.js";
|
|
20
|
+
import { isThinkingLevel } from "./thinking.js";
|
|
21
|
+
import type { ModelAttemptRecord, ModelAttemptSpec, ModelFailureCategory, ToolActivity } from "./types.js";
|
|
22
|
+
|
|
23
|
+
// ---- Shared resource bounds ----------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** Attempt records and `attemptedModels` share this producer/decoder cap. */
|
|
26
|
+
export const MAX_MODEL_ATTEMPT_RECORDS = MAX_ROUTING_MODELS;
|
|
27
|
+
/** Per-record and per-task retained preview caps (UTF-8 bytes). */
|
|
28
|
+
export const MAX_ATTEMPT_PREVIEW_BYTES = 1_024;
|
|
29
|
+
export const MAX_ATTEMPT_PREVIEW_TOTAL_BYTES = 16 * 1_024;
|
|
30
|
+
/** Bounded provider error evidence copied out of the protocol parser. */
|
|
31
|
+
export const MAX_PROVIDER_ERROR_EVIDENCE_BYTES = 2_048;
|
|
32
|
+
|
|
33
|
+
/** Ranked launch bound: min(cap, 1 + floor(maxRetries)); fractional values floor. */
|
|
34
|
+
export function rankedMaxAttempts(maxRetries: number | undefined): number {
|
|
35
|
+
const extra = typeof maxRetries === "number" && Number.isFinite(maxRetries) && maxRetries > 0
|
|
36
|
+
? Math.floor(maxRetries)
|
|
37
|
+
: 0;
|
|
38
|
+
return Math.max(1, Math.min(MAX_MODEL_ATTEMPT_RECORDS, 1 + extra));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- Tool activity latch -------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** Lattice order for the sticky activity latch: none < unknown < started. */
|
|
44
|
+
function activityRank(value: ToolActivity | undefined): number {
|
|
45
|
+
return value === "started" ? 2 : value === "unknown" ? 1 : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Sticky merge: a later complete event never erases started/unknown. */
|
|
49
|
+
export function mergeToolActivity(
|
|
50
|
+
left: ToolActivity | undefined,
|
|
51
|
+
right: ToolActivity | undefined,
|
|
52
|
+
): ToolActivity {
|
|
53
|
+
if (activityRank(left) >= activityRank(right)) return left ?? right ?? "none";
|
|
54
|
+
return right ?? "none";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Absent evidence (legacy/foreign parser) is NOT permission to retry. */
|
|
58
|
+
export function resolveAttemptActivity(result: { toolActivity?: ToolActivity }): ToolActivity {
|
|
59
|
+
return result.toolActivity ?? "unknown";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- Provider error classification ---------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** Only documented primitive diagnostic codes; never retain bodies/headers/details. */
|
|
65
|
+
export function extractProviderError(errorMessage: unknown, diagnostics: unknown): string | undefined {
|
|
66
|
+
if (errorMessage !== undefined && typeof errorMessage !== "string") return undefined;
|
|
67
|
+
const message = typeof errorMessage === "string" ? errorMessage : "";
|
|
68
|
+
// Incomplete evidence cannot drop a negative message tail and trust a code.
|
|
69
|
+
if (Buffer.byteLength(message, "utf8") > MAX_PROVIDER_ERROR_EVIDENCE_BYTES) return undefined;
|
|
70
|
+
const diagnostic = diagnostics && typeof diagnostics === "object" ? diagnostics as Record<string, unknown> : undefined;
|
|
71
|
+
const error = diagnostic?.error && typeof diagnostic.error === "object" ? diagnostic.error as Record<string, unknown> : undefined;
|
|
72
|
+
const rawCode = error?.code;
|
|
73
|
+
let code = "";
|
|
74
|
+
if (rawCode !== undefined) {
|
|
75
|
+
if (typeof rawCode === "number" && Number.isInteger(rawCode) && rawCode >= 100 && rawCode <= 599) code = `status code ${rawCode}`;
|
|
76
|
+
else if (typeof rawCode === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(rawCode)) {
|
|
77
|
+
code = /^[1-5][0-9]{2}$/.test(rawCode) ? `status code ${rawCode}` : rawCode;
|
|
78
|
+
} else return undefined;
|
|
79
|
+
}
|
|
80
|
+
const evidence = [message, code].filter(Boolean).join("\n");
|
|
81
|
+
return evidence.trim() && Buffer.byteLength(evidence, "utf8") <= MAX_PROVIDER_ERROR_EVIDENCE_BYTES ? evidence : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const AVAILABILITY_CATEGORIES: ReadonlySet<ModelFailureCategory> = new Set<ModelFailureCategory>([
|
|
85
|
+
"model_unavailable",
|
|
86
|
+
"rate_limited",
|
|
87
|
+
"service_overload",
|
|
88
|
+
"transport",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
export function isAvailabilityCategory(category: ModelFailureCategory): boolean {
|
|
92
|
+
return AVAILABILITY_CATEGORIES.has(category);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Contextualized HTTP status forms only: a bare number in unrelated text must
|
|
97
|
+
* not classify. `http 503`, `status 503`, `status code: 503` and `error code 503`
|
|
98
|
+
* qualify; a lone "500 tokens" or bare "404" does not.
|
|
99
|
+
*/
|
|
100
|
+
function statusSource(codes: string): string {
|
|
101
|
+
return String.raw`\b(?:status(?:\s+code)?|https?\s*(?:status|error|code)?|error\s+code|err\s+code|response\s+code)\D{0,10}\b(?:${codes})\b`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface Rule { readonly category: ModelFailureCategory; readonly pattern: RegExp }
|
|
105
|
+
|
|
106
|
+
function rule(category: ModelFailureCategory, sources: readonly string[]): Rule {
|
|
107
|
+
return { category, pattern: new RegExp(`(?:${sources.join("|")})`, "i") };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Matched before every other rule: these always stop the ranked path. */
|
|
111
|
+
const STOP_RULES: readonly Rule[] = [
|
|
112
|
+
rule("auth", [
|
|
113
|
+
String.raw`\b(?:invalid|incorrect|missing|expired|revoked|unauthorized|bad)[\s_]*(?:api[\s_-]?key|credentials?|auth(?:entication)?|access[\s_-]?token|token)\b`,
|
|
114
|
+
String.raw`\b(?:authentication|auth)[\s_]*(?:failed|error)\b`,
|
|
115
|
+
String.raw`\bpermission denied\b`,
|
|
116
|
+
String.raw`\baccess\b[^.\n]{0,40}\b(?:denied|forbidden|not granted|disabled|revoked)\b`,
|
|
117
|
+
String.raw`\b(?:do|does) not have\b[^.\n]{0,60}\b(?:access|permission)\b`,
|
|
118
|
+
String.raw`\baccess (?:to )?(?:this|the) model\b[^.\n]{0,24}\b(?:denied|forbidden|not granted|is not granted)\b`,
|
|
119
|
+
statusSource("40[13]"),
|
|
120
|
+
]),
|
|
121
|
+
rule("quota", [
|
|
122
|
+
String.raw`\binsufficient[_\s-]?quota\b`,
|
|
123
|
+
String.raw`\b(?:out of|exceeded|hit|reached)[\s_-]*(?:your[\s_-]*)?(?:current[\s_-]*)?quota\b`,
|
|
124
|
+
String.raw`\bquota\b[^.\n]{0,20}\b(?:exceeded|exhausted|reached|limit|too low)\b`,
|
|
125
|
+
String.raw`\bbilling\b`,
|
|
126
|
+
String.raw`\b(?:out of|exceeded|hit|reached)\b[^.\n]{0,20}\b(?:budget|credit|funds|allowance)\b`,
|
|
127
|
+
String.raw`\b(?:insufficient|negative|zero)\b[^.\n]{0,12}\b(?:balance|credit)\b`,
|
|
128
|
+
String.raw`\b(?:credit|balance|budget|allowance)[\s_-]*(?:exhausted|depleted|reached|limit)\b`,
|
|
129
|
+
String.raw`\b(?:monthly|weekly|rolling|free[-\s]?tier|usage|spend)[^a-z0-9]{0,3}limit[^.\n]{0,24}\b(?:reached|exceeded|exhausted)\b`,
|
|
130
|
+
String.raw`\b(?:go|free)[_\s-]?usagelimiterror\b`,
|
|
131
|
+
String.raw`\bavailable[\s_]*balance\b`,
|
|
132
|
+
String.raw`\blimit[^.\n]{0,40}\b(?:upgrade|purchase|buy|subscribe|plan)\b`,
|
|
133
|
+
String.raw`\bRESOURCE_EXHAUSTED\b`,
|
|
134
|
+
]),
|
|
135
|
+
rule("context_overflow", [
|
|
136
|
+
String.raw`\bcontext[_\s-]?length[_\s-]?exceeded\b`,
|
|
137
|
+
String.raw`\b(?:context|prompt|input|request|conversation)[\s_-]*(?:length|window|size)?[^a-z0-9]{0,4}\b(?:overflow|too long|exceeded|exceeds|maximum)\b`,
|
|
138
|
+
String.raw`\btoo many (?:tokens|messages|characters|words)\b`,
|
|
139
|
+
String.raw`\breduce (?:the )?(?:length|size|input|context)\b`,
|
|
140
|
+
String.raw`\bcontext window\b`,
|
|
141
|
+
statusSource("413"),
|
|
142
|
+
]),
|
|
143
|
+
rule("invalid_request", [
|
|
144
|
+
String.raw`\binvalid[_\s-]?request(?:\s*error)?\b`,
|
|
145
|
+
String.raw`\binvalid[\s_-]*(?:parameter|argument|schema|payload|body|tool|function|messages?[\s_]*format|enum|value|type)\b`,
|
|
146
|
+
String.raw`\b(?:unknown|unsupported)[\s_-]*(?:tool|function|parameter|argument|format)\b`,
|
|
147
|
+
String.raw`\b(?:required|missing)[\s_-]*(?:parameter|argument|field)\b`,
|
|
148
|
+
String.raw`\bextra inputs are not permitted\b`,
|
|
149
|
+
statusSource("400|406|409|415|422|451"),
|
|
150
|
+
]),
|
|
151
|
+
rule("refusal", [
|
|
152
|
+
String.raw`\bcontent[\s_]*policy\b`,
|
|
153
|
+
String.raw`\b(?:request|response|the model)[\s_]*refus(?:ed|al)\b`,
|
|
154
|
+
]),
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Positive availability forms. Explicit model-unavailable evidence can disambiguate
|
|
159
|
+
* HTTP 404; all other negative evidence has priority. Unknown text stops.
|
|
160
|
+
*/
|
|
161
|
+
const AVAILABILITY_RULES: readonly Rule[] = [
|
|
162
|
+
rule("model_unavailable", [
|
|
163
|
+
String.raw`\bmodel[_\s-]?not[_\s-]?(?:found|exist|available|deployed|provisioned)\b`,
|
|
164
|
+
String.raw`\bmodel[_\s-]?unavailable\b`,
|
|
165
|
+
String.raw`\bno such model\b`,
|
|
166
|
+
String.raw`\bthe model\b[^.\n]{0,80}\b(?:does not exist|is not available|was not found|cannot be found|is not deployed)\b`,
|
|
167
|
+
String.raw`\b(?:selected|requested|chosen)[\s_]+model\b[^.\n]{0,60}\b(?:not found|unavailable|does not exist|is not available|cannot be found|is not deployed)\b`,
|
|
168
|
+
String.raw`\bmodelnotfoundexception\b`,
|
|
169
|
+
]),
|
|
170
|
+
rule("rate_limited", [
|
|
171
|
+
String.raw`\brate[\s_-]?limit\w*\b`,
|
|
172
|
+
String.raw`\btoo many requests\b`,
|
|
173
|
+
String.raw`\bthrottl(?:ed|ing|e)\w*\b`,
|
|
174
|
+
statusSource("429"),
|
|
175
|
+
]),
|
|
176
|
+
rule("service_overload", [
|
|
177
|
+
String.raw`\b(?:is|are|currently|experienc\w+)[^.\n]{0,40}\boverloaded\b`,
|
|
178
|
+
String.raw`\boverloaded[_\s-]?(?:error|exception)\b`,
|
|
179
|
+
String.raw`\bhigh demand\b`,
|
|
180
|
+
String.raw`\bservice[^a-z0-9]{0,3}(?:is )?unavailable\b`,
|
|
181
|
+
String.raw`\b(?:internal|server)[\s_]*error\b`,
|
|
182
|
+
String.raw`\bbad gateway\b`,
|
|
183
|
+
String.raw`\bgateway time(?:d|out)\b`,
|
|
184
|
+
statusSource("500|502|503|504|520|524|529"),
|
|
185
|
+
]),
|
|
186
|
+
rule("transport", [
|
|
187
|
+
String.raw`\bE(?:CONNRESET|CONNREFUSED|CONNABORTED|HOSTUNREACH|NETUNREACH|NETDOWN|SHUTDOWN|TIMEDOUT)\b`,
|
|
188
|
+
String.raw`\bEAI_AGAIN\b`,
|
|
189
|
+
String.raw`\bgetaddrinfo\b`,
|
|
190
|
+
String.raw`\bsocket hang up\b`,
|
|
191
|
+
String.raw`\bsocket connection was closed\b`,
|
|
192
|
+
String.raw`\b(?:upstream|origin)[\s_]*connect(?:ion)?[\s_]*(?:error|refused|failure)\b`,
|
|
193
|
+
String.raw`\breset before headers\b`,
|
|
194
|
+
String.raw`\bother side closed\b`,
|
|
195
|
+
String.raw`\bconnection (?:error|lost|closed|reset|refused|failure)\b`,
|
|
196
|
+
String.raw`\bconnect(?:ion)? (?:timed out|timeout)\b`,
|
|
197
|
+
String.raw`\b(?:socket|tls|tcp) (?:error|failure|reset|closed|timed out)\b`,
|
|
198
|
+
String.raw`\bfetch failed\b`,
|
|
199
|
+
String.raw`\bunable to connect\b`,
|
|
200
|
+
]),
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Classify bounded evidence from the latest completed assistant provider error
|
|
205
|
+
* only. `null` means "no usable settled provider error" (never availability);
|
|
206
|
+
* `"unknown"` means unrecognized text (never availability). Inputs other than
|
|
207
|
+
* parser-extracted assistant error strings must not be passed here.
|
|
208
|
+
*
|
|
209
|
+
* Rule order is part of the contract:
|
|
210
|
+
* 1. auth/quota/context/schema/refusal negatives always win;
|
|
211
|
+
* 2. explicit model-unavailable evidence can disambiguate HTTP 404;
|
|
212
|
+
* 3. an unexplained HTTP 404 stops, then other availability rules apply.
|
|
213
|
+
* Oversized evidence is refused entirely (`null`) rather than truncated: a
|
|
214
|
+
* dropped tail could contain a quota/billing statement the prefix test would
|
|
215
|
+
* never see.
|
|
216
|
+
*/
|
|
217
|
+
export function classifyProviderError(evidence: string | undefined): ModelFailureCategory | null {
|
|
218
|
+
if (typeof evidence !== "string") return null;
|
|
219
|
+
if (!evidence.trim()) return null;
|
|
220
|
+
if (Buffer.byteLength(evidence, "utf8") > MAX_PROVIDER_ERROR_EVIDENCE_BYTES) return null;
|
|
221
|
+
for (const candidate of STOP_RULES) if (candidate.pattern.test(evidence)) return candidate.category;
|
|
222
|
+
const modelUnavailable = AVAILABILITY_RULES[0]!;
|
|
223
|
+
if (modelUnavailable.pattern.test(evidence)) return modelUnavailable.category;
|
|
224
|
+
if (new RegExp(statusSource("404"), "i").test(evidence)) return "invalid_request";
|
|
225
|
+
for (const candidate of AVAILABILITY_RULES.slice(1)) if (candidate.pattern.test(evidence)) return candidate.category;
|
|
226
|
+
return "unknown";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- Ranking and attempt-plan validation ----------------------------------------
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Order by descending probability. The returned choice leads a tied maximum;
|
|
233
|
+
* remaining ties keep the configured candidate order (stable sort). Never
|
|
234
|
+
* sorts by name, cost or quality assumptions; zero probabilities stay valid.
|
|
235
|
+
*/
|
|
236
|
+
export function orderRankedModels(
|
|
237
|
+
candidates: ReadonlyArray<{ model: string; probability: number }>,
|
|
238
|
+
choice: string,
|
|
239
|
+
): readonly RankedModelOption[] {
|
|
240
|
+
const tieLead = (model: string): number => (model === choice ? 0 : 1);
|
|
241
|
+
return Object.freeze(candidates
|
|
242
|
+
.map((entry) => Object.freeze({ model: entry.model, probability: entry.probability }))
|
|
243
|
+
.sort((a, b) => b.probability - a.probability || tieLead(a.model) - tieLead(b.model)));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Official Choice contract: `choice` is the (exact) maximum-probability option.
|
|
248
|
+
* A contradictory answer is an invalid decision; never substitute a model.
|
|
249
|
+
*/
|
|
250
|
+
export function choiceIsMaximal(
|
|
251
|
+
probabilities: ReadonlyMap<string, number>,
|
|
252
|
+
choiceKey: string,
|
|
253
|
+
modelName: string,
|
|
254
|
+
): string | undefined {
|
|
255
|
+
const choiceProbability = probabilities.get(choiceKey);
|
|
256
|
+
if (choiceProbability === undefined) return `the selected model ${JSON.stringify(modelName)} has no reported probability`;
|
|
257
|
+
for (const value of probabilities.values()) {
|
|
258
|
+
if (value > choiceProbability) {
|
|
259
|
+
return `the returned choice ${JSON.stringify(modelName)} (probability ${choiceProbability}) is not a maximum-probability option (${value})`;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Validate a ranking before it can become an executable attempt plan.
|
|
267
|
+
* `allowedModels` is the original configured eligibility order. Returns a
|
|
268
|
+
* bounded error string, or undefined when acceptable: unique entries, exact
|
|
269
|
+
* membership without duplicates or omissions (every eligible candidate stays a
|
|
270
|
+
* candidate, including zero probability), finite 0..1 probabilities in
|
|
271
|
+
* non-increasing order, and the first entry equals the selected model.
|
|
272
|
+
*/
|
|
273
|
+
export function validateModelRanking(
|
|
274
|
+
ranked: readonly unknown[] | undefined,
|
|
275
|
+
allowedModels: readonly string[],
|
|
276
|
+
selectedModel: string,
|
|
277
|
+
): string | undefined {
|
|
278
|
+
if (!Array.isArray(ranked) || ranked.length === 0) return "the probability ranking is missing or empty";
|
|
279
|
+
if (ranked.length > MAX_MODEL_ATTEMPT_RECORDS) return `the probability ranking exceeds ${MAX_MODEL_ATTEMPT_RECORDS} entries`;
|
|
280
|
+
const allowed = new Set(allowedModels);
|
|
281
|
+
if (allowed.size !== allowedModels.length) return "the candidate catalog contains duplicate model IDs";
|
|
282
|
+
const seen = new Set<string>();
|
|
283
|
+
const configuredOrder = new Map(allowedModels.map((model, index) => [model, index]));
|
|
284
|
+
let sum = 0;
|
|
285
|
+
let previous: number | undefined;
|
|
286
|
+
let previousModel: string | undefined;
|
|
287
|
+
for (let index = 0; index < ranked.length; index++) {
|
|
288
|
+
const entry = ranked[index] as Record<string, unknown> | undefined;
|
|
289
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return `ranking entry ${index + 1} is not an object`;
|
|
290
|
+
const model = entry.model;
|
|
291
|
+
if (typeof model !== "string" || !model.trim() || model.length > MAX_ROUTING_MODEL_ID_LENGTH) return `ranking entry ${index + 1} has an invalid model ID`;
|
|
292
|
+
if (!allowed.has(model)) return `ranking entry ${index + 1} selects a model outside the eligible candidates`;
|
|
293
|
+
if (seen.has(model)) return `ranking entry ${index + 1} repeats model ${JSON.stringify(model)}`;
|
|
294
|
+
seen.add(model);
|
|
295
|
+
const probability = entry.probability;
|
|
296
|
+
if (typeof probability !== "number" || !Number.isFinite(probability) || probability < 0 || probability > 1) {
|
|
297
|
+
return `ranking entry ${index + 1} has a probability outside the finite range 0..1`;
|
|
298
|
+
}
|
|
299
|
+
if (previous !== undefined && probability > previous) return `ranking entry ${index + 1} is out of probability order`;
|
|
300
|
+
if (previous === probability && previousModel !== selectedModel && configuredOrder.get(previousModel!)! > configuredOrder.get(model)!) {
|
|
301
|
+
return `ranking entry ${index + 1} breaks the configured tie order`;
|
|
302
|
+
}
|
|
303
|
+
previous = probability;
|
|
304
|
+
previousModel = model;
|
|
305
|
+
sum += probability;
|
|
306
|
+
}
|
|
307
|
+
if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE) return "the model probabilities do not sum to 1 within the routing tolerance";
|
|
308
|
+
if (seen.size !== allowed.size) return "the probability ranking does not cover every eligible candidate";
|
|
309
|
+
if ((ranked[0] as { model?: unknown }).model !== selectedModel) return "the ranking does not start with the selected model";
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Validate an attempt plan already present on a spec (SDK boundary defense).
|
|
315
|
+
* A PRESENT-but-malformed plan must never launch and must never be reinterpreted
|
|
316
|
+
* as "unranked": callers reject it fail closed. Only absence selects the legacy
|
|
317
|
+
* unranked path.
|
|
318
|
+
*/
|
|
319
|
+
export function validateAttemptPlan(plan: unknown, initialModel: string | undefined): plan is readonly ModelAttemptSpec[] {
|
|
320
|
+
if (!Array.isArray(plan) || plan.length === 0) return false;
|
|
321
|
+
if (typeof initialModel !== "string" || !initialModel) return false;
|
|
322
|
+
for (const entry of plan) {
|
|
323
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
324
|
+
const record = entry as Record<string, unknown>;
|
|
325
|
+
if (typeof record.model !== "string" || !record.model.trim()) return false;
|
|
326
|
+
if (typeof record.probability !== "number" || !Number.isFinite(record.probability) || record.probability < 0 || record.probability > 1) return false;
|
|
327
|
+
if (record.thinking !== undefined && !isThinkingLevel(record.thinking)) return false;
|
|
328
|
+
}
|
|
329
|
+
return validateModelRanking(
|
|
330
|
+
plan as readonly unknown[],
|
|
331
|
+
plan.map((entry) => (entry as ModelAttemptSpec).model),
|
|
332
|
+
initialModel,
|
|
333
|
+
) === undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---- The ranked attempt decision -------------------------------------------------
|
|
337
|
+
|
|
338
|
+
export interface RankedAttemptFacts {
|
|
339
|
+
/** 1-based launches performed, counting the attempt that just settled. */
|
|
340
|
+
attempt: number;
|
|
341
|
+
maxAttempts: number;
|
|
342
|
+
/** 0-based ranking position used by the attempt that just settled. */
|
|
343
|
+
candidateIndex: number;
|
|
344
|
+
candidateCount: number;
|
|
345
|
+
/** Settled activity for this invocation; absent evidence resolves to unknown. */
|
|
346
|
+
activity: ToolActivity;
|
|
347
|
+
/** Classification of the settled assistant provider error, if any. */
|
|
348
|
+
category: ModelFailureCategory | null;
|
|
349
|
+
/** Settled state of the attempt that just finished. */
|
|
350
|
+
state: ModelAttemptRecord["outcome"];
|
|
351
|
+
cancelled: boolean;
|
|
352
|
+
deadlineExceeded: boolean;
|
|
353
|
+
/** Runner-owned positive proof no child/task work began (queue/admission/spawn). */
|
|
354
|
+
infraPreWork: boolean;
|
|
355
|
+
/** Cumulative reported cost has met or exceeded spec.maxCost. */
|
|
356
|
+
costCeilingReached: boolean;
|
|
357
|
+
/** Cumulative reported turns have met or exceeded spec.maxTurns. */
|
|
358
|
+
turnCeilingReached: boolean;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export type RankedAttemptDecision =
|
|
362
|
+
| { action: "finish" }
|
|
363
|
+
| { action: "advance"; reason: string }
|
|
364
|
+
| { action: "retry_same_model"; reason: string };
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Pure ranked-attempt decision for one settled ranked attempt (called only after the
|
|
368
|
+
* child fully settled and cleaned up). Availability failures advance directly
|
|
369
|
+
* and never retry the same model or wrap; infrastructure retries happen only
|
|
370
|
+
* with conclusive pre-work proof and share the same total budget.
|
|
371
|
+
*/
|
|
372
|
+
export function decideRankedAttempt(facts: RankedAttemptFacts): RankedAttemptDecision {
|
|
373
|
+
if (facts.cancelled || facts.deadlineExceeded) return { action: "finish" };
|
|
374
|
+
if (facts.state !== "failed") return { action: "finish" };
|
|
375
|
+
if (facts.activity !== "none") return { action: "finish" };
|
|
376
|
+
const attemptsRemain = facts.attempt < facts.maxAttempts;
|
|
377
|
+
if (facts.costCeilingReached || facts.turnCeilingReached) return { action: "finish" };
|
|
378
|
+
if (facts.category && isAvailabilityCategory(facts.category)) {
|
|
379
|
+
if (attemptsRemain && facts.candidateIndex + 1 < facts.candidateCount) {
|
|
380
|
+
return { action: "advance", reason: `${facts.category} before any tool execution; advancing to the next ranked candidate` };
|
|
381
|
+
}
|
|
382
|
+
return { action: "finish" };
|
|
383
|
+
}
|
|
384
|
+
if (facts.infraPreWork && attemptsRemain) {
|
|
385
|
+
return { action: "retry_same_model", reason: "conclusive pre-work infrastructure failure; retrying the same candidate" };
|
|
386
|
+
}
|
|
387
|
+
return { action: "finish" };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- Bounded text and preview helpers --------------------------------------------
|
|
391
|
+
|
|
392
|
+
/** Truncate to at most maxBytes of UTF-8 without splitting a code point. */
|
|
393
|
+
export function utf8SafePrefix(value: string, maxBytes: number): string {
|
|
394
|
+
if (maxBytes <= 0) return "";
|
|
395
|
+
const buffer = Buffer.from(value, "utf8");
|
|
396
|
+
if (buffer.length <= maxBytes) return value;
|
|
397
|
+
let end = maxBytes;
|
|
398
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
|
|
399
|
+
return buffer.subarray(0, end).toString("utf8");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Cap one preview to the per-record bound. */
|
|
403
|
+
export function attemptOutputPreview(text: string | undefined): string | undefined {
|
|
404
|
+
if (!text?.trim()) return undefined;
|
|
405
|
+
return utf8SafePrefix(text, MAX_ATTEMPT_PREVIEW_BYTES) || undefined;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Enforce the 16 KiB task total: discard OLDEST preview text first while
|
|
410
|
+
* keeping every record's metadata and session pointer. Mutates in place.
|
|
411
|
+
*/
|
|
412
|
+
export function trimAttemptPreviews(records: readonly ModelAttemptRecord[]): void {
|
|
413
|
+
let total = records.reduce((sum, record) => sum + Buffer.byteLength(record.outputPreview ?? "", "utf8"), 0);
|
|
414
|
+
for (const record of records) {
|
|
415
|
+
if (total <= MAX_ATTEMPT_PREVIEW_TOTAL_BYTES) break;
|
|
416
|
+
if (!record.outputPreview) continue;
|
|
417
|
+
total -= Buffer.byteLength(record.outputPreview, "utf8");
|
|
418
|
+
const kept = utf8SafePrefix(record.outputPreview, Math.max(0, MAX_ATTEMPT_PREVIEW_TOTAL_BYTES - total));
|
|
419
|
+
record.outputPreview = kept || undefined;
|
|
420
|
+
total += Buffer.byteLength(kept, "utf8");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Attributed earlier-output projection for terminal failure delivery. The
|
|
426
|
+
* preview never becomes the final answer/state/model/session; it is only shown
|
|
427
|
+
* when the terminal attempt produced no text of its own.
|
|
428
|
+
*/
|
|
429
|
+
export function earlierAttemptOutputNote(result: {
|
|
430
|
+
state?: ModelAttemptRecord["outcome"];
|
|
431
|
+
liveText?: string;
|
|
432
|
+
modelAttempts?: readonly ModelAttemptRecord[];
|
|
433
|
+
}): string | undefined {
|
|
434
|
+
if (!["failed", "cancelled", "timeout", "lost"].includes(String(result.state))) return undefined;
|
|
435
|
+
if (result.liveText && result.liveText.trim()) return undefined;
|
|
436
|
+
const records = result.modelAttempts;
|
|
437
|
+
if (!Array.isArray(records) || records.length < 2) return undefined;
|
|
438
|
+
for (let index = records.length - 2; index >= 0; index--) {
|
|
439
|
+
const record = records[index]!;
|
|
440
|
+
if (!record.outputPreview?.trim()) continue;
|
|
441
|
+
const session = record.sessionId ? `; session ${record.sessionId}` : "";
|
|
442
|
+
return `[Earlier output preview — attempt ${record.attempt} on ${record.model}${session}; not the final attempt's answer, kept because this attempt failed]\n${record.outputPreview}`;
|
|
443
|
+
}
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
package/src/notifications.ts
CHANGED
|
@@ -78,6 +78,7 @@ export interface CompletionDetailsTask {
|
|
|
78
78
|
tokens: number;
|
|
79
79
|
cost: number;
|
|
80
80
|
model?: string;
|
|
81
|
+
attempts?: number;
|
|
81
82
|
attemptedModels?: string[];
|
|
82
83
|
pointers: string[];
|
|
83
84
|
}
|
|
@@ -94,6 +95,7 @@ export interface CompletionDetailsRun {
|
|
|
94
95
|
durationMs: number;
|
|
95
96
|
/** Kept for single-task consumers; parallel consumers must use tasks[]. */
|
|
96
97
|
model?: string;
|
|
98
|
+
attempts?: number;
|
|
97
99
|
attemptedModels?: string[];
|
|
98
100
|
pointers: string[];
|
|
99
101
|
tasks: CompletionDetailsTask[];
|