Files
curriculum-project-hub/hub/src/agent/cph.ts
T
hongjr03 a2c8fa8eaf 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 全过。
2026-07-07 20:07:02 +08:00

106 lines
4.2 KiB
TypeScript

/**
* `cph` subprocess tools - the Hub<->Courseware coupling point.
*
* The Hub agent does not parse engineering-file models in-process; it calls the
* stable `cph` CLI (ADR-0016 version contract). `cph check` validates a
* project; `cph build` renders a target artifact. Version compatibility is the
* CLI's responsibility - it refuses incompatible `.cph-version` files with a
* `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the
* subprocess and returns its stdout/stderr/exit to the model.
*
* The `cph` binary path is configurable (env `CPH_BIN`, default `cph` on
* PATH). All subprocesses run with `cwd = ctx.workspaceDir` so paths in
* diagnostics are relative to the project root.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { tool } from "ai";
import { z } from "zod";
import type { ToolContext, ToolDefinition } from "./tools.js";
const execFileAsync = promisify(execFile);
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
const DEFAULT_TIMEOUT_MS = 120_000;
interface ExecResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
}
async function runCph(args: readonly string[], workspaceDir: string): Promise<ExecResult> {
try {
const { stdout, stderr } = await execFileAsync(CPH_BIN, args, {
cwd: workspaceDir,
timeout: DEFAULT_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
return { exitCode: 0, stdout, stderr };
} catch (e) {
// execFile rejects on non-zero exit; the error carries stdout/stderr/code.
const err = e as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number | string };
if (err.code === "ENOENT") {
return {
exitCode: 127,
stdout: "",
stderr: `cph binary not found at "${CPH_BIN}" (set CPH_BIN env or install cph on PATH)`,
};
}
return {
exitCode: typeof err.code === "number" ? err.code : 1,
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
};
}
}
/// `cph check <dir>` - validate a curriculum engineering file. Returns the
/// checker's diagnostics (stdout) for the model to act on. Non-zero exit means
/// the file has error diagnostics (ADR-0010); the output is still returned,
/// not thrown - the model needs to see the diagnostics to fix them.
export function cphCheckTool(ctx: ToolContext): ToolDefinition {
return tool({
description:
"Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version...). Non-zero exit means error diagnostics present - read the output to fix them.",
inputSchema: z.object({
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
}),
execute: async (args): Promise<string> => {
const target = args.path ?? ".";
const res = await runCph(["check", target], ctx.workspaceDir);
return JSON.stringify({
exitCode: res.exitCode,
stdout: res.stdout,
stderr: res.stderr,
});
},
});
}
/// `cph build <dir> --target <T> -o <out>` - render a target artifact. The
/// target (student/teacher/...) determines the build (ADR-0009). Output path
/// is relative to the workspace unless absolute.
export function cphBuildTool(ctx: ToolContext): ToolDefinition {
return tool({
description:
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
inputSchema: z.object({
target: z.string().describe("Build target, e.g. 'student', 'teacher'."),
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
output: z.string().describe("Output file path (relative to workspace or absolute).").optional(),
}),
execute: async (args): Promise<string> => {
const target = args.path ?? ".";
const out = args.output ?? `build/${args.target}.pdf`;
const res = await runCph(["build", target, "--target", args.target, "-o", out], ctx.workspaceDir);
return JSON.stringify({
exitCode: res.exitCode,
stdout: res.stdout,
stderr: res.stderr,
output: out,
});
},
});
}