forked from bai/curriculum-project-hub
e7924f78d5
A-3 结构化历史: - 新增 AgentMessage 表(每条消息的 queryable 投影,transcript 文件仍是 agent 读回记忆) - 新增 AgentFileChange 表(run 期间文件变更 before/after hash + operation) - runner.ts: generateText 后 persistAgentMessages 落库 result.responseMessages - workspace.ts writeFileTool: 写前算 beforeHash(SHA-256),写后算 afterHash,通过 ctx.onFileChange sink 记录 - runner.ts flushFileChanges: 收集 sink 的变更批量写入 AgentFileChange - ToolContext 加 onFileChange sink 字段 - trigger.ts 接线 runId/sessionId/prisma 到 RunRequest A-5 lock 心跳: - ProjectAgentLock 加 heartbeatAt 列 - runner.ts onStepFinish 回调:每步 model step 后更新 heartbeatAt(吞错,lock 可能被 force-release) 60 tests 全过(tsc 干净)。
120 lines
4.7 KiB
TypeScript
120 lines
4.7 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.
|
|
*
|
|
* 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<string, ToolFactory>();
|
|
|
|
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<string, ToolDefinition> {
|
|
const built: Record<string, ToolDefinition> = {};
|
|
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<string>,
|
|
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<string> => {
|
|
if (args.chat_id !== ctx.boundChatId) {
|
|
throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId);
|
|
}
|
|
return read(args, ctx, rt);
|
|
},
|
|
});
|
|
}
|