forked from EduCraft/curriculum-project-hub
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:
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tool registry — the agent's narrow, project-scoped tool surface.
|
||||
*
|
||||
* Unlike a general "Claude Code", the Hub agent operates on a curriculum
|
||||
* engineering-file tree (ADR-0007) and reads Feishu context on demand
|
||||
* (ADR-0003). Its tools are therefore bounded:
|
||||
*
|
||||
* - file-tree read/write against the project workspace dir,
|
||||
* - `cph check` / `cph build` subprocess calls (the Courseware-side checker,
|
||||
* coupled only via the stable CLI contract ADR-0016),
|
||||
* - Feishu context reads, which must honor ADR-0003's authorization invariant.
|
||||
*
|
||||
* That last invariant is encoded here: a Feishu context tool handler rejects any
|
||||
* chat id that is not the run's project's bound chat (ADR-0001 binding). This is
|
||||
* `McpReadRequest.Authorized` from `spec/System/Memory.lean` landing in code.
|
||||
*/
|
||||
import type { ToolSpec } from "./provider.js";
|
||||
|
||||
/** Per-run execution context handed to every tool handler. */
|
||||
export interface ToolContext {
|
||||
readonly runId: string;
|
||||
readonly projectId: string;
|
||||
/** ADR-0001: the chat this project is bound to. Feishu reads must stay in it. */
|
||||
readonly boundChatId: string;
|
||||
/** Absolute path to the project's curriculum engineering-file workspace. */
|
||||
readonly workspaceDir: string;
|
||||
}
|
||||
|
||||
/** A tool handler: receives parsed args + run context, returns content for the model. */
|
||||
export type ToolHandler = (args: unknown, ctx: ToolContext) => Promise<string>;
|
||||
|
||||
export interface RegisteredTool {
|
||||
readonly spec: ToolSpec;
|
||||
readonly execute: ToolHandler;
|
||||
}
|
||||
|
||||
export class ToolRegistry {
|
||||
private readonly byName = new Map<string, RegisteredTool>();
|
||||
|
||||
register(tool: RegisteredTool): void {
|
||||
if (this.byName.has(tool.spec.name)) {
|
||||
throw new Error(`duplicate tool: ${tool.spec.name}`);
|
||||
}
|
||||
this.byName.set(tool.spec.name, tool);
|
||||
}
|
||||
|
||||
specs(): readonly ToolSpec[] {
|
||||
return [...this.byName.values()].map((t) => t.spec);
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.byName.has(name);
|
||||
}
|
||||
|
||||
/** Execute a tool by name. Throws on unknown tool or authorization failure. */
|
||||
async execute(name: string, args: unknown, ctx: ToolContext): Promise<string> {
|
||||
const tool = this.byName.get(name);
|
||||
if (tool === undefined) {
|
||||
return JSON.stringify({ error: `unknown tool: ${name}` });
|
||||
}
|
||||
return tool.execute(args, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a Feishu context read targets a chat other than the run's project's
|
||||
* bound chat. This is the runtime enforcement of ADR-0003's
|
||||
* `McpReadRequest.Authorized`: "Claude cannot pass arbitrary chat ids".
|
||||
*/
|
||||
export class UnauthorizedChatRead extends Error {
|
||||
constructor(
|
||||
readonly requestedChatId: string,
|
||||
readonly boundChatId: string,
|
||||
) {
|
||||
super(
|
||||
`refused Feishu context read: requested chat ${requestedChatId} is not the run's project's bound chat ${boundChatId} (ADR-0003)`,
|
||||
);
|
||||
this.name = "UnauthorizedChatRead";
|
||||
}
|
||||
}
|
||||
|
||||
/** Schema for the Feishu context read tool's arguments. */
|
||||
export interface FeishuContextArgs {
|
||||
readonly chat_id: string;
|
||||
readonly anchor: "trigger_message" | "status_card" | "reply" | "thread";
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Feishu context-read tool. The handler enforces ADR-0003's invariant
|
||||
* before any API call: the requested `chat_id` must equal `ctx.boundChatId`.
|
||||
*
|
||||
* The actual Feishu API call (read message / reply chain / thread / recent
|
||||
* group messages) is injected as `read`, so the invariant check is separable
|
||||
* from the transport. In tests `read` can be a stub; in production it wraps
|
||||
* `@larksuiteoapi/node-sdk`.
|
||||
*/
|
||||
export function feishuContextTool(
|
||||
read: (args: FeishuContextArgs, ctx: ToolContext) => Promise<string>,
|
||||
): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "feishu_read_context",
|
||||
description:
|
||||
"Read on-demand Feishu context (a trigger message, status card, reply, or thread) for the current project's bound chat. The chat id must match the project binding.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chat_id: { type: "string", description: "The Feishu chat id to read from." },
|
||||
anchor: {
|
||||
type: "string",
|
||||
enum: ["trigger_message", "status_card", "reply", "thread"],
|
||||
description: "Which kind of anchor to read.",
|
||||
},
|
||||
id: { type: "string", description: "The anchor id (message id or run id)." },
|
||||
},
|
||||
required: ["chat_id", "anchor", "id"],
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = args as FeishuContextArgs;
|
||||
if (a.chat_id !== ctx.boundChatId) {
|
||||
throw new UnauthorizedChatRead(a.chat_id, ctx.boundChatId);
|
||||
}
|
||||
return read(a, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user