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:
+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.
|
||||
|
||||
Reference in New Issue
Block a user