@webmcp-auto-ui/agent 2.5.25 → 2.5.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/autoui-server.ts +44 -0
- package/src/diagnostics.ts +6 -6
- package/src/discovery-cache.ts +17 -3
- package/src/index.ts +18 -4
- package/src/loop.ts +31 -34
- package/src/notebook-widgets/compact.ts +312 -0
- package/src/notebook-widgets/document.ts +372 -0
- package/src/notebook-widgets/editorial.ts +348 -0
- package/src/notebook-widgets/recipes/compact.md +104 -0
- package/src/notebook-widgets/recipes/document.md +100 -0
- package/src/notebook-widgets/recipes/editorial.md +104 -0
- package/src/notebook-widgets/recipes/workspace.md +94 -0
- package/src/notebook-widgets/shared.ts +1064 -0
- package/src/notebook-widgets/workspace.ts +328 -0
- package/src/prompts/claude-prompt-builder.ts +81 -0
- package/src/prompts/gemma4-prompt-builder.ts +205 -0
- package/src/prompts/index.ts +55 -0
- package/src/prompts/mistral-prompt-builder.ts +90 -0
- package/src/prompts/qwen-prompt-builder.ts +90 -0
- package/src/prompts/tool-call-parsers.ts +322 -0
- package/src/prompts/tool-refs.ts +196 -0
- package/src/providers/factory.ts +20 -3
- package/src/providers/transformers-models.ts +143 -0
- package/src/providers/transformers-serialize.ts +81 -0
- package/src/providers/transformers.ts +329 -0
- package/src/providers/transformers.worker.ts +667 -0
- package/src/providers/wasm.ts +150 -510
- package/src/recipes/_generated.ts +515 -0
- package/src/recipes/canary-data.md +50 -0
- package/src/recipes/canary-display.md +99 -0
- package/src/recipes/canary-middle.md +32 -0
- package/src/recipes/hackathon-assemblee-nationale.md +111 -0
- package/src/recipes/hummingbird-data.md +32 -0
- package/src/recipes/hummingbird-display.md +36 -0
- package/src/recipes/hummingbird-middle.md +18 -0
- package/src/recipes/notebook-playbook.md +129 -0
- package/src/tool-layers.ts +33 -157
- package/src/trace-observer.ts +669 -0
- package/src/types.ts +20 -5
- package/src/util/opfs-cache.ts +265 -0
- package/tests/gemma-prompt.test.ts +472 -0
- package/tests/loop.test.ts +5 -5
- package/tests/transformers-serialize.test.ts +103 -0
- package/src/providers/gemma.worker.legacy.ts +0 -123
- package/src/providers/litert.worker.ts +0 -294
- package/src/recipes/widgets/actions.md +0 -28
- package/src/recipes/widgets/alert.md +0 -27
- package/src/recipes/widgets/cards.md +0 -41
- package/src/recipes/widgets/carousel.md +0 -39
- package/src/recipes/widgets/chart-rich.md +0 -51
- package/src/recipes/widgets/chart.md +0 -32
- package/src/recipes/widgets/code.md +0 -21
- package/src/recipes/widgets/d3.md +0 -36
- package/src/recipes/widgets/data-table.md +0 -46
- package/src/recipes/widgets/gallery.md +0 -39
- package/src/recipes/widgets/grid-data.md +0 -57
- package/src/recipes/widgets/hemicycle.md +0 -43
- package/src/recipes/widgets/js-sandbox.md +0 -32
- package/src/recipes/widgets/json-viewer.md +0 -27
- package/src/recipes/widgets/kv.md +0 -31
- package/src/recipes/widgets/list.md +0 -24
- package/src/recipes/widgets/log.md +0 -39
- package/src/recipes/widgets/map.md +0 -49
- package/src/recipes/widgets/profile.md +0 -49
- package/src/recipes/widgets/recipe-browser.md +0 -102
- package/src/recipes/widgets/sankey.md +0 -54
- package/src/recipes/widgets/stat-card.md +0 -43
- package/src/recipes/widgets/stat.md +0 -35
- package/src/recipes/widgets/tags.md +0 -30
- package/src/recipes/widgets/text.md +0 -19
- package/src/recipes/widgets/timeline.md +0 -38
- package/src/recipes/widgets/trombinoscope.md +0 -39
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// @webmcp-auto-ui/agent — trace observer
|
|
2
|
+
// Builds a live visual trace of runAgentLoop execution by maintaining an
|
|
3
|
+
// internal graph (nodes + edges) and projecting it into three canvas widgets:
|
|
4
|
+
// - cytoscape "animated-flow" (directed graph of events)
|
|
5
|
+
// - d3 "tree" (iteration → step hierarchy)
|
|
6
|
+
// - plotly "plotly-sankey" (aggregated transitions by kind)
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
AgentCallbacks,
|
|
10
|
+
ChatMessage,
|
|
11
|
+
ProviderTool,
|
|
12
|
+
LLMResponse,
|
|
13
|
+
ToolCall,
|
|
14
|
+
AgentMetrics,
|
|
15
|
+
} from './types.js';
|
|
16
|
+
|
|
17
|
+
export interface TraceObserverContext {
|
|
18
|
+
addWidget: (
|
|
19
|
+
type: string,
|
|
20
|
+
data: Record<string, unknown>,
|
|
21
|
+
serverName: string,
|
|
22
|
+
) => { id: string } | undefined;
|
|
23
|
+
updateWidget: (id: string, data: Record<string, unknown>) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RoundTripDetail {
|
|
27
|
+
kind: string;
|
|
28
|
+
label: string;
|
|
29
|
+
startMs: number;
|
|
30
|
+
endMs?: number;
|
|
31
|
+
toolName?: string;
|
|
32
|
+
toolArgs?: unknown;
|
|
33
|
+
toolResult?: string;
|
|
34
|
+
toolError?: string;
|
|
35
|
+
messageCount?: number;
|
|
36
|
+
toolCount?: number;
|
|
37
|
+
inputTokens?: number;
|
|
38
|
+
outputTokens?: number;
|
|
39
|
+
latencyMs?: number;
|
|
40
|
+
stopReason?: string;
|
|
41
|
+
iteration?: number;
|
|
42
|
+
originRecipe?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TraceObserver {
|
|
46
|
+
/** Partial AgentCallbacks to merge into runAgentLoop callbacks. */
|
|
47
|
+
callbacks: Partial<AgentCallbacks>;
|
|
48
|
+
/** Mount the 3 widgets on the canvas. Safe to call again after detach. */
|
|
49
|
+
mount: () => { dagId: string; treeId: string; sankeyId: string } | null;
|
|
50
|
+
/** Reset internal state (call when a new run starts). */
|
|
51
|
+
reset: () => void;
|
|
52
|
+
/** Unmount widgets; buffer is preserved so a later mount() restores state. */
|
|
53
|
+
detach: () => void;
|
|
54
|
+
/** Retrieve enriched detail for a trace node id (for mermaid.sequence rendering). */
|
|
55
|
+
getNodeDetail: (nodeId: string) => RoundTripDetail | undefined;
|
|
56
|
+
/** Map of recipe name → body (markdown), accumulated from get_recipe tool results during the run. */
|
|
57
|
+
getLoadedRecipes: () => Map<string, string>;
|
|
58
|
+
/** Current recipe context: name of the most recently loaded recipe via get_recipe, or undefined. */
|
|
59
|
+
getCurrentRecipeContext: () => string | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type NodeKind =
|
|
63
|
+
| 'iteration'
|
|
64
|
+
| 'llm_req'
|
|
65
|
+
| 'llm_resp'
|
|
66
|
+
| 'tool_call'
|
|
67
|
+
| 'tool_result'
|
|
68
|
+
| 'widget'
|
|
69
|
+
| 'trace';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Tree is the single source of truth for node color: d3's scaleOrdinal over
|
|
73
|
+
* schemeTableau10, indexed by the depth-1 ancestor name (= iteration label).
|
|
74
|
+
* We mirror it here byte-for-byte so cytoscape + sankey visually align with
|
|
75
|
+
* the tree. d3 ordinal assigns colors in INSERTION ORDER (first unseen key
|
|
76
|
+
* → palette[0], second → palette[1], …) — reproduce that with a Map.
|
|
77
|
+
*/
|
|
78
|
+
const SCHEME_TABLEAU10: readonly string[] = [
|
|
79
|
+
'#4e79a7', '#f28e2c', '#e15759', '#76b7b2', '#59a14f',
|
|
80
|
+
'#edc949', '#af7aa1', '#ff9da7', '#9c755f', '#bab0ab',
|
|
81
|
+
];
|
|
82
|
+
const ROOT_COLOR = '#1e293b';
|
|
83
|
+
|
|
84
|
+
function makeIterationPalette(): (label: string) => string {
|
|
85
|
+
const seen = new Map<string, string>();
|
|
86
|
+
return (label: string): string => {
|
|
87
|
+
const cached = seen.get(label);
|
|
88
|
+
if (cached) return cached;
|
|
89
|
+
const color = SCHEME_TABLEAU10[seen.size % SCHEME_TABLEAU10.length]!;
|
|
90
|
+
seen.set(label, color);
|
|
91
|
+
return color;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Cytoscape style override — clean typography, smaller arrows, palette by kind. */
|
|
96
|
+
const CYTOSCAPE_STYLE: Array<Record<string, unknown>> = [
|
|
97
|
+
{
|
|
98
|
+
selector: 'node',
|
|
99
|
+
style: {
|
|
100
|
+
'background-color': 'data(color)',
|
|
101
|
+
'label': 'data(label)',
|
|
102
|
+
'color': '#1e293b',
|
|
103
|
+
'font-size': '11px',
|
|
104
|
+
'font-family': 'Inter, system-ui, sans-serif',
|
|
105
|
+
'text-valign': 'center',
|
|
106
|
+
'text-halign': 'center',
|
|
107
|
+
'text-outline-width': 0,
|
|
108
|
+
'text-outline-color': 'transparent',
|
|
109
|
+
'border-width': 1,
|
|
110
|
+
'border-color': '#64748b',
|
|
111
|
+
'width': 28,
|
|
112
|
+
'height': 28,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
selector: 'edge',
|
|
117
|
+
style: {
|
|
118
|
+
'width': 1.5,
|
|
119
|
+
'line-color': '#94a3b8',
|
|
120
|
+
'target-arrow-color': '#94a3b8',
|
|
121
|
+
'target-arrow-shape': 'triangle',
|
|
122
|
+
'arrow-scale': 0.6,
|
|
123
|
+
'curve-style': 'bezier',
|
|
124
|
+
'line-style': 'dashed',
|
|
125
|
+
'line-dash-pattern': [6, 3],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
interface TraceNode {
|
|
131
|
+
id: string;
|
|
132
|
+
kind: NodeKind;
|
|
133
|
+
label: string;
|
|
134
|
+
startMs: number;
|
|
135
|
+
endMs?: number;
|
|
136
|
+
meta: Record<string, unknown>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface TraceEdge {
|
|
140
|
+
from: string;
|
|
141
|
+
to: string;
|
|
142
|
+
weight: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const FLUSH_INTERVAL_MS = 100;
|
|
146
|
+
|
|
147
|
+
export function createTraceObserver(ctx: TraceObserverContext): TraceObserver {
|
|
148
|
+
let nodes: TraceNode[] = [];
|
|
149
|
+
let edges: TraceEdge[] = [];
|
|
150
|
+
// Aggregated transitions keyed by `${fromKind}→${toKind}`
|
|
151
|
+
let sankeyWeights = new Map<string, number>();
|
|
152
|
+
let nodeIdCounter = 0;
|
|
153
|
+
let iterationPalette = makeIterationPalette();
|
|
154
|
+
|
|
155
|
+
/** Climb up meta.iterationId chain until we find the iteration node. */
|
|
156
|
+
function iterationAncestor(node: TraceNode): TraceNode | undefined {
|
|
157
|
+
if (node.kind === 'iteration') return node;
|
|
158
|
+
const iterId = node.meta.iterationId as string | undefined;
|
|
159
|
+
if (!iterId) return undefined;
|
|
160
|
+
return nodes.find((n) => n.id === iterId && n.kind === 'iteration');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function colorForNode(node: TraceNode): string {
|
|
164
|
+
const iter = iterationAncestor(node);
|
|
165
|
+
if (!iter) return ROOT_COLOR;
|
|
166
|
+
return iterationPalette(iter.label);
|
|
167
|
+
}
|
|
168
|
+
let lastNodeByKind: Partial<Record<NodeKind, string>> = {};
|
|
169
|
+
let currentIterationId: string | undefined;
|
|
170
|
+
let currentLlmReqId: string | undefined;
|
|
171
|
+
let currentLlmRespId: string | undefined;
|
|
172
|
+
let lastToolCallId: string | undefined;
|
|
173
|
+
const toolCallIdMap = new Map<string, string>(); // agent tool-call id → trace node id
|
|
174
|
+
const toolCallStartMs = new Map<string, number>();
|
|
175
|
+
|
|
176
|
+
let ids: { dagId: string; treeId: string; sankeyId: string } | null = null;
|
|
177
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
178
|
+
// Per-node detail store, keyed by trace node id
|
|
179
|
+
const nodeDetails = new Map<string, RoundTripDetail>();
|
|
180
|
+
// Accumulated recipe bodies loaded via get_recipe during the run (name → markdown body)
|
|
181
|
+
const loadedRecipes = new Map<string, string>();
|
|
182
|
+
// Name of the most recently loaded recipe via get_recipe. Not reset at iteration
|
|
183
|
+
// boundaries — a recipe loaded in iter N can legitimately guide tool calls in iter N+1
|
|
184
|
+
// (cascade of scripts). Only updated when another get_recipe succeeds.
|
|
185
|
+
let currentRecipeContext: string | undefined = undefined;
|
|
186
|
+
|
|
187
|
+
/** Build a short, graph-friendly label for a tool_call node from tool name + args.
|
|
188
|
+
* Uses a tool-specific emoji so small graph nodes remain legible. */
|
|
189
|
+
function enrichToolLabel(toolName: string, args: unknown): string {
|
|
190
|
+
const a = (args && typeof args === 'object' ? args : {}) as Record<string, unknown>;
|
|
191
|
+
const sanitize = (s: string): string =>
|
|
192
|
+
s.replace(/\s+/g, ' ').replace(/"/g, '').trim();
|
|
193
|
+
const truncate = (s: string): string => (s.length > 40 ? s.slice(0, 40) + '…' : s);
|
|
194
|
+
let emoji = '🔧';
|
|
195
|
+
let kind = toolName;
|
|
196
|
+
let preview: string | undefined;
|
|
197
|
+
// Tool names may be namespaced (e.g. "autoui_ui_get_recipe"). Match by suffix.
|
|
198
|
+
const endsWith = (s: string): boolean => toolName === s || toolName.endsWith('_' + s);
|
|
199
|
+
if (endsWith('query_sql') || /sql/i.test(toolName)) {
|
|
200
|
+
emoji = '🗃️'; kind = 'sql';
|
|
201
|
+
const sql = (a.sql ?? a.query ?? a.statement) as unknown;
|
|
202
|
+
if (typeof sql === 'string') preview = sql;
|
|
203
|
+
} else if (endsWith('run_script')) {
|
|
204
|
+
emoji = '⚙️'; kind = 'script';
|
|
205
|
+
const scr = (a.script ?? a.code ?? a.agentTask) as unknown;
|
|
206
|
+
if (typeof scr === 'string') preview = scr;
|
|
207
|
+
} else if (endsWith('get_recipe')) {
|
|
208
|
+
emoji = '📖'; kind = 'recipe';
|
|
209
|
+
const n = (a.name ?? a.id) as unknown;
|
|
210
|
+
if (typeof n === 'string') preview = n;
|
|
211
|
+
} else if (endsWith('search_recipes')) {
|
|
212
|
+
emoji = '🔍'; kind = 'search';
|
|
213
|
+
const n = (a.query ?? a.name) as unknown;
|
|
214
|
+
if (typeof n === 'string') preview = n;
|
|
215
|
+
}
|
|
216
|
+
const body = preview ? truncate(sanitize(preview)) : '';
|
|
217
|
+
return body ? `${emoji} 【${kind}】 ${body}` : `${emoji} 【${kind}】`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function nextId(kind: NodeKind): string {
|
|
221
|
+
nodeIdCounter += 1;
|
|
222
|
+
return `${kind}_${nodeIdCounter}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function addNode(
|
|
226
|
+
kind: NodeKind,
|
|
227
|
+
label: string,
|
|
228
|
+
meta: Record<string, unknown> = {},
|
|
229
|
+
): TraceNode {
|
|
230
|
+
const node: TraceNode = {
|
|
231
|
+
id: nextId(kind),
|
|
232
|
+
kind,
|
|
233
|
+
label,
|
|
234
|
+
startMs: Date.now(),
|
|
235
|
+
meta,
|
|
236
|
+
};
|
|
237
|
+
nodes.push(node);
|
|
238
|
+
return node;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function addEdge(fromId: string, toId: string, weight = 1): void {
|
|
242
|
+
const existing = edges.find((e) => e.from === fromId && e.to === toId);
|
|
243
|
+
if (existing) {
|
|
244
|
+
existing.weight += weight;
|
|
245
|
+
} else {
|
|
246
|
+
edges.push({ from: fromId, to: toId, weight });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function addSankey(fromKind: NodeKind, toKind: NodeKind, weight = 1): void {
|
|
251
|
+
const key = `${fromKind}→${toKind}`;
|
|
252
|
+
sankeyWeights.set(key, (sankeyWeights.get(key) ?? 0) + weight);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function scheduleFlush(): void {
|
|
256
|
+
if (ids === null || flushTimer !== null) return;
|
|
257
|
+
flushTimer = setTimeout(() => {
|
|
258
|
+
flushTimer = null;
|
|
259
|
+
flush();
|
|
260
|
+
}, FLUSH_INTERVAL_MS);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function summaryForNode(node: TraceNode): string {
|
|
264
|
+
const detail = nodeDetails.get(node.id);
|
|
265
|
+
if (!detail) return node.label;
|
|
266
|
+
const ms = (v: number | undefined): number => Math.round(v ?? 0);
|
|
267
|
+
switch (detail.kind) {
|
|
268
|
+
case 'iteration': {
|
|
269
|
+
const i = detail.iteration;
|
|
270
|
+
return i != null ? `Iteration #${i} — ${detail.label}` : detail.label;
|
|
271
|
+
}
|
|
272
|
+
case 'tool_call': {
|
|
273
|
+
const name = detail.toolName ?? '?';
|
|
274
|
+
const args = detail.toolArgs !== undefined ? JSON.stringify(detail.toolArgs).slice(0, 60) : '';
|
|
275
|
+
if (detail.toolError) return `${name} ✗ ${String(detail.toolError).slice(0, 60)}`;
|
|
276
|
+
if (detail.toolResult !== undefined) {
|
|
277
|
+
return `${name}(${args}) → ${detail.toolResult.slice(0, 60)} (${ms(detail.latencyMs)}ms)`;
|
|
278
|
+
}
|
|
279
|
+
return `${name}(${args}) — pending...`;
|
|
280
|
+
}
|
|
281
|
+
case 'tool_result': {
|
|
282
|
+
const name = detail.toolName ?? '?';
|
|
283
|
+
if (detail.toolError) return `${name} ✗ ${String(detail.toolError).slice(0, 60)}`;
|
|
284
|
+
const res = detail.toolResult ? detail.toolResult.slice(0, 60) : '';
|
|
285
|
+
return `${name} → ${res} (${ms(detail.latencyMs)}ms)`;
|
|
286
|
+
}
|
|
287
|
+
case 'llm_req':
|
|
288
|
+
return `→ LLM: ${detail.messageCount ?? 0} msgs, ${detail.toolCount ?? 0} tools`;
|
|
289
|
+
case 'llm_resp':
|
|
290
|
+
return `← LLM: ${detail.inputTokens ?? 0}in/${detail.outputTokens ?? 0}out, ${ms(detail.latencyMs)}ms (${detail.stopReason ?? '?'})`;
|
|
291
|
+
default:
|
|
292
|
+
return detail.label ?? node.label;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function buildCytoscapeData(): Record<string, unknown> {
|
|
297
|
+
const elements: Array<{ data: Record<string, unknown> }> = [];
|
|
298
|
+
for (const n of nodes) {
|
|
299
|
+
elements.push({
|
|
300
|
+
data: {
|
|
301
|
+
id: n.id,
|
|
302
|
+
label: n.label,
|
|
303
|
+
kind: n.kind,
|
|
304
|
+
color: colorForNode(n),
|
|
305
|
+
summary: summaryForNode(n),
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
for (const e of edges) {
|
|
310
|
+
elements.push({ data: { source: e.from, target: e.to, flow: e.weight } });
|
|
311
|
+
}
|
|
312
|
+
return { elements, style: CYTOSCAPE_STYLE, layout: { name: 'cose', animate: true } };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildTreeData(): Record<string, unknown> {
|
|
316
|
+
interface TreeNode {
|
|
317
|
+
name: string;
|
|
318
|
+
nodeId?: string;
|
|
319
|
+
children?: TreeNode[];
|
|
320
|
+
}
|
|
321
|
+
// No `color` field — tree widget owns its palette (source of truth).
|
|
322
|
+
const root: TreeNode = { name: 'conv', children: [] };
|
|
323
|
+
const iterNodes = nodes.filter((n) => n.kind === 'iteration');
|
|
324
|
+
for (const iter of iterNodes) {
|
|
325
|
+
const iterChild: TreeNode & { summary?: string } = {
|
|
326
|
+
name: iter.label,
|
|
327
|
+
nodeId: iter.id,
|
|
328
|
+
summary: summaryForNode(iter),
|
|
329
|
+
children: [],
|
|
330
|
+
};
|
|
331
|
+
for (const n of nodes) {
|
|
332
|
+
if (n.id === iter.id) continue;
|
|
333
|
+
if (n.meta.iterationId === iter.id) {
|
|
334
|
+
iterChild.children!.push({
|
|
335
|
+
name: n.label,
|
|
336
|
+
nodeId: n.id,
|
|
337
|
+
summary: summaryForNode(n),
|
|
338
|
+
} as TreeNode & { summary: string });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
root.children!.push(iterChild);
|
|
342
|
+
}
|
|
343
|
+
return { root, orientation: 'horizontal' };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function buildSankeyData(): Record<string, unknown> {
|
|
347
|
+
// autoui.sankey shape: { nodes: [{id,label,color}], links: [{source,target,value,color}] }
|
|
348
|
+
// where source/target are node ids (strings), not indexes.
|
|
349
|
+
// Build per-trace-node sankey where links flow from source trace node to target trace node.
|
|
350
|
+
const presentIds = new Set<string>();
|
|
351
|
+
for (const e of edges) {
|
|
352
|
+
presentIds.add(e.from);
|
|
353
|
+
presentIds.add(e.to);
|
|
354
|
+
}
|
|
355
|
+
const sankeyNodes = nodes
|
|
356
|
+
.filter((n) => presentIds.has(n.id))
|
|
357
|
+
.map((n) => ({
|
|
358
|
+
id: n.id,
|
|
359
|
+
label: n.label,
|
|
360
|
+
color: colorForNode(n),
|
|
361
|
+
summary: summaryForNode(n),
|
|
362
|
+
}));
|
|
363
|
+
const sankeyLinks = edges.map((e) => {
|
|
364
|
+
const src = nodes.find((n) => n.id === e.from);
|
|
365
|
+
return {
|
|
366
|
+
source: e.from,
|
|
367
|
+
target: e.to,
|
|
368
|
+
value: e.weight,
|
|
369
|
+
color: src ? colorForNode(src) : ROOT_COLOR,
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
return { nodes: sankeyNodes, links: sankeyLinks };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function flush(): void {
|
|
376
|
+
if (ids === null) return;
|
|
377
|
+
ctx.updateWidget(ids.dagId, buildCytoscapeData());
|
|
378
|
+
ctx.updateWidget(ids.treeId, buildTreeData());
|
|
379
|
+
ctx.updateWidget(ids.sankeyId, buildSankeyData());
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const callbacks: Partial<AgentCallbacks> = {
|
|
383
|
+
onIterationStart: (iteration: number, maxIterations: number): void => {
|
|
384
|
+
const node = addNode('iteration', `iter ${iteration}/${maxIterations}`, {
|
|
385
|
+
iteration,
|
|
386
|
+
maxIterations,
|
|
387
|
+
});
|
|
388
|
+
currentIterationId = node.id;
|
|
389
|
+
nodeDetails.set(node.id, {
|
|
390
|
+
kind: 'iteration',
|
|
391
|
+
label: node.label,
|
|
392
|
+
startMs: node.startMs,
|
|
393
|
+
iteration,
|
|
394
|
+
});
|
|
395
|
+
// Link previous last-of-kind to this iteration where meaningful
|
|
396
|
+
if (lastNodeByKind.iteration) {
|
|
397
|
+
addEdge(lastNodeByKind.iteration, node.id);
|
|
398
|
+
addSankey('iteration', 'iteration');
|
|
399
|
+
}
|
|
400
|
+
lastNodeByKind.iteration = node.id;
|
|
401
|
+
scheduleFlush();
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
onLLMRequest: (messages: ChatMessage[], tools: ProviderTool[]): void => {
|
|
405
|
+
const node = addNode('llm_req', `req (${messages.length} msgs, ${tools.length} tools)`, {
|
|
406
|
+
iterationId: currentIterationId,
|
|
407
|
+
messageCount: messages.length,
|
|
408
|
+
toolCount: tools.length,
|
|
409
|
+
});
|
|
410
|
+
currentLlmReqId = node.id;
|
|
411
|
+
nodeDetails.set(node.id, {
|
|
412
|
+
kind: 'llm_req',
|
|
413
|
+
label: node.label,
|
|
414
|
+
startMs: node.startMs,
|
|
415
|
+
messageCount: messages.length,
|
|
416
|
+
toolCount: tools.length,
|
|
417
|
+
});
|
|
418
|
+
if (currentIterationId) {
|
|
419
|
+
addEdge(currentIterationId, node.id);
|
|
420
|
+
addSankey('iteration', 'llm_req');
|
|
421
|
+
}
|
|
422
|
+
lastNodeByKind.llm_req = node.id;
|
|
423
|
+
scheduleFlush();
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
onLLMResponse: (
|
|
427
|
+
response: LLMResponse,
|
|
428
|
+
latencyMs: number,
|
|
429
|
+
tokens?: { input: number; output: number },
|
|
430
|
+
): void => {
|
|
431
|
+
const outTokens = tokens?.output ?? response.usage?.output_tokens ?? 0;
|
|
432
|
+
const inTokens = tokens?.input ?? response.usage?.input_tokens ?? 0;
|
|
433
|
+
const node = addNode('llm_resp', `resp ${outTokens}tok ${Math.round(latencyMs)}ms`, {
|
|
434
|
+
iterationId: currentIterationId,
|
|
435
|
+
latencyMs,
|
|
436
|
+
inputTokens: inTokens,
|
|
437
|
+
outputTokens: outTokens,
|
|
438
|
+
stopReason: response.stopReason,
|
|
439
|
+
});
|
|
440
|
+
node.endMs = Date.now();
|
|
441
|
+
currentLlmRespId = node.id;
|
|
442
|
+
nodeDetails.set(node.id, {
|
|
443
|
+
kind: 'llm_resp',
|
|
444
|
+
label: node.label,
|
|
445
|
+
startMs: node.startMs,
|
|
446
|
+
endMs: node.endMs,
|
|
447
|
+
inputTokens: inTokens,
|
|
448
|
+
outputTokens: outTokens,
|
|
449
|
+
latencyMs,
|
|
450
|
+
stopReason: response.stopReason,
|
|
451
|
+
});
|
|
452
|
+
if (currentLlmReqId) {
|
|
453
|
+
addEdge(currentLlmReqId, node.id, Math.max(1, outTokens));
|
|
454
|
+
addSankey('llm_req', 'llm_resp', Math.max(1, outTokens));
|
|
455
|
+
}
|
|
456
|
+
lastNodeByKind.llm_resp = node.id;
|
|
457
|
+
scheduleFlush();
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
onToolCall: (call: ToolCall): void => {
|
|
461
|
+
// agent/loop.ts calls onToolCall ONCE per tool, after call.result is set.
|
|
462
|
+
// Create the tool_call node (with args) and, if the call has completed
|
|
463
|
+
// (result or error present), also emit the tool_result node and populate
|
|
464
|
+
// loadedRecipes in one shot.
|
|
465
|
+
const isGetRecipe = call.name === 'get_recipe' || call.name.endsWith('_get_recipe');
|
|
466
|
+
let traceId = toolCallIdMap.get(call.id);
|
|
467
|
+
if (!traceId) {
|
|
468
|
+
const enrichedLabel = enrichToolLabel(call.name, call.args);
|
|
469
|
+
const node = addNode('tool_call', enrichedLabel, {
|
|
470
|
+
iterationId: currentIterationId,
|
|
471
|
+
toolCallId: call.id,
|
|
472
|
+
args: call.args,
|
|
473
|
+
});
|
|
474
|
+
toolCallIdMap.set(call.id, node.id);
|
|
475
|
+
toolCallStartMs.set(call.id, node.startMs);
|
|
476
|
+
const callDetail: RoundTripDetail = {
|
|
477
|
+
kind: 'tool_call',
|
|
478
|
+
label: node.label,
|
|
479
|
+
startMs: node.startMs,
|
|
480
|
+
toolName: call.name,
|
|
481
|
+
toolArgs: call.args,
|
|
482
|
+
};
|
|
483
|
+
// Tag origin recipe (except on get_recipe itself — no self-reference)
|
|
484
|
+
if (!isGetRecipe && currentRecipeContext) {
|
|
485
|
+
callDetail.originRecipe = currentRecipeContext;
|
|
486
|
+
}
|
|
487
|
+
nodeDetails.set(node.id, callDetail);
|
|
488
|
+
if (currentLlmRespId) {
|
|
489
|
+
addEdge(currentLlmRespId, node.id);
|
|
490
|
+
addSankey('llm_resp', 'tool_call');
|
|
491
|
+
}
|
|
492
|
+
lastToolCallId = node.id;
|
|
493
|
+
lastNodeByKind.tool_call = node.id;
|
|
494
|
+
traceId = node.id;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const hasResult = call.result !== undefined || call.error !== undefined;
|
|
498
|
+
if (hasResult) {
|
|
499
|
+
const startMs = toolCallStartMs.get(call.id) ?? Date.now();
|
|
500
|
+
const elapsed = call.elapsed ?? Date.now() - startMs;
|
|
501
|
+
const resultStr = call.result === undefined
|
|
502
|
+
? ''
|
|
503
|
+
: (typeof call.result === 'string' ? call.result : JSON.stringify(call.result));
|
|
504
|
+
const resNode = addNode('tool_result', `${call.name} (${Math.round(elapsed)}ms)`, {
|
|
505
|
+
iterationId: currentIterationId,
|
|
506
|
+
toolCallId: call.id,
|
|
507
|
+
elapsed,
|
|
508
|
+
error: call.error,
|
|
509
|
+
});
|
|
510
|
+
resNode.endMs = Date.now();
|
|
511
|
+
const resultDetail: RoundTripDetail = {
|
|
512
|
+
kind: 'tool_result',
|
|
513
|
+
label: resNode.label,
|
|
514
|
+
startMs: resNode.startMs,
|
|
515
|
+
endMs: resNode.endMs,
|
|
516
|
+
toolName: call.name,
|
|
517
|
+
toolResult: resultStr,
|
|
518
|
+
toolError: call.error,
|
|
519
|
+
latencyMs: elapsed,
|
|
520
|
+
};
|
|
521
|
+
if (!isGetRecipe && currentRecipeContext) {
|
|
522
|
+
resultDetail.originRecipe = currentRecipeContext;
|
|
523
|
+
}
|
|
524
|
+
nodeDetails.set(resNode.id, resultDetail);
|
|
525
|
+
const prior = nodeDetails.get(traceId);
|
|
526
|
+
if (prior) {
|
|
527
|
+
prior.endMs = resNode.endMs;
|
|
528
|
+
prior.toolResult = resultStr;
|
|
529
|
+
prior.toolError = call.error;
|
|
530
|
+
prior.latencyMs = elapsed;
|
|
531
|
+
}
|
|
532
|
+
addEdge(traceId, resNode.id, Math.max(1, elapsed));
|
|
533
|
+
addSankey('tool_call', 'tool_result', Math.max(1, elapsed));
|
|
534
|
+
lastNodeByKind.tool_result = resNode.id;
|
|
535
|
+
// Extract & store recipe body for dblclick anchor matching
|
|
536
|
+
if ((call.name === 'get_recipe' || call.name.endsWith('_get_recipe')) && !call.error && resultStr) {
|
|
537
|
+
try {
|
|
538
|
+
let body = resultStr;
|
|
539
|
+
try {
|
|
540
|
+
const parsed = JSON.parse(resultStr);
|
|
541
|
+
if (parsed && typeof parsed === 'object' && typeof (parsed as { content?: unknown }).content === 'string') {
|
|
542
|
+
body = (parsed as { content: string }).content;
|
|
543
|
+
}
|
|
544
|
+
} catch { /* not JSON — keep raw */ }
|
|
545
|
+
const a = (call.args && typeof call.args === 'object' ? call.args : {}) as Record<string, unknown>;
|
|
546
|
+
const name = (a.name ?? a.id) as unknown;
|
|
547
|
+
if (typeof name === 'string' && name.length > 0 && body.length > 0) {
|
|
548
|
+
loadedRecipes.set(name, body);
|
|
549
|
+
currentRecipeContext = name;
|
|
550
|
+
}
|
|
551
|
+
} catch { /* defensive */ }
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
scheduleFlush();
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
onWidget: (type: string, _data: Record<string, unknown>, serverName?: string): void => {
|
|
558
|
+
const node = addNode('widget', `${serverName ?? '?'}/${type}`, {
|
|
559
|
+
iterationId: currentIterationId,
|
|
560
|
+
type,
|
|
561
|
+
serverName,
|
|
562
|
+
});
|
|
563
|
+
const parent = lastToolCallId ?? currentLlmRespId;
|
|
564
|
+
if (parent) {
|
|
565
|
+
addEdge(parent, node.id);
|
|
566
|
+
// Pick a sensible sankey source kind
|
|
567
|
+
if (lastToolCallId) addSankey('tool_call', 'widget');
|
|
568
|
+
else addSankey('llm_resp', 'widget');
|
|
569
|
+
}
|
|
570
|
+
lastNodeByKind.widget = node.id;
|
|
571
|
+
scheduleFlush();
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
onTrace: (message: string): void => {
|
|
575
|
+
const node = addNode('trace', message.slice(0, 64), {
|
|
576
|
+
iterationId: currentIterationId,
|
|
577
|
+
full: message,
|
|
578
|
+
});
|
|
579
|
+
const parent = currentLlmRespId ?? currentIterationId;
|
|
580
|
+
if (parent) {
|
|
581
|
+
addEdge(parent, node.id);
|
|
582
|
+
addSankey(
|
|
583
|
+
currentLlmRespId ? 'llm_resp' : 'iteration',
|
|
584
|
+
'trace',
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
lastNodeByKind.trace = node.id;
|
|
588
|
+
scheduleFlush();
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
onDone: (_metrics: AgentMetrics): void => {
|
|
592
|
+
// Final flush bypasses throttle to guarantee terminal state renders.
|
|
593
|
+
if (flushTimer !== null) {
|
|
594
|
+
clearTimeout(flushTimer);
|
|
595
|
+
flushTimer = null;
|
|
596
|
+
}
|
|
597
|
+
flush();
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
callbacks,
|
|
603
|
+
mount(): { dagId: string; treeId: string; sankeyId: string } | null {
|
|
604
|
+
if (ids !== null) return ids;
|
|
605
|
+
const dag = ctx.addWidget('animated-flow', buildCytoscapeData(), 'cytoscape');
|
|
606
|
+
const tree = ctx.addWidget('tree', buildTreeData(), 'd3');
|
|
607
|
+
const sankey = ctx.addWidget('sankey', buildSankeyData(), 'autoui');
|
|
608
|
+
if (!dag || !tree || !sankey) return null;
|
|
609
|
+
ids = { dagId: dag.id, treeId: tree.id, sankeyId: sankey.id };
|
|
610
|
+
// Synchronous immediate flush — guarantees widgets reflect full buffer
|
|
611
|
+
// when trace is toggled mid-run (retroactive within current session).
|
|
612
|
+
flush();
|
|
613
|
+
return ids;
|
|
614
|
+
},
|
|
615
|
+
reset(): void {
|
|
616
|
+
nodes = [];
|
|
617
|
+
edges = [];
|
|
618
|
+
sankeyWeights = new Map();
|
|
619
|
+
nodeIdCounter = 0;
|
|
620
|
+
lastNodeByKind = {};
|
|
621
|
+
currentIterationId = undefined;
|
|
622
|
+
currentLlmReqId = undefined;
|
|
623
|
+
currentLlmRespId = undefined;
|
|
624
|
+
lastToolCallId = undefined;
|
|
625
|
+
toolCallIdMap.clear();
|
|
626
|
+
toolCallStartMs.clear();
|
|
627
|
+
nodeDetails.clear();
|
|
628
|
+
loadedRecipes.clear();
|
|
629
|
+
currentRecipeContext = undefined;
|
|
630
|
+
iterationPalette = makeIterationPalette();
|
|
631
|
+
if (flushTimer !== null) {
|
|
632
|
+
clearTimeout(flushTimer);
|
|
633
|
+
flushTimer = null;
|
|
634
|
+
}
|
|
635
|
+
if (ids !== null) {
|
|
636
|
+
ctx.updateWidget(ids.dagId, {
|
|
637
|
+
elements: [],
|
|
638
|
+
style: CYTOSCAPE_STYLE,
|
|
639
|
+
layout: { name: 'cose', animate: true },
|
|
640
|
+
});
|
|
641
|
+
ctx.updateWidget(ids.treeId, {
|
|
642
|
+
root: { name: 'conv', children: [] },
|
|
643
|
+
orientation: 'horizontal',
|
|
644
|
+
});
|
|
645
|
+
ctx.updateWidget(ids.sankeyId, {
|
|
646
|
+
nodes: [],
|
|
647
|
+
links: [],
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
detach(): void {
|
|
652
|
+
// Unmount widgets but keep the buffer so a later mount() is retroactive.
|
|
653
|
+
ids = null;
|
|
654
|
+
if (flushTimer !== null) {
|
|
655
|
+
clearTimeout(flushTimer);
|
|
656
|
+
flushTimer = null;
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
getNodeDetail(nodeId: string): RoundTripDetail | undefined {
|
|
660
|
+
return nodeDetails.get(nodeId);
|
|
661
|
+
},
|
|
662
|
+
getLoadedRecipes(): Map<string, string> {
|
|
663
|
+
return loadedRecipes;
|
|
664
|
+
},
|
|
665
|
+
getCurrentRecipeContext(): string | undefined {
|
|
666
|
+
return currentRecipeContext;
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
}
|