From a08c33205b809a5d156aecbc6e1e9613444afbf0 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Mon, 6 Jul 2026 22:41:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor(hub):=20role=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=9D=A1=E7=9B=AE,=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=98=AF=E5=86=99=E6=AD=BB=E7=9A=84=E9=97=AD=E9=9B=86=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 通过。 --- hub/src/agent/models.ts | 61 +++++++++++++++++++++++++++-------------- hub/src/agent/runner.ts | 2 -- hub/src/server.ts | 8 ++++-- 3 files changed, 46 insertions(+), 25 deletions(-) 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",