forked from bai/curriculum-project-hub
d6724e5a83
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
226 lines
6.5 KiB
TypeScript
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);
|
|
}
|