@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +124 -37
  2. package/package.json +8 -8
  3. package/src/agent-file-toggle.ts +6 -4
  4. package/src/agent-manager.ts +131 -28
  5. package/src/agent-runner.ts +155 -62
  6. package/src/cross-extension-rpc.ts +11 -36
  7. package/src/custom-agents.ts +9 -5
  8. package/src/default-agents.ts +2 -4
  9. package/src/enabled-models.ts +8 -7
  10. package/src/enterprise-local.ts +5 -5
  11. package/src/home-paths.ts +27 -0
  12. package/src/index.ts +191 -90
  13. package/src/invocation-config.ts +3 -16
  14. package/src/memory.ts +9 -9
  15. package/src/mention-clone.ts +9 -5
  16. package/src/model-resolver.ts +41 -11
  17. package/src/model-scope.ts +2 -1
  18. package/src/model-selection.ts +159 -0
  19. package/src/model-tiers.ts +171 -0
  20. package/src/nested-tools.ts +22 -26
  21. package/src/output-file.ts +23 -1
  22. package/src/package-resources.ts +25 -10
  23. package/src/process-budget.ts +6 -3
  24. package/src/quota-admission.ts +21 -9
  25. package/src/resource-loader.ts +51 -0
  26. package/src/schedule.ts +36 -18
  27. package/src/settings.ts +42 -12
  28. package/src/skill-loader.ts +8 -7
  29. package/src/stale-ctx.ts +16 -0
  30. package/src/types.ts +27 -0
  31. package/src/ui/agent-row.ts +13 -3
  32. package/src/ui/agent-widget.ts +15 -4
  33. package/src/ui/conversation-viewer.ts +6 -3
  34. package/src/ui/fallback-flag.ts +90 -0
  35. package/src/ui/fleet-list.ts +363 -53
  36. package/src/ui/fleet-state.ts +154 -0
  37. package/src/ui/notification-view.ts +18 -2
  38. package/src/ui/workflow-card.ts +5 -1
  39. package/src/ui/workflow-dialog.ts +10 -0
  40. package/src/workflow/host.ts +43 -45
  41. package/src/workflow/journal.ts +13 -1
  42. package/src/workflow/progress.ts +4 -0
  43. package/src/workflow/runtime.ts +25 -8
  44. package/src/workflow/saved.ts +19 -17
  45. package/src/workflow/task.ts +10 -0
  46. package/src/workflow/worker-source.ts +156 -38
@@ -1,7 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { resolve } from "node:path";
3
3
  import { Type } from "typebox";
4
- import { configuredAgentModel } from "./model-resolver.js";
5
4
  import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
6
5
 
7
6
  /**
@@ -79,13 +78,11 @@ export const cwdParam = {
79
78
  * Blank means unset: models that fill every optional parameter send `""`.
80
79
  * Existence is checked later by the manager, which re-checks at start time.
81
80
  */
