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
+376
View File
@@ -0,0 +1,376 @@
/**
* Feishu interactive card builder for agent run output.
*
* Produces card JSON with:
* 1. A collapsible tool-use panel (tool steps with status, params, results)
* 2. A collapsible reasoning panel (thinking text)
* 3. The streaming/final answer text (markdown)
*
* Adapted from openclaw-lark's builder.ts, simplified for our
* message.patch-based approach (no CardKit 2.0 streaming_mode).
* Uses JSON 1.0 card format with `collapsible_panel` and `markdown` tags.
*/
import type { ToolUseTraceStep } from "./trace-store.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FeishuCard {
readonly config: Record<string, unknown>;
readonly elements: ReadonlyArray<unknown>;
}
export type CardPhase = "thinking" | "streaming" | "complete";
const STEP_INDENT = "0px 0px 0px 22px";
const MAX_TEXT_LENGTH = 7500; // Feishu card content limit ~30k; leave room for panels
// ---------------------------------------------------------------------------
// Tool name → icon mapping
// ---------------------------------------------------------------------------
const TOOL_ICONS: Record<string, string> = {
read: "doc-filled",
write: "edit-filled",
bash: "terminal-filled",
glob: "search-filled",
grep: "search-filled",
edit: "edit-filled",
send_file: "send-filled",
request_approval: "thumb-up-filled",
feishu_read_context: "search-filled",
};
function toolIcon(toolName: string): string {
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
return TOOL_ICONS[normalized] ?? "tool-filled";
}
// ---------------------------------------------------------------------------
// Card builder
// ---------------------------------------------------------------------------
export function buildAgentCard(params: {
phase: CardPhase;
text: string;
reasoningText: string | undefined;
toolUseSteps: ToolUseTraceStep[];
toolUseElapsedMs: number | undefined;
isError: boolean | undefined;
runId: string | undefined;
}): Record<string, unknown> {
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError } = params;
const elements: unknown[] = [];
// Tool-use panel (always present if there are steps)
if (toolUseSteps.length > 0) {
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
} else if (phase === "thinking" || (phase === "streaming" && text === "")) {
elements.push(buildPendingToolUsePanel());
}
// Reasoning panel
if (reasoningText !== undefined && reasoningText !== "") {
if (phase === "streaming" && text === "") {
// Still thinking: show reasoning inline
elements.push({
tag: "markdown",
content: `\u{1F4AD} \u601d\u8003\u4E2D...\n\n${reasoningText}`,
text_size: "notation",
});
} else if (phase === "complete") {
// Collapsed reasoning panel in complete state
elements.push(buildReasoningPanel(reasoningText));
}
}
// Main text content
if (text !== "") {
elements.push({
tag: "markdown",
content: truncateText(text, MAX_TEXT_LENGTH),
});
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
elements.push({
tag: "markdown",
content: "\u{1F4AD} \u601d\u8003\u4E2D...",
text_size: "notation",
});
}
// Footer for complete state
if (phase === "complete") {
const footerParts: string[] = [];
if (isError === true) footerParts.push("\u274C \u5931\u8D25");
else footerParts.push("\u2705 \u5B8C\u6210");
if (toolUseElapsedMs !== undefined) {
footerParts.push(formatElapsed(toolUseElapsedMs));
}
elements.push({
tag: "div",
text: {
tag: "plain_text",
content: footerParts.join(" \u00B7 "),
text_size: "notation",
text_color: "grey",
},
});
}
return {
config: { wide_screen_mode: true, update_multi: true },
elements,
};
}
// ---------------------------------------------------------------------------
// Tool-use panel
// ---------------------------------------------------------------------------
function buildPendingToolUsePanel(): unknown {
return {
tag: "collapsible_panel",
expanded: false,
header: {
title: {
tag: "plain_text",
content: "\u{1F6E0}\uFE0F \u7B49\u5F85\u5DE5\u5177\u6267\u884C",
text_color: "grey",
text_size: "notation",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
color: "grey",
size: "16px 16px",
},
icon_position: "right",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "4px",
padding: "8px 8px 8px 8px",
elements: [],
};
}
function buildToolUsePanel(steps: ToolUseTraceStep[], elapsedMs?: number, expanded = false): unknown {
const parts: string[] = ["\u{1F6E0}\uFE0F \u5DE5\u5177\u6267\u884C"];
if (steps.length > 0) {
parts.push(`${steps.length} \u6B65`);
}
if (elapsedMs !== undefined && elapsedMs > 0) {
parts.push(`(${formatElapsed(elapsedMs)})`);
}
const stepElements = steps.flatMap((step) => buildToolStepElements(step));
return {
tag: "collapsible_panel",
expanded,
header: {
title: {
tag: "plain_text",
content: parts.join(" \u00B7 "),
text_color: "grey",
text_size: "notation",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
color: "grey",
size: "16px 16px",
},
icon_position: "right",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "4px",
padding: "8px 8px 8px 8px",
elements: stepElements,
};
}
function buildToolStepElements(step: ToolUseTraceStep): unknown[] {
const elements: unknown[] = [buildToolStepTitle(step)];
const detailElement = buildToolStepDetail(step);
if (detailElement !== undefined) {
elements.push(detailElement);
}
const outputElement = buildToolStepOutput(step);
if (outputElement !== undefined) {
elements.push(outputElement);
}
return elements;
}
function buildToolStepTitle(step: ToolUseTraceStep): unknown {
const status = formatStepStatus(step.status);
return {
tag: "div",
icon: {
tag: "standard_icon",
token: toolIcon(step.toolName),
color: "grey",
},
text: {
tag: "lark_md",
content: `**${escapeMarkdown(step.toolName)}** \u00B7 <font color='${status.color}'>${status.label}</font>`,
text_size: "notation",
},
};
}
function buildToolStepDetail(step: ToolUseTraceStep): unknown | undefined {
const detail = extractStepDetail(step);
if (detail === undefined) return undefined;
return {
tag: "div",
margin: STEP_INDENT,
text: {
tag: "plain_text",
content: detail,
text_color: "grey",
text_size: "notation",
},
};
}
function buildToolStepOutput(step: ToolUseTraceStep): unknown | undefined {
const content = buildStepOutputMarkdown(step);
if (content === undefined) return undefined;
return {
tag: "div",
margin: STEP_INDENT,
text: {
tag: "lark_md",
content,
text_size: "notation",
},
};
}
function buildStepOutputMarkdown(step: ToolUseTraceStep): string | undefined {
const lines: string[] = [];
if (step.error !== undefined) {
lines.push("**\u9519\u8BEF**");
lines.push(formatCodeBlock(step.error, "text"));
} else if (step.result !== undefined && step.result !== "") {
const resultStr = typeof step.result === "string" ? step.result : JSON.stringify(step.result, null, 2);
lines.push("**\u7ED3\u679C**");
lines.push(formatCodeBlock(resultStr, typeof step.result === "string" ? "text" : "json"));
}
if (lines.length === 0) return undefined;
return lines.join("\n");
}
function extractStepDetail(step: ToolUseTraceStep): string | undefined {
if (step.input === undefined || step.input === null) return undefined;
if (typeof step.input === "string") return truncateText(step.input, 200);
const input = step.input as Record<string, unknown>;
// Extract the most relevant field based on tool name
const toolLower = step.toolName.toLowerCase().replace(/^mcp_/, "");
if (toolLower === "bash" && typeof input.command === "string") {
return truncateText(input.command, 200);
}
if (toolLower === "read" && typeof input.file_path === "string") {
return input.file_path;
}
if (toolLower === "write" && typeof input.file_path === "string") {
return input.file_path;
}
if (toolLower === "edit" && typeof input.file_path === "string") {
return input.file_path;
}
if (toolLower === "glob" && typeof input.pattern === "string") {
return input.pattern;
}
if (toolLower === "grep" && typeof input.pattern === "string") {
return input.pattern;
}
// Fallback: show keys
const keys = Object.keys(input);
if (keys.length === 0) return undefined;
return keys.slice(0, 4).join(", ");
}
// ---------------------------------------------------------------------------
// Reasoning panel
// ---------------------------------------------------------------------------
function buildReasoningPanel(reasoningText: string): unknown {
return {
tag: "collapsible_panel",
expanded: false,
header: {
title: {
tag: "markdown",
content: "\u{1F4AD} \u601D\u8003",
text_color: "grey",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
size: "16px 16px",
},
icon_position: "follow_text",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "8px",
padding: "8px 8px 8px 8px",
elements: [
{
tag: "markdown",
content: truncateText(reasoningText, MAX_TEXT_LENGTH),
text_size: "notation",
},
],
};
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
function formatStepStatus(status: ToolUseTraceStep["status"]): { label: string; color: string } {
switch (status) {
case "running":
return { label: "\u8FD0\u884C\u4E2D", color: "turquoise" };
case "error":
return { label: "\u5931\u8D25", color: "red" };
case "success":
default:
return { label: "\u6210\u529F", color: "green" };
}
}
function formatCodeBlock(content: string, language: "json" | "text"): string {
const normalized = content.replace(/\r\n/g, "\n").trim();
const fence = "`".repeat(Math.max(3, longestBacktickRun(normalized) + 1));
return `${fence}${language}\n${normalized}\n${fence}`;
}
function longestBacktickRun(value: string): number {
const matches = value.match(/`+/g);
if (matches === null) return 0;
return matches.reduce((max, run) => Math.max(max, run.length), 0);
}
function escapeMarkdown(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
}
function truncateText(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
}
function formatElapsed(ms: number): string {
if (ms < 1000) return `${ms} ms`;
return `${(ms / 1000).toFixed(1)} s`;
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Streaming card controller for agent runs.
*
* Replaces PatchableTextStream for the agent-run use case. Manages a single
* Feishu interactive card through the full run lifecycle:
*
* thinking → tool calls → streaming text → complete
*
* The card is rebuilt from scratch on each flush (throttled at 400ms) using
* the trace store for tool steps + accumulated reasoning/text. This is
* simpler than CardKit 2.0 element-level streaming and works with the
* existing message.patch API.
*
* Lifecycle:
* 1. appendText(delta) — streaming answer text
* 2. appendReasoning(delta) — streaming thinking text
* 3. onToolStart(name, id) — register a tool step in the trace store
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
* 5. finish(finalText) — flush + transition to complete card
* 6. fail(errorText) — flush + transition to error card
*/
import type { FeishuRuntime } from "../client.js";
import { sendCard, patchCard } from "../client.js";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
import {
startToolUseTraceRun,
clearToolUseTraceRun,
recordToolUseStart,
recordToolUseEnd,
getToolUseTraceSteps,
} from "./trace-store.js";
import { buildAgentCard, type CardPhase } from "./builder.js";
export interface StreamingCardSink {
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
readonly patch: (messageId: string, card: Record<string, unknown>) => Promise<void>;
}
export interface StreamingCardOptions {
readonly runId: string;
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly patchIntervalMs: number | undefined;
readonly maxMessageLength: number | undefined;
}
const DEFAULT_PATCH_INTERVAL_MS = 400;
export class StreamingAgentCard {
private currentMessageId: string | null = null;
private text = "";
private reasoningText = "";
private runStartedAt = Date.now();
private toolUseElapsedMs: number | undefined;
private flushChain: Promise<void> = Promise.resolve();
private flushScheduled = false;
private lastPatchAt = 0;
private readonly runId: string;
private readonly rt: FeishuRuntime;
private readonly chatId: string;
private readonly patchIntervalMs: number;
private readonly maxMessageLength: number;
constructor(options: StreamingCardOptions) {
this.runId = options.runId;
this.rt = options.rt;
this.chatId = options.chatId;
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
startToolUseTraceRun(this.runId);
}
// --- Public API ---
appendText(delta: string): void {
if (delta === "") return;
this.text += delta;
this.scheduleFlush();
}
appendReasoning(delta: string): void {
if (delta === "") return;
this.reasoningText += delta;
this.scheduleFlush();
}
onToolStart(toolName: string, toolUseId: string | undefined): void {
recordToolUseStart({ runId: this.runId, toolName, toolUseId, input: undefined });
this.scheduleFlush();
}
onToolEnd(params: {
toolName: string;
toolUseId: string | undefined;
input: unknown;
result: unknown;
error: string | undefined;
durationMs: number | undefined;
}): void {
recordToolUseEnd({ runId: this.runId, ...params });
this.scheduleFlush();
}
async finish(fallbackText: string): Promise<void> {
await this.flushChain;
if (this.text.length > 0) {
await this.flushCard("complete", this.text);
return;
}
// No streaming text was sent. If we never created a card, send one now.
if (this.currentMessageId === null && fallbackText.length > 0) {
this.text = fallbackText;
await this.flushCard("complete", this.text);
} else if (this.currentMessageId !== null) {
// Patch the existing card with the final text
await this.flushCard("complete", fallbackText);
}
clearToolUseTraceRun(this.runId);
}
async fail(errorText: string): Promise<void> {
await this.flushChain;
this.text = errorText;
await this.flushCard("complete", errorText, true);
clearToolUseTraceRun(this.runId);
}
// --- Internal flush logic ---
private scheduleFlush(): void {
if (this.flushScheduled) return;
this.flushScheduled = true;
this.flushChain = this.flushChain.then(async () => {
this.flushScheduled = false;
await this.flush();
});
}
private async flush(): Promise<void> {
if (this.currentMessageId === null) {
await this.flushCard(this.currentPhase(), this.text);
this.lastPatchAt = Date.now();
return;
}
const now = Date.now();
if (now - this.lastPatchAt < this.patchIntervalMs) return;
this.lastPatchAt = now;
await this.flushCard(this.currentPhase(), this.text);
}
private async flushCard(phase: CardPhase, text: string, isError = false): Promise<void> {
const chunks = splitAtBoundary(text, this.maxMessageLength);
const firstChunk = chunks[0];
if (firstChunk === undefined) return;
const toolUseSteps = getToolUseTraceSteps(this.runId);
const card = buildAgentCard({
phase,
text: firstChunk,
reasoningText: this.reasoningText || undefined,
toolUseSteps,
toolUseElapsedMs: this.toolUseElapsedMs,
isError,
runId: this.runId,
});
if (this.currentMessageId === null) {
this.currentMessageId = await sendCard(this.rt, this.chatId, card);
// Send overflow chunks as new messages (rare for agent output)
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
runId: this.runId,
});
this.currentMessageId = await sendCard(this.rt, this.chatId, overflowCard);
}
} else {
await patchCard(this.rt, this.currentMessageId, card);
// For overflow, create new messages
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
runId: this.runId,
});
this.currentMessageId = await sendCard(this.rt, this.chatId, overflowCard);
}
}
}
private currentPhase(): CardPhase {
if (this.text.length > 0) return "streaming";
if (this.reasoningText.length > 0) return "streaming";
return "thinking";
}
}
+225
View File
@@ -0,0 +1,225 @@
/**
* In-memory tool-use trace store, adapted from openclaw-lark's
* tool-use-trace-store. One trace per agent run; the card builder
* reads steps from here to render the tool-use panel.
*
* The store is per-run (not per-session) because each run has its
* own card lifecycle. Steps are capped to prevent unbounded memory.
*/
export interface ToolUseTraceStep {
readonly id: string;
readonly seq: number;
readonly toolName: string;
readonly toolUseId: string | undefined;
readonly input: unknown;
readonly result: unknown;
readonly error: string | undefined;
readonly durationMs: number | undefined;
readonly status: "running" | "success" | "error";
readonly startedAt: number;
readonly finishedAt: number | undefined;
}
interface RunTraceState {
nextSeq: number;
updatedAt: number;
steps: ToolUseTraceStep[];
}
const MAX_STEPS = 64;
const STEP_RUNNING_TIMEOUT_MS = 5 * 60 * 1000;
const RUN_TTL_MS = 30 * 60 * 1000;
const RESULT_LIMIT = 2048;
const GENERIC_LIMIT = 512;
const runs = new Map<string, RunTraceState>();
export function startToolUseTraceRun(runId: string): void {
pruneStaleRuns();
runs.set(runId, { nextSeq: 1, updatedAt: Date.now(), steps: [] });
}
export function clearToolUseTraceRun(runId: string): void {
runs.delete(runId);
}
export function recordToolUseStart(params: {
runId: string;
toolName: string;
toolUseId: string | undefined;
input: unknown;
}): void {
const state = runs.get(params.runId);
if (state === undefined) return;
if (state.steps.length >= MAX_STEPS) {
state.steps.splice(0, state.steps.length - MAX_STEPS + 1);
}
const now = Date.now();
state.steps.push({
id: `${state.nextSeq}`,
seq: state.nextSeq,
toolName: params.toolName,
toolUseId: params.toolUseId,
input: sanitizeTraceValue(params.input),
result: undefined,
error: undefined,
durationMs: undefined,
status: "running",
startedAt: now,
finishedAt: undefined,
});
state.nextSeq += 1;
state.updatedAt = now;
}
export function recordToolUseEnd(params: {
runId: string;
toolName: string;
toolUseId: string | undefined;
result: unknown;
error: string | undefined;
durationMs: number | undefined;
}): void {
const state = runs.get(params.runId);
if (state === undefined) return;
const now = Date.now();
const pendingIndex = findPendingStepIndex(state.steps, params.toolUseId, params.toolName);
if (pendingIndex >= 0) {
const step = state.steps[pendingIndex];
if (step !== undefined) {
(state.steps as Array<ToolUseTraceStep>)[pendingIndex] = {
...step,
status: params.error !== undefined ? "error" : "success",
result: sanitizeTraceValue(params.result),
error: params.error !== undefined ? truncate(params.error, 160) : undefined,
durationMs: params.durationMs,
finishedAt: now,
};
state.updatedAt = now;
return;
}
}
// No matching pending step — record as already-completed
state.steps.push({
id: `${state.nextSeq}`,
seq: state.nextSeq,
toolName: params.toolName,
toolUseId: params.toolUseId,
input: undefined,
result: sanitizeTraceValue(params.result),
error: params.error !== undefined ? truncate(params.error, 160) : undefined,
durationMs: params.durationMs,
status: params.error !== undefined ? "error" : "success",
startedAt: now,
finishedAt: now,
});
state.nextSeq += 1;
state.updatedAt = now;
}
export function getToolUseTraceSteps(runId: string): ToolUseTraceStep[] {
const state = runs.get(runId);
if (state === undefined) return [];
if (Date.now() - state.updatedAt > RUN_TTL_MS) {
runs.delete(runId);
return [];
}
const now = Date.now();
return state.steps.map((step) => {
if (step.status === "running" && now - step.startedAt > STEP_RUNNING_TIMEOUT_MS) {
return { ...step, status: "error" as const, error: "timed out", finishedAt: now };
}
return { ...step };
});
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function findPendingStepIndex(
steps: ToolUseTraceStep[],
toolUseId: string | undefined,
toolName: string | undefined,
): number {
if (toolUseId !== undefined) {
for (let i = steps.length - 1; i >= 0; i -= 1) {
const step = steps[i];
if (step !== undefined && step.status === "running" && step.toolUseId === toolUseId) {
return i;
}
}
}
if (toolName !== undefined) {
for (let i = steps.length - 1; i >= 0; i -= 1) {
const step = steps[i];
if (step !== undefined && step.status === "running" && step.toolName === toolName) {
return i;
}
}
}
return -1;
}
function pruneStaleRuns(): void {
const now = Date.now();
for (const [runId, state] of runs) {
if (now - state.updatedAt > RUN_TTL_MS) {
runs.delete(runId);
}
}
}
function truncate(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
}
/**
* Sanitize a tool input or result value for display.
* Truncates strings, redacts sensitive keys, limits depth.
*/
function sanitizeTraceValue(
value: unknown,
depth = 0,
context: { source: "input" | "result" | undefined; key: string | undefined } = { source: undefined, key: undefined },
): unknown {
if (value === null || value === undefined) return undefined;
if (typeof value === "string") {
const limit = context.source === "result" ? RESULT_LIMIT : GENERIC_LIMIT;
return truncate(value, limit);
}
if (typeof value === "number" || typeof value === "boolean") return value;
if (depth >= 2) return "[truncated]";
if (Array.isArray(value)) {
return value.slice(0, 8).map((item) => sanitizeTraceValue(item, depth + 1, { source: context.source, key: undefined }));
}
if (typeof value === "object") {
const input = value as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const [key, entryValue] of Object.entries(input).slice(0, 12)) {
output[key] = isSensitiveKey(key)
? "[redacted]"
: sanitizeTraceValue(entryValue, depth + 1, { source: context.source, key });
}
return output;
}
return truncate(String(value), 180);
}
const SENSITIVE_KEY_RE =
/token|secret|password|api[_-]?key|authorization|cookie|credential|bearer|session[_-]?id|client[_-]?secret|access[_-]?key/i;
function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEY_RE.test(key);
}