feat(hub): provider-agnostic agent seam 骨架(ADR-0001/0003/0017)

hub/ TS 新部件,平级于 spec/。本仓从零 TS 重写(非迁移旧服务),栈:
Fastify+Prisma 待补;本提交落 provider-agnostic agent runtime 骨架:
- provider.ts: AgentProvider seam,OpenAI-compatible messages+tools+tool_calls
  形状(语言通解,非 OpenAI 承诺);@Claude 仅为触发品牌(ADR-0017)
- openrouter-provider.ts: openai SDK 指向 OpenRouter 的具体适配器,仅做
  Message↔SDK 翻译;agent loop 不 import SDK,换 provider 不改 loop
- runner.ts: 自有 agent loop(dispatch tools/循环到 stop/budget),per-run
  model(ADR-0017 role-based routing,run 带其 model)
- tools.ts: 窄工具面 + ADR-0003 McpReadRequest.Authorized 落代码:
  feishu_read_context 拒绝非本项目绑定 chat 的读取(实测错 chat 抛
  UnauthorizedChatRead,对 chat 放行)
- models.ts: ModelRegistry seam(role→default 映射策略 OPEN)
- server.ts: 接线入口(stub Feishu read,无活凭证即可编译)
tsc --noEmit rc=0;ADR-0003 不变式 smoke 通过。
This commit is contained in:
2026-07-06 22:38:04 +08:00
parent 18aac1ff16
commit 3bca137b48
11 changed files with 1694 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
/**
* 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.
*/
/** Coarse run role, used to pick a default model. Open set: add as product grows. */
export type RunRole = "draft" | "review" | "triage";
/** 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. */
list(): readonly ModelEntry[];
/** Default model id for a given run role, if a routing rule exists. */
defaultFor(role: RunRole): string | 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.
*/
export class InMemoryModelRegistry implements ModelRegistry {
private readonly models: readonly ModelEntry[];
private readonly defaults: Map<RunRole, string>;
constructor(models: readonly ModelEntry[], defaults: Partial<Record<RunRole, string>> = {}) {
this.models = models;
this.defaults = new Map(
(Object.entries(defaults) as [RunRole, string][]).filter(([, v]) => v !== undefined),
);
}
list(): readonly ModelEntry[] {
return this.models;
}
defaultFor(role: RunRole): string | undefined {
return this.defaults.get(role);
}
/** 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;
}
const d = this.defaultFor(role);
if (d !== undefined) return d;
const first = this.models[0];
if (first === undefined) throw new Error("no models enabled for this Hub");
return first.id;
}
}