Skip to content

Commit e99c8ac

Browse files
committed
feat: auto model routing for SkillFlows
1 parent 7da5764 commit e99c8ac

5 files changed

Lines changed: 221 additions & 5 deletions

File tree

src/loader.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export interface AgentManifest {
4242
top_k?: number;
4343
stop_sequences?: string[];
4444
};
45+
routing?: {
46+
enabled?: boolean;
47+
lightweight?: string;
48+
reasoning?: string;
49+
rules?: Array<{ tier: "lightweight" | "reasoning"; match: string[] }>;
50+
};
4551
};
4652
tools: string[];
4753
skills?: string[];

src/model-routing.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Auto Model Routing (issue #48)
2+
//
3+
// Classifies each task in an agent workflow by complexity and routes it to the
4+
// most appropriate model: lightweight tasks (summarize/extract/classify/
5+
// transform) go to a cheap model, while reasoning-intensive tasks (search,
6+
// planning, decision-making, tool orchestration, complex problem solving)
7+
// stay on the configured reasoning model. Explicit per-step / per-skill model
8+
// settings always win, and anything unresolved falls back to the primary model.
9+
10+
export type ModelTier = "lightweight" | "reasoning";
11+
12+
export interface RoutingConfig {
13+
/** Master switch. Defaults to true when a routing block is present. */
14+
enabled?: boolean;
15+
/** Concrete model id for lightweight tasks, e.g. "openai:gpt-4o-mini". */
16+
lightweight?: string;
17+
/** Concrete model id for reasoning tasks, e.g. "openai:gpt-4o". */
18+
reasoning?: string;
19+
/** User overrides for classification — first matching rule wins. */
20+
rules?: Array<{ tier: ModelTier; match: string[] }>;
21+
}
22+
23+
export interface RouteInput {
24+
/** Explicit per-step model (highest priority). May be an alias or model id. */
25+
stepModel?: string;
26+
/** Per-skill default model from SKILL.md frontmatter. May be an alias or id. */
27+
skillModel?: string;
28+
/** Text used to classify the task (typically skill name + step prompt). */
29+
classifyText: string;
30+
/** Routing configuration from agent.yaml (model.routing). */
31+
routing?: RoutingConfig;
32+
/** The agent's primary/preferred model — the ultimate fallback. */
33+
primaryModel?: string;
34+
}
35+
36+
export interface RouteResult {
37+
/** Resolved concrete "provider:model" string (undefined → let runtime decide). */
38+
model?: string;
39+
/** The complexity tier, when the model came from automatic classification. */
40+
tier: ModelTier | null;
41+
/** Where the decision came from. */
42+
source: "step" | "skill" | "auto" | "fallback";
43+
}
44+
45+
// Default task-to-tier keyword framework, derived directly from the issue's
46+
// recommended task-type table. Matched against word starts so "summarize",
47+
// "summary" and "summarization" all hit "summ", without false positives like
48+
// "already" matching "read".
49+
const DEFAULT_LIGHTWEIGHT = [
50+
"summ", "extract", "classif", "transform", "format", "convert",
51+
"parse", "fetch", "read", "load", "lookup", "normaliz", "translat",
52+
"rephrase", "rewrite", "tag", "label", "render",
53+
];
54+
const DEFAULT_REASONING = [
55+
"search", "analy", "plan", "decid", "decision", "orchestrat", "solve",
56+
"reason", "validat", "evaluat", "review", "audit", "diagnos", "debug",
57+
"architect", "design", "strateg", "investigat", "assess", "judge",
58+
"verify", "critique", "infer", "deduc",
59+
];
60+
61+
function matchesAny(text: string, keywords: string[]): boolean {
62+
for (const kw of keywords) {
63+
// Word-start boundary: keyword must begin a word.
64+
const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
65+
if (re.test(text)) return true;
66+
}
67+
return false;
68+
}
69+
70+
/**
71+
* Classify a task into a complexity tier. User-defined rules (from
72+
* model.routing.rules) take precedence over the built-in defaults. When a task
73+
* matches neither — or matches both — it defaults to "reasoning" so that
74+
* reasoning quality is never sacrificed to save cost.
75+
*/
76+
export function classifyTaskTier(
77+
classifyText: string,
78+
rules?: Array<{ tier: ModelTier; match: string[] }>,
79+
): ModelTier {
80+
const text = classifyText || "";
81+
82+
// User overrides first, in declaration order.
83+
if (rules) {
84+
for (const rule of rules) {
85+
if (Array.isArray(rule.match) && matchesAny(text, rule.match)) {
86+
return rule.tier;
87+
}
88+
}
89+
}
90+
91+
const hasReasoning = matchesAny(text, DEFAULT_REASONING);
92+
if (hasReasoning) return "reasoning";
93+
const hasLightweight = matchesAny(text, DEFAULT_LIGHTWEIGHT);
94+
if (hasLightweight) return "lightweight";
95+
96+
// Unknown → keep quality high.
97+
return "reasoning";
98+
}
99+
100+
/**
101+
* Resolve a model reference that may be a routing-tier alias
102+
* ("lightweight"/"reasoning") or a literal "provider:model" id.
103+
*/
104+
export function resolveModelAlias(ref: string | undefined, routing?: RoutingConfig): string | undefined {
105+
if (!ref) return undefined;
106+
if (ref === "lightweight") return routing?.lightweight || undefined;
107+
if (ref === "reasoning") return routing?.reasoning || undefined;
108+
return ref;
109+
}
110+
111+
/**
112+
* Decide which model a task should run on. Precedence:
113+
* 1. explicit per-step model (source: "step")
114+
* 2. per-skill declared model (source: "skill")
115+
* 3. automatic classification (source: "auto") — when routing is enabled
116+
* 4. primary/preferred model (source: "fallback")
117+
*
118+
* Automatic routing is active only when a routing block is present and not
119+
* disabled. If classification picks a tier with no configured model, it falls
120+
* through to the primary model (fallback on routing failure).
121+
*/
122+
export function resolveRoutedModel(input: RouteInput): RouteResult {
123+
const { stepModel, skillModel, classifyText, routing, primaryModel } = input;
124+
125+
// 1. Explicit per-step override.
126+
const fromStep = resolveModelAlias(stepModel, routing);
127+
if (fromStep) return { model: fromStep, tier: null, source: "step" };
128+
129+
// 2. Per-skill declared default.
130+
const fromSkill = resolveModelAlias(skillModel, routing);
131+
if (fromSkill) return { model: fromSkill, tier: null, source: "skill" };
132+
133+
// 3. Automatic classification (opt-in via a routing block).
134+
const autoEnabled = !!routing && routing.enabled !== false && !!(routing.lightweight || routing.reasoning);
135+
if (autoEnabled) {
136+
const tier = classifyTaskTier(classifyText, routing!.rules);
137+
const model = tier === "lightweight" ? routing!.lightweight : routing!.reasoning;
138+
if (model) return { model, tier, source: "auto" };
139+
}
140+
141+
// 4. Fallback to the primary model.
142+
return { model: primaryModel, tier: null, source: "fallback" };
143+
}

src/skills.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface SkillMetadata {
1111
usage_count?: number;
1212
success_count?: number;
1313
failure_count?: number;
14+
model?: string;
1415
}
1516

1617
export interface ParsedSkill extends SkillMetadata {
@@ -96,6 +97,7 @@ export async function discoverSkills(agentDir: string): Promise<SkillMetadata[]>
9697
if (typeof frontmatter.usage_count === "number") meta.usage_count = frontmatter.usage_count;
9798
if (typeof frontmatter.success_count === "number") meta.success_count = frontmatter.success_count;
9899
if (typeof frontmatter.failure_count === "number") meta.failure_count = frontmatter.failure_count;
100+
if (typeof frontmatter.model === "string") meta.model = frontmatter.model;
99101

100102
skills.push(meta);
101103
}

src/voice/server.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { discoverWorkflows, loadFlowDefinition, saveFlowDefinition, deleteFlowDe
1818
import { discoverSchedules, saveSchedule, deleteSchedule, updateScheduleMeta } from "../schedules.js";
1919
import { startScheduler, stopScheduler, reloadSchedules, executeScheduledJob } from "../schedule-runner.js";
2020
import cron from "node-cron";
21+
import yaml from "js-yaml";
22+
import { resolveRoutedModel, type RoutingConfig } from "../model-routing.js";
2123

2224
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
2325
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
@@ -638,10 +640,16 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
638640

639641
const port = opts.port || 3333;
640642
let agentName = "GitAgent";
643+
// Auto Model Routing config from agent.yaml (model.routing). Issue #48.
644+
let modelRouting: RoutingConfig | undefined;
641645
try {
642646
const yamlRaw = readFileSync(join(resolve(opts.agentDir), "agent.yaml"), "utf-8");
643647
const m = yamlRaw.match(/^name:\s*(.+)$/m);
644648
if (m) agentName = m[1].trim();
649+
const parsed = yaml.load(yamlRaw) as any;
650+
if (parsed?.model?.routing && typeof parsed.model.routing === "object") {
651+
modelRouting = parsed.model.routing as RoutingConfig;
652+
}
645653
} catch { /* fallback to default */ }
646654
// Re-read on every request so `npm run build` is picked up live without a server restart.
647655
// The file sits in the OS page cache, so the per-request cost is negligible.
@@ -830,8 +838,19 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
830838
sendToBrowser({ type: "transcript", role: "assistant",
831839
text: `Running flow: ${flow.name} (${flow.steps.length} steps)` });
832840

841+
// Per-skill default model from each skill's SKILL.md frontmatter (`model:`).
842+
// Used as a fallback when a step doesn't set its own `model`.
843+
const skillModels = new Map<string, string>();
844+
for (const s of await discoverSkills(opts.agentDir)) {
845+
if (s.model) skillModels.set(s.name, s.model);
846+
}
847+
833848
let runningContext = userContext;
834849

850+
// Observability for auto model routing (issue #48): per-step model
851+
// selection plus token/cost totals, summarized in the execution log.
852+
const routeLog: Array<{ step: number; skill: string; model: string; tier: string; source: string; tokens: number; costUsd: number }> = [];
853+
835854
for (let i = 0; i < flow.steps.length; i++) {
836855
const step = flow.steps[i];
837856

@@ -859,7 +878,22 @@ export async function startVoiceServer(opts: VoiceServerOptions): Promise<() =>
859878
continue;
860879
}
861880

862-
sendToBrowser({ type: "agent_working" as any, query: `Step ${i + 1}/${flow.steps.length}: ${step.skill}` } as any);
881+
// Auto model routing (issue #48): classify the task by complexity and
882+
// route it to the appropriate model. Explicit per-step model wins, then
883+
// the skill's declared default, then automatic classification, then the
884+
// primary model as fallback.
885+
const route = resolveRoutedModel({
886+
stepModel: step.model,
887+
skillModel: skillModels.get(step.skill),
888+
classifyText: `${step.skill} ${step.prompt}`,
889+
routing: modelRouting,
890+
primaryModel: opts.model,
891+
});
892+
const stepModel = route.model;
893+
const routeNote = route.source === "auto" ? `auto/${route.tier}` : route.source;
894+
895+
sendToBrowser({ type: "agent_working" as any,
896+
query: `Step ${i + 1}/${flow.steps.length}: ${step.skill}${stepModel ? ` (${stepModel} · ${routeNote})` : ""}` } as any);
863897

864898
const prompt = `Use the skill "${step.skill}" (load it with /skill:${step.skill}).
865899
${step.prompt.replace(/\{input\}/g, userContext)}
@@ -870,22 +904,50 @@ ${runningContext}`;
870904
const result = query({
871905
prompt,
872906
dir: opts.agentDir,
873-
model: opts.model,
907+
model: stepModel,
874908
env: opts.env,
875909
});
876910

877911
let stepOutput = "";
912+
let stepTokens = 0;
913+
let stepCost = 0;
878914
for await (const msg of result) {
879915
if (msg.type === "assistant" && msg.content) stepOutput += msg.content;
916+
if (msg.type === "assistant" && msg.usage) {
917+
stepTokens += msg.usage.totalTokens ?? 0;
918+
stepCost += msg.usage.costUsd ?? 0;
919+
}
880920
if (msg.type === "tool_use") sendToBrowser({ type: "tool_call", toolName: msg.toolName, args: msg.args } as any);
881921
if (msg.type === "tool_result") sendToBrowser({ type: "tool_result", toolName: msg.toolName, content: msg.content, isError: msg.isError } as any);
882922
}
883923

924+
routeLog.push({
925+
step: i + 1, skill: step.skill, model: stepModel ?? "(default)",
926+
tier: route.tier ?? "-", source: route.source, tokens: stepTokens, costUsd: stepCost,
927+
});
928+
884929
runningContext += `\n\n[Step ${i + 1} result (${step.skill})]: ${stepOutput}`;
885930
sendToBrowser({ type: "agent_done" as any, result: `Step ${i + 1} complete` } as any);
886931
}
887932

888-
sendToBrowser({ type: "transcript", role: "assistant", text: `Flow "${flow.name}" completed.` });
933+
// Routing summary — model selected per task plus token/cost totals (issue #48).
934+
if (routeLog.length > 0) {
935+
const totalTokens = routeLog.reduce((a, r) => a + r.tokens, 0);
936+
const totalCost = routeLog.reduce((a, r) => a + r.costUsd, 0);
937+
const autoSteps = routeLog.filter((r) => r.source === "auto");
938+
const lightCount = autoSteps.filter((r) => r.tier === "lightweight").length;
939+
console.log(dim(`[routing] Flow "${flow.name}" summary — ${routeLog.length} steps, ${totalTokens} tokens, $${totalCost.toFixed(4)}`));
940+
for (const r of routeLog) {
941+
console.log(dim(`[routing] step ${r.step} ${r.skill}: ${r.model} [${r.source}${r.tier !== "-" ? "/" + r.tier : ""}] ${r.tokens} tok $${r.costUsd.toFixed(4)}`));
942+
}
943+
const autoNote = autoSteps.length > 0
944+
? ` · auto-routed ${autoSteps.length} (${lightCount} → lightweight)`
945+
: "";
946+
sendToBrowser({ type: "transcript", role: "assistant",
947+
text: `Flow "${flow.name}" completed. ${routeLog.length} steps · ${totalTokens} tokens · $${totalCost.toFixed(4)}${autoNote}` });
948+
} else {
949+
sendToBrowser({ type: "transcript", role: "assistant", text: `Flow "${flow.name}" completed.` });
950+
}
889951
}
890952

