@braintrust/pi-extension 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,861 @@
1
+ import { basename, resolve } from "node:path";
2
+ import { hostname, userInfo } from "node:os";
3
+ import type { AgentEndEvent, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import { BraintrustClient, type BraintrustSpanHandle } from "./client.ts";
5
+ import { createLogger, loadConfig } from "./config.ts";
6
+ import { createStateStore } from "./state.ts";
7
+ import type {
8
+ AgentMessageLike,
9
+ AssistantMessageLike,
10
+ ConfigIssue,
11
+ ImageLike,
12
+ NormalizedAgentMessage,
13
+ NormalizedAssistantMessage,
14
+ TraceConfig,
15
+ } from "./types.ts";
16
+ import {
17
+ buildTurnInput,
18
+ extractErrorText,
19
+ formatToolSpanName,
20
+ generateUuid,
21
+ isPlainObject,
22
+ normalizeAssistantMessage,
23
+ normalizeContextMessages,
24
+ normalizeToolResult,
25
+ repoSlugForCwd,
26
+ rootSpanName,
27
+ sessionKeyFor,
28
+ shortHash,
29
+ toUnixSeconds,
30
+ } from "./utils.ts";
31
+
32
+ const EXTENSION_VERSION = "0.1.0";
33
+ const TRACING_STATUS_KEY = "braintrust-tracing";
34
+ const TRACING_WIDGET_KEY = "braintrust-trace-link";
35
+
36
+ interface SessionDescriptor {
37
+ sessionFile: string | undefined;
38
+ sessionId: string | undefined;
39
+ sessionKey: string;
40
+ }
41
+
42
+ interface PendingLlmCall {
43
+ startedAt: number;
44
+ input: NormalizedAgentMessage[];
45
+ }
46
+
47
+ interface TrackedToolStart {
48
+ startedAt: number;
49
+ args: unknown;
50
+ toolName: string;
51
+ }
52
+
53
+ interface ActiveTurn {
54
+ spanId: string;
55
+ span?: BraintrustSpanHandle;
56
+ prompt: string;
57
+ llmCalls: PendingLlmCall[];
58
+ llmCallCount: number;
59
+ toolCallCount: number;
60
+ toolStarts: Map<string, TrackedToolStart>;
61
+ toolParentSpanIds: Map<string, string>;
62
+ lastAssistantMessage?: AssistantMessageLike;
63
+ lastOutput?: NormalizedAssistantMessage;
64
+ error?: string;
65
+ }
66
+
67
+ interface ActiveSession {
68
+ sessionKey: string;
69
+ sessionFile: string | undefined;
70
+ sessionId: string | undefined;
71
+ openedVia?: string;
72
+ parentSessionFile?: string;
73
+ rootSpanId?: string;
74
+ rootSpan?: BraintrustSpanHandle;
75
+ rootSpanRecordId?: string;
76
+ traceRootSpanId?: string;
77
+ parentSpanId?: string;
78
+ traceUrl?: string;
79
+ traceUrlPromise?: Promise<void>;
80
+ startedAt?: number;
81
+ totalTurns: number;
82
+ totalToolCalls: number;
83
+ currentTurn?: ActiveTurn;
84
+ }
85
+
86
+ function hasSessionRoot(session: ActiveSession | undefined): session is ActiveSession & {
87
+ rootSpanId: string;
88
+ traceRootSpanId: string;
89
+ startedAt: number;
90
+ } {
91
+ return Boolean(
92
+ session &&
93
+ typeof session.rootSpanId === "string" &&
94
+ typeof session.traceRootSpanId === "string" &&
95
+ typeof session.startedAt === "number",
96
+ );
97
+ }
98
+
99
+ function getUsername(): string {
100
+ try {
101
+ return userInfo().username;
102
+ } catch {
103
+ return process.env.USER || process.env.USERNAME || "unknown";
104
+ }
105
+ }
106
+
107
+ function getSessionDescriptor(ctx: ExtensionContext): SessionDescriptor {
108
+ const sessionFile = ctx.sessionManager.getSessionFile();
109
+ const sessionId = ctx.sessionManager.getSessionId();
110
+ const sessionKey = sessionKeyFor(sessionFile ? resolve(sessionFile) : undefined, sessionId);
111
+
112
+ return {
113
+ sessionFile,
114
+ sessionId,
115
+ sessionKey,
116
+ };
117
+ }
118
+
119
+ function safeModelName(model: unknown): string | undefined {
120
+ if (!model) return undefined;
121
+ if (typeof model === "string") return model;
122
+ if (!isPlainObject(model)) return undefined;
123
+ const provider = model.provider ?? model.providerId ?? model.providerID;
124
+ const id = model.id ?? model.modelId ?? model.modelID;
125
+ if (typeof provider === "string" && typeof id === "string") return `${provider}/${id}`;
126
+ if (typeof id === "string") return id;
127
+ return undefined;
128
+ }
129
+
130
+ function getPreviousSessionFile(event: unknown): string | undefined {
131
+ if (!isPlainObject(event)) return undefined;
132
+ return typeof event.previousSessionFile === "string" ? event.previousSessionFile : undefined;
133
+ }
134
+
135
+ function getSessionStartReason(event: unknown): string | undefined {
136
+ if (!isPlainObject(event)) return undefined;
137
+ return typeof event.reason === "string" ? event.reason : undefined;
138
+ }
139
+
140
+ function isAssistantMessage(message: unknown): message is AssistantMessageLike {
141
+ return isPlainObject(message) && message.role === "assistant";
142
+ }
143
+
144
+ function findLastAssistant(
145
+ messages: AgentEndEvent["messages"] = [],
146
+ ): AssistantMessageLike | undefined {
147
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
148
+ const message = messages[index];
149
+ if (isAssistantMessage(message)) return message;
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ function standardRootMetadata(ctx: ExtensionContext, config: TraceConfig): Record<string, unknown> {
155
+ const descriptor = getSessionDescriptor(ctx);
156
+ return {
157
+ ...config.additionalMetadata,
158
+ source: "pi",
159
+ extension_version: EXTENSION_VERSION,
160
+ session_id: descriptor.sessionId,
161
+ session_key: descriptor.sessionKey,
162
+ session_file: descriptor.sessionFile,
163
+ workspace: basename(ctx.cwd),
164
+ directory: ctx.cwd,
165
+ repo: repoSlugForCwd(ctx.cwd),
166
+ hostname: hostname(),
167
+ username: getUsername(),
168
+ os: process.platform,
169
+ };
170
+ }
171
+
172
+ function projectTraceUrl(config: TraceConfig, traceId: string | undefined): string | undefined {
173
+ if (!traceId || !config.orgName) return undefined;
174
+ return `${config.appUrl}/app/${encodeURIComponent(config.orgName)}/p/${encodeURIComponent(config.projectName)}/logs?oid=${encodeURIComponent(traceId)}`;
175
+ }
176
+
177
+ function primaryConfigIssue(config: TraceConfig): ConfigIssue | undefined {
178
+ return config.configIssues.find((issue) => issue.severity === "error") ?? config.configIssues[0];
179
+ }
180
+
181
+ function configIssueStatusLabel(issue: ConfigIssue | undefined): string | undefined {
182
+ if (!issue) return undefined;
183
+ return issue.severity === "warning" ? "config warning" : "config error";
184
+ }
185
+
186
+ function setTracingStatus(
187
+ ctx: ExtensionContext,
188
+ config: TraceConfig,
189
+ options: {
190
+ active: boolean;
191
+ initError?: string;
192
+ missingApiKey?: boolean;
193
+ configIssue?: ConfigIssue;
194
+ },
195
+ ): void {
196
+ if (!ctx.hasUI) return;
197
+
198
+ const theme = ctx.ui.theme;
199
+
200
+ if (options.initError) {
201
+ ctx.ui.setStatus(
202
+ TRACING_STATUS_KEY,
203
+ theme.fg("warning", "Braintrust") + theme.fg("dim", " setup failed"),
204
+ );
205
+ return;
206
+ }
207
+
208
+ if (options.active) {
209
+ ctx.ui.setStatus(
210
+ TRACING_STATUS_KEY,
211
+ theme.fg("accent", "Braintrust") +
212
+ theme.fg(
213
+ "dim",
214
+ ` tracing ${config.projectName}${options.configIssue ? " (config warning)" : ""}`,
215
+ ),
216
+ );
217
+ return;
218
+ }
219
+
220
+ if (options.missingApiKey) {
221
+ ctx.ui.setStatus(
222
+ TRACING_STATUS_KEY,
223
+ theme.fg("warning", "Braintrust") + theme.fg("dim", " missing API key"),
224
+ );
225
+ return;
226
+ }
227
+
228
+ const configIssueLabel = configIssueStatusLabel(options.configIssue);
229
+ if (configIssueLabel) {
230
+ ctx.ui.setStatus(
231
+ TRACING_STATUS_KEY,
232
+ theme.fg("warning", "Braintrust") + theme.fg("dim", ` ${configIssueLabel}`),
233
+ );
234
+ return;
235
+ }
236
+
237
+ ctx.ui.setStatus(TRACING_STATUS_KEY, undefined);
238
+ }
239
+
240
+ function makeHyperlink(url: string, text: string): string {
241
+ return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`;
242
+ }
243
+
244
+ function truncateMiddle(value: string, maxLength: number): string {
245
+ if (value.length <= maxLength) return value;
246
+ const left = Math.max(1, Math.floor((maxLength - 1) / 2));
247
+ const right = Math.max(1, maxLength - left - 1);
248
+ return `${value.slice(0, left)}…${value.slice(-right)}`;
249
+ }
250
+
251
+ function shortenTraceUrl(traceUrl: string): string {
252
+ try {
253
+ const parsed = new URL(traceUrl);
254
+ const host = parsed.host.replace(/^www\./, "");
255
+ const oid = parsed.searchParams.get("oid") ?? parsed.searchParams.get("id");
256
+ const shortOid = oid ? truncateMiddle(oid, 16) : undefined;
257
+ const suffix = shortOid ? `?oid=${shortOid}` : "";
258
+ return truncateMiddle(`${host}${parsed.pathname}${suffix}`, 72);
259
+ } catch {
260
+ return truncateMiddle(traceUrl, 72);
261
+ }
262
+ }
263
+
264
+ function displayPath(path: string): string {
265
+ const home = process.env.HOME;
266
+ if (home && path.startsWith(home)) return `~${path.slice(home.length)}`;
267
+ return path;
268
+ }
269
+
270
+ function setTraceWidget(
271
+ ctx: ExtensionContext,
272
+ traceUrl: string | undefined,
273
+ configIssue: ConfigIssue | undefined,
274
+ ): void {
275
+ if (!ctx.hasUI) return;
276
+
277
+ const theme = ctx.ui.theme;
278
+ const lines: string[] = [];
279
+
280
+ if (traceUrl) {
281
+ const label = makeHyperlink(
282
+ traceUrl,
283
+ theme.fg("accent", theme.underline("Braintrust trace ↗")),
284
+ );
285
+ lines.push(label, theme.fg("dim", shortenTraceUrl(traceUrl)));
286
+ }
287
+
288
+ if (configIssue) {
289
+ const issueLabel =
290
+ traceUrl || configIssue.severity === "warning"
291
+ ? "Braintrust config warning"
292
+ : "Braintrust config error";
293
+ lines.push(
294
+ theme.fg("warning", issueLabel),
295
+ theme.fg(
296
+ "dim",
297
+ truncateMiddle(`${displayPath(configIssue.path)}: ${configIssue.message}`, 120),
298
+ ),
299
+ );
300
+ }
301
+
302
+ ctx.ui.setWidget(TRACING_WIDGET_KEY, lines.length > 0 ? lines : undefined, {
303
+ placement: "belowEditor",
304
+ });
305
+ }
306
+
307
+ export default function braintrustPiExtension(pi: ExtensionAPI): void {
308
+ const config = loadConfig(process.cwd());
309
+ const logger = createLogger(config);
310
+ const store = createStateStore(config.stateDir, logger);
311
+
312
+ let client: BraintrustClient | undefined;
313
+ let clientInitializationError: string | undefined;
314
+
315
+ if (config.enabled && config.apiKey) {
316
+ client = new BraintrustClient(config, logger);
317
+ client.initialize().catch((error: unknown) => {
318
+ clientInitializationError = String(error);
319
+ logger.error("failed to initialize Braintrust client", { error: clientInitializationError });
320
+ });
321
+ } else if (config.enabled && !config.apiKey) {
322
+ logger.warn("TRACE_TO_BRAINTRUST is enabled but BRAINTRUST_API_KEY is missing");
323
+ }
324
+
325
+ function tracingEnabled(): boolean {
326
+ return Boolean(config.enabled && client && !clientInitializationError);
327
+ }
328
+
329
+ function refreshTracingUi(ctx: ExtensionContext): void {
330
+ const configIssue = primaryConfigIssue(config);
331
+ setTracingStatus(ctx, config, {
332
+ active: tracingEnabled(),
333
+ initError: clientInitializationError,
334
+ missingApiKey: Boolean(config.enabled && !config.apiKey),
335
+ configIssue,
336
+ });
337
+ setTraceWidget(ctx, activeSession?.traceUrl, configIssue);
338
+ }
339
+
340
+ function persistTraceUrl(session: ActiveSession, traceUrl: string): void {
341
+ session.traceUrl = traceUrl;
342
+ store.patch(session.sessionKey, {
343
+ traceUrl,
344
+ lastSeenAt: Date.now(),
345
+ });
346
+ }
347
+
348
+ function refreshTraceUrl(ctx: ExtensionContext, session: ActiveSession): void {
349
+ if (!client || session.traceUrlPromise || !hasSessionRoot(session)) return;
350
+
351
+ const quickUrl =
352
+ session.traceUrl ??
353
+ client.getSpanLink(session.rootSpan) ??
354
+ projectTraceUrl(config, session.rootSpanRecordId);
355
+
356
+ if (quickUrl && quickUrl !== session.traceUrl) {
357
+ persistTraceUrl(session, quickUrl);
358
+ if (activeSession?.sessionKey === session.sessionKey) refreshTracingUi(ctx);
359
+ }
360
+
361
+ if (!session.rootSpan || session.traceUrl) return;
362
+
363
+ session.traceUrlPromise = client
364
+ .getSpanPermalink(session.rootSpan)
365
+ .then((traceUrl) => {
366
+ if (!traceUrl || traceUrl === session.traceUrl) return;
367
+ persistTraceUrl(session, traceUrl);
368
+ if (activeSession?.sessionKey === session.sessionKey) refreshTracingUi(ctx);
369
+ })
370
+ .finally(() => {
371
+ session.traceUrlPromise = undefined;
372
+ });
373
+ }
374
+
375
+ let activeSession: ActiveSession | undefined;
376
+
377
+ function materializeSessionRoot(
378
+ ctx: ExtensionContext,
379
+ session: ActiveSession,
380
+ ): ActiveSession | undefined {
381
+ if (!client) return undefined;
382
+ if (hasSessionRoot(session)) return session;
383
+
384
+ const startedAt = Date.now();
385
+ const rootSpanId = generateUuid();
386
+ const traceRootSpanId = config.rootSpanId ?? rootSpanId;
387
+ const parentSpanId = config.parentSpanId;
388
+ const rootSpan = client.startSpan({
389
+ spanId: rootSpanId,
390
+ rootSpanId: traceRootSpanId,
391
+ parentSpanId,
392
+ startedAt,
393
+ name: rootSpanName(ctx.cwd),
394
+ type: "task",
395
+ metadata: {
396
+ ...standardRootMetadata(ctx, config),
397
+ opened_via: session.openedVia,
398
+ parent_session_file: session.parentSessionFile,
399
+ },
400
+ });
401
+
402
+ session.rootSpanId = rootSpanId;
403
+ session.rootSpan = rootSpan;
404
+ session.rootSpanRecordId = rootSpan?.id;
405
+ session.traceRootSpanId = traceRootSpanId;
406
+ session.parentSpanId = parentSpanId;
407
+ session.traceUrl = client.getSpanLink(rootSpan) ?? projectTraceUrl(config, rootSpan?.id);
408
+ session.startedAt = startedAt;
409
+
410
+ store.set(session.sessionKey, {
411
+ rootSpanId,
412
+ rootSpanRecordId: rootSpan?.id,
413
+ traceRootSpanId,
414
+ parentSpanId,
415
+ traceUrl: session.traceUrl,
416
+ startedAt,
417
+ totalTurns: session.totalTurns,
418
+ totalToolCalls: session.totalToolCalls,
419
+ lastSeenAt: startedAt,
420
+ sessionFile: session.sessionFile,
421
+ });
422
+ store.schedulePersist(0);
423
+
424
+ refreshTracingUi(ctx);
425
+ refreshTraceUrl(ctx, session);
426
+ return session;
427
+ }
428
+
429
+ async function ensureSession(
430
+ ctx: ExtensionContext,
431
+ options: {
432
+ reason?: string;
433
+ parentSessionFile?: string | undefined;
434
+ createIfMissingRoot?: boolean;
435
+ } = {},
436
+ ): Promise<ActiveSession | undefined> {
437
+ if (!tracingEnabled() || !client) return undefined;
438
+
439
+ const descriptor = getSessionDescriptor(ctx);
440
+ if (activeSession?.sessionKey === descriptor.sessionKey) {
441
+ activeSession.sessionFile = descriptor.sessionFile;
442
+ activeSession.sessionId = descriptor.sessionId;
443
+ if (!hasSessionRoot(activeSession)) {
444
+ activeSession.openedVia ??= options.reason;
445
+ activeSession.parentSessionFile ??= options.parentSessionFile;
446
+ }
447
+
448
+ if (options.createIfMissingRoot === false) {
449
+ refreshTracingUi(ctx);
450
+ return activeSession;
451
+ }
452
+
453
+ return materializeSessionRoot(ctx, activeSession);
454
+ }
455
+
456
+ const persisted = store.get(descriptor.sessionKey);
457
+ if (persisted) {
458
+ activeSession = {
459
+ sessionKey: descriptor.sessionKey,
460
+ sessionFile: descriptor.sessionFile,
461
+ sessionId: descriptor.sessionId,
462
+ openedVia: options.reason,
463
+ parentSessionFile: options.parentSessionFile,
464
+ rootSpanId: persisted.rootSpanId,
465
+ rootSpan: undefined,
466
+ rootSpanRecordId: persisted.rootSpanRecordId,
467
+ traceRootSpanId: persisted.traceRootSpanId ?? persisted.rootSpanId,
468
+ parentSpanId: persisted.parentSpanId,
469
+ traceUrl: persisted.traceUrl ?? projectTraceUrl(config, persisted.rootSpanRecordId),
470
+ startedAt: persisted.startedAt,
471
+ totalTurns: persisted.totalTurns ?? 0,
472
+ totalToolCalls: persisted.totalToolCalls ?? 0,
473
+ currentTurn: undefined,
474
+ };
475
+ store.patch(descriptor.sessionKey, {
476
+ traceUrl: activeSession.traceUrl,
477
+ lastSeenAt: Date.now(),
478
+ sessionFile: descriptor.sessionFile,
479
+ });
480
+ refreshTracingUi(ctx);
481
+ return activeSession;
482
+ }
483
+
484
+ activeSession = {
485
+ sessionKey: descriptor.sessionKey,
486
+ sessionFile: descriptor.sessionFile,
487
+ sessionId: descriptor.sessionId,
488
+ openedVia: options.reason,
489
+ parentSessionFile: options.parentSessionFile,
490
+ totalTurns: 0,
491
+ totalToolCalls: 0,
492
+ currentTurn: undefined,
493
+ };
494
+
495
+ if (options.createIfMissingRoot === false) {
496
+ refreshTracingUi(ctx);
497
+ return activeSession;
498
+ }
499
+
500
+ return materializeSessionRoot(ctx, activeSession);
501
+ }
502
+
503
+ async function finishTurn(
504
+ reason: string,
505
+ endedAt = Date.now(),
506
+ finalAssistantMessage?: AssistantMessageLike,
507
+ ): Promise<void> {
508
+ if (!activeSession?.currentTurn || !client) return;
509
+
510
+ const turn = activeSession.currentTurn;
511
+ const finalAssistant = finalAssistantMessage ?? turn.lastAssistantMessage;
512
+ const finalOutput = finalAssistant
513
+ ? normalizeAssistantMessage(finalAssistant)
514
+ : turn.lastOutput;
515
+ const error =
516
+ turn.error ||
517
+ (finalAssistant?.stopReason === "error" || finalAssistant?.stopReason === "aborted"
518
+ ? extractErrorText(finalAssistant, finalAssistant.errorMessage)
519
+ : undefined);
520
+
521
+ client.logSpan(turn.span, {
522
+ output: finalOutput,
523
+ error,
524
+ metadata: {
525
+ llm_calls: turn.llmCallCount,
526
+ tool_calls: turn.toolCallCount,
527
+ finish_reason: reason,
528
+ },
529
+ });
530
+ client.endSpan(turn.span, endedAt);
531
+
532
+ activeSession.currentTurn = undefined;
533
+ store.patch(activeSession.sessionKey, {
534
+ totalTurns: activeSession.totalTurns,
535
+ totalToolCalls: activeSession.totalToolCalls,
536
+ lastSeenAt: endedAt,
537
+ });
538
+ await store.flush();
539
+ }
540
+
541
+ async function finalizeSession(reason: string, endedAt = Date.now()): Promise<void> {
542
+ if (!activeSession || !client) return;
543
+
544
+ await finishTurn(reason, endedAt);
545
+ if (!hasSessionRoot(activeSession)) return;
546
+
547
+ const summaryMetadata = {
548
+ total_turns: activeSession.totalTurns,
549
+ total_tool_calls: activeSession.totalToolCalls,
550
+ last_close_reason: reason,
551
+ };
552
+
553
+ if (activeSession.rootSpan) {
554
+ client.logSpan(activeSession.rootSpan, {
555
+ metadata: summaryMetadata,
556
+ });
557
+ client.endSpan(activeSession.rootSpan, endedAt);
558
+ } else if (activeSession.rootSpanRecordId) {
559
+ client.updateSpan({
560
+ id: activeSession.rootSpanRecordId,
561
+ spanId: activeSession.rootSpanId,
562
+ rootSpanId: activeSession.traceRootSpanId,
563
+ metadata: summaryMetadata,
564
+ metrics: {
565
+ end: toUnixSeconds(endedAt),
566
+ },
567
+ });
568
+ }
569
+
570
+ store.patch(activeSession.sessionKey, {
571
+ totalTurns: activeSession.totalTurns,
572
+ totalToolCalls: activeSession.totalToolCalls,
573
+ lastSeenAt: endedAt,
574
+ });
575
+ await store.flush();
576
+ }
577
+
578
+ async function rolloverSession(
579
+ ctx: ExtensionContext,
580
+ reason: string,
581
+ previousSessionFile: string | undefined,
582
+ ): Promise<void> {
583
+ if (!tracingEnabled()) return;
584
+
585
+ const previousKey = activeSession?.sessionKey;
586
+ const nextKey = getSessionDescriptor(ctx).sessionKey;
587
+ if (previousKey && previousKey !== nextKey) {
588
+ await finalizeSession(reason);
589
+ activeSession = undefined;
590
+ }
591
+
592
+ await ensureSession(ctx, {
593
+ reason,
594
+ parentSessionFile: previousSessionFile,
595
+ createIfMissingRoot: false,
596
+ });
597
+ }
598
+
599
+ pi.on("session_start", async (event, ctx) => {
600
+ refreshTracingUi(ctx);
601
+
602
+ const reason = getSessionStartReason(event);
603
+ if (reason === "new" || reason === "resume" || reason === "fork") {
604
+ await rolloverSession(
605
+ ctx,
606
+ reason === "fork" ? "session_fork" : "session_switch",
607
+ getPreviousSessionFile(event),
608
+ );
609
+ return;
610
+ }
611
+
612
+ await ensureSession(ctx, {
613
+ reason: "session_start",
614
+ createIfMissingRoot: false,
615
+ });
616
+ });
617
+
618
+ pi.on("session_switch", async (event, ctx) => {
619
+ refreshTracingUi(ctx);
620
+ await rolloverSession(ctx, "session_switch", getPreviousSessionFile(event));
621
+ });
622
+
623
+ pi.on("session_fork", async (event, ctx) => {
624
+ refreshTracingUi(ctx);
625
+ await rolloverSession(ctx, "session_fork", getPreviousSessionFile(event));
626
+ });
627
+
628
+ pi.on("before_agent_start", async (event, ctx) => {
629
+ refreshTracingUi(ctx);
630
+ const session = await ensureSession(ctx, { reason: "agent_start" });
631
+ if (!session || !client || !hasSessionRoot(session)) return;
632
+
633
+ if (session.currentTurn) {
634
+ await finishTurn("replaced_by_new_prompt", Date.now());
635
+ }
636
+
637
+ const startedAt = Date.now();
638
+ session.totalTurns += 1;
639
+ const turnSpanId = generateUuid();
640
+ const turnInput = buildTurnInput(
641
+ event.prompt,
642
+ event.images as readonly ImageLike[] | undefined,
643
+ );
644
+ const turnSpan = client.startSpan({
645
+ spanId: turnSpanId,
646
+ rootSpanId: session.traceRootSpanId,
647
+ parentSpanId: session.rootSpanId,
648
+ startedAt,
649
+ input: turnInput,
650
+ metadata: {
651
+ turn_number: session.totalTurns,
652
+ active_model: safeModelName(ctx.model),
653
+ },
654
+ name: `Turn ${session.totalTurns}`,
655
+ type: "task",
656
+ });
657
+
658
+ session.currentTurn = {
659
+ spanId: turnSpanId,
660
+ span: turnSpan,
661
+ prompt: turnInput,
662
+ llmCalls: [],
663
+ llmCallCount: 0,
664
+ toolCallCount: 0,
665
+ toolStarts: new Map(),
666
+ toolParentSpanIds: new Map(),
667
+ lastAssistantMessage: undefined,
668
+ lastOutput: undefined,
669
+ error: undefined,
670
+ };
671
+
672
+ store.patch(session.sessionKey, {
673
+ totalTurns: session.totalTurns,
674
+ lastSeenAt: startedAt,
675
+ });
676
+ });
677
+
678
+ pi.on("context", async (event) => {
679
+ if (!activeSession?.currentTurn) return;
680
+ activeSession.currentTurn.llmCalls.push({
681
+ startedAt: Date.now(),
682
+ input: normalizeContextMessages(event.messages as unknown as readonly AgentMessageLike[]),
683
+ });
684
+ });
685
+
686
+ pi.on("message_end", async (event) => {
687
+ const session = activeSession;
688
+ if (
689
+ !session?.currentTurn ||
690
+ !isAssistantMessage(event.message) ||
691
+ !client ||
692
+ !hasSessionRoot(session)
693
+ ) {
694
+ return;
695
+ }
696
+ const message = event.message;
697
+
698
+ const pending = session.currentTurn.llmCalls.shift() ?? {
699
+ startedAt: Date.now(),
700
+ input: [{ role: "user", content: session.currentTurn.prompt }],
701
+ };
702
+
703
+ const modelName = safeModelName(message) ?? message.model;
704
+ const endedAt = message.timestamp ?? Date.now();
705
+ const normalizedOutput = normalizeAssistantMessage(message);
706
+ const error =
707
+ message.stopReason === "error" || message.stopReason === "aborted"
708
+ ? extractErrorText(message, message.errorMessage)
709
+ : undefined;
710
+
711
+ session.currentTurn.llmCallCount += 1;
712
+ session.currentTurn.lastAssistantMessage = message;
713
+ session.currentTurn.lastOutput = normalizedOutput;
714
+ if (error) session.currentTurn.error = error;
715
+
716
+ const llmSpanId = generateUuid();
717
+ const llmSpan = client.startSpan({
718
+ spanId: llmSpanId,
719
+ rootSpanId: session.traceRootSpanId,
720
+ parentSpanId: session.currentTurn.spanId,
721
+ startedAt: pending.startedAt,
722
+ input: pending.input,
723
+ metadata: {
724
+ api: message.api,
725
+ provider: message.provider,
726
+ model: modelName,
727
+ stop_reason: message.stopReason,
728
+ cache_read_tokens: message.usage?.cacheRead,
729
+ cache_write_tokens: message.usage?.cacheWrite,
730
+ },
731
+ name: modelName || "llm",
732
+ type: "llm",
733
+ });
734
+
735
+ for (const part of message.content ?? []) {
736
+ if (!isPlainObject(part) || part.type !== "toolCall" || typeof part.id !== "string") {
737
+ continue;
738
+ }
739
+ session.currentTurn.toolParentSpanIds.set(part.id, llmSpanId);
740
+ }
741
+
742
+ client.logSpan(llmSpan, {
743
+ output: [normalizedOutput],
744
+ error,
745
+ metrics: {
746
+ prompt_tokens: message.usage?.input,
747
+ completion_tokens: message.usage?.output,
748
+ tokens: message.usage?.totalTokens,
749
+ },
750
+ });
751
+ client.endSpan(llmSpan, endedAt);
752
+ });
753
+
754
+ pi.on("tool_execution_start", async (event) => {
755
+ if (!activeSession?.currentTurn) return;
756
+ activeSession.currentTurn.toolStarts.set(event.toolCallId, {
757
+ startedAt: Date.now(),
758
+ args: event.args,
759
+ toolName: event.toolName,
760
+ });
761
+ });
762
+
763
+ pi.on("tool_execution_end", async (event) => {
764
+ const session = activeSession;
765
+ if (!session?.currentTurn || !client || !hasSessionRoot(session)) return;
766
+
767
+ const tracked = session.currentTurn.toolStarts.get(event.toolCallId) ?? {
768
+ startedAt: Date.now(),
769
+ args: undefined,
770
+ toolName: event.toolName,
771
+ };
772
+ session.currentTurn.toolStarts.delete(event.toolCallId);
773
+ const parentLlmSpanId = session.currentTurn.toolParentSpanIds.get(event.toolCallId);
774
+ session.currentTurn.toolParentSpanIds.delete(event.toolCallId);
775
+
776
+ const endedAt = Date.now();
777
+ session.totalToolCalls += 1;
778
+ session.currentTurn.toolCallCount += 1;
779
+
780
+ const output = normalizeToolResult(event.result);
781
+ const error = event.isError
782
+ ? extractErrorText(event.result, `${event.toolName} failed`)
783
+ : undefined;
784
+
785
+ if (error && !session.currentTurn.error) {
786
+ session.currentTurn.error = error;
787
+ }
788
+
789
+ const toolSpan = client.startSpan({
790
+ spanId: generateUuid(),
791
+ rootSpanId: session.traceRootSpanId,
792
+ parentSpanId: parentLlmSpanId ?? session.currentTurn.spanId,
793
+ startedAt: tracked.startedAt,
794
+ input: tracked.args,
795
+ metadata: {
796
+ tool_name: event.toolName,
797
+ tool_call_id: event.toolCallId,
798
+ is_error: event.isError,
799
+ parent_llm_span_id: parentLlmSpanId,
800
+ },
801
+ name: formatToolSpanName(event.toolName, tracked.args),
802
+ type: "tool",
803
+ });
804
+
805
+ client.logSpan(toolSpan, {
806
+ output,
807
+ error,
808
+ });
809
+ client.endSpan(toolSpan, endedAt);
810
+
811
+ store.patch(session.sessionKey, {
812
+ totalTurns: session.totalTurns,
813
+ totalToolCalls: session.totalToolCalls,
814
+ lastSeenAt: endedAt,
815
+ });
816
+ });
817
+
818
+ pi.on("agent_end", async (event) => {
819
+ if (!activeSession?.currentTurn) return;
820
+ const finalAssistant = findLastAssistant(event.messages);
821
+ await finishTurn("agent_end", Date.now(), finalAssistant);
822
+ });
823
+
824
+ pi.on("session_shutdown", async (_event, ctx) => {
825
+ if (ctx.hasUI) {
826
+ ctx.ui.setStatus(TRACING_STATUS_KEY, undefined);
827
+ ctx.ui.setWidget(TRACING_WIDGET_KEY, undefined);
828
+ }
829
+ if (client && !clientInitializationError) {
830
+ await finalizeSession("session_shutdown");
831
+ await client.flush();
832
+ }
833
+ activeSession = undefined;
834
+ await store.flush();
835
+ await logger.flush();
836
+ });
837
+
838
+ for (const configIssue of config.configIssues) {
839
+ if (configIssue.severity === "error") {
840
+ logger.error("Braintrust config issue", configIssue);
841
+ } else {
842
+ logger.warn("Braintrust config issue", configIssue);
843
+ }
844
+ }
845
+
846
+ logger.debug("Braintrust pi tracing extension loaded", {
847
+ enabled: config.enabled,
848
+ project: config.projectName,
849
+ hasApiKey: Boolean(config.apiKey),
850
+ logFile: logger.filePath,
851
+ configIssues: config.configIssues,
852
+ configHash: shortHash(
853
+ JSON.stringify({
854
+ enabled: config.enabled,
855
+ project: config.projectName,
856
+ debug: config.debug,
857
+ stateDir: config.stateDir,
858
+ }),
859
+ ),
860
+ });
861
+ }