Files
curriculum-project-hub/hub/src/agent/models.ts
T

118 lines
4.6 KiB
TypeScript

/**
* 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
* is selected through the project console — 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;
/** Immutable skill versions selected by this role at runtime. */
readonly skills?: readonly RoleSkillEntry[] | undefined;
}
export interface RoleSkillEntry {
readonly name: string;
readonly version: string;
readonly contentDigest: string;
}
/** 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<string, RoleEntry>;
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;
}
}