@townco/debugger 0.1.27 → 0.1.29
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 +7 -4
- package/src/App.tsx +6 -0
- package/src/analysis/analyzer.ts +235 -0
- package/src/analysis/embeddings.ts +97 -0
- package/src/analysis/schema.ts +67 -0
- package/src/analysis/types.ts +132 -0
- package/src/analysis-db.ts +238 -0
- package/src/comparison-db.test.ts +28 -5
- package/src/comparison-db.ts +57 -9
- package/src/components/AnalyzeAllButton.tsx +81 -0
- package/src/components/DebuggerHeader.tsx +12 -0
- package/src/components/SessionAnalysisButton.tsx +109 -0
- package/src/components/SessionAnalysisDialog.tsx +155 -0
- package/src/components/UnifiedTimeline.tsx +3 -3
- package/src/components/ui/dialog.tsx +120 -0
- package/src/db.ts +3 -2
- package/src/lib/metrics.ts +101 -5
- package/src/pages/ComparisonView.tsx +258 -135
- package/src/pages/FindSessions.tsx +230 -0
- package/src/pages/SessionList.tsx +76 -10
- package/src/pages/SessionView.tsx +33 -1
- package/src/pages/TownHall.tsx +345 -187
- package/src/schemas.ts +27 -8
- package/src/server.ts +337 -3
- package/src/types.ts +11 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { SessionAnalysis } from "../analysis/types";
|
|
2
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
open: boolean;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
analysis: SessionAnalysis;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatDate(isoString: string): string {
|
|
11
|
+
return new Date(isoString).toLocaleString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function calculateDuration(start: string, end: string): string {
|
|
15
|
+
const startTime = new Date(start).getTime();
|
|
16
|
+
const endTime = new Date(end).getTime();
|
|
17
|
+
const durationMs = endTime - startTime;
|
|
18
|
+
|
|
19
|
+
const seconds = Math.floor(durationMs / 1000);
|
|
20
|
+
const minutes = Math.floor(seconds / 60);
|
|
21
|
+
const hours = Math.floor(minutes / 60);
|
|
22
|
+
|
|
23
|
+
if (hours > 0) {
|
|
24
|
+
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
|
25
|
+
}
|
|
26
|
+
if (minutes > 0) {
|
|
27
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
28
|
+
}
|
|
29
|
+
return `${seconds}s`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Section({
|
|
33
|
+
title,
|
|
34
|
+
children,
|
|
35
|
+
}: {
|
|
36
|
+
title: string;
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="space-y-3">
|
|
41
|
+
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
|
42
|
+
<div className="space-y-2">{children}</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function Field({ label, value }: { label: string; value: string }) {
|
|
48
|
+
return (
|
|
49
|
+
<div className="space-y-1">
|
|
50
|
+
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
|
51
|
+
<div className="text-sm text-foreground whitespace-pre-wrap break-words">
|
|
52
|
+
{value}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Badge({ label, value }: { label: string; value: string }) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="space-y-1">
|
|
61
|
+
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
|
62
|
+
<div className="inline-block px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
|
|
63
|
+
{value}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Metric({ label, value }: { label: string; value: number }) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="space-y-1">
|
|
72
|
+
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
|
73
|
+
<div className="text-2xl font-bold text-foreground">{value}</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function SessionAnalysisDialog({ open, onClose, analysis }: Props) {
|
|
79
|
+
return (
|
|
80
|
+
<Dialog open={open} onOpenChange={onClose}>
|
|
81
|
+
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
|
82
|
+
<DialogHeader>
|
|
83
|
+
<DialogTitle>Session Analysis</DialogTitle>
|
|
84
|
+
</DialogHeader>
|
|
85
|
+
|
|
86
|
+
<div className="space-y-6">
|
|
87
|
+
{/* Task Section */}
|
|
88
|
+
<Section title="Task">
|
|
89
|
+
<Field label="User Query" value={analysis.task.user_query} />
|
|
90
|
+
<Field label="Summary" value={analysis.task.task_summary} />
|
|
91
|
+
<Badge label="Intent" value={analysis.task.intent_type} />
|
|
92
|
+
</Section>
|
|
93
|
+
|
|
94
|
+
{/* Trajectory Section */}
|
|
95
|
+
<Section title="Trajectory">
|
|
96
|
+
<Field
|
|
97
|
+
label="High Level Plan"
|
|
98
|
+
value={analysis.trajectory.high_level_plan}
|
|
99
|
+
/>
|
|
100
|
+
<div className="grid grid-cols-2 gap-4 pt-2">
|
|
101
|
+
<Metric label="Steps" value={analysis.trajectory.num_steps} />
|
|
102
|
+
<Metric
|
|
103
|
+
label="Tool Calls"
|
|
104
|
+
value={analysis.trajectory.num_tool_calls}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
{analysis.trajectory.tools_used.length > 0 && (
|
|
108
|
+
<div className="space-y-2 pt-2">
|
|
109
|
+
<div className="text-xs font-medium text-muted-foreground">
|
|
110
|
+
Tools Used
|
|
111
|
+
</div>
|
|
112
|
+
<div className="flex flex-wrap gap-2">
|
|
113
|
+
{analysis.trajectory.tools_used.map((tool) => (
|
|
114
|
+
<span
|
|
115
|
+
key={tool}
|
|
116
|
+
className="px-2 py-1 bg-secondary text-secondary-foreground rounded text-xs font-medium"
|
|
117
|
+
>
|
|
118
|
+
{tool}
|
|
119
|
+
</span>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</Section>
|
|
125
|
+
|
|
126
|
+
{/* Outcome Section */}
|
|
127
|
+
<Section title="Outcome">
|
|
128
|
+
<div className="flex gap-4">
|
|
129
|
+
<Badge label="Status" value={analysis.outcome.status} />
|
|
130
|
+
<Badge label="Answer Type" value={analysis.outcome.answer_type} />
|
|
131
|
+
</div>
|
|
132
|
+
<Field label="Assessment" value={analysis.outcome.assessment} />
|
|
133
|
+
</Section>
|
|
134
|
+
|
|
135
|
+
{/* Metadata Section */}
|
|
136
|
+
<Section title="Metadata">
|
|
137
|
+
<div className="grid grid-cols-2 gap-4">
|
|
138
|
+
<Field label="Started" value={formatDate(analysis.started_at)} />
|
|
139
|
+
<Field label="Ended" value={formatDate(analysis.ended_at)} />
|
|
140
|
+
</div>
|
|
141
|
+
<Field
|
|
142
|
+
label="Duration"
|
|
143
|
+
value={calculateDuration(analysis.started_at, analysis.ended_at)}
|
|
144
|
+
/>
|
|
145
|
+
<Field label="Agent" value={analysis.agent_name} />
|
|
146
|
+
<Field
|
|
147
|
+
label="Session ID"
|
|
148
|
+
value={analysis.session_id.slice(0, 16)}
|
|
149
|
+
/>
|
|
150
|
+
</Section>
|
|
151
|
+
</div>
|
|
152
|
+
</DialogContent>
|
|
153
|
+
</Dialog>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -231,7 +231,7 @@ export function UnifiedTimeline({
|
|
|
231
231
|
{/* Timeline container */}
|
|
232
232
|
<div
|
|
233
233
|
ref={timelineRef}
|
|
234
|
-
className="border border-border rounded-lg overflow-hidden bg-card flex"
|
|
234
|
+
className="border border-border rounded-lg overflow-hidden bg-card flex max-h-[calc(100vh-250px)]"
|
|
235
235
|
onWheel={handleWheel}
|
|
236
236
|
>
|
|
237
237
|
{/* Fixed left column - processes labels */}
|
|
@@ -245,7 +245,7 @@ export function UnifiedTimeline({
|
|
|
245
245
|
</div>
|
|
246
246
|
|
|
247
247
|
{/* Span labels */}
|
|
248
|
-
<div className="flex-1 bg-white dark:bg-gray-900">
|
|
248
|
+
<div className="flex-1 bg-white dark:bg-gray-900 overflow-y-auto">
|
|
249
249
|
{flatSpans.map(({ node, depth }) => {
|
|
250
250
|
const span = spans.find((s) => s.span_id === node.span_id);
|
|
251
251
|
// This should never be null now due to filtering
|
|
@@ -310,7 +310,7 @@ export function UnifiedTimeline({
|
|
|
310
310
|
|
|
311
311
|
{/* Scrollable right side - message bubbles + timeline */}
|
|
312
312
|
<div
|
|
313
|
-
className="flex-1 overflow-x-auto overflow-y-
|
|
313
|
+
className="flex-1 overflow-x-auto overflow-y-auto"
|
|
314
314
|
style={{ background: "#404040" }}
|
|
315
315
|
>
|
|
316
316
|
<div
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const Dialog = DialogPrimitive.Root;
|
|
8
|
+
|
|
9
|
+
const DialogTrigger = DialogPrimitive.Trigger;
|
|
10
|
+
|
|
11
|
+
const DialogPortal = DialogPrimitive.Portal;
|
|
12
|
+
|
|
13
|
+
const DialogClose = DialogPrimitive.Close;
|
|
14
|
+
|
|
15
|
+
const DialogOverlay = React.forwardRef<
|
|
16
|
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
17
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
18
|
+
>(({ className, ...props }, ref) => (
|
|
19
|
+
<DialogPrimitive.Overlay
|
|
20
|
+
ref={ref}
|
|
21
|
+
className={cn(
|
|
22
|
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
{...props}
|
|
26
|
+
/>
|
|
27
|
+
));
|
|
28
|
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
29
|
+
|
|
30
|
+
const DialogContent = React.forwardRef<
|
|
31
|
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
32
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
|
33
|
+
>(({ className, children, ...props }, ref) => (
|
|
34
|
+
<DialogPortal>
|
|
35
|
+
<DialogOverlay />
|
|
36
|
+
<DialogPrimitive.Content
|
|
37
|
+
ref={ref}
|
|
38
|
+
className={cn(
|
|
39
|
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
{...props}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
|
46
|
+
<X className="size-4" />
|
|
47
|
+
<span className="sr-only">Close</span>
|
|
48
|
+
</DialogPrimitive.Close>
|
|
49
|
+
</DialogPrimitive.Content>
|
|
50
|
+
</DialogPortal>
|
|
51
|
+
));
|
|
52
|
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
53
|
+
|
|
54
|
+
const DialogHeader = ({
|
|
55
|
+
className,
|
|
56
|
+
...props
|
|
57
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
58
|
+
<div
|
|
59
|
+
className={cn(
|
|
60
|
+
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
DialogHeader.displayName = "DialogHeader";
|
|
67
|
+
|
|
68
|
+
const DialogFooter = ({
|
|
69
|
+
className,
|
|
70
|
+
...props
|
|
71
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
72
|
+
<div
|
|
73
|
+
className={cn(
|
|
74
|
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
75
|
+
className,
|
|
76
|
+
)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
DialogFooter.displayName = "DialogFooter";
|
|
81
|
+
|
|
82
|
+
const DialogTitle = React.forwardRef<
|
|
83
|
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
84
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
85
|
+
>(({ className, ...props }, ref) => (
|
|
86
|
+
<DialogPrimitive.Title
|
|
87
|
+
ref={ref}
|
|
88
|
+
className={cn(
|
|
89
|
+
"text-lg font-semibold leading-none tracking-tight",
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
{...props}
|
|
93
|
+
/>
|
|
94
|
+
));
|
|
95
|
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
|
96
|
+
|
|
97
|
+
const DialogDescription = React.forwardRef<
|
|
98
|
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
99
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
100
|
+
>(({ className, ...props }, ref) => (
|
|
101
|
+
<DialogPrimitive.Description
|
|
102
|
+
ref={ref}
|
|
103
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
104
|
+
{...props}
|
|
105
|
+
/>
|
|
106
|
+
));
|
|
107
|
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|
108
|
+
|
|
109
|
+
export {
|
|
110
|
+
Dialog,
|
|
111
|
+
DialogPortal,
|
|
112
|
+
DialogOverlay,
|
|
113
|
+
DialogClose,
|
|
114
|
+
DialogTrigger,
|
|
115
|
+
DialogContent,
|
|
116
|
+
DialogHeader,
|
|
117
|
+
DialogFooter,
|
|
118
|
+
DialogTitle,
|
|
119
|
+
DialogDescription,
|
|
120
|
+
};
|
package/src/db.ts
CHANGED
|
@@ -89,15 +89,16 @@ export class DebuggerDb {
|
|
|
89
89
|
getSpansBySessionAttribute(sessionId: string): Span[] {
|
|
90
90
|
// Use JSON extract to find spans where agent.session_id matches
|
|
91
91
|
return this.db
|
|
92
|
-
.query<Span, [string]>(
|
|
92
|
+
.query<Span, [string, string]>(
|
|
93
93
|
`
|
|
94
94
|
SELECT *
|
|
95
95
|
FROM spans
|
|
96
96
|
WHERE json_extract(attributes, '$."agent.session_id"') = ?
|
|
97
|
+
OR json_extract(attributes, '$."session.id"') = ?
|
|
97
98
|
ORDER BY start_time_unix_nano ASC
|
|
98
99
|
`,
|
|
99
100
|
)
|
|
100
|
-
.all(sessionId);
|
|
101
|
+
.all(sessionId, sessionId);
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
/**
|
package/src/lib/metrics.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SessionMetrics, Span, Trace } from "../types";
|
|
1
|
+
import type { SessionMetrics, Span, ToolCall, Trace } from "../types";
|
|
2
2
|
import { getModelPricing } from "./pricing";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -28,7 +28,7 @@ export function extractMetricsFromSpans(
|
|
|
28
28
|
): Omit<SessionMetrics, "durationMs"> {
|
|
29
29
|
let inputTokens = 0;
|
|
30
30
|
let outputTokens = 0;
|
|
31
|
-
|
|
31
|
+
const toolCalls: ToolCall[] = [];
|
|
32
32
|
|
|
33
33
|
for (const span of spans) {
|
|
34
34
|
const attrs = span.attributes ? JSON.parse(span.attributes) : {};
|
|
@@ -41,14 +41,109 @@ export function extractMetricsFromSpans(
|
|
|
41
41
|
outputTokens += attrs["gen_ai.usage.output_tokens"];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// Count tool calls
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// Count tool calls from dedicated tool spans
|
|
45
|
+
const isToolSpan =
|
|
46
|
+
span.name.includes("tool_call") || span.name.startsWith("tool:");
|
|
47
|
+
if (isToolSpan) {
|
|
48
|
+
const name =
|
|
49
|
+
attrs["tool.name"] ||
|
|
50
|
+
attrs["tool.type"] ||
|
|
51
|
+
attrs["gen_ai.tool_call.name"] ||
|
|
52
|
+
span.name;
|
|
53
|
+
|
|
54
|
+
const rawInput =
|
|
55
|
+
attrs["tool.input"] || attrs["gen_ai.tool_call.input"] || null;
|
|
56
|
+
const rawOutput = attrs["tool.output"] || null;
|
|
57
|
+
|
|
58
|
+
const parseMaybeJson = (val: unknown) => {
|
|
59
|
+
if (typeof val !== "string") return val;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(val);
|
|
62
|
+
} catch {
|
|
63
|
+
return val;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
toolCalls.push({
|
|
68
|
+
name: typeof name === "string" ? name : String(name),
|
|
69
|
+
input: parseMaybeJson(rawInput),
|
|
70
|
+
output: parseMaybeJson(rawOutput),
|
|
71
|
+
startTimeUnixNano: span.start_time_unix_nano,
|
|
72
|
+
endTimeUnixNano: span.end_time_unix_nano,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Also inspect model outputs for declared tool calls (e.g. Anthropic tool_use)
|
|
77
|
+
const messagesRaw =
|
|
78
|
+
attrs["gen_ai.output.messages"] ?? attrs["gen_ai.output.tool_calls"];
|
|
79
|
+
if (messagesRaw) {
|
|
80
|
+
try {
|
|
81
|
+
const messages =
|
|
82
|
+
typeof messagesRaw === "string"
|
|
83
|
+
? JSON.parse(messagesRaw)
|
|
84
|
+
: messagesRaw;
|
|
85
|
+
if (Array.isArray(messages)) {
|
|
86
|
+
for (const msg of messages) {
|
|
87
|
+
if (msg && typeof msg === "object") {
|
|
88
|
+
const toolCallsArray =
|
|
89
|
+
(msg as Record<string, unknown>).tool_calls ?? [];
|
|
90
|
+
if (Array.isArray(toolCallsArray)) {
|
|
91
|
+
for (const call of toolCallsArray) {
|
|
92
|
+
if (call && typeof call === "object") {
|
|
93
|
+
toolCalls.push({
|
|
94
|
+
name:
|
|
95
|
+
(call as Record<string, unknown>).name?.toString() ||
|
|
96
|
+
"tool_call",
|
|
97
|
+
input: (call as Record<string, unknown>).args,
|
|
98
|
+
output: null,
|
|
99
|
+
startTimeUnixNano: span.start_time_unix_nano,
|
|
100
|
+
endTimeUnixNano: span.end_time_unix_nano,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const content = (msg as Record<string, unknown>).content;
|
|
107
|
+
if (Array.isArray(content)) {
|
|
108
|
+
for (const c of content) {
|
|
109
|
+
if (
|
|
110
|
+
c &&
|
|
111
|
+
typeof c === "object" &&
|
|
112
|
+
(c as Record<string, unknown>).type === "tool_use"
|
|
113
|
+
) {
|
|
114
|
+
toolCalls.push({
|
|
115
|
+
name:
|
|
116
|
+
((c as Record<string, unknown>).name as
|
|
117
|
+
| string
|
|
118
|
+
| undefined) || "tool_use",
|
|
119
|
+
input: (c as Record<string, unknown>).input,
|
|
120
|
+
output: null,
|
|
121
|
+
startTimeUnixNano: span.start_time_unix_nano,
|
|
122
|
+
endTimeUnixNano: span.end_time_unix_nano,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// ignore parse errors
|
|
132
|
+
}
|
|
47
133
|
}
|
|
48
134
|
}
|
|
49
135
|
|
|
50
136
|
const totalTokens = inputTokens + outputTokens;
|
|
51
137
|
const estimatedCost = calculateCost(model, inputTokens, outputTokens);
|
|
138
|
+
// Dedupe tool calls using name + start time to avoid double counting when captured in multiple places
|
|
139
|
+
const deduped = new Map<string, ToolCall>();
|
|
140
|
+
for (const call of toolCalls) {
|
|
141
|
+
const key = `${call.name}-${call.startTimeUnixNano ?? ""}`;
|
|
142
|
+
if (!deduped.has(key)) {
|
|
143
|
+
deduped.set(key, call);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const toolCallCount = deduped.size;
|
|
52
147
|
|
|
53
148
|
return {
|
|
54
149
|
inputTokens,
|
|
@@ -56,6 +151,7 @@ export function extractMetricsFromSpans(
|
|
|
56
151
|
totalTokens,
|
|
57
152
|
estimatedCost,
|
|
58
153
|
toolCallCount,
|
|
154
|
+
toolCalls: Array.from(deduped.values()),
|
|
59
155
|
};
|
|
60
156
|
}
|
|
61
157
|
|