Skip to content

Commit 9e9014b

Browse files
committed
Add model groups and spawn routing
1 parent b051c2b commit 9e9014b

35 files changed

Lines changed: 4271 additions & 31 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Model Groups manager** — added `/model-groups` with durable project/global JSON persistence, boot validation, CRUD TUI flows, per-model thinking levels, and operator notifications for invalid configs or unavailable model refs.
13+
- **Model Groups spawn routing**`spawn` can route children through an optional exact Model Group name with names-only prompt guidance, `#group` autocomplete sugar that shows model/thinking details, authenticated random entry selection, thinking inheritance/clamping, and routed/fallback result identity lines.
14+
1015
### Changed
1116

1217
- Spawned child agents now inherit active registered parent tools executable in the child session, including MCP/extension tools such as ChunkHound when active and registered, while still excluding spawn and handoff and preserving child-local notebook tools.
1318

19+
### Fixed
20+
21+
- Model Groups add-model navigation now uses Pi's key matcher for Escape/left-arrow handling and filters provider/model choices to authenticated models.
22+
1423
## [0.3.0] - 2026-05-23
1524

1625
### Added

index.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import { registerHandoffTool } from "./handoff/tool.js";
3131
import { registerHandoffCommand } from "./handoff/command.js";
3232
import { registerHandoffCompaction } from "./handoff/compact.js";
3333
import { registerSpawnTool } from "./spawn/index.js";
34+
import { registerModelGroupsCommand } from "./model-groups/command.js";
35+
import { registerModelGroupAutocomplete } from "./model-groups/autocomplete.js";
36+
import { getEffectiveModelGroupNames } from "./model-groups/router.js";
37+
import { loadModelGroups, summarizeBootValidation, validateModelGroups } from "./model-groups/store.js";
3438
import {
3539
STATUS_KEY_HANDOFF,
3640
STATUS_KEY_TOPIC,
@@ -39,6 +43,24 @@ import {
3943
} from "./tui.js";
4044
import { formatPagePreview } from "./notebook/store.js";
4145

46+
function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext) {
47+
if (!ctx.cwd || !(ctx as any).modelRegistry) return null;
48+
const loadedModelGroups = loadModelGroups(ctx.cwd);
49+
const resolvedModelGroups = validateModelGroups(loadedModelGroups, (ctx as any).modelRegistry);
50+
state.modelGroups.groups = resolvedModelGroups;
51+
state.modelGroups.validation = { groups: resolvedModelGroups, loadIssues: loadedModelGroups.issues };
52+
return state.modelGroups.validation;
53+
}
54+
55+
function modelGroupsPromptSection(names: string[]): string | undefined {
56+
if (names.length === 0) return undefined;
57+
return `\n## Model Groups for spawn\n` +
58+
`Available Model Groups: ${names.join(", ")}\n` +
59+
`When the operator asks to spawn with one of these groups, or mentions #group-name, call spawn with group set to the exact group name only when the mapping is known and confident. ` +
60+
`If no known/confident group is requested, omit group and inherit the parent model/thinking. ` +
61+
`The group list is names-only; do not assume provider/model membership, thinking levels, auth status, validation details, or storage paths from it.`;
62+
}
63+
4264
export default function (pi: ExtensionAPI): void {
4365
const state: AgenticodingState = createState();
4466

@@ -55,6 +77,7 @@ export default function (pi: ExtensionAPI): void {
5577

5678
// ── Register commands ───────────────────────────────────────────
5779
registerHandoffCommand(pi, state);
80+
registerModelGroupsCommand(pi, state);
5881

5982
// ── /notebook command — interactive page selector ────────────────
6083
pi.registerCommand("notebook", {
@@ -162,6 +185,7 @@ export default function (pi: ExtensionAPI): void {
162185
pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
163186
// Update TUI indicators before each user-prompt agent run
164187
updateIndicators(ctx, state);
188+
refreshModelGroupsState(state, ctx);
165189

166190
const parts: string[] = [event.systemPrompt];
167191

@@ -181,6 +205,11 @@ export default function (pi: ExtensionAPI): void {
181205
);
182206
}
183207

208+
const modelGroupSection = modelGroupsPromptSection(getEffectiveModelGroupNames(state.modelGroups.groups));
209+
if (modelGroupSection) {
210+
parts.push(modelGroupSection);
211+
}
212+
184213
// Inject notebook listing so the LLM always knows what's available
185214
const entryNames = Array.from(state.notebookPages.keys()).sort();
186215
if (entryNames.length > 0) {
@@ -239,6 +268,20 @@ export default function (pi: ExtensionAPI): void {
239268
ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined);
240269
}
241270
}
271+
272+
registerModelGroupAutocomplete(ctx, state);
273+
const validation = refreshModelGroupsState(state, ctx);
274+
if (validation && ctx.hasUI) {
275+
for (const issue of validation.loadIssues) {
276+
const backupNote = issue.backupFailed ? "; backup failed, original file left untouched" : "";
277+
ctx.ui.notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${issue.sourcePath}); using empty config for that scope${backupNote}`, "warning");
278+
}
279+
const { unavailableCount, overrideCount } = summarizeBootValidation(validation.groups);
280+
if (unavailableCount > 0 || overrideCount > 0) {
281+
ctx.ui.notify(`Model Groups boot validation: ${unavailableCount} unavailable model references · ${overrideCount} project overrides`, "warning");
282+
}
283+
}
284+
242285
updateIndicators(ctx, state);
243286
});
244287

model-groups/autocomplete.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2+
import type { AgenticodingState } from "../state.js";
3+
import { getEffectiveModelGroups } from "./router.js";
4+
import type { ModelGroupModel, ResolvedModelGroup } from "./types.js";
5+
6+
const registeredUis = new WeakSet<object>();
7+
8+
function isUnavailable(group: ResolvedModelGroup, entry: ModelGroupModel): boolean {
9+
return group.validation.unavailableRefs.some((ref) => ref.provider === entry.provider && ref.modelId === entry.modelId);
10+
}
11+
12+
function formatModelGroupRouteDetails(group: ResolvedModelGroup): string {
13+
if (group.models.length === 0) return "No models configured";
14+
return group.models
15+
.map((entry) => {
16+
const thinking = entry.thinkingLevel ?? "inherit";
17+
const unavailable = isUnavailable(group, entry) ? " (unavailable)" : "";
18+
return `${entry.provider}/${entry.modelId}${thinking}${unavailable}`;
19+
})
20+
.join("; ");
21+
}
22+
23+
export function createModelGroupAutocompleteProvider(state: AgenticodingState) {
24+
return (current: any) => ({
25+
async getSuggestions(lines: string[], cursorLine: number, cursorCol: number, options: unknown) {
26+
const line = lines[cursorLine] ?? "";
27+
const beforeCursor = line.slice(0, cursorCol);
28+
const match = beforeCursor.match(/(?:^|[\t ])#([^\s#]*)$/);
29+
if (!match) {
30+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
31+
}
32+
33+
const partial = (match[1] ?? "").toLowerCase();
34+
const groups = getEffectiveModelGroups(state.modelGroups.groups);
35+
const items = groups
36+
.filter((group) => group.name.toLowerCase().startsWith(partial))
37+
.map((group) => ({
38+
value: `#${group.name}`,
39+
label: `#${group.name}`,
40+
description: formatModelGroupRouteDetails(group),
41+
}));
42+
return { prefix: `#${match[1] ?? ""}`, items };
43+
},
44+
45+
applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: unknown, prefix: string) {
46+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
47+
},
48+
49+
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number) {
50+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
51+
},
52+
});
53+
}
54+
55+
export function registerModelGroupAutocomplete(ctx: ExtensionContext, state: AgenticodingState): void {
56+
if (!ctx.hasUI) return;
57+
const ui = ctx.ui as unknown as { addAutocompleteProvider?: (factory: ReturnType<typeof createModelGroupAutocompleteProvider>) => void };
58+
if (typeof ui.addAutocompleteProvider !== "function") return;
59+
const key = ui as object;
60+
if (registeredUis.has(key)) return;
61+
registeredUis.add(key);
62+
ui.addAutocompleteProvider(createModelGroupAutocompleteProvider(state));
63+
}

