pi-better-harness 0.34.2 → 0.35.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.
@@ -0,0 +1,1074 @@
1
+ /**
2
+ * Model and effort resolution for one catalog launch, or for a catalog-free call.
3
+ *
4
+ * The runtime does not read task prose. Callers translate an authoritative
5
+ * workflow or skill instruction into `invocation` / `authoritative` before
6
+ * this runs. Spawn only `decision.launch` when `status` is `"ready"`.
7
+ *
8
+ * Effort support comes from the host adapter. Inherited substitution uses nearest distance on the ordered effort
9
+ * list and breaks ties toward the lower effort. That is intentional: Pi's
10
+ * `clampThinkingLevel` walks upward first and is not the catalog rule.
11
+ * Availability is the foreground registry snapshot only. This module does not
12
+ * call a provider, retry, or substitute after a later startup failure.
13
+ */
14
+
15
+ import { resolveSelection, type EffectiveDefinition, type FieldValue } from "./catalog-resolver.ts";
16
+ import type { CatalogSnapshot } from "./catalog-store.ts";
17
+ import type { Diagnostic } from "./catalog-schema.ts";
18
+ import { THINKING_LEVELS, parseModelThinking, type ThinkingLevel } from "./thinking.ts";
19
+ import {
20
+ normalizeTierPolicy,
21
+ type NormalizedTierCandidate,
22
+ type TierPolicy,
23
+ DEFAULT_TIER_POLICY,
24
+ } from "./tier-policy.ts";
25
+
26
+ export type { TierPolicy, TierSpec, TierCandidate } from "./tier-policy.ts";
27
+ export { DEFAULT_TIER_POLICY, configureTierPolicy, normalizeTierPolicy } from "./tier-policy.ts";
28
+
29
+ export const ModelResolutionCodes = {
30
+ unavailableExplicitModel: "unavailable-explicit-model",
31
+ ambiguousModel: "ambiguous-model",
32
+ invalidEffort: "invalid-effort",
33
+ unsupportedExplicitEffort: "unsupported-explicit-effort",
34
+ unknownTier: "unknown-tier",
35
+ modelFallback: "model-fallback",
36
+ effortAdjusted: "effort-adjusted",
37
+ noUsableModel: "no-usable-model",
38
+ registryRequired: "registry-required",
39
+ registryUnreadable: "registry-unreadable",
40
+ notLaunchable: "not-launchable",
41
+ } as const;
42
+
43
+ export type ModelRequestSource =
44
+ | "invocation"
45
+ | "authoritative"
46
+ | "agent-override"
47
+ | "role-default"
48
+ | "configured-default"
49
+ | "foreground"
50
+ | "absent";
51
+
52
+ export type ModelActualSource = ModelRequestSource | "tier-candidate";
53
+
54
+ export type EffortSource =
55
+ | "invocation-thinking"
56
+ | "invocation-suffix"
57
+ | "authoritative-effort"
58
+ | "authoritative-suffix"
59
+ | "agent-override"
60
+ | "role-default"
61
+ | "configured-default"
62
+ | "foreground-suffix"
63
+ | "absent";
64
+
65
+ /** Fields Pi's support helper actually reads. Callers may pass a full `Model`. */
66
+ export interface ResolvableModel {
67
+ id: string;
68
+ provider: string;
69
+ reasoning: boolean;
70
+ thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
71
+ supportedEffortLevels?: readonly ThinkingLevel[];
72
+ }
73
+
74
+ export interface ModelRegistryView {
75
+ /** Foreground available set. Must not perform a live inference probe. */
76
+ getAvailable(): readonly ResolvableModel[];
77
+ /** Optional registry lookup for a clearer unavailable-model diagnostic. Not availability. */
78
+ find?(provider: string, modelId: string): ResolvableModel | undefined;
79
+ }
80
+
81
+ export interface ModelResolutionContext {
82
+ registry?: ModelRegistryView;
83
+ tiers?: TierPolicy;
84
+ /** `provider/model` of the foreground session. Its effort is not an input. */
85
+ foregroundModel?: string;
86
+ /** Existing subagent `defaultModel`. Used only when the catalog has no model preference. */
87
+ configuredDefaultModel?: string | null;
88
+ effortSupport?: (model: ResolvableModel) => readonly ThinkingLevel[];
89
+ }
90
+
91
+ export interface ResolveModelInput extends ModelResolutionContext {
92
+ /**
93
+ * Omit for a catalog-free call. Precedence then stays invocation model,
94
+ * configured default, foreground model, with explicit thinking above the
95
+ * selected string's `@effort` suffix. Availability and nearest-effort
96
+ * adjustment are not applied.
97
+ */
98
+ effective?: EffectiveDefinition;
99
+ /** Structured tool/command parameters. Not a prompt. */
100
+ invocation?: {
101
+ model?: string;
102
+ thinking?: ThinkingLevel | string;
103
+ };
104
+ /**
105
+ * Structured translation of an authoritative task, workflow, or skill
106
+ * instruction. Read only when `effective` is set, so a catalog-free call
107
+ * cannot grow a new input. Below invocation, above saved overrides.
108
+ */
109
+ authoritative?: {
110
+ model?: string;
111
+ effort?: ThinkingLevel | string;
112
+ };
113
+ }
114
+
115
+ export interface ModelSelection {
116
+ requested: string | null;
117
+ requestedSource: ModelRequestSource;
118
+ actual: string | null;
119
+ source: ModelActualSource;
120
+ /** Invocation or authoritative model. Default fallback must not replace it. */
121
+ explicitRequest: boolean;
122
+ reason: string;
123
+ }
124
+
125
+ export interface EffortSelection {
126
+ requested: ThinkingLevel | null;
127
+ requestedSource: EffortSource;
128
+ actual: ThinkingLevel | null;
129
+ source: EffortSource;
130
+ /** Saved agent overrides and invocation/authoritative choices are explicit. Role defaults are not. */
131
+ explicit: boolean;
132
+ adjusted: boolean;
133
+ reason: string;
134
+ }
135
+
136
+ export const STARTUP_FAILURE_POLICY = {
137
+ action: "surface-error-no-retry-no-substitution",
138
+ detail: "Foreground registry availability does not prove the child provider will initialize. If startup fails, surface that lifecycle error. Do not resolve again to swap models and do not retry the launch automatically.",
139
+ } as const;
140
+
141
+ export interface ModelDecision {
142
+ status: "ready" | "blocked";
143
+ catalogFree: boolean;
144
+ /**
145
+ * The only spawn payload. Null when blocked. `thinking` omitted means the
146
+ * child keeps the model default; foreground effort is never copied.
147
+ */
148
+ launch: null | { model?: string; thinking?: ThinkingLevel };
149
+ modelSelection: ModelSelection;
150
+ effortSelection: EffortSelection;
151
+ diagnostics: Diagnostic[];
152
+ automaticRetry: false;
153
+ substitutionAfterStart: false;
154
+ availabilityIsStartupGuarantee: false;
155
+ startupFailure: typeof STARTUP_FAILURE_POLICY;
156
+ }
157
+
158
+ export type CatalogAvailability =
159
+ | "preferred"
160
+ | "same-tier"
161
+ | "foreground"
162
+ | "configured-default"
163
+ | "unavailable"
164
+ | "catalog-blocked";
165
+
166
+ export interface CatalogResolutionAttachment {
167
+ id: string;
168
+ schemaLaunchable: boolean;
169
+ /** Schema launchable and a default launch (no invocation override) is ready. */
170
+ launchable: boolean;
171
+ /** Registry membership of the catalog model field before fallback. Null when the field is empty. */
172
+ preferredModelAvailable: boolean | null;
173
+ availability: CatalogAvailability;
174
+ decision?: ModelDecision;
175
+ /** The catalog capability object, unchanged. Resolution grants nothing. */
176
+ capabilities: EffectiveDefinition["capabilities"];
177
+ diagnostics: readonly Diagnostic[];
178
+ }
179
+
180
+ const NO_CAPABILITY_GRANT: EffectiveDefinition["capabilities"] = {
181
+ grantedByCatalog: false,
182
+ note: "Tool selection, extension loading, sandbox, workspace, and nested delegation stay on the existing spawn path. Model resolution does not grant or remove capabilities.",
183
+ };
184
+
185
+ interface AvailableIndex {
186
+ models: Map<string, ResolvableModel>;
187
+ find?: ModelRegistryView["find"];
188
+ }
189
+
190
+ interface LocatedModel {
191
+ status: "available" | "missing" | "ambiguous" | "malformed";
192
+ model?: ResolvableModel;
193
+ canonical?: string;
194
+ matches?: string[];
195
+ }
196
+
197
+ interface SplitModel {
198
+ model?: string;
199
+ suffix?: ThinkingLevel;
200
+ error?: string;
201
+ }
202
+
203
+ interface EffortPick {
204
+ value: ThinkingLevel | null;
205
+ source: EffortSource;
206
+ explicit: boolean;
207
+ }
208
+
209
+ export function resolveModel(input: ResolveModelInput): ModelDecision {
210
+ if (!input.effective) return resolveCatalogFree(input);
211
+ if (!input.effective.launchable) return blockedDefinition(input.effective);
212
+ const registry = readRegistry(input.registry);
213
+ if (!registry.ok) {
214
+ return finish(true, false, emptyModel("absent", registry.message), emptyEffort(registry.message), [
215
+ problem(input.effective, registry.code, registry.message, true, "model"),
216
+ ]);
217
+ }
218
+ return resolveCatalog(input.effective, input, registry.index);
219
+ }
220
+
221
+ /** Spawn helper. Throws when resolution blocked, so a child cannot start from a failed decision. */
222
+ export function launchParameters(decision: ModelDecision): { model?: string; thinking?: ThinkingLevel } {
223
+ if (decision.status !== "ready" || decision.launch === null) {
224
+ const detail = decision.diagnostics.map((item) => item.message).join(" ") || "Model resolution blocked launch.";
225
+ throw new Error(`${detail} No child was started.`);
226
+ }
227
+ return decision.launch;
228
+ }
229
+
230
+ export function assessSelection(
231
+ snapshot: CatalogSnapshot,
232
+ selector: { agentId?: string; roleId?: string },
233
+ context: ModelResolutionContext,
234
+ ): CatalogResolutionAttachment {
235
+ const resolved = resolveSelection(snapshot, selector);
236
+ const effective = resolved.effective;
237
+ const id = effective?.id ?? selector.agentId ?? selector.roleId ?? "";
238
+ const capabilities = effective?.capabilities ?? NO_CAPABILITY_GRANT;
239
+ if (!effective || !resolved.launchable) {
240
+ return {
241
+ id,
242
+ schemaLaunchable: false,
243
+ launchable: false,
244
+ preferredModelAvailable: preferredAvailability(effective, context.registry),
245
+ availability: "catalog-blocked",
246
+ capabilities,
247
+ diagnostics: resolved.diagnostics,
248
+ };
249
+ }
250
+ const decision = resolveModel({
251
+ effective,
252
+ registry: context.registry,
253
+ tiers: context.tiers,
254
+ foregroundModel: context.foregroundModel,
255
+ configuredDefaultModel: context.configuredDefaultModel,
256
+ effortSupport: context.effortSupport,
257
+ });
258
+ return {
259
+ id,
260
+ schemaLaunchable: true,
261
+ launchable: decision.status === "ready",
262
+ preferredModelAvailable: preferredAvailability(effective, context.registry),
263
+ availability: availabilityOf(decision),
264
+ decision,
265
+ capabilities,
266
+ diagnostics: [...resolved.diagnostics, ...decision.diagnostics],
267
+ };
268
+ }
269
+
270
+ export function assessCatalog(snapshot: CatalogSnapshot, context: ModelResolutionContext): CatalogResolutionAttachment[] {
271
+ const entries = [...snapshot.roles.values(), ...snapshot.agents.values(), ...snapshot.blocked];
272
+ const seen = new Set<string>();
273
+ const attachments: CatalogResolutionAttachment[] = [];
274
+ for (const entry of entries) {
275
+ if (seen.has(entry.id)) continue;
276
+ seen.add(entry.id);
277
+ const selector = entry.kind === "agent" ? { agentId: entry.id } : { roleId: entry.id };
278
+ attachments.push(assessSelection(snapshot, selector, context));
279
+ }
280
+ attachments.sort((left, right) => left.id.localeCompare(right.id));
281
+ return attachments;
282
+ }
283
+
284
+ /**
285
+ * Nearest supported effort. Equal distance keeps the lower level.
286
+ * `supported` should be Pi's `getSupportedThinkingLevels` result.
287
+ */
288
+ export function nearestSupportedEffort(
289
+ requested: ThinkingLevel,
290
+ supported: readonly ThinkingLevel[],
291
+ ): { level: ThinkingLevel; tied: boolean } | undefined {
292
+ const supportedSet = THINKING_LEVELS.filter((level) => supported.includes(level));
293
+ const requestedIndex = THINKING_LEVELS.indexOf(requested);
294
+ if (supportedSet.length === 0 || requestedIndex < 0) return undefined;
295
+ if (supportedSet.includes(requested)) return { level: requested, tied: false };
296
+ let bestDistance = Number.POSITIVE_INFINITY;
297
+ let best: ThinkingLevel[] = [];
298
+ for (const level of supportedSet) {
299
+ const distance = Math.abs(THINKING_LEVELS.indexOf(level) - requestedIndex);
300
+ if (distance < bestDistance) {
301
+ bestDistance = distance;
302
+ best = [level];
303
+ } else if (distance === bestDistance) best.push(level);
304
+ }
305
+ best.sort((left, right) => THINKING_LEVELS.indexOf(left) - THINKING_LEVELS.indexOf(right));
306
+ const level = best[0];
307
+ if (!level) return undefined;
308
+ return { level, tied: best.length > 1 };
309
+ }
310
+
311
+ export function supportedEfforts(model: ResolvableModel): ThinkingLevel[] {
312
+ const levels = model.supportedEffortLevels ?? [];
313
+ return THINKING_LEVELS.filter((level) => levels.includes(level));
314
+ }
315
+
316
+ function resolveCatalog(effective: EffectiveDefinition, input: ResolveModelInput, index: AvailableIndex): ModelDecision {
317
+ const invocation = splitModelInput(input.invocation?.model);
318
+ const authoritative = splitModelInput(input.authoritative?.model);
319
+ const invocationEffort = parseEffort(input.invocation?.thinking, "invocation thinking");
320
+ const authoritativeEffort = parseEffort(input.authoritative?.effort, "authoritative effort");
321
+ if (!invocationEffort.ok || !authoritativeEffort.ok || invocation.error || authoritative.error) {
322
+ const invalid = [
323
+ invocation.error,
324
+ authoritative.error,
325
+ invocationEffort.ok ? undefined : invocationEffort.error,
326
+ authoritativeEffort.ok ? undefined : authoritativeEffort.error,
327
+ ].filter((item): item is string => Boolean(item));
328
+ return finish(false, false, {
329
+ requested: invocation.model ?? authoritative.model ?? catalogRequestedModel(effective),
330
+ requestedSource: invocation.model ? "invocation" : authoritative.model ? "authoritative" : catalogRequestedSource(effective),
331
+ actual: null,
332
+ source: "absent",
333
+ explicitRequest: Boolean(invocation.model || authoritative.model),
334
+ reason: invalid.join(" "),
335
+ }, emptyEffort(invalid.join(" ")), [
336
+ problem(effective, ModelResolutionCodes.invalidEffort, `${invalid.join(" ")} No child was started.`, true, "effort"),
337
+ ]);
338
+ }
339
+
340
+ const modelOutcome = selectCatalogModel(effective, input, index, invocation, authoritative);
341
+ const effortPick = pickEffort(effective, invocation, authoritative, invocationEffort.value, authoritativeEffort.value);
342
+ if (!modelOutcome.ok || !modelOutcome.model) {
343
+ return finish(false, false, modelOutcome.selection, describeUnresolvedEffort(effortPick, modelOutcome.selection.reason), modelOutcome.diagnostics);
344
+ }
345
+
346
+ const effortOutcome = applyEffort(effective, modelOutcome.model, effortPick, input.effortSupport);
347
+ if (!effortOutcome.ok) {
348
+ return finish(false, false, modelOutcome.selection, effortOutcome.selection, [
349
+ ...modelOutcome.diagnostics,
350
+ effortOutcome.diagnostic,
351
+ ]);
352
+ }
353
+ return finish(false, true, modelOutcome.selection, effortOutcome.selection, [
354
+ ...modelOutcome.diagnostics,
355
+ ...effortOutcome.diagnostics,
356
+ ], {
357
+ model: modelOutcome.selection.actual ?? undefined,
358
+ thinking: effortOutcome.selection.actual ?? undefined,
359
+ });
360
+ }
361
+
362
+ function resolveCatalogFree(input: ResolveModelInput): ModelDecision {
363
+ const effort = parseEffort(input.invocation?.thinking, "invocation thinking");
364
+ if (!effort.ok) {
365
+ return finish(true, false, emptyModel("absent", effort.error), emptyEffort(effort.error), [
366
+ problem(undefined, ModelResolutionCodes.invalidEffort, `${effort.error} No child was started.`, true, "effort"),
367
+ ]);
368
+ }
369
+ const chosen = firstText(input.invocation?.model, input.configuredDefaultModel, input.foregroundModel);
370
+ const requestedSource: ModelRequestSource = text(input.invocation?.model)
371
+ ? "invocation"
372
+ : text(input.configuredDefaultModel)
373
+ ? "configured-default"
374
+ : text(input.foregroundModel)
375
+ ? "foreground"
376
+ : "absent";
377
+ let parsed: { model: string | undefined; thinking: ThinkingLevel | undefined };
378
+ try {
379
+ parsed = parseModelThinking(chosen, effort.value);
380
+ } catch (error) {
381
+ const message = error instanceof Error ? error.message : String(error);
382
+ return finish(true, false, {
383
+ requested: chosen ?? null,
384
+ requestedSource,
385
+ actual: null,
386
+ source: "absent",
387
+ explicitRequest: requestedSource === "invocation",
388
+ reason: message,
389
+ }, emptyEffort(message), [
390
+ problem(undefined, ModelResolutionCodes.invalidEffort, `${message} No child was started.`, true, "effort"),
391
+ ]);
392
+ }
393
+ const suffixSource: EffortSource = !parsed.thinking
394
+ ? "absent"
395
+ : effort.value
396
+ ? "invocation-thinking"
397
+ : requestedSource === "configured-default"
398
+ ? "configured-default"
399
+ : requestedSource === "foreground"
400
+ ? "foreground-suffix"
401
+ : "invocation-suffix";
402
+ const modelReason = parsed.model
403
+ ? `Catalog-free model ${parsed.model} came from ${requestedSource}. Availability was not checked.`
404
+ : "Catalog-free call did not set a model. The child keeps Pi's own model default.";
405
+ const effortReason = parsed.thinking
406
+ ? `Catalog-free effort ${parsed.thinking} came from ${suffixSource}. Supported-effort adjustment was not applied.`
407
+ : "Catalog-free call did not set an effort. Foreground effort was not copied.";
408
+ return finish(true, true, {
409
+ requested: parsed.model ?? null,
410
+ requestedSource,
411
+ actual: parsed.model ?? null,
412
+ source: parsed.model ? requestedSource : "absent",
413
+ explicitRequest: requestedSource === "invocation" && Boolean(parsed.model),
414
+ reason: modelReason,
415
+ }, {
416
+ requested: parsed.thinking ?? null,
417
+ requestedSource: suffixSource,
418
+ actual: parsed.thinking ?? null,
419
+ source: suffixSource,
420
+ explicit: suffixSource !== "absent",
421
+ adjusted: false,
422
+ reason: effortReason,
423
+ }, [], {
424
+ model: parsed.model,
425
+ thinking: parsed.thinking,
426
+ });
427
+ }
428
+
429
+ interface ModelOutcome {
430
+ ok: boolean;
431
+ model?: ResolvableModel;
432
+ selection: ModelSelection;
433
+ diagnostics: Diagnostic[];
434
+ }
435
+
436
+ function selectCatalogModel(
437
+ effective: EffectiveDefinition,
438
+ input: ResolveModelInput,
439
+ index: AvailableIndex,
440
+ invocation: SplitModel,
441
+ authoritative: SplitModel,
442
+ ): ModelOutcome {
443
+ if (invocation.model) {
444
+ return explicitModel(effective, index, invocation.model, "invocation");
445
+ }
446
+ if (authoritative.model) {
447
+ return explicitModel(effective, index, authoritative.model, "authoritative");
448
+ }
449
+ const preference = readModelField(effective.model);
450
+ if (preference.kind === "preference") {
451
+ const located = locate(preference.model, index, false);
452
+ if (located.status === "available" && located.model && located.canonical) {
453
+ return {
454
+ ok: true,
455
+ model: located.model,
456
+ selection: {
457
+ requested: preference.model,
458
+ requestedSource: preference.source,
459
+ actual: located.canonical,
460
+ source: preference.source,
461
+ explicitRequest: false,
462
+ reason: `Preferred ${preference.source} model ${located.canonical} is in the foreground available set.`,
463
+ },
464
+ diagnostics: [],
465
+ };
466
+ }
467
+ return fallbackPreference(effective, input, index, preference.model, preference.source, located);
468
+ }
469
+ return runtimeDefaultChain(effective, input, index, preference.source);
470
+ }
471
+
472
+ function explicitModel(
473
+ effective: EffectiveDefinition,
474
+ index: AvailableIndex,
475
+ requested: string,
476
+ source: "invocation" | "authoritative",
477
+ ): ModelOutcome {
478
+ const located = locate(requested, index, true);
479
+ if (located.status === "available" && located.model && located.canonical) {
480
+ return {
481
+ ok: true,
482
+ model: located.model,
483
+ selection: {
484
+ requested,
485
+ requestedSource: source,
486
+ actual: located.canonical,
487
+ source,
488
+ explicitRequest: true,
489
+ reason: `Explicit ${source} model ${located.canonical} is in the foreground available set. Default-model fallback was not considered.`,
490
+ },
491
+ diagnostics: [],
492
+ };
493
+ }
494
+ if (located.status === "ambiguous") {
495
+ const message = `Explicit ${source} model ${JSON.stringify(requested)} matches more than one foreground model (${located.matches?.join(", ")}). Pass provider/model. No substitute was selected and no child was started.`;
496
+ return {
497
+ ok: false,
498
+ selection: {
499
+ requested,
500
+ requestedSource: source,
501
+ actual: null,
502
+ source: "absent",
503
+ explicitRequest: true,
504
+ reason: message,
505
+ },
506
+ diagnostics: [problem(effective, ModelResolutionCodes.ambiguousModel, message, true, "model")],
507
+ };
508
+ }
509
+ const known = knownUnavailable(requested, index.find);
510
+ const message = `Explicit ${source} model ${requested} is not in the foreground available set.${known} Default-model, same-tier, and foreground fallback were not applied. Choose an available provider/model or make this one available. No child was started.`;
511
+ return {
512
+ ok: false,
513
+ selection: {
514
+ requested,
515
+ requestedSource: source,
516
+ actual: null,
517
+ source: "absent",
518
+ explicitRequest: true,
519
+ reason: message,
520
+ },
521
+ diagnostics: [problem(effective, ModelResolutionCodes.unavailableExplicitModel, message, true, "model")],
522
+ };
523
+ }
524
+
525
+ function fallbackPreference(
526
+ effective: EffectiveDefinition,
527
+ input: ResolveModelInput,
528
+ index: AvailableIndex,
529
+ requested: string,
530
+ requestedSource: "agent-override" | "role-default",
531
+ located: LocatedModel,
532
+ ): ModelOutcome {
533
+ const tier = readTier(effective.tier);
534
+ const policy = normalizeTierPolicy(input.tiers ?? DEFAULT_TIER_POLICY);
535
+ const skipped: string[] = [];
536
+ const diagnostics: Diagnostic[] = [];
537
+ let tierKnown = false;
538
+ if (tier.value) {
539
+ const spec = policy.tiers[tier.value];
540
+ if (!spec) {
541
+ diagnostics.push(problem(
542
+ effective,
543
+ ModelResolutionCodes.unknownTier,
544
+ `Tier ${JSON.stringify(tier.value)} has no configured membership. No model from another tier was guessed.`,
545
+ false,
546
+ "tier",
547
+ "warning",
548
+ ));
549
+ } else {
550
+ tierKnown = true;
551
+ for (const note of policy.ignored) skipped.push(note);
552
+ const selected = firstEligibleCandidate(spec.members, spec.candidates, requested, index, skipped);
553
+ if (selected) {
554
+ const why = skipped.length > 0 ? ` Skipped: ${skipped.join("; ")}.` : "";
555
+ const reason = `Preferred ${requestedSource} model ${requested} is not usable (${unusableBecause(located)}). Selected configured same-tier candidate ${selected.canonical} from tier ${tier.value}.${why} Resolved effort is kept and was not taken from this model.`;
556
+ diagnostics.push(problem(effective, ModelResolutionCodes.modelFallback, reason, false, "model", "info"));
557
+ return {
558
+ ok: true,
559
+ model: selected.model,
560
+ selection: {
561
+ requested,
562
+ requestedSource,
563
+ actual: selected.canonical,
564
+ source: "tier-candidate",
565
+ explicitRequest: false,
566
+ reason,
567
+ },
568
+ diagnostics,
569
+ };
570
+ }
571
+ }
572
+ } else {
573
+ diagnostics.push(problem(
574
+ effective,
575
+ ModelResolutionCodes.unknownTier,
576
+ "No tier mapping is set on the definition. Same-tier substitution was not guessed.",
577
+ false,
578
+ "tier",
579
+ "warning",
580
+ ));
581
+ }
582
+ const tierReason = tierKnown
583
+ ? `Tier ${tier.value} has no eligible available candidate.${skipped.length > 0 ? ` Skipped: ${skipped.join("; ")}.` : " No candidates are configured."}`
584
+ : tier.value
585
+ ? `Tier ${JSON.stringify(tier.value)} is not a configured tier.`
586
+ : "Tier mapping is absent.";
587
+ return useForeground(effective, input, index, {
588
+ requested,
589
+ requestedSource,
590
+ explicitRequest: false,
591
+ prefix: `Preferred ${requestedSource} model ${requested} is not usable (${unusableBecause(located)}). ${tierReason}`,
592
+ }, diagnostics);
593
+ }
594
+
595
+ function runtimeDefaultChain(
596
+ effective: EffectiveDefinition,
597
+ input: ResolveModelInput,
598
+ index: AvailableIndex,
599
+ requestedSource: ModelRequestSource,
600
+ ): ModelOutcome {
601
+ const cleared = requestedSource === "agent-override"
602
+ ? "The saved agent model override is null, so the role model was not used."
603
+ : "The catalog definition has no model preference.";
604
+ const configured = splitModelInput(input.configuredDefaultModel ?? undefined);
605
+ if (configured.model && !configured.error) {
606
+ const located = locate(configured.model, index, true);
607
+ if (located.status === "available" && located.model && located.canonical) {
608
+ const reason = `${cleared} Using configured default model ${located.canonical}. Tier candidates were not consulted.`;
609
+ return {
610
+ ok: true,
611
+ model: located.model,
612
+ selection: {
613
+ requested: null,
614
+ requestedSource,
615
+ actual: located.canonical,
616
+ source: "configured-default",
617
+ explicitRequest: false,
618
+ reason,
619
+ },
620
+ diagnostics: [problem(effective, ModelResolutionCodes.modelFallback, reason, false, "model", "info")],
621
+ };
622
+ }
623
+ }
624
+ return useForeground(effective, input, index, {
625
+ requested: null,
626
+ requestedSource,
627
+ explicitRequest: false,
628
+ prefix: `${cleared} Configured default model ${text(input.configuredDefaultModel) ? input.configuredDefaultModel : "(none)"} was not an available provider/model.`,
629
+ }, []);
630
+ }
631
+
632
+ function useForeground(
633
+ effective: EffectiveDefinition,
634
+ input: ResolveModelInput,
635
+ index: AvailableIndex,
636
+ base: { requested: string | null; requestedSource: ModelRequestSource; explicitRequest: boolean; prefix: string },
637
+ diagnostics: Diagnostic[],
638
+ ): ModelOutcome {
639
+ const foreground = splitModelInput(input.foregroundModel);
640
+ const located = foreground.model && !foreground.error ? locate(foreground.model, index, true) : { status: "missing" as const };
641
+ if (located.status === "available" && located.model && located.canonical) {
642
+ const reason = `${base.prefix} Using foreground model ${located.canonical}. Foreground effort was not inherited.`;
643
+ diagnostics.push(problem(effective, ModelResolutionCodes.modelFallback, reason, false, "model", "info"));
644
+ return {
645
+ ok: true,
646
+ model: located.model,
647
+ selection: {
648
+ requested: base.requested,
649
+ requestedSource: base.requestedSource,
650
+ actual: located.canonical,
651
+ source: "foreground",
652
+ explicitRequest: false,
653
+ reason,
654
+ },
655
+ diagnostics,
656
+ };
657
+ }
658
+ const message = `${base.prefix} Foreground model ${text(input.foregroundModel) ? input.foregroundModel : "(not set)"} is not in the foreground available set. No child was started. Recovery: make the preferred model available, add an ordered same-tier candidate (set crossProvider only to opt into another provider), or switch the foreground session to an available provider/model. This resolver does not probe the provider and will not retry.`;
659
+ diagnostics.push(problem(effective, ModelResolutionCodes.noUsableModel, message, true, "model"));
660
+ return {
661
+ ok: false,
662
+ selection: {
663
+ requested: base.requested,
664
+ requestedSource: base.requestedSource,
665
+ actual: null,
666
+ source: "absent",
667
+ explicitRequest: base.explicitRequest,
668
+ reason: message,
669
+ },
670
+ diagnostics,
671
+ };
672
+ }
673
+
674
+ function firstEligibleCandidate(
675
+ members: readonly string[],
676
+ candidates: readonly NormalizedTierCandidate[],
677
+ preferred: string,
678
+ index: AvailableIndex,
679
+ skipped: string[],
680
+ ): { model: ResolvableModel; canonical: string } | undefined {
681
+ const memberSet = new Set(members);
682
+ const preferredProvider = providerOf(preferred);
683
+ for (const candidate of candidates) {
684
+ if (!memberSet.has(candidate.model)) {
685
+ skipped.push(`${candidate.model} is not a member of the tier`);
686
+ continue;
687
+ }
688
+ const cross = preferredProvider === undefined || candidate.provider !== preferredProvider;
689
+ if (cross && !candidate.crossProvider) {
690
+ skipped.push(`${candidate.model} is cross-provider without opt-in`);
691
+ continue;
692
+ }
693
+ const found = index.models.get(candidate.model);
694
+ if (!found) {
695
+ skipped.push(`${candidate.model} is not in the foreground available set`);
696
+ continue;
697
+ }
698
+ return { model: found, canonical: `${found.provider}/${found.id}` };
699
+ }
700
+ return undefined;
701
+ }
702
+
703
+ function pickEffort(
704
+ effective: EffectiveDefinition,
705
+ invocation: SplitModel,
706
+ authoritative: SplitModel,
707
+ invocationThinking: ThinkingLevel | undefined,
708
+ authoritativeThinking: ThinkingLevel | undefined,
709
+ ): EffortPick {
710
+ if (invocationThinking) return { value: invocationThinking, source: "invocation-thinking", explicit: true };
711
+ if (invocation.suffix) return { value: invocation.suffix, source: "invocation-suffix", explicit: true };
712
+ if (authoritativeThinking) return { value: authoritativeThinking, source: "authoritative-effort", explicit: true };
713
+ if (authoritative.suffix) return { value: authoritative.suffix, source: "authoritative-suffix", explicit: true };
714
+ if (effective.effort.explicit) {
715
+ return {
716
+ value: effective.effort.value,
717
+ source: "agent-override",
718
+ explicit: true,
719
+ };
720
+ }
721
+ if (effective.effort.value) return { value: effective.effort.value, source: "role-default", explicit: false };
722
+ return { value: null, source: "absent", explicit: false };
723
+ }
724
+
725
+ function applyEffort(
726
+ effective: EffectiveDefinition,
727
+ model: ResolvableModel,
728
+ pick: EffortPick,
729
+ effortSupport: ModelResolutionContext["effortSupport"] = supportedEfforts,
730
+ ): { ok: true; selection: EffortSelection; diagnostics: Diagnostic[] } | { ok: false; selection: EffortSelection; diagnostic: Diagnostic } {
731
+ if (pick.value === null) {
732
+ const reason = pick.explicit
733
+ ? "Saved agent effort override is null, so the role effort was not used. The child keeps the model default. Foreground effort was not copied."
734
+ : "No effort was requested. The child keeps the model default. Foreground effort was not copied.";
735
+ return {
736
+ ok: true,
737
+ selection: {
738
+ requested: null,
739
+ requestedSource: pick.source,
740
+ actual: null,
741
+ source: pick.source,
742
+ explicit: pick.explicit,
743
+ adjusted: false,
744
+ reason,
745
+ },
746
+ diagnostics: [],
747
+ };
748
+ }
749
+ const supported = effortSupport(model);
750
+ const modelId = `${model.provider}/${model.id}`;
751
+ if (supported.includes(pick.value)) {
752
+ const kept = pick.explicit ? "Explicit effort is supported by the resolved model." : "Inherited effort is supported by the resolved model and was kept.";
753
+ return {
754
+ ok: true,
755
+ selection: {
756
+ requested: pick.value,
757
+ requestedSource: pick.source,
758
+ actual: pick.value,
759
+ source: pick.source,
760
+ explicit: pick.explicit,
761
+ adjusted: false,
762
+ reason: `${kept} Requested ${pick.value}, actual ${pick.value}, source ${pick.source}.`,
763
+ },
764
+ diagnostics: [],
765
+ };
766
+ }
767
+ if (pick.explicit) {
768
+ const reason = `Explicit effort ${pick.value} (${pick.source}) is not supported by ${modelId}. Supported levels: ${supported.join(", ") || "(none)"}. Saved agent overrides stay explicit on fallback and are not moved to a nearby level. No child was started.`;
769
+ return {
770
+ ok: false,
771
+ selection: {
772
+ requested: pick.value,
773
+ requestedSource: pick.source,
774
+ actual: null,
775
+ source: pick.source,
776
+ explicit: true,
777
+ adjusted: false,
778
+ reason,
779
+ },
780
+ diagnostic: problem(effective, ModelResolutionCodes.unsupportedExplicitEffort, reason, true, "effort"),
781
+ };
782
+ }
783
+ const nearest = nearestSupportedEffort(pick.value, supported);
784
+ if (!nearest) {
785
+ const reason = `Inherited effort ${pick.value} cannot be adjusted because ${modelId} reports no supported effort. No child was started.`;
786
+ return {
787
+ ok: false,
788
+ selection: {
789
+ requested: pick.value,
790
+ requestedSource: pick.source,
791
+ actual: null,
792
+ source: pick.source,
793
+ explicit: false,
794
+ adjusted: false,
795
+ reason,
796
+ },
797
+ diagnostic: problem(effective, ModelResolutionCodes.noUsableModel, reason, true, "effort"),
798
+ };
799
+ }
800
+ const tie = nearest.tied ? " Equal-distance candidates were broken toward the lower effort." : "";
801
+ const reason = `Inherited effort ${pick.value} (${pick.source}) is not supported by ${modelId}. Actual effort is ${nearest.level}. Supported levels: ${supported.join(", ")}.${tie} The model was not changed to chase an effort.`;
802
+ return {
803
+ ok: true,
804
+ selection: {
805
+ requested: pick.value,
806
+ requestedSource: pick.source,
807
+ actual: nearest.level,
808
+ source: pick.source,
809
+ explicit: false,
810
+ adjusted: true,
811
+ reason,
812
+ },
813
+ diagnostics: [problem(effective, ModelResolutionCodes.effortAdjusted, reason, false, "effort", "warning")],
814
+ };
815
+ }
816
+
817
+ function readModelField(field: FieldValue<string>): { kind: "preference"; model: string; source: "agent-override" | "role-default" } | { kind: "none"; source: ModelRequestSource } {
818
+ if (field.explicit) {
819
+ if (field.value && field.value.trim()) return { kind: "preference", model: field.value.trim(), source: "agent-override" };
820
+ return { kind: "none", source: "agent-override" };
821
+ }
822
+ if (field.value && field.value.trim()) return { kind: "preference", model: field.value.trim(), source: "role-default" };
823
+ return { kind: "none", source: "absent" };
824
+ }
825
+
826
+ function readTier(field: FieldValue<string>): { value: string | null; explicit: boolean } {
827
+ if (field.explicit) return { value: field.value?.trim() || null, explicit: true };
828
+ if (field.value && field.value.trim()) return { value: field.value.trim(), explicit: false };
829
+ return { value: null, explicit: false };
830
+ }
831
+
832
+ function locate(raw: string, index: AvailableIndex, allowUniqueId: boolean): LocatedModel {
833
+ const trimmed = raw.trim();
834
+ const slash = trimmed.indexOf("/");
835
+ if (slash > 0 && slash < trimmed.length - 1) {
836
+ const found = index.models.get(trimmed);
837
+ if (!found) return { status: "missing" };
838
+ return { status: "available", model: found, canonical: `${found.provider}/${found.id}` };
839
+ }
840
+ if (!allowUniqueId) return { status: "malformed" };
841
+ const matches = [...index.models.values()].filter((model) => model.id === trimmed);
842
+ if (matches.length === 1 && matches[0]) {
843
+ return { status: "available", model: matches[0], canonical: `${matches[0].provider}/${matches[0].id}` };
844
+ }
845
+ if (matches.length > 1) return { status: "ambiguous", matches: matches.map((model) => `${model.provider}/${model.id}`) };
846
+ return { status: "missing" };
847
+ }
848
+
849
+ function readRegistry(registry: ModelRegistryView | undefined): { ok: true; index: AvailableIndex } | { ok: false; code: string; message: string } {
850
+ if (!registry || typeof registry.getAvailable !== "function") {
851
+ return {
852
+ ok: false,
853
+ code: ModelResolutionCodes.registryRequired,
854
+ message: "Catalog model resolution needs the foreground model registry (getAvailable). No provider was probed and no child was started.",
855
+ };
856
+ }
857
+ let listed: readonly ResolvableModel[];
858
+ try {
859
+ listed = registry.getAvailable();
860
+ } catch (error) {
861
+ const detail = error instanceof Error ? error.message : String(error);
862
+ return {
863
+ ok: false,
864
+ code: ModelResolutionCodes.registryUnreadable,
865
+ message: `Foreground model registry could not be read (${detail}). No provider was probed and no child was started.`,
866
+ };
867
+ }
868
+ if (!Array.isArray(listed)) {
869
+ return {
870
+ ok: false,
871
+ code: ModelResolutionCodes.registryUnreadable,
872
+ message: "Foreground model registry getAvailable did not return a list. No child was started.",
873
+ };
874
+ }
875
+ const models = new Map<string, ResolvableModel>();
876
+ for (const model of listed) {
877
+ if (!model || typeof model.provider !== "string" || typeof model.id !== "string" || typeof model.reasoning !== "boolean") continue;
878
+ models.set(`${model.provider}/${model.id}`, model);
879
+ }
880
+ return { ok: true, index: { models, find: registry.find } };
881
+ }
882
+
883
+ function knownUnavailable(raw: string, find: ModelRegistryView["find"]): string {
884
+ if (!find) return "";
885
+ const slash = raw.indexOf("/");
886
+ if (slash <= 0 || slash === raw.length - 1) return "";
887
+ try {
888
+ const found = find(raw.slice(0, slash), raw.slice(slash + 1));
889
+ if (found) return ` ${raw} is known to the registry but is not in the foreground available set.`;
890
+ } catch {
891
+ return "";
892
+ }
893
+ return "";
894
+ }
895
+
896
+ function preferredAvailability(effective: EffectiveDefinition | undefined, registry: ModelRegistryView | undefined): boolean | null {
897
+ const value = effective?.model.value?.trim();
898
+ if (!value) return null;
899
+ const read = readRegistry(registry);
900
+ if (!read.ok) return false;
901
+ return read.index.models.has(value);
902
+ }
903
+
904
+ function availabilityOf(decision: ModelDecision): CatalogAvailability {
905
+ if (decision.status !== "ready") return "unavailable";
906
+ if (decision.modelSelection.source === "tier-candidate") return "same-tier";
907
+ if (decision.modelSelection.source === "foreground") return "foreground";
908
+ if (decision.modelSelection.source === "configured-default") return "configured-default";
909
+ return "preferred";
910
+ }
911
+
912
+ function blockedDefinition(effective: EffectiveDefinition): ModelDecision {
913
+ const diagnostics = [...effective.diagnostics];
914
+ if (!diagnostics.some((item) => item.blocking)) {
915
+ diagnostics.push(problem(
916
+ effective,
917
+ ModelResolutionCodes.notLaunchable,
918
+ `Definition ${effective.id} is not launchable. No model was selected and no child was started.`,
919
+ true,
920
+ "model",
921
+ ));
922
+ }
923
+ return finish(false, false, {
924
+ requested: catalogRequestedModel(effective),
925
+ requestedSource: catalogRequestedSource(effective),
926
+ actual: null,
927
+ source: "absent",
928
+ explicitRequest: false,
929
+ reason: `Definition ${effective.id} is not launchable. No child was started.`,
930
+ }, {
931
+ requested: effective.effort.value,
932
+ requestedSource: effective.effort.explicit ? "agent-override" : effective.effort.value ? "role-default" : "absent",
933
+ actual: null,
934
+ source: effective.effort.explicit ? "agent-override" : effective.effort.value ? "role-default" : "absent",
935
+ explicit: effective.effort.explicit,
936
+ adjusted: false,
937
+ reason: "Effort was not applied because the definition is not launchable.",
938
+ }, diagnostics);
939
+ }
940
+
941
+ function catalogRequestedModel(effective: EffectiveDefinition): string | null {
942
+ return effective.model.value;
943
+ }
944
+
945
+ function catalogRequestedSource(effective: EffectiveDefinition): ModelRequestSource {
946
+ if (effective.model.explicit) return "agent-override";
947
+ if (effective.model.value) return "role-default";
948
+ return "absent";
949
+ }
950
+
951
+ function describeUnresolvedEffort(pick: EffortPick, because: string): EffortSelection {
952
+ return {
953
+ requested: pick.value,
954
+ requestedSource: pick.source,
955
+ actual: null,
956
+ source: pick.source,
957
+ explicit: pick.explicit,
958
+ adjusted: false,
959
+ reason: `Effort ${pick.value ?? "(none)"} from ${pick.source} was not applied. ${because}`,
960
+ };
961
+ }
962
+
963
+ function unusableBecause(located: LocatedModel): string {
964
+ if (located.status === "malformed") return "it is not provider/model";
965
+ if (located.status === "ambiguous") return "it matches more than one foreground model";
966
+ return "it is not in the foreground available set";
967
+ }
968
+
969
+ function providerOf(model: string): string | undefined {
970
+ const slash = model.indexOf("/");
971
+ if (slash <= 0 || slash === model.length - 1) return undefined;
972
+ return model.slice(0, slash);
973
+ }
974
+
975
+ function splitModelInput(model: string | undefined): SplitModel {
976
+ if (model === undefined || model === null) return {};
977
+ if (typeof model !== "string") return { error: `model must be a string; got ${typeof model}` };
978
+ const trimmed = model.trim();
979
+ if (!trimmed) return {};
980
+ try {
981
+ const parsed = parseModelThinking(trimmed, undefined);
982
+ return { model: parsed.model, suffix: parsed.thinking };
983
+ } catch (error) {
984
+ return { error: error instanceof Error ? error.message : String(error) };
985
+ }
986
+ }
987
+
988
+ function parseEffort(value: unknown, label: string): { ok: true; value?: ThinkingLevel; error?: undefined } | { ok: false; error: string } {
989
+ if (value === undefined || value === null) return { ok: true };
990
+ if (typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value)) {
991
+ return { ok: true, value: value as ThinkingLevel };
992
+ }
993
+ return { ok: false, error: `${label} must be one of: ${THINKING_LEVELS.join(", ")}; got ${JSON.stringify(value)}.` };
994
+ }
995
+
996
+ function text(value: string | null | undefined): string | undefined {
997
+ if (typeof value !== "string") return undefined;
998
+ const trimmed = value.trim();
999
+ return trimmed.length > 0 ? trimmed : undefined;
1000
+ }
1001
+
1002
+ function firstText(...values: Array<string | null | undefined>): string | undefined {
1003
+ for (const value of values) {
1004
+ const trimmed = text(value);
1005
+ if (trimmed) return trimmed;
1006
+ }
1007
+ return undefined;
1008
+ }
1009
+
1010
+ function compactLaunch(launch?: { model?: string; thinking?: ThinkingLevel }): { model?: string; thinking?: ThinkingLevel } {
1011
+ const compacted: { model?: string; thinking?: ThinkingLevel } = {};
1012
+ if (launch?.model) compacted.model = launch.model;
1013
+ if (launch?.thinking) compacted.thinking = launch.thinking;
1014
+ return compacted;
1015
+ }
1016
+
1017
+ function emptyModel(source: ModelRequestSource, reason: string): ModelSelection {
1018
+ return { requested: null, requestedSource: source, actual: null, source: "absent", explicitRequest: false, reason };
1019
+ }
1020
+
1021
+ function emptyEffort(reason: string): EffortSelection {
1022
+ return {
1023
+ requested: null,
1024
+ requestedSource: "absent",
1025
+ actual: null,
1026
+ source: "absent",
1027
+ explicit: false,
1028
+ adjusted: false,
1029
+ reason,
1030
+ };
1031
+ }
1032
+
1033
+ function finish(
1034
+ catalogFree: boolean,
1035
+ ready: boolean,
1036
+ modelSelection: ModelSelection,
1037
+ effortSelection: EffortSelection,
1038
+ diagnostics: Diagnostic[],
1039
+ launch?: { model?: string; thinking?: ThinkingLevel },
1040
+ ): ModelDecision {
1041
+ return {
1042
+ status: ready ? "ready" : "blocked",
1043
+ catalogFree,
1044
+ launch: ready ? compactLaunch(launch) : null,
1045
+ modelSelection,
1046
+ effortSelection,
1047
+ diagnostics,
1048
+ automaticRetry: false,
1049
+ substitutionAfterStart: false,
1050
+ availabilityIsStartupGuarantee: false,
1051
+ startupFailure: STARTUP_FAILURE_POLICY,
1052
+ };
1053
+ }
1054
+
1055
+ function problem(
1056
+ effective: EffectiveDefinition | undefined,
1057
+ code: string,
1058
+ message: string,
1059
+ blocking: boolean,
1060
+ field: string,
1061
+ severity: Diagnostic["severity"] = blocking ? "error" : "info",
1062
+ ): Diagnostic {
1063
+ return {
1064
+ severity,
1065
+ code,
1066
+ message,
1067
+ id: effective?.id,
1068
+ path: effective?.source?.path,
1069
+ scope: effective?.source?.scope,
1070
+ field,
1071
+ blocking,
1072
+ structural: false,
1073
+ };
1074
+ }