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:
+64
-94
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user