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:
2026-07-07 20:07:02 +08:00
parent afaf5bee09
commit a2c8fa8eaf
17 changed files with 516 additions and 808 deletions
+36 -66
View File
@@ -1,16 +1,17 @@
/**
* Workspace file tools bounded read/write/list against the project's
* Workspace file tools - bounded read/write/list against the project's
* curriculum engineering-file tree (ADR-0007 directory tree).
*
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
* requested relative path against the workspace root and rejects any path that
* escapes it (via `..` or absolute paths). This is the agent's only file
* surface there is no general bash escape hatch, by design (the Hub agent is
* narrower than a general "Claude Code").
* surface; the Hub agent is narrower than a general coding agent.
*/
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
import { join, resolve, relative, isAbsolute } from "node:path";
import type { RegisteredTool } from "./tools.js";
import { tool } from "ai";
import { z } from "zod";
import type { ToolContext, ToolDefinition } from "./tools.js";
/** Thrown when a requested path escapes the project workspace root. */
export class PathEscape extends Error {
@@ -38,68 +39,42 @@ function confine(requestedPath: string, workspaceDir: string): string {
return resolved;
}
interface ReadFileArgs {
readonly path: string;
}
export function readFileTool(): RegisteredTool {
return {
spec: {
name: "read_file",
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
parameters: {
type: "object",
properties: { path: { type: "string", description: "Relative path within the workspace." } },
required: ["path"],
},
},
async execute(args, ctx): Promise<string> {
const a = args as ReadFileArgs;
export function readFileTool(ctx: ToolContext): ToolDefinition {
return tool({
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
inputSchema: z.object({
path: z.string().describe("Relative path within the workspace."),
}),
execute: async (args): Promise<string> => {
try {
const full = confine(a.path, ctx.workspaceDir);
const full = confine(args.path, ctx.workspaceDir);
return await readFile(full, "utf8");
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
},
};
});
}
interface WriteFileArgs {
readonly path: string;
readonly content: string;
}
export function writeFileTool(): RegisteredTool {
return {
spec: {
name: "write_file",
description: "Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Relative path within the workspace." },
content: { type: "string", description: "The full file content to write." },
},
required: ["path", "content"],
},
},
async execute(args, ctx): Promise<string> {
const a = args as WriteFileArgs;
export function writeFileTool(ctx: ToolContext): ToolDefinition {
return tool({
description:
"Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
inputSchema: z.object({
path: z.string().describe("Relative path within the workspace."),
content: z.string().describe("The full file content to write."),
}),
execute: async (args): Promise<string> => {
try {
const full = confine(a.path, ctx.workspaceDir);
const full = confine(args.path, ctx.workspaceDir);
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, a.content, "utf8");
return JSON.stringify({ ok: true, path: a.path, bytes: a.content.length });
await writeFile(full, args.content, "utf8");
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
} catch (e) {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
},
};
}
interface ListFilesArgs {
readonly path?: string;
});
}
interface Entry {
@@ -107,19 +82,14 @@ interface Entry {
readonly kind: "file" | "dir";
}
export function listFilesTool(): RegisteredTool {
return {
spec: {
name: "list_files",
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
parameters: {
type: "object",
properties: { path: { type: "string", description: "Relative path within the workspace; defaults to root." } },
},
},
async execute(args, ctx): Promise<string> {
const a = (args as ListFilesArgs) ?? {};
const sub = a.path ?? ".";
export function listFilesTool(ctx: ToolContext): ToolDefinition {
return tool({
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
inputSchema: z.object({
path: z.string().describe("Relative path within the workspace; defaults to root.").optional(),
}),
execute: async (args): Promise<string> => {
const sub = args.path ?? ".";
try {
const full = confine(sub, ctx.workspaceDir);
const entries = await readdir(full, { withFileTypes: true });
@@ -132,5 +102,5 @@ export function listFilesTool(): RegisteredTool {
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
}
},
};
});
}