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:
2026-07-09 17:18:40 +08:00
parent 346aee5a68
commit d6724e5a83
7 changed files with 964 additions and 32 deletions
+46 -20
View File
@@ -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 },