forked from EduCraft/curriculum-project-hub
refactor(hub): 手搓 agent loop 换成 Vercel AI SDK
将 provider/runner/tools 三件手搓轮子替换为 AI SDK v7 的 generateText + tool:
- 删 openrouter-provider.ts(翻译层消失)、provider.ts 自定义消息类型
- runner.ts 循环体改为 generateText({stopWhen: stepCountIs}),保留 RunRequest/RunResult 公共签名
- tools.ts ToolRegistry 改为 ToolFactory + build(ctx, names?) 按 role 白名单构建 tools record
- workspace/cph/feishu 工具改写为 tool() 定义,execute 闭包捕获 per-run ToolContext
- transcript.ts 改为 ModelMessage[] JSONL(0.0.0 cutover,无迁移)
- server.ts/trigger.ts 接 modelFactory + toolWhitelist
- 测试:helpers 写 MockLanguageModel implements LanguageModelV4;5 个测试改写 + 新增 runner.test.ts
- 移除未使用的 openai 依赖
- ADR-0017 Consequences 同步:loop 委托给 AI SDK,provider seam 是 LanguageModel
tsc 干净,54 tests 全过。
This commit is contained in:
+41
-81
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Tool registry — the agent's narrow, project-scoped tool surface.
|
||||
* 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
|
||||
@@ -10,13 +10,13 @@
|
||||
* 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.
|
||||
* The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool
|
||||
* construction and per-role whitelisting (ADR-0017).
|
||||
*/
|
||||
import type { ToolSpec } from "./provider.js";
|
||||
import { tool, type Tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Per-run execution context handed to every tool handler. */
|
||||
/** Per-run execution context closed over by every tool execute function. */
|
||||
export interface ToolContext {
|
||||
readonly runId: string;
|
||||
readonly projectId: string;
|
||||
@@ -26,64 +26,34 @@ export interface ToolContext {
|
||||
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 type ToolDefinition = Tool;
|
||||
export type ToolFactory = (ctx: ToolContext) => ToolDefinition;
|
||||
|
||||
export class ToolRegistry {
|
||||
private readonly byName = new Map<string, RegisteredTool>();
|
||||
private readonly factories = new Map<string, ToolFactory>();
|
||||
|
||||
register(tool: RegisteredTool): void {
|
||||
if (this.byName.has(tool.spec.name)) {
|
||||
throw new Error(`duplicate tool: ${tool.spec.name}`);
|
||||
register(name: string, factory: ToolFactory): void {
|
||||
if (this.factories.has(name)) {
|
||||
throw new Error(`duplicate tool: ${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);
|
||||
this.factories.set(name, factory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a per-run view restricted to `names`. Names not registered are
|
||||
* silently dropped (a role's whitelist may name tools that a particular Hub
|
||||
* instance doesn't register). The returned registry shares the handler
|
||||
* references — no copy of the closures.
|
||||
*
|
||||
* Per-role tool whitelisting (ADR-0017: role-based routing is product
|
||||
* config). The runner receives a subset registry so the model literally
|
||||
* never sees tools outside its role's whitelist: the tool list sent to the
|
||||
* provider is `subset.specs()`, and `execute` on a name not in the subset
|
||||
* returns an error.
|
||||
* 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.
|
||||
*/
|
||||
subset(names: readonly string[]): ToolRegistry {
|
||||
const allowed = new Set(names);
|
||||
const view = new ToolRegistry();
|
||||
for (const [name, tool] of this.byName) {
|
||||
if (allowed.has(name)) {
|
||||
// Bypass the duplicate check: we're constructing from an already-valid
|
||||
// registry, not re-registering.
|
||||
view.byName.set(name, tool);
|
||||
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 view;
|
||||
return built;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +82,9 @@ export interface FeishuContextArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* 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
|
||||
@@ -123,32 +94,21 @@ export interface FeishuContextArgs {
|
||||
export function feishuContextTool(
|
||||
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
|
||||
rt: unknown,
|
||||
): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "feishu_read_context",
|
||||
): 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.",
|
||||
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"],
|
||||
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);
|
||||
},
|
||||
},
|
||||
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);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user