891953
// ── File API helpers ────────────────────────────────────────────────
@@ -2555,7 +2617,7 @@ return false;
25552617

25562618
} else if (url.pathname === "/api/flows/save" && req.method === "POST") {
25572619
const body = await readBody(req);
2558-
let parsed: { name: string; description: string; steps: { skill: string; prompt: string; channel?: string }[] };
2620+
let parsed: { name: string; description: string; steps: { skill: string; prompt: string; channel?: string; model?: string }[] };
25592621
try { parsed = JSON.parse(body); } catch { return jsonReply(res, 400, { error: "Invalid JSON" }); }
25602622
if (!parsed.name || !parsed.steps?.length) return jsonReply(res, 400, { error: "Missing name or steps" });
25612623
try {

src/workflows.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface SkillFlowStep {
77
skill: string;
88
prompt: string;
99
channel?: string;
10+
model?: string;
1011
}
1112

1213
export interface SkillFlowDefinition {
@@ -68,6 +69,7 @@ export async function discoverWorkflows(agentDir: string): Promise<WorkflowMetad
6869
skill: String(s.skill || ""),
6970
prompt: String(s.prompt || ""),
7071
...(s.channel ? { channel: String(s.channel) } : {}),
72+
...(s.model ? { model: String(s.model) } : {}),
7173
})),
7274
} : { type: "basic" as const }),
7375
});
@@ -113,6 +115,7 @@ export async function loadFlowDefinition(filePath: string): Promise<SkillFlowDef
113115
skill: String(s.skill || ""),
114116
prompt: String(s.prompt || ""),
115117
...(s.channel ? { channel: String(s.channel) } : {}),
118+
...(s.model ? { model: String(s.model) } : {}),
116119
})),
117120
};
118121
}
@@ -130,7 +133,7 @@ export async function saveFlowDefinition(agentDir: string, flow: SkillFlowDefini
130133
const content = yaml.dump({
131134
name: flow.name,
132135
description: flow.description || "",
133-
steps: flow.steps.map((s) => ({ skill: s.skill, prompt: s.prompt, ...(s.channel ? { channel: s.channel } : {}) })),
136+
steps: flow.steps.map((s) => ({ skill: s.skill, prompt: s.prompt, ...(s.channel ? { channel: s.channel } : {}), ...(s.model ? { model: s.model } : {}) })),
134137
}, { lineWidth: 120 });
135138
await writeFile(filePath, content, "utf-8");
136139
return filePath;

0 commit comments

Comments
 (0)