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
+64 -94
View File
@@ -1,22 +1,22 @@
/**
* Provider-agnostic agent loop.
* Provider-bound agent runner.
*
* The loop is ours (ADR-0017): dispatch tools, fold results, continue until the
* model stops, a budget is exhausted, or a tool fails unrecoverably. It depends
* only on {@link AgentProvider} + {@link ToolRegistry}, never on a vendor SDK.
*
* Session/provider binding (ADR-0017): a run is bound to one model. Switching
* model is a *new* run + new session; cross-run continuity is carried by project
* memory/anchors (ADR-0003), not by this loop.
* The AI SDK owns tool-call dispatch and message folding. The Hub owns the
* per-run tool context, role/model selection, transcript persistence, and the
* ADR-0017 session boundary: a run is bound to one model. Switching model is a
* new run + new session; cross-run continuity is carried by project
* memory/anchors (ADR-0003), not by this runner.
*/
import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.js";
import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
import type { ToolContext, ToolRegistry } from "./tools.js";
import { readTranscript, appendTranscript } from "./transcript.js";
export type ModelFactory = (modelId: string) => LanguageModel;
/** What the runner needs about the project to seed and bound a run. */
export interface ProjectContext {
readonly projectId: string;
/** ADR-0001 binding the chat this project is bound to. */
/** ADR-0001 binding - the chat this project is bound to. */
readonly boundChatId: string;
/** Absolute path to the curriculum engineering-file workspace. */
readonly workspaceDir: string;
@@ -30,6 +30,8 @@ export interface RunRequest {
readonly systemPrompt: string | undefined;
/** Iteration cap before the run is returned as `length`. Default 25. */
readonly maxIterations?: number;
/** Per-role tool whitelist (ADR-0017). Undefined means all registered tools. */
readonly toolWhitelist?: readonly string[] | undefined;
/** Path to the session transcript JSONL. If set, prior messages are loaded
* from it before the run, and new messages are appended after. */
readonly transcriptPath?: string;
@@ -40,20 +42,15 @@ export type RunStatus = "completed" | "length" | "failed";
export interface RunResult {
readonly status: RunStatus;
/** Full transcript of the run, for audit (ADR Audit, content OPEN). */
readonly messages: readonly Message[];
readonly messages: readonly ModelMessage[];
readonly usage: { inputTokens: number; outputTokens: number };
readonly error?: string;
}
const DEFAULT_MAX_ITERATIONS = 25;
/**
* Run one agent task to completion (or budget exhaustion).
*
* @throws if the provider call itself fails before any tool runs.
*/
export async function runAgent(
provider: AgentProvider,
modelFactory: ModelFactory,
tools: ToolRegistry,
req: RunRequest,
): Promise<RunResult> {
@@ -63,108 +60,81 @@ export async function runAgent(
boundChatId: req.project.boundChatId,
workspaceDir: req.project.workspaceDir,
};
const messages: Message[] = [];
const aiTools = tools.build(ctx, req.toolWhitelist);
// Load prior session messages from transcript (continuity across @bot calls
// in the same session). ADR-0003: session messages group chat history.
let loadedCount = 0;
if (req.transcriptPath !== undefined) {
const prior = await readTranscript(req.transcriptPath);
loadedCount = prior.length;
messages.push(...prior);
}
// in the same session). ADR-0003: session messages != group chat history.
let messages: ModelMessage[] = req.transcriptPath !== undefined ? await readTranscript(req.transcriptPath) : [];
const loadedCount = messages.length;
if (req.systemPrompt !== undefined && messages.length === 0) {
// System prompt only at the very start of a session; on resume it's
// already in the transcript.
messages.push({ role: "system", parts: [{ type: "text", text: req.systemPrompt }] });
messages = [{ role: "system", content: req.systemPrompt }];
}
messages.push({ role: "user", parts: [{ type: "text", text: req.prompt }] });
messages = [...messages, { role: "user", content: req.prompt }];
const cap = req.maxIterations ?? DEFAULT_MAX_ITERATIONS;
let inputTokens = 0;
let outputTokens = 0;
for (let i = 0; i < cap; i++) {
let res: ChatResponse;
try {
res = await provider.chat({
model: req.model,
messages,
tools: tools.specs(),
});
} catch (e) {
const result: RunResult = {
status: "failed",
messages,
usage: { inputTokens, outputTokens },
error: e instanceof Error ? e.message : String(e),
};
await appendTranscriptIfSet(req, messages, loadedCount);
return result;
}
try {
const result = await generateText({
model: modelFactory(req.model),
messages,
allowSystemInMessages: true,
tools: aiTools,
stopWhen: stepCountIs(cap),
});
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
await appendTranscriptIfSet(req, allMessages, loadedCount);
messages.push(res.message);
if (res.usage !== undefined) {
inputTokens += res.usage.inputTokens;
outputTokens += res.usage.outputTokens;
}
const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap;
const status: RunStatus =
result.finishReason === "stop"
? "completed"
: result.finishReason === "length" || stoppedByStepCap
? "length"
: "failed";
const error =
status === "failed"
? `finishReason=${result.finishReason}`
: stoppedByStepCap
? `exceeded maxIterations=${cap}`
: undefined;
if (res.finishReason === "stop") {
await appendTranscriptIfSet(req, messages, loadedCount);
return { status: "completed", messages, usage: { inputTokens, outputTokens } };
}
if (res.finishReason !== "tool_calls") {
await appendTranscriptIfSet(req, messages, loadedCount);
return { status: "length", messages, usage: { inputTokens, outputTokens } };
}
const calls = toolCallsOfAssistant(res.message);
for (const call of calls) {
const args = safeParseArgs(call);
const content = await tools.execute(call.name, args, ctx);
messages.push({
role: "tool",
parts: [{ type: "tool_result", tool_call_id: call.id, content }],
});
}
return {
status,
messages: allMessages,
usage: {
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
},
...(error !== undefined ? { error } : {}),
};
} catch (e) {
await appendTranscriptIfSet(req, messages, loadedCount);
return {
status: "failed",
messages,
usage: { inputTokens: 0, outputTokens: 0 },
error: e instanceof Error ? e.message : String(e),
};
}
await appendTranscriptIfSet(req, messages, loadedCount);
return {
status: "length",
messages,
usage: { inputTokens, outputTokens },
error: `exceeded maxIterations=${cap}`,
};
}
/// Append only the messages produced this run (skip the `loadedCount` prior
/// ones already in the file). Errors are swallowed a transcript write
/// ones already in the file). Errors are swallowed - a transcript write
/// failure must not lose the run result.
async function appendTranscriptIfSet(req: RunRequest, messages: readonly Message[], loadedCount: number): Promise<void> {
async function appendTranscriptIfSet(req: RunRequest, messages: readonly ModelMessage[], loadedCount: number): Promise<void> {
if (req.transcriptPath === undefined) return;
const newMessages = messages.slice(loadedCount);
if (newMessages.length === 0) return;
try {
await appendTranscript(req.transcriptPath, newMessages);
} catch {
// Swallow transcript is best-effort persistence; the run result is
// Swallow - transcript is best-effort persistence; the run result is
// returned regardless. ADR-0002 lock ensures no concurrent writer.
}
}
function toolCallsOfAssistant(msg: Message): readonly ToolCallPart[] {
if (msg.role !== "assistant") return [];
return msg.parts.filter((p): p is ToolCallPart => p.type === "tool_call");
}
function safeParseArgs(call: ToolCallPart): unknown {
try {
return JSON.parse(call.arguments);
} catch {
return {};
}
}
function cryptoRandomId(): string {
return globalThis.crypto.randomUUID();
}