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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user