refactor(hub): role 改为数据条目,不再是写死的闭集枚举

ADR-0017 说 role-based routing 是 product/admin config,不是 spec invariant。
之前 RunRole = "draft"|"review"|"triage" 闭集违反这条——role 钉死在代码里,
教师/admin 无法自定义。改为:
- RoleEntry(id+label+defaultModel)是数据,registry 持有 role 集合
- roleId 退化为 string,新 role 是配置改动不是代码改动
- runner 不再带 role 字段(model 已由 caller resolve 好传入,role 透传无意义)
- 未知 role 优雅降级到首个启用 model,不抛
tsc --noEmit rc=0;role-as-data + ADR-0003 不变式 smoke 通过。
This commit is contained in:
2026-07-06 22:41:39 +08:00
parent 3bca137b48
commit a08c33205b
3 changed files with 46 additions and 25 deletions
+41 -20
View File
@@ -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<RunRole, string>;
private readonly roles: Map<string, RoleEntry>;
constructor(models: readonly ModelEntry[], defaults: Partial<Record<RunRole, string>> = {}) {
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;
-2
View File
@@ -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. */
+5 -3
View File
@@ -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",