Files
curriculum-project-hub/hub/src/agent/runner.ts
T
hongjr03 e7924f78d5 feat(hub): 结构化运行历史(A-3) + lock 心跳(A-5)
A-3 结构化历史:
- 新增 AgentMessage 表(每条消息的 queryable 投影,transcript 文件仍是 agent 读回记忆)
- 新增 AgentFileChange 表(run 期间文件变更 before/after hash + operation)
- runner.ts: generateText 后 persistAgentMessages 落库 result.responseMessages
- workspace.ts writeFileTool: 写前算 beforeHash(SHA-256),写后算 afterHash,通过 ctx.onFileChange sink 记录
- runner.ts flushFileChanges: 收集 sink 的变更批量写入 AgentFileChange
- ToolContext 加 onFileChange sink 字段
- trigger.ts 接线 runId/sessionId/prisma 到 RunRequest

A-5 lock 心跳:
- ProjectAgentLock 加 heartbeatAt 列
- runner.ts onStepFinish 回调:每步 model step 后更新 heartbeatAt(吞错,lock 可能被 force-release)

60 tests 全过(tsc 干净)。
2026-07-07 22:20:30 +08:00

260 lines
10 KiB
TypeScript

/**
* Provider-bound agent runner.
*
* 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 { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
import type { PrismaClient, Prisma } from "@prisma/client";
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. */
readonly boundChatId: string;
/** Absolute path to the curriculum engineering-file workspace. */
readonly workspaceDir: string;
}
export interface RunRequest {
readonly prompt: string;
/** Resolved model id (already chosen by the caller per ADR-0017). */
readonly model: string;
readonly project: ProjectContext;
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;
/** The real AgentRun.id (created by the caller before runAgent). Used for
* AgentMessage.runId and ProjectAgentLock heartbeat updates (A-5 liveness). */
readonly runId: string;
/** The AgentSession.id, for AgentMessage.sessionId. */
readonly sessionId: string;
/** Prisma client for persisting AgentMessage rows + lock heartbeats. */
readonly prisma: PrismaClient;
}
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 ModelMessage[];
readonly usage: { inputTokens: number; outputTokens: number };
readonly error?: string;
}
const DEFAULT_MAX_ITERATIONS = 25;
export async function runAgent(
modelFactory: ModelFactory,
tools: ToolRegistry,
req: RunRequest,
): Promise<RunResult> {
// A-3 file-change capture: the writeFile tool calls ctx.onFileChange with
// before/after hash when it modifies a workspace file. The runner injects
// this collector buffer; it is flushed to AgentFileChange rows after the
// run (success or failure). Undefined onFileChange = no-op (unit tests
// that don't exercise writes), so we always wire it here.
const fileChanges: FileChange[] = [];
const ctx: ToolContext = {
runId: req.runId,
projectId: req.project.projectId,
boundChatId: req.project.boundChatId,
workspaceDir: req.project.workspaceDir,
onFileChange: (c) => {
fileChanges.push(c);
},
};
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 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 = [{ role: "system", content: req.systemPrompt }];
}
messages = [...messages, { role: "user", content: req.prompt }];
const cap = req.maxIterations ?? DEFAULT_MAX_ITERATIONS;
try {
const result = await generateText({
model: modelFactory(req.model),
messages,
allowSystemInMessages: true,
tools: aiTools,
stopWhen: stepCountIs(cap),
onStepFinish: async () => {
// A-5 liveness: refresh the lock heartbeat after each model step so
// the liveness monitor sees activity while a long multi-step run is
// in flight. The lock may have been force-released (admin) between
// steps; that's not the runner's problem — swallow the update error.
try {
await req.prisma.projectAgentLock.update({
where: { runId: req.runId },
data: { heartbeatAt: new Date() },
});
} catch {
// Lock gone (force-released) or never held — best-effort.
}
},
});
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
await appendTranscriptIfSet(req, allMessages, loadedCount);
await persistAgentMessages(req, result.responseMessages);
await flushFileChanges(req, fileChanges);
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;
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);
await persistAgentMessages(req, messages);
await flushFileChanges(req, fileChanges);
return {
status: "failed",
messages,
usage: { inputTokens: 0, outputTokens: 0 },
error: e instanceof Error ? e.message : String(e),
};
}
}
/// Append only the messages produced this run (skip the `loadedCount` prior
/// 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 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
// returned regardless. ADR-0002 lock ensures no concurrent writer.
}
}
/// Persist AgentMessage rows for the messages produced during this run
/// (A-3 structured history). Each ModelMessage becomes one row: text parts
/// are joined into `content`; non-text parts (tool-call / tool-result /
/// file / reasoning / …) are JSON-encoded into `attachments`. Best-effort:
/// a write failure on one row is swallowed — the run result is the source of
/// truth and must not be lost over persistence.
async function persistAgentMessages(req: RunRequest, messages: readonly ModelMessage[]): Promise<void> {
for (const msg of messages) {
try {
const { text, attachments } = extractMessageContent(msg);
await req.prisma.agentMessage.create({
data: {
sessionId: req.sessionId,
runId: req.runId,
role: msg.role,
content: text,
// AI SDK parts are JSON-serializable by construction; assert for Prisma's InputJsonValue.
attachments: attachments as Prisma.InputJsonValue,
},
});
} catch {
// Swallow: structured-history persistence is best-effort; a single row
// failure must not abort the run or lose the run result.
}
}
}
/// Extract the `content` string and `attachments` payload from a ModelMessage.
/// Runtime-narrows the AI SDK's part-typed content union: string content →
/// text directly; array content → join `text` parts, collect the rest as
/// attachments. Narrowing via `in` / `typeof` keeps the access compiler-checked
/// rather than asserted.
function extractMessageContent(msg: ModelMessage): { text: string; attachments: unknown[] } {
const content = msg.content;
if (typeof content === "string") {
return { text: content, attachments: [] };
}
if (!Array.isArray(content)) {
return { text: "", attachments: [content] };
}
const textParts: string[] = [];
const attachments: unknown[] = [];
for (const part of content) {
if (
part !== null &&
typeof part === "object" &&
"type" in part &&
part.type === "text" &&
"text" in part &&
typeof part.text === "string"
) {
textParts.push(part.text);
} else {
attachments.push(part);
}
}
return { text: textParts.join("\n"), attachments };
}
/// The file-change record the writeFile tool emits via ctx.onFileChange.
/// Derived from the ToolContext sink signature so it can't drift.
type FileChange = Parameters<NonNullable<ToolContext["onFileChange"]>>[0];
/// Flush the buffered file-change records to AgentFileChange rows (A-3).
/// Called after the run completes (success or failure) with whatever the
/// writeFile tool captured. Best-effort: a flush failure is swallowed — the
/// run result is the source of truth, not the change log.
async function flushFileChanges(req: RunRequest, changes: readonly FileChange[]): Promise<void> {
if (changes.length === 0) return;
try {
await req.prisma.agentFileChange.createMany({
data: changes.map((c) => ({
runId: req.runId,
projectId: req.project.projectId,
path: c.path,
operation: c.operation,
beforeHash: c.beforeHash,
afterHash: c.afterHash,
diff: c.diff,
metadata: {},
})),
});
} catch {
// Swallow: file-change capture is best-effort observability; must not
// lose the run result over a change-log write failure.
}
}