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:
+65
-9
@@ -28,7 +28,7 @@
|
||||
* is the mechanism, the contract pins the invariant.
|
||||
*/
|
||||
import { homedir } from "node:os";
|
||||
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
export interface ProjectContext {
|
||||
@@ -39,8 +39,10 @@ export interface ProjectContext {
|
||||
|
||||
export type StreamEvent =
|
||||
| { readonly type: "text-delta"; readonly text: string }
|
||||
| { readonly type: "tool-start"; readonly toolName: string }
|
||||
| { readonly type: "tool-end"; readonly toolName: string }
|
||||
| { readonly type: "thinking-delta"; readonly text: string }
|
||||
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
|
||||
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
|
||||
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
|
||||
| { readonly type: "finish" };
|
||||
|
||||
export type StreamCallback = (event: StreamEvent) => void;
|
||||
@@ -156,25 +158,30 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
options,
|
||||
});
|
||||
|
||||
// Track tool start timestamps for duration calculation
|
||||
const toolStartTimestamps = new Map<string, number>();
|
||||
|
||||
for await (const message of conversation) {
|
||||
switch (message.type) {
|
||||
case "stream_event": {
|
||||
// Partial messages — real-time text deltas + tool call chunks.
|
||||
const evt = (message as SDKPartialAssistantMessage).event;
|
||||
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
|
||||
onStream?.({ type: "text-delta", text: evt.delta.text });
|
||||
}
|
||||
if (evt.type === "content_block_delta" && evt.delta.type === "thinking_delta") {
|
||||
onStream?.({ type: "thinking-delta", text: evt.delta.thinking });
|
||||
}
|
||||
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
|
||||
onStream?.({ type: "tool-start", toolName: evt.content_block.name });
|
||||
const toolUseId = evt.content_block.id;
|
||||
toolStartTimestamps.set(toolUseId, Date.now());
|
||||
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
|
||||
}
|
||||
if (evt.type === "content_block_stop") {
|
||||
// Tool end is hard to attribute from stream events alone;
|
||||
// the assistant message below carries tool_use blocks.
|
||||
// Tool end is attributed from the assistant message below.
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
// Complete assistant message (one turn done). Extract text + usage.
|
||||
const msg = (message as SDKAssistantMessage).message;
|
||||
await heartbeat();
|
||||
numTurns++;
|
||||
@@ -185,7 +192,20 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
assistantText += block.text;
|
||||
}
|
||||
if (block.type === "tool_use") {
|
||||
onStream?.({ type: "tool-end", toolName: block.name });
|
||||
const durationMs = toolStartTimestamps.has(block.id)
|
||||
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
|
||||
: undefined;
|
||||
onStream?.({
|
||||
type: "tool-end",
|
||||
toolName: block.name,
|
||||
toolUseId: block.id,
|
||||
input: block.input,
|
||||
...(durationMs !== undefined ? { durationMs } : {}),
|
||||
});
|
||||
}
|
||||
if (block.type === "thinking" && typeof block.thinking === "string") {
|
||||
// Complete thinking block from the assistant message.
|
||||
// Stream deltas already delivered this; no separate event needed.
|
||||
}
|
||||
}
|
||||
await persistAgentMessage(req, "assistant", assistantText);
|
||||
@@ -195,6 +215,30 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "user": {
|
||||
// User messages carry tool_result blocks — what each tool returned.
|
||||
const msg = (message as SDKUserMessage).message;
|
||||
const content = msg.content;
|
||||
if (!Array.isArray(content)) break;
|
||||
for (const block of content) {
|
||||
if (typeof block !== "object" || block === null || block.type !== "tool_result") continue;
|
||||
// Narrowed to BetaToolResultBlockParam by discriminant + field presence
|
||||
if (!("tool_use_id" in block)) continue;
|
||||
const toolUseId = block.tool_use_id;
|
||||
const isError = block.is_error === true;
|
||||
const resultText = extractToolResultText(block.content);
|
||||
const durationMs = toolStartTimestamps.get(toolUseId);
|
||||
onStream?.({
|
||||
type: "tool-result",
|
||||
toolUseId,
|
||||
toolName: toolUseId,
|
||||
result: resultText,
|
||||
isError,
|
||||
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "result": {
|
||||
const result = message as SDKResultMessage;
|
||||
sdkSessionId = result.session_id;
|
||||
@@ -243,3 +287,15 @@ async function persistAgentMessage(req: RunRequest, role: string, content: strin
|
||||
// Best-effort projection: a history write failure must not break the run.
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolResultText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
|
||||
parts.push(part.text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user