forked from bai/curriculum-project-hub
118 lines
4.6 KiB
TypeScript
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
|
|
* 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;
|
|
/** 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;
|
|
}
|
|
}
|