/** * 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. * * The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool * construction and per-role whitelisting (ADR-0017). */ import { tool, type Tool } from "ai"; import { z } from "zod"; /** Per-run execution context closed over by every tool execute function. */ 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; /** Optional sink for file-change capture (A-3). The writeFile tool calls * this with before/after hash when it modifies a file; the runner injects * a collector that flushes to AgentFileChange at run end. Undefined when * the runner doesn't wire capture (e.g. unit tests). */ readonly onFileChange?: (change: { path: string; operation: "CREATE" | "UPDATE" | "DELETE" | "RENAME"; beforeHash: string | null; afterHash: string | null; diff: string | null }) => void; } export type ToolDefinition = Tool; export type ToolFactory = (ctx: ToolContext) => ToolDefinition; export class ToolRegistry { private readonly factories = new Map(); register(name: string, factory: ToolFactory): void { if (this.factories.has(name)) { throw new Error(`duplicate tool: ${name}`); } this.factories.set(name, factory); } /** * Build the AI SDK `tools` record. If `names` is given, include only those * tools (per-role whitelist, ADR-0017). Names not registered are silently * dropped because role config may name tools a Hub instance does not provide. */ build(ctx: ToolContext, names: readonly string[] | undefined): Record { const built: Record = {}; const selected = names ?? [...this.factories.keys()]; for (const name of selected) { const factory = this.factories.get(name); if (factory !== undefined) { built[name] = factory(ctx); } } return built; } } /** * 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 factory. The execute closure 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, rt: unknown) => Promise, rt: unknown, ): ToolFactory { return (ctx) => tool({ 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.", inputSchema: z.object({ chat_id: z.string().describe("The Feishu chat id to read from."), anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."), id: z.string().describe("The anchor id (message id or run id)."), }), execute: async (args): Promise => { if (args.chat_id !== ctx.boundChatId) { throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId); } return read(args, ctx, rt); }, }); }