@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9
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 +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
package/src/invocation-config.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
|
-
import {
|
|
5
|
-
import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
|
|
4
|
+
import type { AgentConfig, IsolationMode, ThinkingLevel } from "./types.js";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* The model-facing `isolation` parameter, shared by the `Agent` tool and the
|
|
@@ -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
|
|
@@ -169,7 +156,3 @@ export function resolveAgentInvocationConfig(
|
|
|
169
156
|
isolation,
|
|
170
157
|
};
|
|
171
158
|
}
|
|
172
|
-
|
|
173
|
-
export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
|
|
174
|
-
return runInBackground ? defaultJoinMode : undefined;
|
|
175
|
-
}
|
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,21 @@
|
|
|
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
|
-
import { runInChildSessionContext } from "./child-context.js";
|
|
64
|
+
import { bindChildResourceLoader, runInChildSessionContext } from "./child-context.js";
|
|
65
|
+
import { errorMessage } from "./error-message.js";
|
|
66
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
67
67
|
import { agentMentionReminder } from "./mention.js";
|
|
68
|
+
import { createHomeResourceLoader } from "./resource-loader.js";
|
|
68
69
|
import type { SubagentType } from "./types.js";
|
|
69
70
|
|
|
70
71
|
export interface MentionCloneOptions {
|
|
72
|
+
/** Home directory for the clone's user resources and settings. */
|
|
73
|
+
homeDir?: string;
|
|
71
74
|
/** The MAIN session's context — what the spawn is attributed to, and the
|
|
72
75
|
* source of both the conversation and the live system prompt. */
|
|
73
76
|
ctx: ExtensionContext;
|
|
@@ -140,9 +143,10 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
|
|
|
140
143
|
// would add on top (AGENTS.md, skills, APPEND_SYSTEM.md) is already in the
|
|
141
144
|
// rendered prompt, so it is suppressed, as agent-runner does.
|
|
142
145
|
const systemPrompt = ctx.getSystemPrompt();
|
|
143
|
-
const
|
|
146
|
+
const agentDir = resolveAgentDir(opts.homeDir);
|
|
147
|
+
const loader = createHomeResourceLoader({
|
|
144
148
|
cwd: ctx.cwd,
|
|
145
|
-
agentDir
|
|
149
|
+
agentDir,
|
|
146
150
|
noExtensions: true,
|
|
147
151
|
noSkills: true,
|
|
148
152
|
noPromptTemplates: true,
|
|
@@ -150,11 +154,12 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
|
|
|
150
154
|
noContextFiles: true,
|
|
151
155
|
systemPromptOverride: (base: string | undefined) => systemPrompt || base,
|
|
152
156
|
appendSystemPromptOverride: () => [],
|
|
153
|
-
});
|
|
154
|
-
await
|
|
157
|
+
}, opts.homeDir);
|
|
158
|
+
await bindChildResourceLoader(loader).reload();
|
|
155
159
|
const created = await runInChildSessionContext(() =>
|
|
156
160
|
createAgentSession({
|
|
157
161
|
cwd: ctx.cwd,
|
|
162
|
+
agentDir,
|
|
158
163
|
// The parent's root-to-leaf path, which the clone projects itself:
|
|
159
164
|
// compaction applied, branch summaries substituted. Not `getEntries()`,
|
|
160
165
|
// whose last entry is not the leaf after `/tree` navigation. In memory,
|
|
@@ -184,7 +189,7 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
|
|
|
184
189
|
// renderer produces, where the reminder trails the message it is about.
|
|
185
190
|
await session.prompt(`${message}\n\n${agentMentionReminder(type)}`);
|
|
186
191
|
} catch (err) {
|
|
187
|
-
return { spawned, error:
|
|
192
|
+
return { spawned, error: errorMessage(err) };
|
|
188
193
|
} finally {
|
|
189
194
|
session?.dispose?.();
|
|
190
195
|
}
|
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,22 +41,21 @@ 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
|
/**
|
|
54
50
|
* Narrows fuzzy candidates to the models the user enabled (pi's `enabledModels`),
|
|
55
51
|
* as lowercase `provider/id` keys. Injected rather than imported so this module
|
|
56
|
-
* stays free of settings I/O; `undefined` means no scope is configured.
|
|
52
|
+
* stays free of settings I/O; `undefined` means no scope is configured. An
|
|
53
|
+
* unresolved dispatch passes its config root only for its diagnostic.
|
|
57
54
|
*/
|
|
58
|
-
export type ModelScopeProvider = (
|
|
55
|
+
export type ModelScopeProvider = (
|
|
56
|
+
registry: ModelRegistry,
|
|
57
|
+
context?: { cwd: string; homeDir?: string },
|
|
58
|
+
) => ReadonlySet<string> | undefined;
|
|
59
59
|
|
|
60
60
|
let modelScopeProvider: ModelScopeProvider | undefined;
|
|
61
61
|
|
|
@@ -87,6 +87,40 @@ export function describeModel(
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A CONFIG model (`agentModels`, an agent's `model:`, a stored job's model) that
|
|
92
|
+
* did not resolve, so the spawn ran on the parent model instead. Recorded once on
|
|
93
|
+
* the `AgentRecord` by the shared spawn path; every surface reads it from there.
|
|
94
|
+
*/
|
|
95
|
+
export interface ModelFallback {
|
|
96
|
+
/** The configured input that failed to resolve. */
|
|
97
|
+
input: string;
|
|
98
|
+
/** Further configured inputs that also failed (e.g. a stored job model AND an `agentModels` override). */
|
|
99
|
+
also?: string[];
|
|
100
|
+
/** `provider/id` of the model actually used; absent when the session default (a parent with no model) ran. */
|
|
101
|
+
used?: string;
|
|
102
|
+
/** 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. */
|
|
103
|
+
via?: "parent" | "agent";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The fallback outcome for `input` when the spawn ran on `parent`. */
|
|
107
|
+
export function modelFallbackTo(input: string, parent: { provider: string; id: string } | undefined): ModelFallback {
|
|
108
|
+
return parent ? { input, used: `${parent.provider}/${parent.id}` } : { input };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The disclosure line for a fallback: same `Note:` prefix as the unknown-agent-type
|
|
113
|
+
* note. Empty string when there was none, so callers can concatenate it blindly.
|
|
114
|
+
*/
|
|
115
|
+
export function modelFallbackNote(fallback: ModelFallback | undefined): string {
|
|
116
|
+
if (!fallback) return "";
|
|
117
|
+
const whose = fallback.via === "agent" ? "the agent's own model" : "parent model";
|
|
118
|
+
const used = fallback.used ? `${whose} ${fallback.used}` : "the session default model";
|
|
119
|
+
const inputs = [fallback.input, ...(fallback.also ?? [])];
|
|
120
|
+
const named = inputs.length === 1 ? `model "${inputs[0]}" did` : `models ${inputs.map(i => `"${i}"`).join(", ")} did`;
|
|
121
|
+
return `Note: configured ${named} not resolve; running on ${used}.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
90
124
|
/** API id pi gives virtual catalog entries such as `router/default`; they never reach a provider. */
|
|
91
125
|
const VIRTUAL_MODEL_API = "pi-virtual";
|
|
92
126
|
|
|
@@ -156,16 +190,17 @@ function compareVersionsDesc(a: ModelVersion | undefined, b: ModelVersion | unde
|
|
|
156
190
|
export function resolveModel(
|
|
157
191
|
input: string,
|
|
158
192
|
registry: ModelRegistry,
|
|
193
|
+
context?: { cwd: string; homeDir?: string },
|
|
159
194
|
): any | string {
|
|
160
195
|
const aliasCandidates = modelAliases.get(input.trim().toLowerCase());
|
|
161
196
|
if (aliasCandidates) {
|
|
162
197
|
for (const candidate of aliasCandidates) {
|
|
163
|
-
const resolved =
|
|
164
|
-
if (
|
|
198
|
+
const resolved = exactTierModel(candidate, registry);
|
|
199
|
+
if (resolved) return resolved;
|
|
165
200
|
}
|
|
166
201
|
return `Model alias "${input}" has no available candidate.\n\nCandidates:\n${aliasCandidates.map(candidate => ` ${candidate}`).join("\n")}`;
|
|
167
202
|
}
|
|
168
|
-
return resolveCanonicalModel(input, registry);
|
|
203
|
+
return resolveCanonicalModel(input, registry, context);
|
|
169
204
|
}
|
|
170
205
|
|
|
171
206
|
/**
|
|
@@ -174,20 +209,44 @@ export function resolveModel(
|
|
|
174
209
|
* match score. A bare family name like "opus" therefore means the newest opus
|
|
175
210
|
* the user has enabled, not whichever id happens to be shortest.
|
|
176
211
|
*/
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
212
|
+
function scopedCandidates<T extends { entry: ModelEntry }>(
|
|
213
|
+
candidates: T[],
|
|
214
|
+
registry: ModelRegistry,
|
|
215
|
+
context?: { cwd: string; homeDir?: string },
|
|
216
|
+
): T[] {
|
|
217
|
+
const scope = modelScopeProvider?.(registry, context);
|
|
218
|
+
const scoped = scope ? candidates.filter(candidate => scope.has(`${candidate.entry.provider}/${candidate.entry.id}`.toLowerCase())) : [];
|
|
219
|
+
return scoped.length > 0 ? scoped : candidates;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Stable tie-breaker independent of registry insertion order and locale. */
|
|
223
|
+
function compareModelIdentity(a: ModelEntry, b: ModelEntry): number {
|
|
224
|
+
const left = `${a.provider}/${a.id}`;
|
|
225
|
+
const right = `${b.provider}/${b.id}`;
|
|
226
|
+
return Number(left > right) - Number(left < right);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function rankFuzzy(
|
|
230
|
+
matches: { entry: ModelEntry; score: number }[],
|
|
231
|
+
registry: ModelRegistry,
|
|
232
|
+
context?: { cwd: string; homeDir?: string },
|
|
233
|
+
): ModelEntry | undefined {
|
|
234
|
+
const ranked = scopedCandidates(matches, registry, context)
|
|
182
235
|
.map(c => ({ ...c, version: parseModelVersion(c.entry.id) }))
|
|
183
|
-
.sort((a, b) => compareVersionsDesc(a.version, b.version) || b.score - a.score);
|
|
236
|
+
.sort((a, b) => compareVersionsDesc(a.version, b.version) || b.score - a.score || compareModelIdentity(a.entry, b.entry));
|
|
184
237
|
return ranked[0]?.entry;
|
|
185
238
|
}
|
|
186
239
|
|
|
240
|
+
/** Numeric runs stay ordered and contiguous: `5-5` cannot reuse the `5` in `4-5`. */
|
|
241
|
+
function versionRuns(value: string): string[] {
|
|
242
|
+
return [...value.replace(/-\d{8}$/, "").matchAll(/(?:^|[\s-])(\d+(?:[\s-]+\d+)*)(?=$|[\s-])/g)].map(match => match[1]!.replace(/\s+/g, "-"));
|
|
243
|
+
}
|
|
244
|
+
|
|
187
245
|
/** Resolve an exact or fuzzy provider/model request without consulting aliases. */
|
|
188
246
|
function resolveCanonicalModel(
|
|
189
247
|
input: string,
|
|
190
248
|
registry: ModelRegistry,
|
|
249
|
+
context?: { cwd: string; homeDir?: string },
|
|
191
250
|
): any | string {
|
|
192
251
|
// Available models (those with auth configured)
|
|
193
252
|
const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
|
|
@@ -209,6 +268,7 @@ function resolveCanonicalModel(
|
|
|
209
268
|
// "claude-haiku-4-5" (dot vs dash in the version) resolve to the same model.
|
|
210
269
|
const normalize = (s: string) => s.toLowerCase().replace(/\./g, "-");
|
|
211
270
|
const query = normalize(input);
|
|
271
|
+
const requestedVersions = versionRuns(query.slice(slashIdx + 1));
|
|
212
272
|
|
|
213
273
|
// Score each model: prefer exact id match > id contains > name contains > provider+id contains
|
|
214
274
|
const matches: { entry: ModelEntry; score: number }[] = [];
|
|
@@ -217,6 +277,12 @@ function resolveCanonicalModel(
|
|
|
217
277
|
const id = normalize(m.id);
|
|
218
278
|
const name = normalize(m.name);
|
|
219
279
|
const full = normalize(`${m.provider}/${m.id}`);
|
|
280
|
+
const idVersions = versionRuns(id);
|
|
281
|
+
const versions = idVersions.length > 0 ? idVersions : versionRuns(name);
|
|
282
|
+
// Filter before scope/ranking, including substring and name matches. A
|
|
283
|
+
// major-only input can select a newer minor, but never another major or
|
|
284
|
+
// a minor token masquerading as the requested major.
|
|
285
|
+
if (!requestedVersions.every(requested => versions.some(version => version === requested || version.startsWith(`${requested}-`)))) continue;
|
|
220
286
|
|
|
221
287
|
let score = 0;
|
|
222
288
|
if (id === query || full === query) {
|
|
@@ -241,8 +307,9 @@ function resolveCanonicalModel(
|
|
|
241
307
|
|
|
242
308
|
// An exact id (or provider/id) is the caller naming a model, not describing
|
|
243
309
|
// one: it wins outright, whatever the scope or any newer sibling.
|
|
244
|
-
const exact = matches.
|
|
245
|
-
const
|
|
310
|
+
const exact = matches.filter(c => c.score === 100);
|
|
311
|
+
const exactBest = exact.sort((a, b) => compareModelIdentity(a.entry, b.entry))[0];
|
|
312
|
+
const bestMatch = exactBest ? exactBest.entry : rankFuzzy(matches, registry, context);
|
|
246
313
|
if (bestMatch) {
|
|
247
314
|
const found = registry.find(bestMatch.provider, bestMatch.id);
|
|
248
315
|
if (found) return found;
|
|
@@ -253,13 +320,13 @@ function resolveCanonicalModel(
|
|
|
253
320
|
// named provider is preferred when present; this only kicks in when it isn't,
|
|
254
321
|
// so the same model from another provider beats falling back to "inherit".
|
|
255
322
|
if (slashIdx !== -1) {
|
|
256
|
-
const bare = resolveCanonicalModel(input.slice(slashIdx + 1), registry);
|
|
323
|
+
const bare = resolveCanonicalModel(input.slice(slashIdx + 1), registry, context);
|
|
257
324
|
if (typeof bare !== "string") return bare;
|
|
258
325
|
}
|
|
259
326
|
|
|
260
327
|
// 4. No match — list available models
|
|
261
|
-
const modelList = all
|
|
262
|
-
.map(
|
|
328
|
+
const modelList = scopedCandidates(all.map(entry => ({ entry })), registry, context)
|
|
329
|
+
.map(({ entry }) => ` ${entry.provider}/${entry.id}`)
|
|
263
330
|
.sort()
|
|
264
331
|
.join("\n");
|
|
265
332
|
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
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,170 @@
|
|
|
1
|
+
/** One selection policy for every dispatch, independent of its transport. */
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { type Clock, realClock } from "./clock.js";
|
|
4
|
+
import { describeModel, describeVirtualModel, getAgentModels, getModelAliases, isVirtualModel, type ModelFallback, type ModelRegistry, modelFallbackNote, modelFallbackTo, resolveModel } from "./model-resolver.js";
|
|
5
|
+
import { checkModelScope } from "./model-scope.js";
|
|
6
|
+
import { exactTierModel, getModelResolution, parseTierBinding, resolveTier, type TierBinding, type TierDisclosure, type TierOutcome, tierBinding, tierNote } from "./model-tiers.js";
|
|
7
|
+
import { type AdmissionEvents, admissionOutcome, type QuotaBlock } from "./quota-admission.js";
|
|
8
|
+
|
|
9
|
+
/** Exact compatibility pins for user-defined legacy Explore definitions. User aliases win. */
|
|
10
|
+
const LEGACY_MODEL_PINS = new Map([
|
|
11
|
+
["claude-haiku-4-5", "anthropic/claude-haiku-4-5"],
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** The winning precedence level, before resolution or fallback. */
|
|
15
|
+
export type ModelSource = "param" | "agentModels:type" | "agentModels:*" | "definition" | "parent";
|
|
16
|
+
|
|
17
|
+
/** Keep the original request even when it could not be resolved. */
|
|
18
|
+
export interface ModelSelection {
|
|
19
|
+
requested?: string;
|
|
20
|
+
source: ModelSource;
|
|
21
|
+
model: Model<Api> | undefined;
|
|
22
|
+
outcome: "resolved" | "inherited" | "fallback";
|
|
23
|
+
fallback?: ModelFallback;
|
|
24
|
+
scopeWarning?: string;
|
|
25
|
+
/** Serializable tier provenance, retained when the answering model changes. */
|
|
26
|
+
tier?: TierOutcome;
|
|
27
|
+
/** A prepared binding is resolved with admission before starting the child. */
|
|
28
|
+
tierBinding?: TierBinding;
|
|
29
|
+
tierAdmitted?: boolean;
|
|
30
|
+
/** Startup-only parent floor if credentials/profile disappear between preflight and admission. */
|
|
31
|
+
tierParent?: Model<Api>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Choose the input once: explicit parameter > type > wildcard > definition; blank strings are unset. */
|
|
35
|
+
export function modelRequest(type: string, definitionModel?: string, paramModel?: string | Model<Api> | null): {
|
|
36
|
+
requested?: string;
|
|
37
|
+
source: ModelSource;
|
|
38
|
+
} {
|
|
39
|
+
if (paramModel != null && (typeof paramModel !== "string" || paramModel.trim() !== "")) {
|
|
40
|
+
return { requested: typeof paramModel === "string" ? paramModel : `${paramModel.provider}/${paramModel.id}`, source: "param" };
|
|
41
|
+
}
|
|
42
|
+
const overrides = getAgentModels();
|
|
43
|
+
const typed = overrides.get(type.toLowerCase());
|
|
44
|
+
if (typed?.trim()) return { requested: typed, source: "agentModels:type" };
|
|
45
|
+
const wildcard = overrides.get("*");
|
|
46
|
+
if (wildcard?.trim()) return { requested: wildcard, source: "agentModels:*" };
|
|
47
|
+
if (definitionModel?.trim()) return { requested: definitionModel, source: "definition" };
|
|
48
|
+
return { source: "parent" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Choose an exact configured input or tier binding before record creation.
|
|
53
|
+
* Only explicit non-tier tool inputs retain fuzzy matching. Tier admission is
|
|
54
|
+
* asynchronous and completes through admitModelSelection before startup; this
|
|
55
|
+
* synchronous preflight preserves spawn's immediate-id and validation contract.
|
|
56
|
+
* An already-open resume keeps the record and does not select again.
|
|
57
|
+
*/
|
|
58
|
+
export function selectModel(request: {
|
|
59
|
+
homeDir?: string;
|
|
60
|
+
agentType: string;
|
|
61
|
+
definitionModel?: string;
|
|
62
|
+
paramModel?: string | Model<Api> | null;
|
|
63
|
+
parentModel: Model<Api> | undefined;
|
|
64
|
+
registry: ModelRegistry;
|
|
65
|
+
cwd: string;
|
|
66
|
+
agentLabel?: string;
|
|
67
|
+
}): ModelSelection | string {
|
|
68
|
+
const input = modelRequest(request.agentType, request.definitionModel, request.paramModel);
|
|
69
|
+
let selection: ModelSelection = { ...input, model: request.parentModel, outcome: "inherited" };
|
|
70
|
+
if (input.requested) {
|
|
71
|
+
const requested = input.requested.trim();
|
|
72
|
+
const legacy = getModelAliases().get(requested.toLowerCase());
|
|
73
|
+
const legacyBinding = legacy ? parseTierBinding("legacy", legacy) : undefined;
|
|
74
|
+
// Preserve existing enterprise manifests during the built-in Explore migration.
|
|
75
|
+
// An explicit cheap binding always wins, even when it is unavailable.
|
|
76
|
+
const cheapCompatibility = requested === "@cheap" && request.agentType === "Explore"
|
|
77
|
+
&& input.source === "definition"
|
|
78
|
+
? parseTierBinding("cheap", getModelAliases().get("claude-haiku-4-5")) : undefined;
|
|
79
|
+
const binding = requested.startsWith("@") ? tierBinding(requested.slice(1)) ?? cheapCompatibility
|
|
80
|
+
: legacyBinding ? { ...legacyBinding, tier: requested } : undefined;
|
|
81
|
+
if (requested.startsWith("@") || legacy) {
|
|
82
|
+
const tier = requested.startsWith("@") ? requested.slice(1) : requested;
|
|
83
|
+
const refs = binding?.kind === "routed" ? [binding.profile] : binding?.members ?? [];
|
|
84
|
+
const model = refs.map(ref => exactTierModel(ref, request.registry)).find(Boolean);
|
|
85
|
+
if (!binding || !model) {
|
|
86
|
+
const outcome: TierOutcome = binding ? { kind: "unavailable", tier, tried: refs } : { kind: "unmapped", tier };
|
|
87
|
+
const mode = outcome.kind === "unmapped" ? getModelResolution().onUnmapped : getModelResolution().onUnavailable;
|
|
88
|
+
if (mode === "error") return `Model tier @${tier} is ${outcome.kind}.`;
|
|
89
|
+
selection = { ...selection, tier: outcome, outcome: "fallback", fallback: modelFallbackTo(input.requested, request.parentModel) };
|
|
90
|
+
} else {
|
|
91
|
+
selection = { ...input, model, outcome: "resolved", tierBinding: binding, tierParent: request.parentModel };
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
const compatibilityPin = input.source === "definition" && request.agentType === "Explore"
|
|
95
|
+
? LEGACY_MODEL_PINS.get(requested) : undefined;
|
|
96
|
+
const resolved = request.paramModel != null && typeof request.paramModel === "object"
|
|
97
|
+
? request.paramModel : input.source === "param" ? resolveModel(requested, request.registry, { cwd: request.cwd, homeDir: request.homeDir })
|
|
98
|
+
: exactTierModel(compatibilityPin ?? requested, request.registry) ?? `Model not found: "${requested}".`;
|
|
99
|
+
if (typeof resolved !== "string") selection = { ...input, model: resolved, outcome: "resolved" };
|
|
100
|
+
else if (compatibilityPin) {
|
|
101
|
+
const tier: TierOutcome = { kind: "unavailable", tier: requested, tried: [compatibilityPin] };
|
|
102
|
+
if (getModelResolution().onUnavailable === "error") return `Model tier @${requested} is unavailable.`;
|
|
103
|
+
selection = { ...selection, tier, outcome: "fallback", fallback: modelFallbackTo(input.requested, request.parentModel) };
|
|
104
|
+
} else {
|
|
105
|
+
if (input.source === "param" || getModelResolution().onUnavailable === "error") return resolved;
|
|
106
|
+
selection = { ...selection, outcome: "fallback", fallback: modelFallbackTo(input.requested, request.parentModel) };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const scope = checkModelScope({
|
|
111
|
+
model: selection.model, cwd: request.cwd, modelRegistry: request.registry,
|
|
112
|
+
callerSupplied: input.source === "param" && !selection.tierBinding && !selection.tier, agentLabel: request.agentLabel ?? request.agentType,
|
|
113
|
+
modelInput: input.requested, homeDir: request.homeDir,
|
|
114
|
+
});
|
|
115
|
+
if (scope.kind === "error") return scope.message;
|
|
116
|
+
if (scope.kind === "warn") selection.scopeWarning = scope.message;
|
|
117
|
+
return selection;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Label an admitted physical model before any response, without replacing the virtual selection. */
|
|
121
|
+
export function describeSelection(selection: ModelSelection | undefined, registry: ModelRegistry): { modelName?: string; modelId?: string } {
|
|
122
|
+
const admitted = selection?.tier?.kind === "routed" ? selection.tier.admitted : undefined;
|
|
123
|
+
if (admitted) {
|
|
124
|
+
const slash = admitted.indexOf("/");
|
|
125
|
+
const provider = admitted.slice(0, slash);
|
|
126
|
+
const id = admitted.slice(slash + 1);
|
|
127
|
+
return describeModel(registry.find(provider, id) ?? { provider, id });
|
|
128
|
+
}
|
|
129
|
+
const model = selection?.model;
|
|
130
|
+
return model ? isVirtualModel(model) ? describeVirtualModel(model) : describeModel(model) : {};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Resolved selection identity for model-facing text, not an actionable fallback warning. */
|
|
134
|
+
export function modelIdentityNote(carrier: { modelSelection?: ModelSelection; modelTier?: TierDisclosure } | undefined): string {
|
|
135
|
+
const selected = carrier?.modelSelection?.model;
|
|
136
|
+
// Tier notes already carry admission/answer provenance. Literal virtual
|
|
137
|
+
// selections have no such evidence, so never claim their alias is a leaf.
|
|
138
|
+
return selected && !carrier?.modelTier
|
|
139
|
+
? `Resolved model: ${selected.provider}/${selected.id}${isVirtualModel(selected) ? " (routing alias; physical response model: unknown)" : ""}.`
|
|
140
|
+
: "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One disclosure for records, persisted details, workflows and notices. */
|
|
144
|
+
export function modelSelectionNote(carrier: { modelTier?: TierDisclosure; modelFallback?: ModelFallback; invocation?: { modelId?: string } } | undefined): string {
|
|
145
|
+
const tier = carrier?.modelTier;
|
|
146
|
+
const fallback = carrier?.modelFallback;
|
|
147
|
+
const sameRequest = tier && (fallback?.input === tier.tier || fallback?.input === `@${tier.tier}`);
|
|
148
|
+
return [tierNote(tier, carrier?.invocation?.modelId), sameRequest ? "" : modelFallbackNote(fallback)].filter(Boolean).join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Admission for tiers is made once and is shared by every transport. */
|
|
152
|
+
export async function admitModelSelection(selection: ModelSelection, registry: ModelRegistry, events: AdmissionEvents, clock: Clock = realClock, signal?: AbortSignal): Promise<QuotaBlock | undefined> {
|
|
153
|
+
if (!selection.tierBinding || selection.tierAdmitted) return selection.tier?.kind === "exhausted" ? selection.tier.block : undefined;
|
|
154
|
+
selection.tier = await resolveTier(selection.tierBinding.tier, {
|
|
155
|
+
registry, binding: selection.tierBinding, admit: ref => admissionOutcome(events, ref, undefined, clock, signal),
|
|
156
|
+
});
|
|
157
|
+
selection.tierAdmitted = true;
|
|
158
|
+
if (selection.tier.kind === "routed" || selection.tier.kind === "static") selection.model = selection.tier.model;
|
|
159
|
+
if (selection.tier.kind === "exhausted") return selection.tier.block;
|
|
160
|
+
// The binding is handed to `resolveTier`, so it is never `unmapped` here.
|
|
161
|
+
if (selection.tier.kind === "unavailable") {
|
|
162
|
+
if (getModelResolution().onUnavailable === "error") throw new Error(`Model tier @${selection.tier.tier} is unavailable.`);
|
|
163
|
+
selection.model = selection.tierParent;
|
|
164
|
+
selection.outcome = "fallback";
|
|
165
|
+
selection.fallback = modelFallbackTo(selection.requested ?? `@${selection.tier.tier}`, selection.tierParent);
|
|
166
|
+
delete selection.tierBinding;
|
|
167
|
+
}
|
|
168
|
+
delete selection.tierParent;
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|