forked from EduCraft/curriculum-project-hub
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:
Generated
+1172
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^3.0.5",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@larksuiteoapi/node-sdk": "^1.66.1",
|
||||
"@prisma/client": "^6.19.3",
|
||||
|
||||
@@ -32,13 +32,13 @@ export interface RoleEntry {
|
||||
* run's messages only at session start (resume reads it from the transcript,
|
||||
* 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
|
||||
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
|
||||
* 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. */
|
||||
|
||||
+114
-215
@@ -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
|
||||
* 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.
|
||||
* Uses `@anthropic-ai/claude-agent-sdk`'s `query()` as the agent loop. The SDK
|
||||
* owns tool dispatch, context compaction, streaming — we just map its events
|
||||
* to the Feishu streaming callback.
|
||||
*
|
||||
* 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 type { PrismaClient, Prisma } from "@prisma/client";
|
||||
import type { ToolContext, ToolRegistry } from "./tools.js";
|
||||
import { readTranscript, appendTranscript } from "./transcript.js";
|
||||
import { query, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
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 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 {
|
||||
readonly prompt: string;
|
||||
/** Resolved model id (already chosen by the caller per ADR-0017). */
|
||||
readonly model: string;
|
||||
readonly model: string | undefined;
|
||||
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 maxTurns?: number;
|
||||
readonly runId: string;
|
||||
/** The AgentSession.id, for AgentMessage.sessionId. */
|
||||
readonly sessionId: string;
|
||||
/** Prisma client for persisting AgentMessage rows + lock heartbeats. */
|
||||
readonly prisma: PrismaClient;
|
||||
readonly onStream?: StreamCallback;
|
||||
}
|
||||
|
||||
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 text: string;
|
||||
readonly usage: { inputTokens: number; outputTokens: number };
|
||||
readonly numTurns: number;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ITERATIONS = 25;
|
||||
const DEFAULT_MAX_TURNS = 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);
|
||||
},
|
||||
export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
const onStream = req.onStream;
|
||||
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
|
||||
|
||||
// Lock heartbeat: refresh on each step. Best-effort.
|
||||
const heartbeat = async () => {
|
||||
try {
|
||||
await req.prisma.projectAgentLock.update({
|
||||
where: { runId: req.runId },
|
||||
data: { heartbeatAt: new Date() },
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
};
|
||||
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 {
|
||||
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 options: Parameters<typeof query>[0]["options"] = {
|
||||
cwd: req.project.workspaceDir,
|
||||
allowedTools: ["Read", "Write", "Bash", "Glob", "Grep"],
|
||||
maxTurns: cap,
|
||||
includePartialMessages: true,
|
||||
permissionMode: "bypassPermissions" as const,
|
||||
};
|
||||
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
||||
if (req.model !== undefined) options.model = req.model;
|
||||
|
||||
const conversation = query({
|
||||
prompt: req.prompt,
|
||||
options,
|
||||
});
|
||||
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;
|
||||
for await (const message of conversation) {
|
||||
switch (message.type) {
|
||||
case "stream_event": {
|
||||
// Partial messages — real-time text deltas + tool call chunks.
|
||||
const evt = (message as SDKPartialAssistantMessage).event;
|
||||
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
|
||||
onStream?.({ type: "text-delta", text: evt.delta.text });
|
||||
}
|
||||
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
|
||||
onStream?.({ type: "tool-start", toolName: evt.content_block.name });
|
||||
}
|
||||
if (evt.type === "content_block_stop") {
|
||||
// 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 {
|
||||
status,
|
||||
messages: allMessages,
|
||||
usage: {
|
||||
inputTokens: result.usage.inputTokens ?? 0,
|
||||
outputTokens: result.usage.outputTokens ?? 0,
|
||||
},
|
||||
status: error !== undefined ? "failed" : "completed",
|
||||
text: fullText,
|
||||
usage,
|
||||
numTurns,
|
||||
...(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 },
|
||||
text: fullText,
|
||||
usage,
|
||||
numTurns,
|
||||
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
@@ -48,41 +48,83 @@ export interface MessageReceiveEvent {
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a text message to a chat. */
|
||||
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
|
||||
// SDK exposes im.v1 dynamically at runtime; the generated types are weak
|
||||
// there, so cast through the known shape.
|
||||
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> {
|
||||
/** Send a text-as-card message (looks like text to the user, but is patchable
|
||||
* because Feishu only allows patching interactive/card messages, not text). */
|
||||
export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
|
||||
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<{ data?: { message_id?: string } }> } } };
|
||||
};
|
||||
const res = await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: chatId,
|
||||
msg_type: "interactive",
|
||||
content: JSON.stringify(card),
|
||||
try {
|
||||
const res = await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: { receive_id: chatId, 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",
|
||||
},
|
||||
});
|
||||
return res.data?.message_id ?? null;
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a run-status card: status text + model selector + cancel button. */
|
||||
@@ -104,7 +146,7 @@ export function buildRunStatusCard(opts: {
|
||||
tag: "action",
|
||||
actions: [
|
||||
{
|
||||
tag: "select",
|
||||
tag: "select_static",
|
||||
placeholder: { tag: "plain_text", content: "切换 model" },
|
||||
options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })),
|
||||
value: opts.model,
|
||||
|
||||
+51
-55
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* Feishu @bot trigger → AgentRun lifecycle.
|
||||
* Feishu @bot trigger → AgentRun lifecycle (streaming).
|
||||
*
|
||||
* The core path (ADR-0001..0003, 0017):
|
||||
* 1. A message mentioning the bot arrives in a project group.
|
||||
* 2. Resolve the chat → project binding (ADR-0001). Unknown chat ⇒ ignored.
|
||||
* 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.
|
||||
* Sends a "processing" card immediately, patches it with text deltas + tool
|
||||
* status as the agent streams, then replaces it with a final status card on
|
||||
* completion. Throttled to ~1 patch/sec to avoid spamming the Feishu API.
|
||||
*
|
||||
* The agent model id comes from the ModelRegistry (ADR-0017 role-based
|
||||
* routing, role-as-data). The trigger does not pick a model directly.
|
||||
* ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
|
||||
* provider-bound session, transcript continuity, ADR-0003-authorized reads.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { sendText, sendCard, buildRunStatusCard, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||
import type { ToolRegistry } from "../agent/tools.js";
|
||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, type FeishuRuntime, type MessageReceiveEvent } from "./client.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 { canTriggerAgent, canTriggerRole } from "../permission.js";
|
||||
import { transcriptPath } from "../agent/transcript.js";
|
||||
@@ -26,8 +20,6 @@ import { writeAudit } from "../audit.js";
|
||||
|
||||
interface TriggerDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly modelFactory: ModelFactory;
|
||||
readonly tools: ToolRegistry;
|
||||
readonly models: ModelRegistry;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
}
|
||||
@@ -234,7 +226,6 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendText(rt, chatId, `已开始处理(role: ${roleId}, model: ${model})。`);
|
||||
|
||||
const projectCtx: ProjectContext = {
|
||||
projectId,
|
||||
@@ -242,62 +233,77 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
workspaceDir: project.workspaceDir,
|
||||
};
|
||||
|
||||
// Run the agent — fire-and-forget from the ws handler's perspective.
|
||||
// Per-run tools + systemPrompt come from the role bundle (ADR-0017).
|
||||
runAgent(deps.modelFactory, deps.tools, {
|
||||
// Emoji reaction on the user's message (acknowledge receipt).
|
||||
await reactToMessage(rt, msg.message_id, "THUMBSUP");
|
||||
|
||||
// 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,
|
||||
model,
|
||||
project: projectCtx,
|
||||
systemPrompt,
|
||||
toolWhitelist: role?.tools,
|
||||
transcriptPath: transcriptPath(project.workspaceDir, session.id),
|
||||
runId: run.id,
|
||||
sessionId: session.id,
|
||||
prisma: deps.prisma,
|
||||
onStream: (event) => {
|
||||
if (event.type === "text-delta") {
|
||||
accText += event.text;
|
||||
void flushText();
|
||||
}
|
||||
},
|
||||
})
|
||||
.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({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status:
|
||||
result.status === "completed"
|
||||
? "COMPLETED"
|
||||
: result.status === "length"
|
||||
? "TIMED_OUT"
|
||||
: "FAILED",
|
||||
status: result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
error: result.error ?? null,
|
||||
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 sendCard(rt, chatId, buildRunStatusCard({
|
||||
status: statusReply(result.status),
|
||||
model,
|
||||
models: deps.models.listModels(),
|
||||
runId: run.id,
|
||||
}));
|
||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status } });
|
||||
})
|
||||
.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 {
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
|
||||
});
|
||||
} 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 sendCard(rt, chatId, buildRunStatusCard({
|
||||
status: "处理出错",
|
||||
model,
|
||||
models: deps.models.listModels(),
|
||||
runId: run.id,
|
||||
}));
|
||||
if (currentMsgId !== null && accText.length === 0) {
|
||||
await patchTextMessage(rt, currentMsgId, `⚠️ 处理出错: ${String(e)}`);
|
||||
}
|
||||
})
|
||||
.finally(async () => {
|
||||
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.
|
||||
|
||||
+8
-44
@@ -13,13 +13,8 @@
|
||||
import "dotenv/config";
|
||||
import Fastify from "fastify";
|
||||
import { prisma } from "./db.js";
|
||||
import { createModelFactory } from "./agent/provider.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 { readFeishuContext } from "./feishu/read.js";
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
@@ -33,8 +28,7 @@ function requireEnv(name: string): string {
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const databaseUrl = requireEnv("DATABASE_URL");
|
||||
void databaseUrl; // prisma reads DATABASE_URL from env directly
|
||||
requireEnv("OPENROUTER_API_KEY");
|
||||
void databaseUrl;
|
||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
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() }));
|
||||
|
||||
// --- Agent seam wiring ---
|
||||
const modelFactory = createModelFactory();
|
||||
// Model registry (role-as-data, ADR-0017). Claude Code SDK uses model
|
||||
// aliases like 'sonnet', 'haiku' — the registry maps roles to these.
|
||||
const models = new InMemoryModelRegistry(
|
||||
[
|
||||
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true },
|
||||
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
|
||||
{ id: "claude-sonnet-4-20250514", label: "Claude Sonnet", toolCapable: true },
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "draft",
|
||||
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"],
|
||||
},
|
||||
{ id: "draft", label: "草稿", defaultModel: "claude-sonnet-4-20250514" },
|
||||
{ id: "review", label: "审校", defaultModel: "claude-sonnet-4-20250514" },
|
||||
],
|
||||
);
|
||||
|
||||
// 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 larkClient = createLarkClient(feishuConfig);
|
||||
const feishuRt: FeishuRuntime = { client: larkClient, 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 });
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: app.log });
|
||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
|
||||
|
||||
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
||||
|
||||
Reference in New Issue
Block a user