forked from EduCraft/curriculum-project-hub
afaf5bee09
RoleEntry 扩成 (model, systemPrompt, tools) bundle,id 兼任 slash command 名。 ToolRegistry.subset() 支持按白名单构造 per-run 视图,模型永远看不到 role 外的工具。 trigger 解析 /<role> 命令,查 RoleEntry 组装 systemPrompt + 子集 registry 传给 runner。 新增 RoleTriggerGrant 表 + canTriggerRole gate,与 ADR-0004 canTriggerAgent 串联: 先问'能不能触发 agent',再问'能触发哪个 role'。未配置 role 放行(back-compat)。 - models.ts: RoleEntry 加 systemPrompt + tools 字段 - tools.ts: ToolRegistry.subset(names) 返回共享 handler 的子集视图 - trigger.ts: extractRole 解析 slash; 传 systemPrompt + runTools; catch 链容错 P2025 - runner.ts: RunRequest.systemPrompt 改 string | undefined (exactOptionalPropertyTypes) - schema.prisma + migration: RoleTriggerGrant(projectId, roleId, principal, revokedAt) - permission.ts: canTriggerRole gate (有 grant 记录即白名单模式,含 revoked) - server.ts: draft/review 两个 role 加 systemPrompt + tools 白名单 - 测试: role-permission.test.ts (5) + trigger.test.ts (+3), 53 全绿
106 lines
4.2 KiB
TypeScript
106 lines
4.2 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 {@link ToolRegistry.subset};
|
|
* the model never sees tools outside its role's whitelist, even if it tries to
|
|
* call them — security does not rely on the system prompt.
|
|
*/
|
|
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 (resume reads it from the transcript,
|
|
* see runner.ts). `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.
|
|
* Names not present in the registry are silently dropped at run setup.
|
|
*/
|
|
readonly tools: readonly string[] | undefined;
|
|
}
|
|
|
|
/** 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;
|
|
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;
|
|
}
|
|
}
|