Files
curriculum-project-hub/hub/src/agent/tools.ts
T
hongjr03 ce767e9cab feat(hub): 真实飞书读取 + 状态卡片 + 权限 gate + 部署脚本
FeishuRead(ADR-0003): feishuContextTool 从 stub 换成真实 lark
  im.v1.message.get(单条 trigger/reply/status_card)+ list(thread 回复);
  ADR-0003 chat 授权不变式不变(handler 已 gate)。
StatusCard: sendCard + buildRunStatusCard——完成/出错发 interactive card
  (status 文案 + model 选择器 + 取消按钮带 runId),替代纯文本。
Permission(ADR-0004, project-wise): triggerAgent 查 PermissionGrant 对
  project 的 edit+ 能力(manage⊇edit 单调);sender open_id 当 principal
  (子类型学 OPEN,务实选择,permission.ts 标注);per-artifact OPEN;
  settings 组合规则 OPEN。无权限回 '无权限触发'。
Deploy: cph-hub.service systemd unit(ExecStartPre 跑 prisma migrate)+
  install_service.sh(idempotent,seed env)+ deploy_platform.sh
  (rsync + npm ci + build + restart + healthz)。
client.ts 抽 createLarkClient/startFeishuListenerWithClient,tools 与
  ws 共用同一 client。
tsc rc=0;prisma validate 通过;deploy 脚本 bash -n 通过。
2026-07-06 23:44:07 +08:00

130 lines
4.6 KiB
TypeScript

/**
* 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, rt: unknown) => Promise<string>,
rt: unknown,
): 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, rt);
},
};
}