Skip to content

Commit 91b81bb

Browse files
authored
feat(pi-subagent)!: add configurable model-class routing (#73)
1 parent 959730a commit 91b81bb

6 files changed

Lines changed: 251 additions & 33 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pi-subagent/CONTEXT.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ Run one bounded task in one isolated Pi child process using a configured role.
99
- **Main**: Pi session delegating work.
1010
- **Subagent**: isolated Pi child process handling one task.
1111
- **Role**: user-owned Markdown profile defining name, description, system instructions, exact tools, extensions, and Skill names.
12-
- **Route**: model and thinking-level pair selected for one delegation; defaults to Main route.
12+
- **Model Class**: `fast`, `balanced`, or `frontier`, chosen by Main from task complexity.
13+
- **Route**: configured model and thinking-level pair selected from Model Class; omitted class uses `balanced`, then Main route fallback.
1314
- **Delegated Task**: one bounded work request sent from Main to one Role.
1415

1516
## Invariants
1617

1718
- One Delegated Task creates one ephemeral child process and no saved session.
1819
- Ambient child extensions and Skills stay disabled; Role explicitly selects resources.
1920
- Role Skill names resolve through Main's effective Pi Skill registry; unavailable names warn and skip without blocking delegation.
20-
- Main selects Role and may select Route per task.
21-
- Role config lives only in user `config/pi-subagent` directory; repository roles do not execute.
21+
- Main selects Role and Model Class per task; omitted class uses configured `balanced`, then Main route fallback.
22+
- Role config lives only in user `config/pi-subagent` directory; model routes live in user `config/pi-subagent.json`; repository roles do not execute.

packages/pi-subagent/README.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @henryqw/pi-subagent
22

3-
Delegate one bounded task to one isolated Pi process. Main chooses role and may override model and thinking level per task.
3+
Delegate one bounded task to one isolated Pi process. Main chooses role and model class per task.
44

55
Based on Pi's authoritative [`examples/extensions/subagent`](https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions/subagent): child processes use `pi --mode json -p --no-session`.
66

@@ -12,7 +12,7 @@ pi install npm:@henryqw/pi-subagent
1212

1313
## Configure roles
1414

15-
Use Pi's existing role Markdown format in package-owned `~/.pi/agent/config/pi-subagent/*.md`. No JSON config or profile layer.
15+
Use Pi's existing role Markdown format in package-owned `~/.pi/agent/config/pi-subagent/*.md`. Configure `fast`, `balanced`, and `frontier` model routes with `/subagent`.
1616

1717
```markdown
1818
---
@@ -45,22 +45,35 @@ String lists may also use comma-separated text, matching Pi's example role files
4545

4646
Skill entries use Pi Skill names, normally Skill directory names, not filesystem paths. At delegation time, package resolves names from Main's effective Pi Skill registry and passes matching files to child. Missing or unavailable Skills produce warning and are skipped; they do not block delegation. This preserves Main's trust and Skill collision decisions.
4747

48-
Pi's example `agents/` directory contains sample Role files, not another runtime mechanism. This package reuses that Markdown format but ships no presets: model choice and capabilities stay explicit in user config. No nested `agents/` directory is needed because `pi-subagent` config contains only Roles.
48+
Pi's example `agents/` directory contains sample Role files, not another runtime mechanism. This package reuses that Markdown format but ships no presets: role capabilities and model routes stay explicit in user config. No nested `agents/` directory is needed because Roles remain Markdown files.
4949

50-
Reload Pi after adding or changing role files so tool description exposes current roles.
50+
Reload Pi after adding or changing role files or manually editing model config. `/subagent` applies changes immediately.
5151

5252
## Execution
5353

5454
Main calls `delegate_task` with:
5555

5656
- `role`: configured role name
5757
- `task`: one bounded task
58-
- `model`: optional exact `provider/model`; defaults to Main model
59-
- `thinkingLevel`: optional; defaults to Main thinking level
58+
- `modelClass`: optional `fast`, `balanced`, or `frontier`; defaults to configured `balanced`, then Main route
59+
60+
Run `/subagent` once per class. Select class, authenticated text model, and supported thinking level. Config lives in `~/.pi/agent/config/pi-subagent.json`:
61+
62+
```json
63+
{
64+
"models": {
65+
"fast": { "model": "provider/fast-model", "thinkingLevel": "off" },
66+
"balanced": { "model": "provider/balanced-model", "thinkingLevel": "medium" },
67+
"frontier": { "model": "provider/frontier-model", "thinkingLevel": "max" }
68+
}
69+
}
70+
```
71+
72+
Explicit class routes must be configured and available. Omitted `modelClass` falls back to Main when `balanced` route is missing or stale.
6073

6174
Each call starts isolated child process. Ambient extensions and skills are disabled. Only role resources load. Child uses delegated working directory and normal Pi project context files, inheriting Main's project approval decision. Abort terminates child process group.
6275

63-
Model and thinking overrides must exist in Main model registry. Invalid role config, model, or thinking level fails before child starts.
76+
Configured model and thinking level must exist in Main model registry. Invalid role config or explicit model class fails before child starts.
6477

6578
Main-visible streaming updates, final output, and errors are capped at 50 KiB of UTF-8 text. Error collection stays bounded while child runs; malformed JSON events above 1 MiB fail delegation. Truncated output ends with exact omitted-byte count.
6679

packages/pi-subagent/extensions/subagent.ts

Lines changed: 147 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { spawn } from "node:child_process";
2-
import { existsSync, readFileSync, readdirSync } from "node:fs";
2+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
33
import { mkdtemp, rm, writeFile } from "node:fs/promises";
44
import { tmpdir } from "node:os";
5-
import { basename, isAbsolute, join } from "node:path";
5+
import { basename, dirname, isAbsolute, join } from "node:path";
66
import { getSupportedThinkingLevels, StringEnum } from "@earendil-works/pi-ai";
77
import { type ExtensionAPI, type ExtensionContext, getAgentDir, parseFrontmatter, type Theme } from "@earendil-works/pi-coding-agent";
88
import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
99
import { Type } from "typebox";
1010

1111
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
12+
const MODEL_CLASSES = ["fast", "balanced", "frontier"] as const;
13+
const configPath = () => join(getAgentDir(), "config", "pi-subagent.json");
1214
const MAX_OUTPUT_BYTES = 50 * 1024;
1315
const MAX_JSON_EVENT_BYTES = 1024 * 1024;
1416
const WIDGET_KEY = "subagent-status";
@@ -18,6 +20,9 @@ const MAX_WIDGET_ROWS = 8;
1820
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1921

2022
type ThinkingLevel = (typeof THINKING_LEVELS)[number];
23+
type ModelClass = (typeof MODEL_CLASSES)[number];
24+
type ConfiguredModel = { model: string; thinkingLevel: ThinkingLevel };
25+
type Config = { models: Partial<Record<ModelClass, ConfiguredModel>> };
2126
type Role = {
2227
name: string;
2328
description: string;
@@ -44,6 +49,62 @@ type WidgetItem = {
4449
removeAt?: number;
4550
};
4651

52+
const defaultConfig = (): Config => ({ models: {} });
53+
const isModelClass = (value: unknown): value is ModelClass =>
54+
typeof value === "string" && MODEL_CLASSES.includes(value as ModelClass);
55+
const isThinkingLevel = (value: unknown): value is ThinkingLevel =>
56+
typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel);
57+
const isModelReference = (value: unknown): value is string => {
58+
if (typeof value !== "string" || value !== value.trim() || value.includes("\0")) return false;
59+
const separator = value.indexOf("/");
60+
return separator > 0 && separator < value.length - 1;
61+
};
62+
63+
function readConfig(): { value: Config; invalid: boolean } {
64+
try {
65+
const value = JSON.parse(readFileSync(configPath(), "utf8")) as unknown;
66+
if (!value || typeof value !== "object" || Array.isArray(value)) return { value: defaultConfig(), invalid: true };
67+
const record = value as Record<string, unknown>;
68+
if (Object.keys(record).some((key) => key !== "models")) return { value: defaultConfig(), invalid: true };
69+
if (record.models === undefined) return { value: defaultConfig(), invalid: false };
70+
if (!record.models || typeof record.models !== "object" || Array.isArray(record.models)) {
71+
return { value: defaultConfig(), invalid: true };
72+
}
73+
const modelRecord = record.models as Record<string, unknown>;
74+
const models: Config["models"] = {};
75+
let invalid = Object.keys(modelRecord).some((key) => !isModelClass(key));
76+
for (const modelClass of MODEL_CLASSES) {
77+
if (!Object.hasOwn(modelRecord, modelClass)) continue;
78+
const candidate = modelRecord[modelClass];
79+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
80+
invalid = true;
81+
continue;
82+
}
83+
const route = candidate as Record<string, unknown>;
84+
if (isModelReference(route.model) && isThinkingLevel(route.thinkingLevel)) {
85+
models[modelClass] = { model: route.model, thinkingLevel: route.thinkingLevel };
86+
} else invalid = true;
87+
}
88+
return { value: { models }, invalid };
89+
} catch (error: unknown) {
90+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
91+
return { value: defaultConfig(), invalid: false };
92+
}
93+
return { value: defaultConfig(), invalid: true };
94+
}
95+
}
96+
97+
function writeConfig(config: Config): void {
98+
const file = configPath();
99+
mkdirSync(dirname(file), { recursive: true });
100+
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
101+
}
102+
103+
const modelReference = (model: { provider: string; id: string }): string => `${model.provider}/${model.id}`;
104+
const availableTextModels = (ctx: ExtensionContext) => ctx.modelRegistry
105+
.getAvailable()
106+
.filter((model) => model.input.includes("text"));
107+
47108
const cleanText = (value: unknown, field: string, file: string): string => {
48109
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
49110
throw new Error(`${file}: ${field} must be non-empty text.`);
@@ -404,9 +465,8 @@ async function runPi(
404465
const Parameters = Type.Object({
405466
role: Type.String({ description: "Configured Subagent role name" }),
406467
task: Type.String({ description: "One bounded task with needed context and expected result" }),
407-
model: Type.Optional(Type.String({ description: "Exact provider/model; defaults to Main model" })),
408-
thinkingLevel: Type.Optional(StringEnum(THINKING_LEVELS, {
409-
description: "Thinking level; defaults to Main thinking level",
468+
modelClass: Type.Optional(StringEnum(MODEL_CLASSES, {
469+
description: "Classify task complexity: fast for narrow lookups or mechanical edits; balanced for normal bounded work; frontier for ambiguous, cross-cutting, or high-risk reasoning. Defaults to configured balanced, then Main route.",
410470
})),
411471
});
412472

@@ -439,6 +499,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
439499
let widgetTimer: ReturnType<typeof setInterval> | undefined;
440500
let spinnerIndex = 0;
441501
let activeTui: TUI | undefined;
502+
let config = defaultConfig();
442503

443504
const stopWidgetTimer = () => {
444505
if (!widgetTimer) return;
@@ -512,7 +573,16 @@ export default function subagentExtension(pi: ExtensionAPI): void {
512573
requestWidgetRender();
513574
};
514575

515-
pi.on("session_start", (_event, ctx) => ensureWidget(ctx));
576+
const refreshConfig = (ctx: ExtensionContext) => {
577+
const loaded = readConfig();
578+
config = loaded.value;
579+
if (loaded.invalid) ctx.ui.notify("Invalid pi-subagent config values were ignored.", "warning");
580+
};
581+
582+
pi.on("session_start", (_event, ctx) => {
583+
ensureWidget(ctx);
584+
refreshConfig(ctx);
585+
});
516586
pi.on("session_shutdown", (_event, ctx) => {
517587
stopWidgetTimer();
518588
widgetItems.clear();
@@ -521,10 +591,52 @@ export default function subagentExtension(pi: ExtensionAPI): void {
521591
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
522592
});
523593

594+
pi.registerCommand("subagent", {
595+
description: "configure fast, balanced, and frontier Subagent model routes",
596+
handler: async (args, ctx) => {
597+
if (args.trim()) {
598+
ctx.ui.notify("Usage: /subagent", "warning");
599+
return;
600+
}
601+
refreshConfig(ctx);
602+
const modelClass = await ctx.ui.select("Subagent model class", [...MODEL_CLASSES]);
603+
if (!isModelClass(modelClass)) return;
604+
const models = availableTextModels(ctx);
605+
if (!models.length) {
606+
ctx.ui.notify("No authenticated text models are available.", "warning");
607+
return;
608+
}
609+
const saved = config.models[modelClass];
610+
const references = models.map(modelReference).sort();
611+
const selected = await ctx.ui.select(
612+
`${modelClass} Subagent model · saved: ${saved?.model ?? "none"}`,
613+
references,
614+
);
615+
const selectedModel = models.find((model) => modelReference(model) === selected);
616+
if (!selectedModel) return;
617+
const levels = getSupportedThinkingLevels(selectedModel);
618+
const thinkingLevel = await ctx.ui.select(
619+
`${modelClass} Subagent thinking level · saved: ${saved?.thinkingLevel ?? "none"}`,
620+
levels,
621+
);
622+
if (!isThinkingLevel(thinkingLevel) || !levels.some((level) => level === thinkingLevel)) return;
623+
try {
624+
const latest = readConfig();
625+
const route = { model: modelReference(selectedModel), thinkingLevel };
626+
const next = { ...latest.value, models: { ...latest.value.models, [modelClass]: route } };
627+
writeConfig(next);
628+
config = next;
629+
ctx.ui.notify(`${modelClass} Subagent set to ${route.model} with thinking ${thinkingLevel}.`, "info");
630+
} catch {
631+
ctx.ui.notify("Couldn't save pi-subagent model config.", "warning");
632+
}
633+
},
634+
});
635+
524636
pi.registerTool({
525637
name: "delegate_task",
526638
label: "Subagent",
527-
description: `Delegate one bounded task to one isolated Pi Subagent. Roles: ${roleSummary()}. Select model and thinking level only when task needs a different route. Request concise conclusions and file/line references; split broad scouting work.`,
639+
description: `Delegate one bounded task to one isolated Pi Subagent. Roles: ${roleSummary()}. Choose fast for narrow work, balanced for normal work, or frontier for ambiguous and high-risk work. Request concise conclusions and file/line references; split broad scouting work.`,
528640
parameters: Parameters,
529641
async execute(toolCallId, params, signal, onUpdate, ctx) {
530642
const task = cleanText(params.task, "task", "delegate_task");
@@ -534,16 +646,33 @@ export default function subagentExtension(pi: ExtensionAPI): void {
534646
throw new Error(`Unknown Subagent role: ${params.role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
535647
}
536648

537-
const modelReference = params.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
538-
if (!modelReference || modelReference.includes("\0")) throw new Error("Subagent model is missing.");
539-
const model = ctx.modelRegistry.getAvailable().find(
540-
(candidate) => `${candidate.provider}/${candidate.id}` === modelReference && candidate.input.includes("text"),
541-
);
542-
if (!model) throw new Error(`Subagent model is unavailable: ${modelReference}.`);
543-
544-
const thinkingLevel = params.thinkingLevel ?? ctx.thinkingLevel;
649+
refreshConfig(ctx);
650+
if (!ctx.model) throw new Error("delegate_task requires an active Pi model.");
651+
if (params.modelClass !== undefined && !isModelClass(params.modelClass)) {
652+
throw new Error("delegate_task modelClass must be fast, balanced, or frontier.");
653+
}
654+
const modelClass = params.modelClass ?? "balanced";
655+
const configuredModel = config.models[modelClass];
656+
if (params.modelClass && !configuredModel) {
657+
throw new Error(`No ${modelClass} Subagent model configured; run /subagent.`);
658+
}
659+
const selectedModelInfo = configuredModel && availableTextModels(ctx)
660+
.find((model) => modelReference(model) === configuredModel.model);
661+
if (params.modelClass && configuredModel && !selectedModelInfo) {
662+
throw new Error(`Configured ${modelClass} Subagent model is unavailable; run /subagent.`);
663+
}
664+
const thinkingAvailable = Boolean(configuredModel && selectedModelInfo
665+
&& getSupportedThinkingLevels(selectedModelInfo).some((level) => level === configuredModel.thinkingLevel));
666+
if (params.modelClass && configuredModel && selectedModelInfo && !thinkingAvailable) {
667+
throw new Error(`Configured ${modelClass} Subagent thinking level is unavailable; run /subagent.`);
668+
}
669+
const configuredRoute = thinkingAvailable ? configuredModel : undefined;
670+
const modelReferenceValue = configuredRoute?.model ?? modelReference(ctx.model);
671+
const model = configuredRoute ? selectedModelInfo : ctx.model;
672+
if (!model) throw new Error("Subagent model metadata is unavailable.");
673+
const thinkingLevel = configuredRoute?.thinkingLevel ?? ctx.thinkingLevel;
545674
if (thinkingLevel && !getSupportedThinkingLevels(model).includes(thinkingLevel)) {
546-
throw new Error(`Subagent thinking level ${thinkingLevel} is unavailable for ${modelReference}.`);
675+
throw new Error(`Subagent thinking level ${thinkingLevel} is unavailable for ${modelReferenceValue}.`);
547676
}
548677

549678
const resolvedSkills = resolveSkillPaths(pi, role.skills);
@@ -564,13 +693,13 @@ export default function subagentExtension(pi: ExtensionAPI): void {
564693
for (const skill of resolvedSkills.paths) args.push("--skill", skill);
565694
if (role.tools.length) args.push("--tools", role.tools.join(","));
566695
else args.push("--no-tools");
567-
args.push("--model", modelReference);
696+
args.push("--model", modelReferenceValue);
568697
if (thinkingLevel) args.push("--thinking", thinkingLevel);
569698
args.push(ctx.isProjectTrusted() ? "--approve" : "--no-approve");
570699
args.push("--append-system-prompt", promptPath, `Task: ${task}`);
571700

572701
startWidgetItem(toolCallId, role.name, model.id, thinkingLevel, task, ctx);
573-
const details = { role: role.name, model: modelReference, thinkingLevel };
702+
const details = { role: role.name, model: modelReferenceValue, thinkingLevel };
574703
const result = await runPi(
575704
args,
576705
ctx.cwd,

packages/pi-subagent/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@henryqw/pi-subagent",
3-
"version": "0.1.1",
3+
"version": "1.0.0",
44
"description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
55
"keywords": [
66
"pi-package",

0 commit comments

Comments
 (0)