82
- export function resolveCwdParam(cwd: string | undefined, sessionCwd: string): string | undefined {
81
+ export function resolveCwdParam(cwd: string | undefined, sessionCwd: string, homeDir = homedir()): string | undefined {
83
82
  const trimmed = cwd?.trim();
84
83
  if (!trimmed) return undefined;
85
- // Bun 1.3 ignores HOME in os.homedir(); honor the same explicit override as Node.
86
- const home = process.env[process.platform === "win32" ? "USERPROFILE" : "HOME"] || homedir();
87
- if (trimmed === "~") return home;
88
- if (trimmed.startsWith("~/")) return resolve(home, trimmed.slice(2));
84
+ if (trimmed === "~") return homeDir;
85
+ if (trimmed.startsWith("~/")) return resolve(homeDir, trimmed.slice(2));
89
86
  return resolve(sessionCwd, trimmed);
90
87
  }
91
88
 
@@ -107,8 +104,6 @@ interface AgentInvocationParams {
107
104
  }
108
105
 
109
106
  interface ResolveOptions {
110
- /** The resolved agent type, for the `agentModels` lookup. Omitted → only the `"*"` entry applies. */
111
- agentType?: string;
112
107
  /**
113
108
  * Whether worktree isolation is permitted at all. False when the project set
114
109
  * `worktreeIsolation: false`, which drops a requested worktree rather than
@@ -135,8 +130,6 @@ export function resolveAgentInvocationConfig(
135
130
  params: AgentInvocationParams,
136
131
  opts?: ResolveOptions,
137
132
  ): {
138
- modelInput?: string;
139
- modelFromParams: boolean;
140
133
  thinking?: ThinkingLevel;
141
134
  maxTurns?: number;
142
135
  inheritContext: boolean;
@@ -151,12 +144,6 @@ export function resolveAgentInvocationConfig(
151
144
  const isolation = requested === "worktree" && opts?.worktreeAllowed !== false ? "worktree" : undefined;
152
145
 
153
146
  return {
154
- // A frontmatter model is a portable default, not a policy boundary. An
155
- // explicit model on the Agent call must win so an orchestrator can select a
156
- // specific model (e.g. a `router/*` virtual model) for this dispatch. Below
157
- // it, the global `agentModels` setting outranks the definition's own default.
158
- modelInput: params.model ?? configuredAgentModel(opts?.agentType ?? agentConfig?.name ?? "", agentConfig?.model),
159
- modelFromParams: params.model != null,
160
147
  // A specialist's frontmatter is a reusable default, not a policy boundary:
161
148
  // an explicit dispatch chooses the effort for this run. Pi still clamps the
162
149
  // selected level to the resolved model, and agent-manager records that
package/src/memory.ts CHANGED
@@ -13,8 +13,8 @@
13
13
 
14
14
  import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { join, } from "node:path";
17
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import { join } from "node:path";
17
+ import { resolveAgentDir } from "./home-paths.js";
18
18
  import type { MemoryScope } from "./types.js";
19
19
 
20
20
  /** Maximum lines to read from MEMORY.md */
@@ -58,17 +58,17 @@ export function safeReadFile(filePath: string): string | undefined {
58
58
  * Resolve the memory directory path for a given agent + scope + cwd.
59
59
  * Throws if agentName contains path traversal characters.
60
60
  */
61
- export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string {
61
+ export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string, homeDir = homedir()): string {
62
62
  if (isUnsafeName(agentName)) {
63
63
  throw new Error(`Unsafe agent name for memory directory: "${agentName}"`);
64
64
  }
65
65
  switch (scope) {
66
66
  case "user": {
67
- const current = join(getAgentDir(), "agent-memory", agentName);
67
+ const current = join(resolveAgentDir(homeDir), "agent-memory", agentName);
68
68
  // Legacy location from when this path was hardcoded. Keep using it if it
69
69
  // already holds this agent's memory and the new location hasn't been
70
70
  // created yet — otherwise existing memories would be silently orphaned.
71
- const legacy = join(homedir(), ".pi", "agent-memory", agentName);
71
+ const legacy = join(homeDir, ".pi", "agent-memory", agentName);
72
72
  if (!existsSync(current) && existsSync(legacy) && !isSymlink(legacy)) {
73
73
  return legacy;
74
74
  }
@@ -120,8 +120,8 @@ export function readMemoryIndex(memoryDir: string): string | undefined {
120
120
  * Build the memory block to inject into the agent's system prompt.
121
121
  * Also ensures the memory directory exists (creates it if needed).
122
122
  */
123
- export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
124
- const memoryDir = resolveMemoryDir(agentName, scope, cwd);
123
+ export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string, homeDir = homedir()): string {
124
+ const memoryDir = resolveMemoryDir(agentName, scope, cwd, homeDir);
125
125
  // Create the memory directory so the agent can immediately write to it
126
126
  ensureMemoryDir(memoryDir);
127
127
 
@@ -162,8 +162,8 @@ This memory persists across sessions. Use it to build up knowledge over time.`;
162
162
  * Build a read-only memory block for agents that lack write/edit tools.
163
163
  * Does NOT create the memory directory — agents can only consume existing memory.
164
164
  */
165
- export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
166
- const memoryDir = resolveMemoryDir(agentName, scope, cwd);
165
+ export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string, homeDir = homedir()): string {
166
+ const memoryDir = resolveMemoryDir(agentName, scope, cwd, homeDir);
167
167
  const existingMemory = readMemoryIndex(memoryDir);
168
168
 
169
169
  const header = `# Agent Memory (read-only)
@@ -56,18 +56,20 @@
56
56
  import type { Model } from "@earendil-works/pi-ai";
57
57
  import {
58
58
  createAgentSession,
59
- DefaultResourceLoader,
60
59
  type ExtensionContext,
61
60
  type ExtensionToolContext,
62
- getAgentDir,
63
61
  SessionManager,
64
62
  type ToolDefinition,
65
63
  } from "@earendil-works/pi-coding-agent";
66
64
  import { runInChildSessionContext } from "./child-context.js";
65
+ import { resolveAgentDir } from "./home-paths.js";
67
66
  import { agentMentionReminder } from "./mention.js";
67
+ import { createHomeResourceLoader } from "./resource-loader.js";
68
68
  import type { SubagentType } from "./types.js";
69
69
 
70
70
  export interface MentionCloneOptions {
71
+ /** Home directory for the clone's user resources and settings. */
72
+ homeDir?: string;
71
73
  /** The MAIN session's context — what the spawn is attributed to, and the
72
74
  * source of both the conversation and the live system prompt. */
73
75
  ctx: ExtensionContext;
@@ -140,9 +142,10 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
140
142
  // would add on top (AGENTS.md, skills, APPEND_SYSTEM.md) is already in the
141
143
  // rendered prompt, so it is suppressed, as agent-runner does.
142
144
  const systemPrompt = ctx.getSystemPrompt();
143
- const loader = new DefaultResourceLoader({
145
+ const agentDir = resolveAgentDir(opts.homeDir);
146
+ const loader = createHomeResourceLoader({
144
147
  cwd: ctx.cwd,
145
- agentDir: getAgentDir(),
148
+ agentDir,
146
149
  noExtensions: true,
147
150
  noSkills: true,
148
151
  noPromptTemplates: true,
@@ -150,11 +153,12 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
150
153
  noContextFiles: true,
151
154
  systemPromptOverride: (base: string | undefined) => systemPrompt || base,
152
155
  appendSystemPromptOverride: () => [],
153
- });
156
+ }, opts.homeDir);
154
157
  await runInChildSessionContext(() => loader.reload());
155
158
  const created = await runInChildSessionContext(() =>
156
159
  createAgentSession({
157
160
  cwd: ctx.cwd,
161
+ agentDir,
158
162
  // The parent's root-to-leaf path, which the clone projects itself:
159
163
  // compaction applied, branch summaries substituted. Not `getEntries()`,
160
164
  // whose last entry is not the leaf after `/tree` navigation. In memory,
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Model resolution: exact match ("provider/modelId") with fuzzy fallback.
2
+ * Model resolution: exact match ("provider/modelId") with fuzzy fallback for tool inputs.
3
3
  */
4
+ import { exactTierModel } from "./model-tiers.js";
4
5
 
5
6
  export interface ModelEntry {
6
7
  id: string;
@@ -40,14 +41,9 @@ export function setAgentModels(models: Record<string, string> | undefined): void
40
41
  agentModels = new Map(Object.entries(models ?? {}).map(([type, model]) => [type.toLowerCase(), model]));
41
42
  }
42
43
 
43
- /**
44
- * The model input a spawn of `type` should use when the caller named none:
45
- * `agentModels[type]`, then `agentModels["*"]`, then the agent definition's own
46
- * `model:` (`definitionModel`). The parent model is the floor, applied by the
47
- * caller when this returns `undefined`.
48
- */
49
- export function configuredAgentModel(type: string, definitionModel: string | undefined): string | undefined {
50
- return agentModels.get(type.toLowerCase()) ?? agentModels.get("*") ?? definitionModel;
44
+ /** A snapshot for the shared selection policy; callers cannot mutate configuration. */
45
+ export function getAgentModels(): ReadonlyMap<string, string> {
46
+ return new Map(agentModels);
51
47
  }
52
48
 
53
49
  /**
@@ -87,6 +83,40 @@ export function describeModel(
87
83
  };
88
84
  }
89
85
 
86
+ /**
87
+ * A CONFIG model (`agentModels`, an agent's `model:`, a stored job's model) that
88
+ * did not resolve, so the spawn ran on the parent model instead. Recorded once on
89
+ * the `AgentRecord` by the shared spawn path; every surface reads it from there.
90
+ */
91
+ export interface ModelFallback {
92
+ /** The configured input that failed to resolve. */
93
+ input: string;
94
+ /** Further configured inputs that also failed (e.g. a stored job model AND an `agentModels` override). */
95
+ also?: string[];
96
+ /** `provider/id` of the model actually used; absent when the session default (a parent with no model) ran. */
97
+ used?: string;
98
+ /** Whose model `used` is: the parent's (default) or the agent's own configured model (`agentModels` / definition `model:`), which took over from an unresolved stored model. */
99
+ via?: "parent" | "agent";
100
+ }
101
+
102
+ /** The fallback outcome for `input` when the spawn ran on `parent`. */
103
+ export function modelFallbackTo(input: string, parent: { provider: string; id: string } | undefined): ModelFallback {
104
+ return parent ? { input, used: `${parent.provider}/${parent.id}` } : { input };
105
+ }
106
+
107
+ /**
108
+ * The disclosure line for a fallback: same `Note:` prefix as the unknown-agent-type
109
+ * note. Empty string when there was none, so callers can concatenate it blindly.
110
+ */
111
+ export function modelFallbackNote(fallback: ModelFallback | undefined): string {
112
+ if (!fallback) return "";
113
+ const whose = fallback.via === "agent" ? "the agent's own model" : "parent model";
114
+ const used = fallback.used ? `${whose} ${fallback.used}` : "the session default model";
115
+ const inputs = [fallback.input, ...(fallback.also ?? [])];
116
+ const named = inputs.length === 1 ? `model "${inputs[0]}" did` : `models ${inputs.map(i => `"${i}"`).join(", ")} did`;
117
+ return `Note: configured ${named} not resolve; running on ${used}.`;
118
+ }
119
+
90
120
  /** API id pi gives virtual catalog entries such as `router/default`; they never reach a provider. */
91
121
  const VIRTUAL_MODEL_API = "pi-virtual";
92
122
 
@@ -160,8 +190,8 @@ export function resolveModel(
160
190
  const aliasCandidates = modelAliases.get(input.trim().toLowerCase());
161
191
  if (aliasCandidates) {
162
192
  for (const candidate of aliasCandidates) {
163
- const resolved = resolveCanonicalModel(candidate, registry);
164
- if (typeof resolved !== "string") return resolved;
193
+ const resolved = exactTierModel(candidate, registry);
194
+ if (resolved) return resolved;
165
195
  }
166
196
  return `Model alias "${input}" has no available candidate.\n\nCandidates:\n${aliasCandidates.map(candidate => ` ${candidate}`).join("\n")}`;
167
197
  }
@@ -39,6 +39,7 @@ export type ModelScopeVerdict =
39
39
  * user authored/installed this agent or chose the parent's model; trust it).
40
40
  */
41
41
  export function checkModelScope(args: {
42
+ homeDir?: string;
42
43
  model: { provider: string; id: string } | undefined;
43
44
  cwd: string;
44
45
  modelRegistry: ModelRegistryRef;
@@ -52,7 +53,7 @@ export function checkModelScope(args: {
52
53
  const { model, cwd, modelRegistry, callerSupplied, agentLabel, modelInput } = args;
53
54
  if (!scopeModelsEnabled || !model) return { kind: "ok" };
54
55
 
55
- const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd);
56
+ const allowed = resolveEnabledModels(readEnabledModels(cwd, args.homeDir), modelRegistry, cwd, args.homeDir);
56
57
  if (!allowed || isModelInScope(model, allowed)) return { kind: "ok" };
57
58
 
58
59
  if (callerSupplied) {
@@ -0,0 +1,159 @@
1
+ /** One selection policy for every dispatch, independent of its transport. */
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import { describeModel, describeVirtualModel, getAgentModels, getModelAliases, isVirtualModel, type ModelFallback, type ModelRegistry, modelFallbackNote, modelFallbackTo, resolveModel } from "./model-resolver.js";
4
+ import { checkModelScope } from "./model-scope.js";
5
+ import { exactTierModel, getModelResolution, parseTierBinding, resolveTier, type TierBinding, type TierDisclosure, type TierOutcome, tierBinding, tierNote } from "./model-tiers.js";
6
+ import { type AdmissionEvents, admissionOutcome, type QuotaBlock } from "./quota-admission.js";
7
+
8
+ /** Exact compatibility pins for user-defined legacy Explore definitions. User aliases win. */
9
+ const LEGACY_MODEL_PINS = new Map([
10
+ ["claude-haiku-4-5", "anthropic/claude-haiku-4-5"],
11
+ ]);
12
+
13
+ /** The winning precedence level, before resolution or fallback. */
14
+ export type ModelSource = "param" | "agentModels:type" | "agentModels:*" | "definition" | "parent";
15
+
16
+ /** Keep the original request even when it could not be resolved. */
17
+ export interface ModelSelection {
18
+ requested?: string;
19
+ source: ModelSource;
20
+ model: Model<Api> | undefined;
21
+ outcome: "resolved" | "inherited" | "fallback";
22
+ fallback?: ModelFallback;
23
+ scopeWarning?: string;
24
+ /** Serializable tier provenance, retained when the answering model changes. */
25
+ tier?: TierOutcome;
26
+ /** A prepared binding is resolved with admission before starting the child. */
27
+ tierBinding?: TierBinding;
28
+ tierAdmitted?: boolean;
29
+ /** Startup-only parent floor if credentials/profile disappear between preflight and admission. */
30
+ tierParent?: Model<Api>;
31
+ }
32
+
33
+ /** Choose the input once: explicit parameter > type > wildcard > definition; blank strings are unset. */
34
+ export function modelRequest(type: string, definitionModel?: string, paramModel?: string | Model<Api> | null): {
35
+ requested?: string;
36
+ source: ModelSource;
37
+ } {
38
+ if (paramModel != null && (typeof paramModel !== "string" || paramModel.trim() !== "")) {
39
+ return { requested: typeof paramModel === "string" ? paramModel : `${paramModel.provider}/${paramModel.id}`, source: "param" };
40
+ }
41
+ const overrides = getAgentModels();
42
+ const typed = overrides.get(type.toLowerCase());
43
+ if (typed?.trim()) return { requested: typed, source: "agentModels:type" };
44
+ const wildcard = overrides.get("*");
45
+ if (wildcard?.trim()) return { requested: wildcard, source: "agentModels:*" };
46
+ if (definitionModel?.trim()) return { requested: definitionModel, source: "definition" };
47
+ return { source: "parent" };
48
+ }
49
+
50
+ /**
51
+ * Choose an exact configured input or tier binding before record creation.
52
+ * Only explicit non-tier tool inputs retain fuzzy matching. Tier admission is
53
+ * asynchronous and completes through admitModelSelection before startup; this
54
+ * synchronous preflight preserves spawn's immediate-id and validation contract.
55
+ * An already-open resume keeps the record and does not select again.
56
+ */
57
+ export function selectModel(request: {
58
+ homeDir?: string;
59
+ agentType: string;
60
+ definitionModel?: string;
61
+ paramModel?: string | Model<Api> | null;
62
+ parentModel: Model<Api> | undefined;
63
+ registry: ModelRegistry;
64
+ cwd: string;
65
+ agentLabel?: string;
66
+ }): ModelSelection | string {
67
+ const input = modelRequest(request.agentType, request.definitionModel, request.paramModel);
68
+ let selection: ModelSelection = { ...input, model: request.parentModel, outcome: "inherited" };
69
+ if (input.requested) {
70
+ const requested = input.requested.trim();
71
+ const legacy = getModelAliases().get(requested.toLowerCase());
72
+ const legacyBinding = legacy ? parseTierBinding("legacy", legacy) : undefined;
73
+ // Preserve existing enterprise manifests during the built-in Explore migration.
74
+ // An explicit cheap binding always wins, even when it is unavailable.
75
+ const cheapCompatibility = requested === "@cheap" && request.agentType === "Explore"
76
+ && input.source === "definition"
77
+ ? parseTierBinding("cheap", getModelAliases().get("claude-haiku-4-5")) : undefined;
78
+ const binding = requested.startsWith("@") ? tierBinding(requested.slice(1)) ?? cheapCompatibility
79
+ : legacyBinding ? { ...legacyBinding, tier: requested } : undefined;
80
+ if (requested.startsWith("@") || legacy) {
81
+ const tier = requested.startsWith("@") ? requested.slice(1) : requested;
82
+ const refs = binding?.kind === "routed" ? [binding.profile] : binding?.members ?? [];
83
+ const model = refs.map(ref => exactTierModel(ref, request.registry)).find(Boolean);
84
+ if (!binding || !model) {
85
+ const outcome: TierOutcome = binding ? { kind: "unavailable", tier, tried: refs } : { kind: "unmapped", tier };
86
+ const mode = outcome.kind === "unmapped" ? getModelResolution().onUnmapped : getModelResolution().onUnavailable;
87
+ if (mode === "error") return `Model tier @${tier} is ${outcome.kind}.`;
88
+ selection = { ...selection, tier: outcome, outcome: "fallback", fallback: modelFallbackTo(input.requested, request.parentModel) };
89
+ } else {
90
+ selection = { ...input, model, outcome: "resolved", tierBinding: binding, tierParent: request.parentModel };
91
+ }
92
+ } else {
93
+ const compatibilityPin = input.source === "definition" && request.agentType === "Explore"
94
+ ? LEGACY_MODEL_PINS.get(requested) : undefined;
95
+ const resolved = request.paramModel != null && typeof request.paramModel === "object"
96
+ ? request.paramModel : input.source === "param" ? resolveModel(requested, request.registry)
97
+ : exactTierModel(compatibilityPin ?? requested, request.registry) ?? `Model not found: "${requested}".`;
98
+ if (typeof resolved !== "string") selection = { ...input, model: resolved, outcome: "resolved" };
99
+ else if (compatibilityPin) {
100
+ const tier: TierOutcome = { kind: "unavailable", tier: requested, tried: [compatibilityPin] };
101
+ if (getModelResolution().onUnavailable === "error") return `Model tier @${requested} is unavailable.`;
102
+ selection = { ...selection, tier, outcome: "fallback", fallback: modelFallbackTo(input.requested, request.parentModel) };
103
+ } else {
104
+ if (input.source === "param" || getModelResolution().onUnavailable === "error") return resolved;
105
+ selection = { ...selection, outcome: "fallback", fallback: modelFallbackTo(input.requested, request.parentModel) };
106
+ }
107
+ }
108
+ }
109
+ const scope = checkModelScope({
110
+ model: selection.model, cwd: request.cwd, modelRegistry: request.registry,
111
+ callerSupplied: input.source === "param" && !selection.tierBinding && !selection.tier, agentLabel: request.agentLabel ?? request.agentType,
112
+ modelInput: input.requested, homeDir: request.homeDir,
113
+ });
114
+ if (scope.kind === "error") return scope.message;
115
+ if (scope.kind === "warn") selection.scopeWarning = scope.message;
116
+ return selection;
117
+ }
118
+
119
+ /** Label an admitted physical model before any response, without replacing the virtual selection. */
120
+ export function describeSelection(selection: ModelSelection | undefined, registry: ModelRegistry): { modelName?: string; modelId?: string } {
121
+ const admitted = selection?.tier?.kind === "routed" ? selection.tier.admitted : undefined;
122
+ if (admitted) {
123
+ const slash = admitted.indexOf("/");
124
+ const provider = admitted.slice(0, slash);
125
+ const id = admitted.slice(slash + 1);
126
+ return describeModel(registry.find(provider, id) ?? { provider, id });
127
+ }
128
+ const model = selection?.model;
129
+ return model ? isVirtualModel(model) ? describeVirtualModel(model) : describeModel(model) : {};
130
+ }
131
+
132
+ /** One disclosure for records, persisted details, workflows and notices. */
133
+ export function modelSelectionNote(carrier: { modelTier?: TierDisclosure; modelFallback?: ModelFallback; invocation?: { modelId?: string } } | undefined): string {
134
+ const tier = carrier?.modelTier;
135
+ const fallback = carrier?.modelFallback;
136
+ const sameRequest = tier && (fallback?.input === tier.tier || fallback?.input === `@${tier.tier}`);
137
+ return [tierNote(tier, carrier?.invocation?.modelId), sameRequest ? "" : modelFallbackNote(fallback)].filter(Boolean).join("\n");
138
+ }
139
+
140
+ /** Admission for tiers is made once and is shared by every transport. */
141
+ export async function admitModelSelection(selection: ModelSelection, registry: ModelRegistry, events: AdmissionEvents): Promise<QuotaBlock | undefined> {
142
+ if (!selection.tierBinding || selection.tierAdmitted) return selection.tier?.kind === "exhausted" ? selection.tier.block : undefined;
143
+ selection.tier = await resolveTier(selection.tierBinding.tier, {
144
+ registry, binding: selection.tierBinding, admit: ref => admissionOutcome(events, ref),
145
+ });
146
+ selection.tierAdmitted = true;
147
+ if (selection.tier.kind === "routed" || selection.tier.kind === "static") selection.model = selection.tier.model;
148
+ if (selection.tier.kind === "exhausted") return selection.tier.block;
149
+ if (selection.tier.kind === "unavailable" || selection.tier.kind === "unmapped") {
150
+ const mode = selection.tier.kind === "unmapped" ? getModelResolution().onUnmapped : getModelResolution().onUnavailable;
151
+ if (mode === "error") throw new Error(`Model tier @${selection.tier.tier} is ${selection.tier.kind}.`);
152
+ selection.model = selection.tierParent;
153
+ selection.outcome = "fallback";
154
+ selection.fallback = modelFallbackTo(selection.requested ?? `@${selection.tier.tier}`, selection.tierParent);
155
+ delete selection.tierBinding;
156
+ }
157
+ delete selection.tierParent;
158
+ return undefined;
159
+ }
@@ -0,0 +1,171 @@
1
+ /** Exact, provider-neutral model tiers. Router profiles retain ownership of failover. */
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import type { ModelRegistry } from "./model-resolver.js";
4
+ import type { QuotaBlock } from "./quota-admission.js";
5
+
6
+ /** One registered router profile OR an ordered exact physical chain, never both. */
7
+ export type TierBinding =
8
+ | { kind: "routed"; tier: string; profile: string }
9
+ | { kind: "static"; tier: string; members: string[] };
10
+
11
+ /** Why an earlier static member could not be selected. */
12
+ interface TierSkip {
13
+ ref: string;
14
+ why: "no-credentials" | "quota";
15
+ }
16
+
17
+ /** Resolution is explicit even when no model can run; callers never guess a neighbouring tier. */
18
+ export type TierOutcome =
19
+ | { kind: "routed"; tier: string; model: Model<Api>; profile: string; admitted?: string; answered?: string }
20
+ | { kind: "static"; tier: string; model: Model<Api>; member: string; skipped: TierSkip[]; quotaSignal: boolean; answered?: string }
21
+ | { kind: "unmapped"; tier: string }
22
+ | { kind: "unavailable"; tier: string; tried: string[] }
23
+ | { kind: "exhausted"; tier: string; block: QuotaBlock; skipped: TierSkip[] };
24
+
25
+ /** Persist identity and decisions, never provider URLs, headers or credential-bearing model overlays. */
26
+ export type TierDisclosure = TierOutcome extends infer Outcome
27
+ ? Outcome extends { model: Model<Api> } ? Omit<Outcome, "model"> & { model: Pick<Model<Api>, "provider" | "id" | "name" | "api"> } : Outcome
28
+ : never;
29
+
30
+ /** Project a runtime outcome onto its credential-free persisted shape. */
31
+ export function tierDisclosure(outcome: TierDisclosure): TierDisclosure {
32
+ if (outcome.kind !== "routed" && outcome.kind !== "static") return outcome;
33
+ const { provider, id, name, api } = outcome.model;
34
+ return { ...outcome, model: { provider, id, name, api } };
35
+ }
36
+
37
+ /** Trusted policy for configuration failures; quota exhaustion is handled separately. */
38
+ export interface ModelResolution {
39
+ onUnmapped?: "error" | "inherit";
40
+ onUnavailable?: "error" | "inherit";
41
+ }
42
+
43
+ /** Admission metadata includes the physical label and distinguishes fail-open from measured admission. */
44
+ export interface TierAdmission {
45
+ block?: QuotaBlock;
46
+ resolvedModel?: string;
47
+ quotaSignal: boolean;
48
+ }
49
+
50
+ const TIER_NAME = /^[a-z0-9][a-z0-9._-]*$/;
51
+ const MODEL_REF = /^[^\s/]+\/\S+$/;
52
+ let bindings: Record<string, string[]> = {};
53
+ let policy: ModelResolution = {};
54
+
55
+ /** Reject the entire binding, never silently shorten a malformed chain. */
56
+ export function parseTierBinding(tier: string, members: unknown): TierBinding | undefined {
57
+ if (tier.trim() !== tier || !TIER_NAME.test(tier) || !Array.isArray(members) || members.length === 0
58
+ || members.some(ref => typeof ref !== "string" || ref.trim() !== ref || !MODEL_REF.test(ref))) return undefined;
59
+ const refs = members as string[];
60
+ if (refs.some(ref => ref.startsWith("router/"))) {
61
+ return refs.length === 1 ? { kind: "routed", tier, profile: refs[0]! } : undefined;
62
+ }
63
+ return { kind: "static", tier, members: [...refs] };
64
+ }
65
+
66
+ /** Settings have already been validated at the trusted global boundary. */
67
+ export function setModelTiers(tiers: Record<string, string[]> | undefined, resolution: ModelResolution | undefined): void {
68
+ bindings = Object.fromEntries(Object.entries(tiers ?? {}).map(([key, refs]) => [key, [...refs]]));
69
+ policy = { ...resolution };
70
+ }
71
+
72
+ /** Return a snapshot; missing policies mean inherit with disclosure. */
73
+ export function getModelResolution(): ModelResolution {
74
+ return { ...policy };
75
+ }
76
+
77
+ /** A missing or malformed name is unmapped, including bare @ and mixed case. */
78
+ export function tierBinding(tier: string): TierBinding | undefined {
79
+ return parseTierBinding(tier, bindings[tier]);
80
+ }
81
+
82
+ /** Exact lookup independent of enabledModels, version ranking and provider retry. */
83
+ export function exactTierModel(ref: string, registry: ModelRegistry): Model<Api> | undefined {
84
+ if (ref.trim() !== ref || !MODEL_REF.test(ref)) return undefined;
85
+ const slash = ref.indexOf("/");
86
+ const found = registry.find(ref.slice(0, slash), ref.slice(slash + 1)) as Model<Api> | undefined;
87
+ if (!found) return undefined;
88
+ if (ref.startsWith("router/")) return found.api === "pi-virtual" ? found : undefined;
89
+ const available = registry.getAvailable?.() ?? registry.getAll();
90
+ return available.some(model => model.provider === found.provider && model.id === found.id) ? found : undefined;
91
+ }
92
+
93
+ /** Resolve once per dispatch; a refused routed tier never tries another profile. */
94
+ export async function resolveTier(tier: string, deps: {
95
+ registry: ModelRegistry;
96
+ admit: (ref: string) => Promise<TierAdmission>;
97
+ binding?: TierBinding;
98
+ }): Promise<TierOutcome> {
99
+ const binding = deps.binding ?? tierBinding(tier);
100
+ if (!binding) return { kind: "unmapped", tier };
101
+ if (binding.kind === "routed") {
102
+ const model = exactTierModel(binding.profile, deps.registry);
103
+ if (!model) return { kind: "unavailable", tier, tried: [binding.profile] };
104
+ const admission = await deps.admit(binding.profile);
105
+ if (admission.block) return { kind: "exhausted", tier, block: admission.block, skipped: [] };
106
+ return { kind: "routed", tier, model, profile: binding.profile, admitted: admission.resolvedModel };
107
+ }
108
+ const skipped: TierSkip[] = [];
109
+ const blocks: QuotaBlock[] = [];
110
+ for (const member of binding.members) {
111
+ const model = exactTierModel(member, deps.registry);
112
+ if (!model) {
113
+ skipped.push({ ref: member, why: "no-credentials" });
114
+ continue;
115
+ }
116
+ const admission = await deps.admit(member);
117
+ if (admission.block) {
118
+ skipped.push({ ref: member, why: "quota" });
119
+ blocks.push(admission.block);
120
+ continue;
121
+ }
122
+ return { kind: "static", tier, model, member, skipped, quotaSignal: admission.quotaSignal };
123
+ }
124
+ if (blocks.length) {
125
+ // Wait only if every refusal permits it. Unknown resets must not invent a wake time.
126
+ const resets = blocks.flatMap(block => block.resetAt === undefined ? [] : [block.resetAt]);
127
+ return { kind: "exhausted", tier, skipped, block: {
128
+ model: `@${tier}`, wait: blocks.every(block => block.wait !== false),
129
+ resetAt: resets.length ? Math.min(...resets) : undefined,
130
+ } };
131
+ }
132
+ return { kind: "unavailable", tier, tried: [...binding.members] };
133
+ }
134
+
135
+ /** Validate stored provenance without re-resolving a historical request against today's registry. */
136
+ export function isTierDisclosure(raw: unknown): raw is TierDisclosure {
137
+ if (!raw || typeof raw !== "object") return false;
138
+ const value = raw as Record<string, unknown>;
139
+ if (typeof value.tier !== "string") return false;
140
+ if (value.kind === "unmapped") return true;
141
+ if (value.kind === "unavailable") return Array.isArray(value.tried) && value.tried.every(ref => typeof ref === "string");
142
+ const skipsValid = Array.isArray(value.skipped) && value.skipped.every(skip => skip && typeof skip === "object"
143
+ && typeof skip.ref === "string" && (skip.why === "quota" || skip.why === "no-credentials"));
144
+ if (value.kind === "exhausted") {
145
+ const block = value.block as QuotaBlock | undefined;
146
+ return skipsValid && !!block && typeof block.model === "string"
147
+ && (block.wait === undefined || typeof block.wait === "boolean")
148
+ && (block.resetAt === undefined || (typeof block.resetAt === "number" && Number.isFinite(block.resetAt)));
149
+ }
150
+ const model = value.model as Partial<Model<Api>> | undefined;
151
+ if (!model || typeof model.provider !== "string" || typeof model.id !== "string"
152
+ || typeof model.name !== "string" || typeof model.api !== "string") return false;
153
+ if (value.answered !== undefined && typeof value.answered !== "string") return false;
154
+ if (value.kind === "routed") return typeof value.profile === "string" && value.profile.startsWith("router/")
155
+ && (value.admitted === undefined || typeof value.admitted === "string");
156
+ return value.kind === "static" && typeof value.member === "string" && skipsValid && typeof value.quotaSignal === "boolean";
157
+ }
158
+
159
+ /** Durable one-line disclosure, also useful before the first assistant response. */
160
+ export function tierNote(outcome: TierDisclosure | undefined, answering?: string): string {
161
+ if (!outcome) return "";
162
+ const tier = `@${outcome.tier}`;
163
+ const answered = answering ?? ((outcome.kind === "routed" || outcome.kind === "static") ? outcome.answered : undefined);
164
+ switch (outcome.kind) {
165
+ case "routed": return `Model: ${tier} -> ${answered ?? outcome.admitted ?? outcome.profile} via ${outcome.profile}${answered && outcome.admitted && answered !== outcome.admitted ? ` (switched from ${outcome.admitted} within tier)` : ""}`;
166
+ case "static": return `Model: ${tier} -> ${answered ?? outcome.member} (static${outcome.quotaSignal ? "" : "; no quota signal"})${outcome.skipped.length ? `; skipped ${outcome.skipped.map(skip => `${skip.ref}: ${skip.why}`).join(", ")}` : ""}`;
167
+ case "unmapped": return `Note: model tier ${tier} is unmapped; inheriting the parent model.`;
168
+ case "unavailable": return `Note: model tier ${tier} is unavailable; inheriting the parent model.`;
169
+ case "exhausted": return `Model: ${tier} is exhausted.`;
170
+ }
171
+ }