@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.
- package/README.md +124 -37
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +131 -28
- package/src/agent-runner.ts +155 -62
- package/src/cross-extension-rpc.ts +11 -36
- package/src/custom-agents.ts +9 -5
- package/src/default-agents.ts +2 -4
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +5 -5
- package/src/home-paths.ts +27 -0
- package/src/index.ts +191 -90
- package/src/invocation-config.ts +3 -16
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +9 -5
- package/src/model-resolver.ts +41 -11
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +159 -0
- package/src/model-tiers.ts +171 -0
- package/src/nested-tools.ts +22 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +6 -3
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +36 -18
- package/src/settings.ts +42 -12
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +27 -0
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +15 -4
- package/src/ui/conversation-viewer.ts +6 -3
- package/src/ui/fallback-flag.ts +90 -0
- package/src/ui/fleet-list.ts +363 -53
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +18 -2
- package/src/ui/workflow-card.ts +5 -1
- package/src/ui/workflow-dialog.ts +10 -0
- package/src/workflow/host.ts +43 -45
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/progress.ts +4 -0
- package/src/workflow/runtime.ts +25 -8
- package/src/workflow/saved.ts +19 -17
- package/src/workflow/task.ts +10 -0
- package/src/workflow/worker-source.ts +156 -38
package/src/invocation-config.ts
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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
|
|
17
|
-
import {
|
|
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(
|
|
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(
|
|
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)
|
package/src/mention-clone.ts
CHANGED
|
@@ -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
|
|
145
|
+
const agentDir = resolveAgentDir(opts.homeDir);
|
|
146
|
+
const loader = createHomeResourceLoader({
|
|
144
147
|
cwd: ctx.cwd,
|
|
145
|
-
agentDir
|
|
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,
|
package/src/model-resolver.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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 =
|
|
164
|
-
if (
|
|
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
|
}
|
package/src/model-scope.ts
CHANGED
|
@@ -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
|
+
}
|