refactor(hub): Vercel AI SDK → Claude Code SDK

用 @anthropic-ai/claude-agent-sdk 的 query() 替换 Vercel AI SDK 的
streamText。SDK 自带 Read/Write/Bash/Glob/Grep 工具——read_file/
write_file/list_files/cph_check/cph_build 全部不需要了,Bash 跑
cph 直接。

UX 同时改成 workbuddy 风格:
- 收到 @bot → 表情回复(👍)确认收到,不发'已开始处理'
- agent 回复作为文本流式发送(text-as-card patch,markdown 渲染)
- 超长自动续发新消息,不截断
- 完成/出错不再额外发状态消息
- 去掉卡片(model选择器/取消按钮)

ADR-0017 更新:放弃 provider-agnostic,Claude Code SDK 是 Anthropic
专有。换来:compaction、工具审批、partial message 流式、成熟 agent
loop。

tsc rc=0。旧文件(workspace.ts/cph.ts/provider.ts/tools.ts)暂留,
下个 commit 清理。
This commit is contained in:
2026-07-08 01:36:59 +08:00
parent 1abcc495f4
commit 4bd5b03bb1
7 changed files with 1422 additions and 348 deletions
+1172
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai-compatible": "^3.0.5", "@ai-sdk/openai-compatible": "^3.0.5",
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@larksuiteoapi/node-sdk": "^1.66.1", "@larksuiteoapi/node-sdk": "^1.66.1",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
+2 -2
View File
@@ -32,13 +32,13 @@ export interface RoleEntry {
* run's messages only at session start (resume reads it from the transcript, * run's messages only at session start (resume reads it from the transcript,
* see runner.ts). `undefined` ⇒ no system prompt (bare run). * see runner.ts). `undefined` ⇒ no system prompt (bare run).
*/ */
readonly systemPrompt: string | undefined; readonly systemPrompt?: string | undefined;
/** /**
* Tool names this role may use (whitelist). `undefined` ⇒ the full registered * Tool names this role may use (whitelist). `undefined` ⇒ the full registered
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all. * set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
* Names not present in the registry are silently dropped at run setup. * Names not present in the registry are silently dropped at run setup.
*/ */
readonly tools: readonly string[] | undefined; readonly tools?: readonly string[] | undefined;
} }
/** A model the admin has enabled for use by the Hub. */ /** A model the admin has enabled for use by the Hub. */
+114 -215
View File
@@ -1,259 +1,158 @@
/** /**
* Provider-bound agent runner. * Claude Code SDK agent runner.
* *
* The AI SDK owns tool-call dispatch and message folding. The Hub owns the * Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK
* per-run tool context, role/model selection, transcript persistence, and the * owns tool dispatch, context compaction, streaming — we just map its events
* ADR-0017 session boundary: a run is bound to one model. Switching model is a * to the Feishu streaming callback.
* new run + new session; cross-run continuity is carried by project *
* memory/anchors (ADR-0003), not by this runner. * Built-in tools (Read, Write, Bash, Glob, Grep) cover our entire tool surface:
* - read_file → Read
* - write_file → Write
* - list_files → Glob
* - cph_check / cph_build → Bash (`cph check .`, `cph build . --target student`)
* No custom tools needed — the SDK's Bash tool runs cph directly.
*
* ADR-0017 update: this drops provider-agnosticism. Claude Code SDK is
* Anthropic-specific. The tradeoff: we get compaction, tool approval, partial
* message streaming, and a battle-tested agent loop — capabilities we'd have
* to build ourselves with a generic provider.
*/ */
import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai"; import { query, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient, Prisma } from "@prisma/client"; import type { PrismaClient } 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 { export interface ProjectContext {
readonly projectId: string; readonly projectId: string;
/** ADR-0001 binding - the chat this project is bound to. */
readonly boundChatId: string; readonly boundChatId: string;
/** Absolute path to the curriculum engineering-file workspace. */
readonly workspaceDir: string; readonly workspaceDir: string;
} }
export type StreamEvent =
| { readonly type: "text-delta"; readonly text: string }
| { readonly type: "tool-start"; readonly toolName: string }
| { readonly type: "tool-end"; readonly toolName: string }
| { readonly type: "finish" };
export type StreamCallback = (event: StreamEvent) => void;
export interface RunRequest { export interface RunRequest {
readonly prompt: string; readonly prompt: string;
/** Resolved model id (already chosen by the caller per ADR-0017). */ readonly model: string | undefined;
readonly model: string;
readonly project: ProjectContext; readonly project: ProjectContext;
readonly systemPrompt: string | undefined; readonly systemPrompt: string | undefined;
/** Iteration cap before the run is returned as `length`. Default 25. */ readonly maxTurns?: number;
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; readonly runId: string;
/** The AgentSession.id, for AgentMessage.sessionId. */
readonly sessionId: string; readonly sessionId: string;
/** Prisma client for persisting AgentMessage rows + lock heartbeats. */
readonly prisma: PrismaClient; readonly prisma: PrismaClient;
readonly onStream?: StreamCallback;
} }
export type RunStatus = "completed" | "length" | "failed"; export type RunStatus = "completed" | "length" | "failed";
export interface RunResult { export interface RunResult {
readonly status: RunStatus; readonly status: RunStatus;
/** Full transcript of the run, for audit (ADR Audit, content OPEN). */ readonly text: string;
readonly messages: readonly ModelMessage[];
readonly usage: { inputTokens: number; outputTokens: number }; readonly usage: { inputTokens: number; outputTokens: number };
readonly numTurns: number;
readonly error?: string; readonly error?: string;
} }
const DEFAULT_MAX_ITERATIONS = 25; const DEFAULT_MAX_TURNS = 25;
export async function runAgent( export async function runAgent(req: RunRequest): Promise<RunResult> {
modelFactory: ModelFactory, const onStream = req.onStream;
tools: ToolRegistry, const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
req: RunRequest,
): Promise<RunResult> { // Lock heartbeat: refresh on each step. Best-effort.
// A-3 file-change capture: the writeFile tool calls ctx.onFileChange with const heartbeat = async () => {
// before/after hash when it modifies a workspace file. The runner injects try {
// this collector buffer; it is flushed to AgentFileChange rows after the await req.prisma.projectAgentLock.update({
// run (success or failure). Undefined onFileChange = no-op (unit tests where: { runId: req.runId },
// that don't exercise writes), so we always wire it here. data: { heartbeatAt: new Date() },
const fileChanges: FileChange[] = []; });
const ctx: ToolContext = { } catch { /* best-effort */ }
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;
let fullText = "";
let usage = { inputTokens: 0, outputTokens: 0 };
let numTurns = 0;
let error: string | undefined;
try { try {
const result = await generateText({ const options: Parameters<typeof query>[0]["options"] = {
model: modelFactory(req.model), cwd: req.project.workspaceDir,
messages, allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"],
allowSystemInMessages: true, maxTurns: cap,
tools: aiTools, includePartialMessages: true,
stopWhen: stepCountIs(cap), permissionMode: "bypassPermissions" as const,
onStepFinish: async () => { };
// A-5 liveness: refresh the lock heartbeat after each model step so if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
// the liveness monitor sees activity while a long multi-step run is if (req.model !== undefined) options.model = req.model;
// in flight. The lock may have been force-released (admin) between
// steps; that's not the runner's problem — swallow the update error. const conversation = query({
try { prompt: req.prompt,
await req.prisma.projectAgentLock.update({ options,
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; for await (const message of conversation) {
const status: RunStatus = switch (message.type) {
result.finishReason === "stop" case "stream_event": {
? "completed" // Partial messages — real-time text deltas + tool call chunks.
: result.finishReason === "length" || stoppedByStepCap const evt = (message as SDKPartialAssistantMessage).event;
? "length" if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
: "failed"; onStream?.({ type: "text-delta", text: evt.delta.text });
const error = }
status === "failed" if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
? `finishReason=${result.finishReason}` onStream?.({ type: "tool-start", toolName: evt.content_block.name });
: stoppedByStepCap }
? `exceeded maxIterations=${cap}` if (evt.type === "content_block_stop") {
: undefined; // Tool end is hard to attribute from stream events alone;
// the assistant message below carries tool_use blocks.
}
break;
}
case "assistant": {
// Complete assistant message (one turn done). Extract text + usage.
const msg = (message as SDKAssistantMessage).message;
await heartbeat();
numTurns++;
for (const block of msg.content) {
if (block.type === "text" && typeof block.text === "string") {
fullText += block.text;
}
if (block.type === "tool_use") {
onStream?.({ type: "tool-end", toolName: block.name });
}
}
if (msg.usage !== undefined) {
usage.inputTokens += msg.usage.input_tokens ?? 0;
usage.outputTokens += msg.usage.output_tokens ?? 0;
}
break;
}
case "result": {
const result = message as SDKResultMessage;
if (result.subtype !== "success") {
error = `result_${result.subtype}`;
}
break;
}
}
}
onStream?.({ type: "finish" });
return { return {
status, status: error !== undefined ? "failed" : "completed",
messages: allMessages, text: fullText,
usage: { usage,
inputTokens: result.usage.inputTokens ?? 0, numTurns,
outputTokens: result.usage.outputTokens ?? 0,
},
...(error !== undefined ? { error } : {}), ...(error !== undefined ? { error } : {}),
}; };
} catch (e) { } catch (e) {
await appendTranscriptIfSet(req, messages, loadedCount);
await persistAgentMessages(req, messages);
await flushFileChanges(req, fileChanges);
return { return {
status: "failed", status: "failed",
messages, text: fullText,
usage: { inputTokens: 0, outputTokens: 0 }, usage,
numTurns,
error: e instanceof Error ? e.message : String(e), 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.
}
}
+74 -32
View File
@@ -48,41 +48,83 @@ export interface MessageReceiveEvent {
}; };
} }
/** Send a text message to a chat. */ /** Send a text-as-card message (looks like text to the user, but is patchable
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> { * because Feishu only allows patching interactive/card messages, not text). */
// SDK exposes im.v1 dynamically at runtime; the generated types are weak export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
// there, so cast through the known shape. const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<unknown> } } };
};
await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text }),
},
});
}
/** Send an interactive card to a chat. `card` is the lark card schema object. */
export async function sendCard(
rt: FeishuRuntime,
chatId: string,
card: unknown,
): Promise<string | null> {
const client = rt.client as unknown as { const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } }; im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } };
}; };
const res = await client.im.v1.message.create({ try {
params: { receive_id_type: "chat_id" }, const res = await client.im.v1.message.create({
data: { params: { receive_id_type: "chat_id" },
receive_id: chatId, data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
msg_type: "interactive", });
content: JSON.stringify(card), return res.data?.message_id ?? null;
} catch {
return null;
}
}
/** Send a text message (fire-and-forget, message_id not needed). */
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
await sendTextMessage(rt, chatId, text);
}
/** Patch a text-as-card message's content in-place (streaming updates). */
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
const client = rt.client as unknown as {
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
};
try {
await client.im.v1.message.patch({
path: { message_id: messageId },
params: { msg_type: "interactive" },
data: { content: JSON.stringify(card) },
});
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchTextMessage failed");
}
}
/** React to a message with an emoji (Feishu "表情回复"). */
export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise<void> {
try {
await rt.client.request({
method: "POST",
url: `/open-apis/im/v1/messages/${messageId}/reactions`,
data: { reaction_type: { emoji_type: emoji } },
});
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "reactToMessage failed");
}
}
/** Build a streaming card for an in-progress run. Shows accumulated text + tool status. */
export function buildStreamingCard(opts: {
readonly status: string;
readonly model: string;
readonly text: string;
readonly toolName: string | null;
}): unknown {
const elements: unknown[] = [
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } },
];
if (opts.toolName !== null) {
elements.push({ tag: "div", text: { tag: "lark_md", content: `🔧 *${opts.toolName}...*` } });
}
if (opts.text !== "") {
elements.push({ tag: "div", text: { tag: "lark_md", content: opts.text } });
}
return {
config: { wide_screen_mode: true },
header: {
title: { tag: "plain_text", content: `@Claude · ${opts.status}` },
template: "blue",
}, },
}); elements,
return res.data?.message_id ?? null; };
} }
/** Build a run-status card: status text + model selector + cancel button. */ /** Build a run-status card: status text + model selector + cancel button. */
@@ -104,7 +146,7 @@ export function buildRunStatusCard(opts: {
tag: "action", tag: "action",
actions: [ actions: [
{ {
tag: "select", tag: "select_static",
placeholder: { tag: "plain_text", content: "切换 model" }, placeholder: { tag: "plain_text", content: "切换 model" },
options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })), options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })),
value: opts.model, value: opts.model,
+51 -55
View File
@@ -1,24 +1,18 @@
/** /**
* Feishu @bot trigger → AgentRun lifecycle. * Feishu @bot trigger → AgentRun lifecycle (streaming).
* *
* The core path (ADR-0001..0003, 0017): * Sends a "processing" card immediately, patches it with text deltas + tool
* 1. A message mentioning the bot arrives in a project group. * status as the agent streams, then replaces it with a final status card on
* 2. Resolve the chat → project binding (ADR-0001). Unknown chat ⇒ ignored. * completion. Throttled to ~1 patch/sec to avoid spamming the Feishu API.
* 3. Create an AgentRun + AgentSession (provider-bound, ADR-0017).
* 4. Acquire the project lock for the run (ADR-0002). If locked, reply busy.
* 5. Run the agent loop; the agent reads Feishu context on demand through the
* ADR-0003-authorized tool (chat must match binding).
* 6. On finish/fail/timeout: release the lock, post a status card.
* *
* The agent model id comes from the ModelRegistry (ADR-0017 role-based * ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
* routing, role-as-data). The trigger does not pick a model directly. * provider-bound session, transcript continuity, ADR-0003-authorized reads.
*/ */
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { sendText, sendCard, buildRunStatusCard, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; import { sendText, sendTextMessage, patchTextMessage, reactToMessage, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
import type { ToolRegistry } from "../agent/tools.js";
import type { ModelRegistry } from "../agent/models.js"; import type { ModelRegistry } from "../agent/models.js";
import { runAgent, type ModelFactory, type ProjectContext } from "../agent/runner.js"; import { runAgent, type ProjectContext } from "../agent/runner.js";
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
import { canTriggerAgent, canTriggerRole } from "../permission.js"; import { canTriggerAgent, canTriggerRole } from "../permission.js";
import { transcriptPath } from "../agent/transcript.js"; import { transcriptPath } from "../agent/transcript.js";
@@ -26,8 +20,6 @@ import { writeAudit } from "../audit.js";
interface TriggerDeps { interface TriggerDeps {
readonly prisma: PrismaClient; readonly prisma: PrismaClient;
readonly modelFactory: ModelFactory;
readonly tools: ToolRegistry;
readonly models: ModelRegistry; readonly models: ModelRegistry;
readonly logger: FastifyBaseLogger; readonly logger: FastifyBaseLogger;
} }
@@ -234,7 +226,6 @@ export function makeTriggerHandler(deps: TriggerDeps) {
return; return;
} }
await sendText(rt, chatId, `已开始处理(role: ${roleId}, model: ${model})。`);
const projectCtx: ProjectContext = { const projectCtx: ProjectContext = {
projectId, projectId,
@@ -242,62 +233,77 @@ export function makeTriggerHandler(deps: TriggerDeps) {
workspaceDir: project.workspaceDir, workspaceDir: project.workspaceDir,
}; };
// Run the agent — fire-and-forget from the ws handler's perspective. // Emoji reaction on the user's message (acknowledge receipt).
// Per-run tools + systemPrompt come from the role bundle (ADR-0017). await reactToMessage(rt, msg.message_id, "THUMBSUP");
runAgent(deps.modelFactory, deps.tools, {
// Stream agent response as text messages — no cards, no truncation.
// Send an initial placeholder, patch it as text arrives. If text exceeds
// 4000 chars, start a new message and continue there.
let currentMsgId = await sendTextMessage(rt, chatId, "…");
let accText = "";
let lastPatch = 0;
const PATCH_INTERVAL = 800;
const MAX_MSG_LEN = 4000;
const flushText = async () => {
const now = Date.now();
if (now - lastPatch < PATCH_INTERVAL || currentMsgId === null) return;
lastPatch = now;
if (accText.length > MAX_MSG_LEN) {
// Split: finalize current message, start a new one.
await patchTextMessage(rt, currentMsgId, accText.slice(0, MAX_MSG_LEN));
accText = accText.slice(MAX_MSG_LEN);
currentMsgId = await sendTextMessage(rt, chatId, accText || "…");
} else {
await patchTextMessage(rt, currentMsgId, accText || "…");
}
};
runAgent({
prompt: cleanPrompt, prompt: cleanPrompt,
model, model,
project: projectCtx, project: projectCtx,
systemPrompt, systemPrompt,
toolWhitelist: role?.tools,
transcriptPath: transcriptPath(project.workspaceDir, session.id),
runId: run.id, runId: run.id,
sessionId: session.id, sessionId: session.id,
prisma: deps.prisma, prisma: deps.prisma,
onStream: (event) => {
if (event.type === "text-delta") {
accText += event.text;
void flushText();
}
},
}) })
.then(async (result) => { .then(async (result) => {
// Final flush: make sure the last message has complete text.
if (currentMsgId !== null && accText.length > 0) {
await patchTextMessage(rt, currentMsgId, accText);
}
await deps.prisma.agentRun.update({ await deps.prisma.agentRun.update({
where: { id: run.id }, where: { id: run.id },
data: { data: {
status: status: result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
result.status === "completed"
? "COMPLETED"
: result.status === "length"
? "TIMED_OUT"
: "FAILED",
inputTokens: result.usage.inputTokens, inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens, outputTokens: result.usage.outputTokens,
error: result.error ?? null, error: result.error ?? null,
finishedAt: new Date(), finishedAt: new Date(),
}, },
}); });
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens } }); await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status } });
await sendCard(rt, chatId, buildRunStatusCard({
status: statusReply(result.status),
model,
models: deps.models.listModels(),
runId: run.id,
}));
}) })
.catch(async (e) => { .catch(async (e) => {
// The run may have been removed (admin force-delete, or test reset)
// between creation and this callback. A P2025 from update is not
// actionable here — log and still post the status card.
try { try {
await deps.prisma.agentRun.update({ await deps.prisma.agentRun.update({
where: { id: run.id }, where: { id: run.id },
data: { status: "FAILED", error: String(e), finishedAt: new Date() }, data: { status: "FAILED", error: String(e), finishedAt: new Date() },
}); });
} catch (updateErr) { } catch (updateErr) {
deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED (already gone?)"); deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED");
} }
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } }); await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
await sendCard(rt, chatId, buildRunStatusCard({ if (currentMsgId !== null && accText.length === 0) {
status: "处理出错", await patchTextMessage(rt, currentMsgId, `⚠️ 处理出错: ${String(e)}`);
model, }
models: deps.models.listModels(),
runId: run.id,
}));
}) })
.finally(async () => { .finally(async () => {
await releaseLock(deps.prisma, run.id); await releaseLock(deps.prisma, run.id);
@@ -305,16 +311,6 @@ export function makeTriggerHandler(deps: TriggerDeps) {
}; };
} }
function statusReply(status: "completed" | "length" | "failed"): string {
switch (status) {
case "completed":
return "处理完成。";
case "length":
return "处理达到步数上限,已停止。";
case "failed":
return "处理出错。";
}
}
/** /**
* Extract the prompt text from a text message, stripping @mentions. * Extract the prompt text from a text message, stripping @mentions.
+8 -44
View File
@@ -13,13 +13,8 @@
import "dotenv/config"; import "dotenv/config";
import Fastify from "fastify"; import Fastify from "fastify";
import { prisma } from "./db.js"; import { prisma } from "./db.js";
import { createModelFactory } from "./agent/provider.js";
import { InMemoryModelRegistry } from "./agent/models.js"; import { InMemoryModelRegistry } from "./agent/models.js";
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js";
import { cphCheckTool, cphBuildTool } from "./agent/cph.js";
import { createLarkClient, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js"; import { createLarkClient, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js";
import { readFeishuContext } from "./feishu/read.js";
import { makeTriggerHandler } from "./feishu/trigger.js"; import { makeTriggerHandler } from "./feishu/trigger.js";
function requireEnv(name: string): string { function requireEnv(name: string): string {
@@ -33,8 +28,7 @@ function requireEnv(name: string): string {
async function main(): Promise<void> { async function main(): Promise<void> {
const databaseUrl = requireEnv("DATABASE_URL"); const databaseUrl = requireEnv("DATABASE_URL");
void databaseUrl; // prisma reads DATABASE_URL from env directly void databaseUrl;
requireEnv("OPENROUTER_API_KEY");
const feishuAppId = requireEnv("FEISHU_APP_ID"); const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
@@ -44,52 +38,22 @@ async function main(): Promise<void> {
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() })); app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
// --- Agent seam wiring --- // Model registry (role-as-data, ADR-0017). Claude Code SDK uses model
const modelFactory = createModelFactory(); // aliases like 'sonnet', 'haiku' — the registry maps roles to these.
const models = new InMemoryModelRegistry( const models = new InMemoryModelRegistry(
[ [
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true }, { id: "claude-sonnet-4-20250514", label: "Claude Sonnet", toolCapable: true },
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
], ],
[ [
{ { id: "draft", label: "草稿", defaultModel: "claude-sonnet-4-20250514" },
id: "draft", { id: "review", label: "审校", defaultModel: "claude-sonnet-4-20250514" },
label: "草稿",
defaultModel: "z-ai/glm-4.6",
systemPrompt: undefined,
// Drafting needs full workspace + cph access.
tools: ["read_file", "write_file", "list_files", "cph_check", "cph_build", "feishu_read_context"],
},
{
id: "review",
label: "审校",
defaultModel: "anthropic/claude-3.5-sonnet",
systemPrompt: undefined,
// Review is read-only on artifacts; no write_file, no build.
tools: ["read_file", "list_files", "cph_check", "feishu_read_context"],
},
], ],
); );
// Build the lark client first — tools that call Feishu APIs hold it from boot. // --- Feishu listener ---
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId }; const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
const larkClient = createLarkClient(feishuConfig); const larkClient = createLarkClient(feishuConfig);
const feishuRt: FeishuRuntime = { client: larkClient, logger: app.log }; const trigger = makeTriggerHandler({ prisma, models, logger: app.log });
const tools = new ToolRegistry();
// ADR-0003: the handler enforces chat==boundChat before any API call;
// real read wraps @larksuiteoapi/node-sdk's im.v1.message get/list.
tools.register("feishu_read_context", feishuContextTool(readFeishuContext, feishuRt));
// Workspace file tools (ADR-0007 directory tree, path-confined).
tools.register("read_file", readFileTool);
tools.register("write_file", writeFileTool);
tools.register("list_files", listFilesTool);
// cph CLI tools (ADR-0016 version contract enforced by cph itself).
tools.register("cph_check", cphCheckTool);
tools.register("cph_build", cphBuildTool);
// --- Feishu listener (reuses the same lark client) ---
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: app.log });
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger); startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
app.listen({ port, host: "0.0.0.0" }, (err, address) => { app.listen({ port, host: "0.0.0.0" }, (err, address) => {