model-groups/command.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2+
import type { AgenticodingState } from "../state.js";
3+
import { createModelGroupsComponent } from "./tui.js";
4+
5+
export function registerModelGroupsCommand(pi: ExtensionAPI, state: AgenticodingState): void {
6+
pi.registerCommand("model-groups", {
7+
description: "Manage Model Groups",
8+
handler: async (_args, ctx) => {
9+
if (!ctx.hasUI) return;
10+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
11+
createModelGroupsComponent(tui, theme, ctx.modelRegistry, ctx.cwd, done, {
12+
initialValidation: state.modelGroups.validation,
13+
notify: (message, type) => ctx.ui.notify(message, type),
14+
onRefresh: (validation) => {
15+
state.modelGroups.groups = validation.groups;
16+
state.modelGroups.validation = validation;
17+
},
18+
}),
19+
);
20+
},
21+
});
22+
}

model-groups/router.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2+
import { clampThinkingLevel, type Api, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
3+
import type { ResolvedModelGroup } from "./types.js";
4+
5+
export type SpawnRouteStatus = "inherited" | "routed" | "unknown-fallback";
6+
7+
export interface SpawnModelRoute {
8+
status: SpawnRouteStatus;
9+
requestedGroup?: string;
10+
groupName?: string;
11+
model: Model<Api>;
12+
provider: string;
13+
modelId: string;
14+
thinking: ModelThinkingLevel;
15+
}
16+
17+
export type SpawnRouteErrorReason = "empty" | "no-usable-models";
18+
19+
export class SpawnRouteError extends Error {
20+
readonly kind = "unusable-group" as const;
21+
readonly group: string;
22+
readonly reason: SpawnRouteErrorReason;
23+
24+
constructor(group: string, reason: SpawnRouteErrorReason) {
25+
const detail = reason === "empty"
26+
? "has no model entries"
27+
: "has no configured/authenticated usable models";
28+
super(`Model Group '${group}' ${detail}.`);
29+
this.name = "SpawnRouteError";
30+
this.group = group;
31+
this.reason = reason;
32+
}
33+
}
34+
35+
function parentProvider(model: Model<Api>): string {
36+
return typeof model.provider === "string" ? model.provider : "";
37+
}
38+
39+
function effectiveGroupMap(groups: ResolvedModelGroup[]): Map<string, ResolvedModelGroup> {
40+
const byName = new Map<string, ResolvedModelGroup>();
41+
for (const group of groups) {
42+
if (group.validation?.shadowedByProject) continue;
43+
const existing = byName.get(group.name);
44+
if (!existing || group.scope === "project") byName.set(group.name, group);
45+
}
46+
return byName;
47+
}
48+
49+
export function getEffectiveModelGroups(groups: ResolvedModelGroup[]): ResolvedModelGroup[] {
50+
return [...effectiveGroupMap(groups).values()].sort((a, b) => a.name.localeCompare(b.name));
51+
}
52+
53+
export function getEffectiveModelGroupNames(groups: ResolvedModelGroup[]): string[] {
54+
return getEffectiveModelGroups(groups).map((group) => group.name);
55+
}
56+
57+
export function resolveSpawnModelRoute(options: {
58+
requestedGroup?: string;
59+
groups: ResolvedModelGroup[];
60+
parentModel: Model<Api>;
61+
parentThinking: ModelThinkingLevel;
62+
modelRegistry: Pick<ModelRegistry, "find" | "hasConfiguredAuth">;
63+
rng?: () => number;
64+
}): SpawnModelRoute {
65+
const requestedGroup = options.requestedGroup?.trim();
66+
const inherited = (status: "inherited" | "unknown-fallback"): SpawnModelRoute => ({
67+
status,
68+
...(status === "unknown-fallback" && requestedGroup ? { requestedGroup } : {}),
69+
model: options.parentModel,
70+
provider: parentProvider(options.parentModel),
71+
modelId: options.parentModel.id,
72+
thinking: options.parentThinking,
73+
});
74+
75+
if (!requestedGroup) return inherited("inherited");
76+
77+
const group = effectiveGroupMap(options.groups).get(requestedGroup);
78+
if (!group) return inherited("unknown-fallback");
79+
if (group.models.length === 0) throw new SpawnRouteError(group.name, "empty");
80+
81+
const usable = group.models
82+
.map((entry) => {
83+
const model = options.modelRegistry.find(entry.provider, entry.modelId) as Model<Api> | undefined;
84+
return model && options.modelRegistry.hasConfiguredAuth(model)
85+
? { entry, model }
86+
: undefined;
87+
})
88+
.filter((entry): entry is { entry: typeof group.models[number]; model: Model<Api> } => Boolean(entry));
89+
90+
if (usable.length === 0) throw new SpawnRouteError(group.name, "no-usable-models");
91+
92+
const rng = options.rng ?? Math.random;
93+
const index = Math.min(usable.length - 1, Math.max(0, Math.floor(rng() * usable.length)));
94+
const selected = usable[index];
95+
const requestedThinking = selected.entry.thinkingLevel ?? options.parentThinking;
96+
const thinking = clampThinkingLevel(selected.model, requestedThinking);
97+
return {
98+
status: "routed",
99+
requestedGroup,
100+
groupName: group.name,
101+
model: selected.model,
102+
provider: selected.entry.provider,
103+
modelId: selected.entry.modelId,
104+
thinking,
105+
};
106+
}

0 commit comments

Comments
 (0)