/** * Per-run model selection & role-based routing. * * ADR-0017 consequence: role-based model routing (different run kinds default * to different models) is a **product/admin configuration** concern, not a spec * invariant. This registry is the seam for that config; the concrete policy * (which models are admin-enabled, which role maps to which default) is `OPEN` * here — decided by admin settings + ADR, not hard-coded. */ /** * A named role preset — the full per-run agent bundle. Roles are **data**, not * a code enum: admin/teachers define them (ADR-0017: role-based routing is * product config, not a spec invariant). `roleId` is an opaque string and * doubles as the slash-command name (`/draft ...`) — the registry holds the * role set, so new roles are added by configuration, not by editing code. * * A role bundles everything that distinguishes one agent persona from another: * model, system prompt, and the tool surface (files / cph / feishu / skills / * mcps). Per-run tool whitelisting is enforced by the Claude SDK runner and * the cph_hub MCP server builder; security does not rely on the system prompt. */ import { assertSupportedRoleTools } from "./roleTools.js"; export interface RoleEntry { readonly id: string; /** Human label for the teacher-side switcher UX. */ readonly label: string; /** Default model id for runs under this role, if a routing rule is set. */ readonly defaultModel: string | undefined; /** * System prompt seeding the agent's persona/instructions. Prepended to the * run's messages only at session start; Claude SDK resume restores later * turns from the provider session. `undefined` ⇒ no system prompt (bare run). */ readonly systemPrompt?: string | undefined; /** * Tool names this role may use (whitelist). `undefined` ⇒ the full registered * set (back-compat / unrestricted roles). An empty array ⇒ no tools at all. * Invalid names fail fast when settings are loaded or the run is set up. */ readonly tools?: readonly string[] | undefined; } /** A model the admin has enabled for use by the Hub. */ export interface ModelEntry { readonly id: string; /** Human label for the teacher-side switcher UX. */ readonly label: string; /** True when the model is known to support tool use reliably. */ readonly toolCapable: boolean; } export interface ModelRegistry { /** All models admin-enabled for this Hub instance. */ listModels(): readonly ModelEntry[]; /** All role presets currently configured (admin/teacher-defined). */ listRoles(): readonly RoleEntry[]; /** The role preset with the given id, if any. */ role(id: string): RoleEntry | undefined; /** Resolve a model id: explicit request → role default → first enabled. */ resolve(requestedModel: string | undefined, roleId: string): string; } /** * A simple in-memory registry. Real wiring reads from admin settings / DB; this * is the skeleton seam. Both the model list and the role set are pluggable data * — adding a role is a config change, not a code change. */ export class InMemoryModelRegistry implements ModelRegistry { private readonly models: readonly ModelEntry[]; private readonly roles: Map; constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) { this.models = models; for (const role of roles) { if (role.tools !== undefined) assertSupportedRoleTools(role.tools); } this.roles = new Map(roles.map((r) => [r.id, r])); } listModels(): readonly ModelEntry[] { return this.models; } listRoles(): readonly RoleEntry[] { return [...this.roles.values()]; } role(id: string): RoleEntry | undefined { return this.roles.get(id); } /** * Resolve a model id for a run. Priority: teacher's explicit request → the * role preset's default → the first admin-enabled model. `roleId` is a string * so unknown roles degrade gracefully to the fallback rather than throwing. */ resolve(requestedModel: string | undefined, roleId: string): string { if (requestedModel !== undefined && this.models.some((m) => m.id === requestedModel)) { return requestedModel; } const role = this.roles.get(roleId); if (role?.defaultModel !== undefined) return role.defaultModel; const first = this.models[0]; if (first === undefined) throw new Error("no models enabled for this Hub"); return first.id; } }