Files
curriculum-project-hub/hub/src/feishu/card/trace-store.ts
T
hongjr03 d6724e5a83 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
2026-07-09 17:18:40 +08:00

226 lines
6.5 KiB
TypeScript

/**
* 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);
}