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:
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user