diff --git a/hub/src/agent/models.ts b/hub/src/agent/models.ts index 4bae3bc..446e35b 100644 --- a/hub/src/agent/models.ts +++ b/hub/src/agent/models.ts @@ -8,8 +8,20 @@ * here — decided by admin settings + ADR, not hard-coded. */ -/** Coarse run role, used to pick a default model. Open set: add as product grows. */ -export type RunRole = "draft" | "review" | "triage"; +/** + * A named role preset that maps to a default model. 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 here — + * the registry holds the role set, so new roles are added by configuration, + * not by editing this file. + */ +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; +} /** A model the admin has enabled for use by the Hub. */ export interface ModelEntry { @@ -22,41 +34,50 @@ export interface ModelEntry { export interface ModelRegistry { /** All models admin-enabled for this Hub instance. */ - list(): readonly ModelEntry[]; - /** Default model id for a given run role, if a routing rule exists. */ - defaultFor(role: RunRole): string | undefined; + 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; } /** * A simple in-memory registry. Real wiring reads from admin settings / DB; this - * is the skeleton seam. The policy (role→model map) is intentionally pluggable. + * 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 defaults: Map; + private readonly roles: Map; - constructor(models: readonly ModelEntry[], defaults: Partial> = {}) { + constructor(models: readonly ModelEntry[], roles: readonly RoleEntry[] = []) { this.models = models; - this.defaults = new Map( - (Object.entries(defaults) as [RunRole, string][]).filter(([, v]) => v !== undefined), - ); + this.roles = new Map(roles.map((r) => [r.id, r])); } - list(): readonly ModelEntry[] { + listModels(): readonly ModelEntry[] { return this.models; } - defaultFor(role: RunRole): string | undefined { - return this.defaults.get(role); + listRoles(): readonly RoleEntry[] { + return [...this.roles.values()]; } - /** Resolve a model id, falling back to the role default, then the first enabled. */ - resolve(requested: string | undefined, role: RunRole): string { - if (requested !== undefined && this.models.some((m) => m.id === requested)) { - return requested; + 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 d = this.defaultFor(role); - if (d !== undefined) return d; + 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; diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index cd32d96..7d3965d 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -11,7 +11,6 @@ */ import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.js"; import type { ToolContext, ToolRegistry } from "./tools.js"; -import type { RunRole } from "./models.js"; /** What the runner needs about the project to seed and bound a run. */ export interface ProjectContext { @@ -26,7 +25,6 @@ export interface RunRequest { readonly prompt: string; /** Resolved model id (already chosen by the caller per ADR-0017). */ readonly model: string; - readonly role: RunRole; readonly project: ProjectContext; readonly systemPrompt?: string; /** Iteration cap before the run is returned as `length`. Default 25. */ diff --git a/hub/src/server.ts b/hub/src/server.ts index 6951271..bf73431 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -24,7 +24,11 @@ function main(): void { { id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true }, { id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true }, ], - { draft: "z-ai/glm-4.6", review: "anthropic/claude-3.5-sonnet" }, + [ + // Roles are data here — admin/teachers add presets, not code changes. + { id: "draft", label: "草稿", defaultModel: "z-ai/glm-4.6" }, + { id: "review", label: "审校", defaultModel: "anthropic/claude-3.5-sonnet" }, + ], ); const tools = new ToolRegistry(); @@ -36,11 +40,9 @@ function main(): void { ); const model = models.resolve(undefined, "draft"); - runAgent(provider, tools, { prompt: "ping", model, - role: "draft", project: { projectId: "proj-skeleton", boundChatId: "chat-skeleton",