forked from bai/curriculum-project-hub
feat: streaming agent card with tool-use trace, reasoning panel, and tool results
Replace text-only PatchableTextStream with a unified StreamingAgentCard that renders the full agent run lifecycle in a single Feishu interactive card: - Tool-use panel: collapsible, shows each tool call with status (running/ success/error), input summary, and result/error in code blocks - Reasoning panel: collapsible, streams thinking text inline then collapses on completion - Answer text: rendered via native Feishu markdown component (no post-row round-trip) Runner now captures previously-discarded SDK events: - thinking_delta → reasoning stream - tool_use id + input on content_block_start / assistant message - tool_result from user messages (was entirely unhandled) New files: - card/trace-store.ts: per-run in-memory tool-use trace with secret redaction - card/builder.ts: Feishu card JSON builder (tool panel + reasoning + text) - card/streaming-card.ts: StreamingAgentCard controller (400ms throttled) client.ts: add sendCard/patchCard raw JSON primitives
This commit is contained in:
+46
-20
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Feishu @bot trigger → AgentRun lifecycle (streaming).
|
||||
*
|
||||
* 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.
|
||||
* Sends a "processing" reaction immediately, then streams a single
|
||||
* interactive card through the full agent run lifecycle: thinking → tool
|
||||
* calls (with trace panel) → streaming answer text → final card. The card
|
||||
* shows a collapsible tool-use panel, a collapsible reasoning panel, and
|
||||
* the markdown answer text. Throttled to ~2.5 patches/sec to avoid
|
||||
* spamming the Feishu API.
|
||||
*
|
||||
* ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
|
||||
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
|
||||
@@ -14,8 +15,6 @@ import type { FastifyBaseLogger } from "fastify";
|
||||
import {
|
||||
sendText,
|
||||
sendTextMessage,
|
||||
sendInteractiveCardMessage,
|
||||
patchTextMessage,
|
||||
reactToMessage,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
@@ -32,7 +31,7 @@ import type { RuntimeSettings } from "../settings/runtime.js";
|
||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
|
||||
import { writeAudit } from "../audit.js";
|
||||
import { PatchableTextStream } from "./textStream.js";
|
||||
import { StreamingAgentCard } from "./card/streaming-card.js";
|
||||
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||
import { readFeishuContext } from "./read.js";
|
||||
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
|
||||
@@ -265,12 +264,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
return removeReaction(rt, msg.message_id, processingReactionId);
|
||||
};
|
||||
|
||||
// Stream agent response as text-as-card messages. Lazy-init: don't send
|
||||
// anything until the first text-delta arrives (avoids empty placeholder).
|
||||
const textStream = new PatchableTextStream({
|
||||
create: (text) => sendInteractiveCardMessage(rt, chatId, text),
|
||||
patch: (messageId, text) => patchTextMessage(rt, messageId, text),
|
||||
});
|
||||
// Streaming agent card: single interactive card through the full run
|
||||
// lifecycle (thinking → tool calls → streaming text → complete).
|
||||
// Shows tool-use trace panel + reasoning panel + answer text.
|
||||
const card = new StreamingAgentCard({ runId: run.id, rt, chatId, patchIntervalMs: undefined, maxMessageLength: undefined });
|
||||
const deliveredFiles: string[] = [];
|
||||
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
||||
rt,
|
||||
@@ -297,21 +294,49 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
sessionId: session.id,
|
||||
prisma: deps.prisma,
|
||||
onStream: (event) => {
|
||||
if (event.type === "text-delta") {
|
||||
textStream.append(event.text);
|
||||
switch (event.type) {
|
||||
case "text-delta":
|
||||
card.appendText(event.text);
|
||||
break;
|
||||
case "thinking-delta":
|
||||
card.appendReasoning(event.text);
|
||||
break;
|
||||
case "tool-start":
|
||||
card.onToolStart(event.toolName, event.toolUseId);
|
||||
break;
|
||||
case "tool-end":
|
||||
card.onToolEnd({
|
||||
toolName: event.toolName,
|
||||
toolUseId: event.toolUseId,
|
||||
input: event.input,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
break;
|
||||
case "tool-result":
|
||||
card.onToolEnd({
|
||||
toolName: event.toolName,
|
||||
toolUseId: event.toolUseId,
|
||||
input: undefined,
|
||||
result: event.result,
|
||||
error: event.isError ? event.result : undefined,
|
||||
durationMs: event.durationMs,
|
||||
});
|
||||
break;
|
||||
case "finish":
|
||||
break;
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(async (result) => {
|
||||
// Final flush: patch the last message with complete text, or send
|
||||
// the full response if no streaming message was created yet.
|
||||
const finalText =
|
||||
result.text !== ""
|
||||
? result.text
|
||||
: result.status === "failed" && result.error !== undefined
|
||||
? `处理失败: ${result.error}`
|
||||
? `\u5904\u7406\u5931\u8D25: ${result.error}`
|
||||
: result.text;
|
||||
await textStream.finish(finalText);
|
||||
await card.finish(finalText);
|
||||
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
|
||||
if (metadataPatch !== null) {
|
||||
await deps.prisma.agentSession.update({
|
||||
@@ -342,6 +367,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
if (removedProcessingReaction) {
|
||||
await addReaction(rt, msg.message_id, "CrossMark");
|
||||
}
|
||||
await card.fail(e instanceof Error ? e.message : String(e));
|
||||
try {
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
|
||||
Reference in New Issue
Block a user