@townco/debugger 0.1.66 → 0.1.68

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.
@@ -0,0 +1,391 @@
1
+ import type { ConversationTrace, Log, Span } from "../types";
2
+
3
+ // ============================================================================
4
+ // Types
5
+ // ============================================================================
6
+
7
+ export interface ConversationItem {
8
+ id: string;
9
+ type: "user" | "agent" | "thinking" | "tool_call";
10
+ content: string;
11
+ timestamp: number;
12
+ spanId?: string;
13
+ toolName?: string;
14
+ toolInput?: unknown;
15
+ toolOutput?: unknown;
16
+ /** Label shown above the content (e.g., "Agent", "Thinking stream") */
17
+ label?: string;
18
+ /** If this item is from a subagent, the span_id of the parent Task span */
19
+ subagentSpanId?: string;
20
+ /** Short identifier for the subagent (last 4 chars of span_id) */
21
+ subagentId?: string;
22
+ }
23
+
24
+ export interface Segment {
25
+ id: string;
26
+ traceId: string;
27
+ startTime: number;
28
+ endTime: number;
29
+ conversationItems: ConversationItem[];
30
+ spans: Span[];
31
+ logs: Log[];
32
+ }
33
+
34
+ export interface TraceData {
35
+ trace: ConversationTrace;
36
+ spans: Span[];
37
+ logs: Log[];
38
+ }
39
+
40
+ // ============================================================================
41
+ // Utility Functions
42
+ // ============================================================================
43
+
44
+ function parseAttributes(attrs: string | null): Record<string, unknown> {
45
+ if (!attrs) return {};
46
+ try {
47
+ return JSON.parse(attrs);
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ function formatTimestamp(timestampNano: number): string {
54
+ const date = new Date(timestampNano / 1_000_000);
55
+ return date.toLocaleTimeString("en-US", {
56
+ hour: "2-digit",
57
+ minute: "2-digit",
58
+ second: "2-digit",
59
+ hour12: false,
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Extract thinking/reasoning content from a chat span if available
65
+ */
66
+ function extractThinkingContent(span: Span): string | null {
67
+ const attrs = parseAttributes(span.attributes);
68
+ const outputMessages = attrs["gen_ai.output.messages"];
69
+
70
+ if (!outputMessages) return null;
71
+
72
+ try {
73
+ const messages =
74
+ typeof outputMessages === "string"
75
+ ? JSON.parse(outputMessages)
76
+ : outputMessages;
77
+
78
+ if (!Array.isArray(messages)) return null;
79
+
80
+ // Look for thinking blocks in the content
81
+ for (const msg of messages) {
82
+ if (msg.role === "assistant" || msg.role === "ai") {
83
+ const content = msg.content;
84
+ if (Array.isArray(content)) {
85
+ for (const block of content) {
86
+ if (block.type === "thinking" && block.thinking) {
87
+ return block.thinking;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ } catch {
94
+ // Ignore parse errors
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Check if a span is a chat/LLM call span
102
+ */
103
+ function isChatSpan(span: Span): boolean {
104
+ const attrs = parseAttributes(span.attributes);
105
+ return span.name.startsWith("chat") && "gen_ai.input.messages" in attrs;
106
+ }
107
+
108
+ /**
109
+ * Check if a span is a subagent (Task) span
110
+ *
111
+ * A span is considered a subagent if:
112
+ * - It's a tool call span (agent.tool_call)
113
+ * - AND the tool name matches one of these patterns:
114
+ * - Exactly "Task" (the built-in subagent tool)
115
+ * - Contains "subagent" (case-insensitive)
116
+ */
117
+ function isSubagentSpan(span: Span): boolean {
118
+ if (span.name !== "agent.tool_call") return false;
119
+ const attrs = parseAttributes(span.attributes);
120
+ const toolName = attrs["tool.name"];
121
+
122
+ if (typeof toolName !== "string") return false;
123
+
124
+ // Check for Task or any tool name containing "subagent" (case-insensitive)
125
+ const isSubagent =
126
+ toolName === "Task" || toolName.toLowerCase().includes("subagent");
127
+
128
+ return isSubagent;
129
+ }
130
+
131
+ /**
132
+ * Get a short identifier from a span_id (last 4 characters)
133
+ */
134
+ function getShortId(spanId: string): string {
135
+ return spanId.slice(-4);
136
+ }
137
+
138
+ interface SubagentInfo {
139
+ spanId: string;
140
+ shortId: string;
141
+ }
142
+
143
+ /**
144
+ * Build a map of span_id -> subagent info for all spans that are descendants of a Task span
145
+ */
146
+ function buildSubagentMap(spans: Span[]): Map<string, SubagentInfo> {
147
+ const subagentMap = new Map<string, SubagentInfo>();
148
+ const spanById = new Map(spans.map((s) => [s.span_id, s]));
149
+
150
+ // Find all Task (subagent) spans
151
+ const taskSpans = spans.filter(isSubagentSpan);
152
+
153
+ console.log("[DEBUG] Found subagent tool call spans:", {
154
+ count: taskSpans.length,
155
+ spans: taskSpans.map((s) => {
156
+ const attrs = parseAttributes(s.attributes);
157
+ return {
158
+ spanId: s.span_id.slice(-4),
159
+ toolName: attrs["tool.name"],
160
+ spanName: s.name,
161
+ };
162
+ }),
163
+ });
164
+
165
+ // For each Task span, mark it and all its descendants
166
+ for (const taskSpan of taskSpans) {
167
+ const subagentInfo: SubagentInfo = {
168
+ spanId: taskSpan.span_id,
169
+ shortId: getShortId(taskSpan.span_id),
170
+ };
171
+
172
+ // Mark the Task span itself
173
+ subagentMap.set(taskSpan.span_id, subagentInfo);
174
+
175
+ // Track descendants for logging
176
+ const descendants: string[] = [];
177
+
178
+ // Find all descendants using BFS
179
+ const queue: string[] = [taskSpan.span_id];
180
+ while (queue.length > 0) {
181
+ const currentId = queue.shift();
182
+ if (!currentId) break;
183
+ // Find children of current span
184
+ for (const span of spans) {
185
+ if (span.parent_span_id === currentId) {
186
+ subagentMap.set(span.span_id, subagentInfo);
187
+ queue.push(span.span_id);
188
+ descendants.push(span.span_id);
189
+ }
190
+ }
191
+ }
192
+
193
+ console.log("[DEBUG] Subagent descendant tree:", {
194
+ rootSpanId: taskSpan.span_id.slice(-4),
195
+ shortId: subagentInfo.shortId,
196
+ descendantCount: descendants.length,
197
+ descendants: descendants.map((id) => ({
198
+ spanId: id.slice(-4),
199
+ spanName: spanById.get(id)?.name || "unknown",
200
+ })),
201
+ });
202
+ }
203
+
204
+ return subagentMap;
205
+ }
206
+
207
+ // ============================================================================
208
+ // Segmentation Function
209
+ // ============================================================================
210
+
211
+ /**
212
+ * Per-Turn Segmentation: Each user message + full agent response = one segment
213
+ * Spans are grouped by their trace_id, preserving full hierarchy
214
+ */
215
+ export function segmentByTurn(
216
+ traces: ConversationTrace[],
217
+ traceDataMap: Map<string, TraceData>,
218
+ ): Segment[] {
219
+ const segments: Segment[] = [];
220
+
221
+ for (const trace of traces) {
222
+ const data = traceDataMap.get(trace.trace_id);
223
+ if (!data) continue;
224
+
225
+ // Build subagent map for this trace's spans
226
+ const subagentMap = buildSubagentMap(data.spans);
227
+
228
+ // Debug: Log subagent map contents
229
+ if (subagentMap.size > 0) {
230
+ console.log("[DEBUG] Subagent map for trace:", trace.trace_id.slice(-4), {
231
+ subagentCount: subagentMap.size,
232
+ subagents: Array.from(subagentMap.entries()).map(([spanId, info]) => ({
233
+ spanId: spanId.slice(-4),
234
+ subagentSpanId: info.spanId.slice(-4),
235
+ shortId: info.shortId,
236
+ })),
237
+ });
238
+ }
239
+
240
+ const conversationItems: ConversationItem[] = [];
241
+
242
+ // Add user message if available
243
+ if (trace.userInput) {
244
+ conversationItems.push({
245
+ id: `${trace.trace_id}-user`,
246
+ type: "user",
247
+ content: trace.userInput,
248
+ timestamp: trace.start_time_unix_nano,
249
+ });
250
+ }
251
+
252
+ // Add agent messages (chat and tool calls)
253
+ for (const msg of trace.agentMessages) {
254
+ // Check if this message is from a subagent
255
+ const subagentInfo = msg.spanId ? subagentMap.get(msg.spanId) : undefined;
256
+
257
+ // Debug: Log message processing
258
+ if (msg.spanId) {
259
+ console.log("[DEBUG] Processing agent message:", {
260
+ type: msg.type,
261
+ spanId: msg.spanId.slice(-4),
262
+ hasSubagentInfo: !!subagentInfo,
263
+ subagentShortId: subagentInfo?.shortId,
264
+ contentPreview: msg.content.slice(0, 50),
265
+ });
266
+ }
267
+
268
+ if (msg.type === "chat") {
269
+ const item: ConversationItem = {
270
+ id: `${trace.trace_id}-agent-${msg.spanId}`,
271
+ type: "agent",
272
+ content: msg.content,
273
+ timestamp: msg.timestamp,
274
+ spanId: msg.spanId,
275
+ label: subagentInfo ? `Subagent .${subagentInfo.shortId}` : "Agent",
276
+ };
277
+ if (subagentInfo) {
278
+ item.subagentSpanId = subagentInfo.spanId;
279
+ item.subagentId = subagentInfo.shortId;
280
+ }
281
+ conversationItems.push(item);
282
+ } else if (msg.type === "tool_call" && msg.toolName) {
283
+ const item: ConversationItem = {
284
+ id: `${trace.trace_id}-tool-${msg.spanId}`,
285
+ type: "tool_call",
286
+ content: msg.toolName || msg.content,
287
+ timestamp: msg.timestamp,
288
+ spanId: msg.spanId,
289
+ toolName: msg.toolName,
290
+ toolInput: msg.toolInput,
291
+ toolOutput: msg.toolOutput,
292
+ label: "Tool Call",
293
+ };
294
+ if (subagentInfo) {
295
+ item.subagentSpanId = subagentInfo.spanId;
296
+ item.subagentId = subagentInfo.shortId;
297
+ }
298
+ conversationItems.push(item);
299
+ }
300
+ }
301
+
302
+ // Check for thinking content in chat spans
303
+ for (const span of data.spans) {
304
+ if (isChatSpan(span)) {
305
+ const thinking = extractThinkingContent(span);
306
+ if (thinking) {
307
+ // Insert thinking before the corresponding chat message
308
+ const chatItemIndex = conversationItems.findIndex(
309
+ (item) => item.spanId === span.span_id && item.type === "agent",
310
+ );
311
+ if (chatItemIndex > 0) {
312
+ const subagentInfo = subagentMap.get(span.span_id);
313
+ const item: ConversationItem = {
314
+ id: `${trace.trace_id}-thinking-${span.span_id}`,
315
+ type: "thinking",
316
+ content: thinking,
317
+ timestamp: span.start_time_unix_nano,
318
+ spanId: span.span_id,
319
+ label: "Thinking stream",
320
+ };
321
+ if (subagentInfo) {
322
+ item.subagentSpanId = subagentInfo.spanId;
323
+ item.subagentId = subagentInfo.shortId;
324
+ }
325
+ conversationItems.splice(chatItemIndex, 0, item);
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ // Sort by timestamp
332
+ conversationItems.sort((a, b) => a.timestamp - b.timestamp);
333
+
334
+ const startTime =
335
+ data.spans.length > 0
336
+ ? Math.min(...data.spans.map((s) => s.start_time_unix_nano))
337
+ : trace.start_time_unix_nano;
338
+ const endTime =
339
+ data.spans.length > 0
340
+ ? Math.max(...data.spans.map((s) => s.end_time_unix_nano))
341
+ : trace.start_time_unix_nano;
342
+
343
+ segments.push({
344
+ id: trace.trace_id,
345
+ traceId: trace.trace_id,
346
+ startTime,
347
+ endTime,
348
+ conversationItems,
349
+ spans: data.spans,
350
+ logs: data.logs,
351
+ });
352
+ }
353
+
354
+ return segments;
355
+ }
356
+
357
+ // ============================================================================
358
+ // Span Utilities
359
+ // ============================================================================
360
+
361
+ /**
362
+ * Get token count for a span
363
+ */
364
+ export function getSpanTokens(span: Span): number {
365
+ const attrs = parseAttributes(span.attributes);
366
+ const inputTokens = (attrs["gen_ai.usage.input_tokens"] as number) || 0;
367
+ const outputTokens = (attrs["gen_ai.usage.output_tokens"] as number) || 0;
368
+ return inputTokens + outputTokens;
369
+ }
370
+
371
+ /**
372
+ * Get duration in milliseconds for a span
373
+ */
374
+ export function getSpanDurationMs(span: Span): number {
375
+ return (span.end_time_unix_nano - span.start_time_unix_nano) / 1_000_000;
376
+ }
377
+
378
+ /**
379
+ * Format duration for display
380
+ */
381
+ export function formatDuration(durationMs: number): string {
382
+ if (durationMs < 1) {
383
+ return `${(durationMs * 1000).toFixed(0)}μs`;
384
+ }
385
+ if (durationMs < 1000) {
386
+ return `${durationMs.toFixed(0)}ms`;
387
+ }
388
+ return `${(durationMs / 1000).toFixed(1)}s`;
389
+ }
390
+
391
+ export { formatTimestamp };