pi-langfuse 1.1.0 → 1.2.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/index.ts CHANGED
@@ -7,1007 +7,35 @@
7
7
  * - one tool observation per tool call, keyed by toolCallId
8
8
  */
9
9
 
10
- import { readFileSync, existsSync, writeFileSync } from "node:fs";
11
- import { resolve, dirname, basename } from "node:path";
12
- import { fileURLToPath } from "node:url";
10
+ import { basename } from "node:path";
13
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
12
 
15
- // ============================================
16
- // Configuration
17
- // ============================================
18
-
19
- interface Config {
20
- publicKey: string;
21
- secretKey: string;
22
- host: string;
23
- }
24
-
25
- const EXT_DIR = resolve(dirname(fileURLToPath(import.meta.url)));
26
- const CONFIG_PATH = resolve(EXT_DIR, "config.json");
27
- const DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com";
28
-
29
- function loadConfigFromFile(): Config | null {
30
- if (existsSync(CONFIG_PATH)) {
31
- try {
32
- const content = readFileSync(CONFIG_PATH, "utf-8");
33
- const config = JSON.parse(content) as Config;
34
- if (config.publicKey && config.secretKey) {
35
- return {
36
- publicKey: config.publicKey,
37
- secretKey: config.secretKey,
38
- host: config.host || DEFAULT_LANGFUSE_HOST,
39
- };
40
- }
41
- } catch (e) {
42
- console.warn("📊 Langfuse: Failed to load config.json", e);
43
- }
44
- }
45
-
46
- return null;
47
- }
48
-
49
- function loadConfigFromEnv(): Config | null {
50
- const publicKey = process.env.LANGFUSE_PUBLIC_KEY || "";
51
- const secretKey = process.env.LANGFUSE_SECRET_KEY || "";
52
- if (!publicKey || !secretKey) {
53
- return null;
54
- }
55
-
56
- return {
57
- publicKey,
58
- secretKey,
59
- host: process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
60
- };
61
- }
62
-
63
- function saveConfig(config: Config) {
64
- writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
65
- }
66
-
67
- // ============================================
68
- // Langfuse SDK facade (lazy-loaded)
69
- // ============================================
70
-
71
- interface LangfuseObservation {
72
- id?: string;
73
- traceId?: string;
74
- update(body?: ObservationUpdate): LangfuseObservation;
75
- end(body?: ObservationUpdate): void;
76
- startObservation?(
77
- name: string,
78
- body?: ObservationUpdate,
79
- options?: { asType?: "agent" | "generation" | "tool" | "span" },
80
- ): LangfuseObservation;
81
- setTraceIO?(body?: { input?: unknown; output?: unknown }): void;
82
- }
83
-
84
- interface ObservationUpdate {
85
- input?: unknown;
86
- output?: unknown;
87
- metadata?: Record<string, unknown>;
88
- model?: string;
89
- usageDetails?: Record<string, number>;
90
- usage?: Record<string, number>;
91
- costDetails?: Record<string, number>;
92
- level?: "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
93
- statusMessage?: string;
94
- }
95
-
96
- interface LangfuseScoreClient {
97
- score?: {
98
- create(body: {
99
- traceId?: string;
100
- sessionId?: string;
101
- observationId?: string;
102
- name: string;
103
- value: number;
104
- dataType?: "NUMERIC" | "BOOLEAN";
105
- }): unknown;
106
- };
107
- flush?: () => Promise<void>;
108
- shutdown?: () => Promise<void>;
109
- }
110
-
111
- interface LangfuseRuntime {
112
- startObservation: (
113
- name: string,
114
- body?: ObservationUpdate,
115
- options?: { asType?: "agent" | "generation" | "tool" | "span" },
116
- ) => LangfuseObservation;
117
- propagateAttributes: (
118
- params: {
119
- sessionId?: string;
120
- traceName?: string;
121
- metadata?: Record<string, string>;
122
- tags?: string[];
123
- },
124
- fn: () => LangfuseObservation,
125
- ) => LangfuseObservation;
126
- scoreClient: LangfuseScoreClient;
127
- spanProcessor?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };
128
- sdk?: { start?: () => void; shutdown?: () => Promise<void> };
129
- }
130
-
131
- let runtime: LangfuseRuntime | null = null;
132
- let config: Config | null = loadConfigFromFile() ?? loadConfigFromEnv();
133
- let setupAttemptedThisSession = false;
134
-
135
- async function getRuntime(): Promise<LangfuseRuntime> {
136
- if (!config) {
137
- throw new Error("Langfuse config is not set");
138
- }
139
-
140
- if (!runtime) {
141
- const [{ NodeSDK }, { LangfuseSpanProcessor }, tracing, { LangfuseClient }] = await Promise.all([
142
- import("@opentelemetry/sdk-node"),
143
- import("@langfuse/otel"),
144
- import("@langfuse/tracing"),
145
- import("@langfuse/client"),
146
- ]);
147
-
148
- const spanProcessor = new LangfuseSpanProcessor({
149
- publicKey: config.publicKey,
150
- secretKey: config.secretKey,
151
- baseUrl: config.host,
152
- });
153
- const sdk = new NodeSDK({ spanProcessors: [spanProcessor] });
154
- sdk.start();
155
-
156
- runtime = {
157
- startObservation: tracing.startObservation as unknown as LangfuseRuntime["startObservation"],
158
- propagateAttributes: tracing.propagateAttributes as unknown as LangfuseRuntime["propagateAttributes"],
159
- scoreClient: new LangfuseClient({
160
- publicKey: config.publicKey,
161
- secretKey: config.secretKey,
162
- baseUrl: config.host,
163
- }) as LangfuseScoreClient,
164
- spanProcessor,
165
- sdk,
166
- };
167
- }
168
-
169
- return runtime as LangfuseRuntime;
170
- }
171
-
172
- async function shutdownRuntime(): Promise<void> {
173
- if (!runtime) {
174
- return;
175
- }
176
-
177
- try {
178
- await runtime.scoreClient.flush?.();
179
- await runtime.scoreClient.shutdown?.();
180
- await runtime.spanProcessor?.forceFlush?.();
181
- await runtime.spanProcessor?.shutdown?.();
182
- await runtime.sdk?.shutdown?.();
183
- } catch (e) {
184
- console.warn("📊 Langfuse: Failed to flush/shutdown cleanly", e);
185
- } finally {
186
- runtime = null;
187
- }
188
- }
189
-
190
- // ============================================
191
- // State
192
- // ============================================
193
-
194
- interface GenerationState {
195
- observation: LangfuseObservation;
196
- requestKey: string;
197
- ended: boolean;
198
- metadata: Record<string, unknown>;
199
- }
200
-
201
- interface ToolState {
202
- observation: LangfuseObservation;
203
- toolName: string;
204
- ended: boolean;
205
- }
206
-
207
- interface AgentState {
208
- root?: LangfuseObservation;
209
- traceId?: string;
210
- promptInput?: unknown;
211
- cwd?: string;
212
- generationSeq: number;
213
- activeGenerations: Map<string, GenerationState>;
214
- generationOrder: string[];
215
- activeTools: Map<string, ToolState>;
216
- latestAssistantOutput?: unknown;
217
- providerMetadataByRequest: Map<string, Record<string, unknown>>;
218
- }
219
-
220
- let currentSessionId = "";
221
- let currentModel = "";
222
- let currentProvider = "";
223
- let agentState: AgentState | null = null;
224
-
225
- // Evaluation tracking state
226
- let toolCallCount = 0;
227
- let errorCount = 0;
228
- let turnCount = 0;
229
-
230
- const MAX_STRING_LENGTH = 12_000;
231
- const MAX_TOOL_PAYLOAD_LENGTH = 24_000;
232
- const MAX_DEPTH = 6;
233
- const MAX_ARRAY_ITEMS = 50;
234
- const MAX_OBJECT_KEYS = 80;
235
-
236
- function truncate(value: string, maxLength = MAX_STRING_LENGTH): string {
237
- return value.length > maxLength ? `${value.slice(0, maxLength)}... [truncated]` : value;
238
- }
239
-
240
- function tryParseJson(value: string): unknown {
241
- const trimmed = value.trim();
242
- if (!trimmed || !["{", "["].includes(trimmed[0])) {
243
- return value;
244
- }
245
-
246
- try {
247
- return JSON.parse(trimmed);
248
- } catch {
249
- return value;
250
- }
251
- }
252
-
253
- function shapePayload(value: unknown, options: { maxString?: number; depth?: number } = {}): unknown {
254
- const maxString = options.maxString ?? MAX_STRING_LENGTH;
255
- const depth = options.depth ?? MAX_DEPTH;
256
-
257
- function visit(item: unknown, remainingDepth: number, seen: WeakSet<object>): unknown {
258
- if (typeof item === "string") {
259
- const truncated = truncate(item, maxString);
260
- const parsed = tryParseJson(truncated);
261
- if (parsed === truncated) {
262
- return truncated;
263
- }
264
- return visit(parsed, remainingDepth - 1, seen);
265
- }
266
-
267
- if (
268
- item === null ||
269
- typeof item === "undefined" ||
270
- typeof item === "number" ||
271
- typeof item === "boolean"
272
- ) {
273
- return item;
274
- }
275
-
276
- if (typeof item === "bigint") {
277
- return item.toString();
278
- }
279
-
280
- if (typeof item === "function" || typeof item === "symbol") {
281
- return `[${typeof item}]`;
282
- }
283
-
284
- if (remainingDepth <= 0) {
285
- return `[max depth ${depth} reached]`;
286
- }
287
-
288
- if (Array.isArray(item)) {
289
- return item.slice(0, MAX_ARRAY_ITEMS).map((entry) => visit(entry, remainingDepth - 1, seen));
290
- }
291
-
292
- if (item instanceof Error) {
293
- return {
294
- name: item.name,
295
- message: item.message,
296
- stack: item.stack ? truncate(item.stack, maxString) : undefined,
297
- };
298
- }
299
-
300
- if (typeof item === "object") {
301
- if (seen.has(item)) {
302
- return "[circular]";
303
- }
304
- seen.add(item);
305
-
306
- const output: Record<string, unknown> = {};
307
- for (const [key, entry] of Object.entries(item as Record<string, unknown>).slice(0, MAX_OBJECT_KEYS)) {
308
- output[key] = visit(entry, remainingDepth - 1, seen);
309
- }
310
- return output;
311
- }
312
-
313
- return String(item);
314
- }
315
-
316
- return visit(value, depth, new WeakSet<object>());
317
- }
318
-
319
- function safeSerialize(value: unknown, maxLength = MAX_TOOL_PAYLOAD_LENGTH): string {
320
- try {
321
- return truncate(JSON.stringify(shapePayload(value, { maxString: maxLength }), null, 2), maxLength);
322
- } catch {
323
- return `[unserializable ${typeof value}]`;
324
- }
325
- }
326
-
327
- function extractTextContent(content: unknown, maxLength?: number): string | undefined {
328
- if (typeof content === "string") {
329
- return maxLength ? truncate(content, maxLength) : content;
330
- }
331
-
332
- if (!Array.isArray(content)) {
333
- return undefined;
334
- }
335
-
336
- const text = content
337
- .map((item) => {
338
- if (!item || typeof item !== "object") return "";
339
- const block = item as { type?: string; text?: string; thinking?: string };
340
- return block.type === "text" && block.text ? block.text : "";
341
- })
342
- .filter(Boolean)
343
- .join("\n");
344
-
345
- if (!text) {
346
- return undefined;
347
- }
348
-
349
- return maxLength ? truncate(text, maxLength) : text;
350
- }
351
-
352
- function extractToolCalls(message: Record<string, unknown>): unknown | undefined {
353
- return (
354
- message.toolCalls ??
355
- message.tool_calls ??
356
- message.function_calls ??
357
- (message.content && Array.isArray(message.content)
358
- ? message.content.filter((block) => {
359
- return block && typeof block === "object" && ["tool_use", "tool_call"].includes(String((block as { type?: string }).type));
360
- })
361
- : undefined)
362
- );
363
- }
364
-
365
- function extractAssistantOutput(message: unknown): unknown | undefined {
366
- if (!message || typeof message !== "object") {
367
- return undefined;
368
- }
369
-
370
- const msg = message as Record<string, unknown>;
371
- const text = extractTextContent(msg.content);
372
- if (text) {
373
- return text;
374
- }
375
-
376
- const toolCalls = extractToolCalls(msg);
377
- if (Array.isArray(toolCalls) && toolCalls.length > 0) {
378
- return { toolCalls: shapePayload(toolCalls) };
379
- }
380
-
381
- if (toolCalls) {
382
- return { toolCalls: shapePayload(toolCalls) };
383
- }
384
-
385
- return shapePayload(msg);
386
- }
387
-
388
- function extractFinalAssistant(messages: unknown): Record<string, unknown> | undefined {
389
- if (!Array.isArray(messages)) {
390
- return undefined;
391
- }
392
- return messages.filter((message) => message?.role === "assistant").pop() as Record<string, unknown> | undefined;
393
- }
394
-
395
- function getRequestKey(event: Record<string, unknown>, fallback: string): string {
396
- return String(
397
- event.requestId ??
398
- event.providerRequestId ??
399
- event.messageId ??
400
- event.turnId ??
401
- event.turnIndex ??
402
- event.id ??
403
- fallback,
404
- );
405
- }
406
-
407
- function getToolCallId(event: Record<string, unknown>): string | undefined {
408
- const id = event.toolCallId ?? event.id ?? event.callId ?? event.tool_use_id ?? event.toolUseId;
409
- return id === undefined || id === null ? undefined : String(id);
410
- }
411
-
412
- function getToolName(event: Record<string, unknown>): string {
413
- return String(
414
- event.toolName ??
415
- event.name ??
416
- event.tool ??
417
- event.functionName ??
418
- (event.call && typeof event.call === "object" ? (event.call as Record<string, unknown>).name : undefined) ??
419
- "tool",
420
- );
421
- }
422
-
423
- function getToolInput(event: Record<string, unknown>): unknown {
424
- return (
425
- event.input ??
426
- event.args ??
427
- event.arguments ??
428
- event.params ??
429
- (event.call && typeof event.call === "object" ? (event.call as Record<string, unknown>).input : undefined) ??
430
- event
431
- );
432
- }
433
-
434
- function getProviderPayload(event: Record<string, unknown>): unknown {
435
- return event.request ?? event.payload ?? event.body ?? event.providerPayload ?? event.messages ?? event;
436
- }
437
-
438
- function getMessageFromEvent(event: Record<string, unknown>): Record<string, unknown> | undefined {
439
- if (event.message && typeof event.message === "object") {
440
- return event.message as Record<string, unknown>;
441
- }
442
- if (event.role || event.content) {
443
- return event;
444
- }
445
- return undefined;
446
- }
447
-
448
- function extractUsage(messageOrEvent: Record<string, unknown>): Record<string, number> | undefined {
449
- const usage = (messageOrEvent.usage ??
450
- (messageOrEvent.message && typeof messageOrEvent.message === "object"
451
- ? (messageOrEvent.message as Record<string, unknown>).usage
452
- : undefined)) as Record<string, unknown> | undefined;
453
- if (!usage || typeof usage !== "object") {
454
- return undefined;
455
- }
456
-
457
- const input = Number(usage.input ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens ?? 0);
458
- const output = Number(usage.output ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens ?? 0);
459
- const total = Number(usage.total ?? usage.totalTokens ?? usage.total_tokens ?? input + output);
460
- const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? usage.cachedTokens ?? 0);
461
- const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
462
-
463
- return {
464
- input,
465
- output,
466
- total,
467
- ...(cacheRead ? { cacheRead } : {}),
468
- ...(cacheWrite ? { cacheWrite } : {}),
469
- };
470
- }
471
-
472
- function extractCostDetails(messageOrEvent: Record<string, unknown>): Record<string, number> | undefined {
473
- const usage = (messageOrEvent.usage ??
474
- (messageOrEvent.message && typeof messageOrEvent.message === "object"
475
- ? (messageOrEvent.message as Record<string, unknown>).usage
476
- : undefined)) as Record<string, unknown> | undefined;
477
- const cost = (messageOrEvent.cost ?? usage?.cost ?? messageOrEvent.costDetails) as Record<string, unknown> | undefined;
478
- if (!cost || typeof cost !== "object") {
479
- return undefined;
480
- }
481
-
482
- const input = Number(cost.input ?? cost.inputCost ?? 0);
483
- const output = Number(cost.output ?? cost.outputCost ?? 0);
484
- const total = Number(cost.total ?? cost.totalCost ?? input + output);
485
-
486
- return { input, output, total };
487
- }
488
-
489
- function extractResponseMetadata(event: Record<string, unknown>): Record<string, unknown> {
490
- return shapePayload(
491
- {
492
- status: event.status ?? event.statusCode ?? event.httpStatus,
493
- headers: event.headers,
494
- responseHeaders: event.responseHeaders,
495
- providerMetadata: event.providerMetadata ?? event.metadata,
496
- requestId: event.requestId ?? event.providerRequestId,
497
- },
498
- { depth: 4, maxString: 4_000 },
499
- ) as Record<string, unknown>;
500
- }
501
-
502
- function updateTraceIO(input?: unknown, output?: unknown) {
503
- const root = agentState?.root;
504
- if (!root?.setTraceIO) {
505
- return;
506
- }
507
-
508
- try {
509
- root.setTraceIO({ input, output });
510
- } catch {
511
- // Older SDKs may omit setTraceIO; root IO still mirrors trace IO in current Langfuse.
512
- }
513
- }
514
-
515
- function resetRunState() {
516
- agentState = null;
517
- toolCallCount = 0;
518
- errorCount = 0;
519
- turnCount = 0;
520
- }
521
-
522
- function computeEvaluationScores() {
523
- const toolSuccessRate = toolCallCount > 0 ? (toolCallCount - errorCount) / toolCallCount : 1;
524
- const sessionHadErrors = errorCount > 0;
525
-
526
- return {
527
- tool_call_count: toolCallCount,
528
- turn_count: turnCount,
529
- total_tool_errors: errorCount,
530
- tool_success_rate: toolSuccessRate,
531
- session_had_errors: sessionHadErrors ? 1 : 0,
532
- };
533
- }
534
-
535
- async function sendScore(name: string, value: number, options: { traceId?: string; observationId?: string } = {}) {
536
- try {
537
- const rt = await getRuntime();
538
- rt.scoreClient.score?.create({
539
- name,
540
- value,
541
- dataType: name === "session_had_errors" || name === "tool_is_error" ? "BOOLEAN" : "NUMERIC",
542
- traceId: options.traceId,
543
- observationId: options.observationId,
544
- sessionId: options.traceId ? undefined : currentSessionId || undefined,
545
- });
546
- } catch (e) {
547
- console.warn(`📊 Langfuse: Failed to send score ${name}`, e);
548
- }
549
- }
550
-
551
- async function ensureConfig(ctx: any): Promise<boolean> {
552
- if (config) {
553
- return true;
554
- }
555
-
556
- if (setupAttemptedThisSession) {
557
- return false;
558
- }
559
- setupAttemptedThisSession = true;
560
-
561
- if (!ctx.hasUI) {
562
- console.log("📊 Langfuse: Missing config. Run this extension in Pi UI to complete setup, or set LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_BASE_URL.");
563
- return false;
564
- }
565
-
566
- ctx.ui.notify("Langfuse setup required. Enter your API keys to enable tracing.", "info");
567
-
568
- const publicKey = (await ctx.ui.input("Langfuse public key:", "pk-lf-..."))?.trim();
569
- if (!publicKey) {
570
- ctx.ui.notify("Langfuse setup cancelled.", "warning");
571
- return false;
572
- }
573
-
574
- const secretKey = (await ctx.ui.input("Langfuse secret key:", "sk-lf-..."))?.trim();
575
- if (!secretKey) {
576
- ctx.ui.notify("Langfuse setup cancelled.", "warning");
577
- return false;
578
- }
579
-
580
- const hostInput = (await ctx.ui.input("Langfuse host:", DEFAULT_LANGFUSE_HOST))?.trim();
581
- config = {
582
- publicKey,
583
- secretKey,
584
- host: hostInput || DEFAULT_LANGFUSE_HOST,
585
- };
586
-
587
- try {
588
- saveConfig(config);
589
- ctx.ui.notify(`Langfuse config saved to ${CONFIG_PATH}`, "info");
590
- return true;
591
- } catch (error) {
592
- console.warn("📊 Langfuse: Failed to save config.json", error);
593
- ctx.ui.notify("Failed to save Langfuse config.json. Check extension directory permissions.", "error");
594
- config = null;
595
- return false;
596
- }
597
- }
598
-
599
- async function promptForConfig(ctx: any): Promise<boolean> {
600
- setupAttemptedThisSession = false;
601
- config = null;
602
- await shutdownRuntime();
603
- return ensureConfig(ctx);
604
- }
605
-
606
- async function startAgentRun(event: Record<string, unknown>, ctx: any) {
607
- if (!(await ensureConfig(ctx))) {
608
- return;
609
- }
610
-
611
- try {
612
- const rt = await getRuntime();
613
- const cwd = String(
614
- (event.systemPromptOptions && typeof event.systemPromptOptions === "object"
615
- ? (event.systemPromptOptions as Record<string, unknown>).cwd
616
- : undefined) ?? process.cwd(),
617
- );
618
-
619
- if (!currentModel && ctx.model) {
620
- currentModel = ctx.model.id || "";
621
- currentProvider = ctx.model.provider || "";
622
- }
623
-
624
- const promptInput = shapePayload({
625
- prompt: event.prompt,
626
- images: event.images,
627
- context: event.context ?? event.attachments,
628
- });
629
-
630
- agentState = {
631
- cwd,
632
- promptInput,
633
- generationSeq: 0,
634
- activeGenerations: new Map(),
635
- generationOrder: [],
636
- activeTools: new Map(),
637
- providerMetadataByRequest: new Map(),
638
- };
639
-
640
- const root = rt.propagateAttributes(
641
- {
642
- sessionId: currentSessionId ? truncate(currentSessionId, 200) : undefined,
643
- traceName: "pi-agent",
644
- metadata: {
645
- cwd: truncate(cwd, 200),
646
- ...(currentModel ? { model: truncate(currentModel, 200) } : {}),
647
- ...(currentProvider ? { provider: truncate(currentProvider, 200) } : {}),
648
- },
649
- },
650
- () =>
651
- rt.startObservation(
652
- "pi-agent",
653
- {
654
- input: promptInput,
655
- metadata: {
656
- cwd,
657
- model: currentModel || undefined,
658
- provider: currentProvider || undefined,
659
- sessionId: currentSessionId || undefined,
660
- },
661
- },
662
- { asType: "agent" },
663
- ),
664
- );
665
-
666
- agentState.root = root;
667
- agentState.traceId = root.traceId;
668
- updateTraceIO(promptInput, undefined);
669
- } catch (e) {
670
- console.warn("📊 Langfuse: Failed to create agent observation", e);
671
- }
672
- }
673
-
674
- function getOpenGeneration(): GenerationState | undefined {
675
- if (!agentState) {
676
- return undefined;
677
- }
678
-
679
- for (let i = agentState.generationOrder.length - 1; i >= 0; i--) {
680
- const key = agentState.generationOrder[i];
681
- const state = agentState.activeGenerations.get(key);
682
- if (state && !state.ended) {
683
- return state;
684
- }
685
- }
686
-
687
- return undefined;
688
- }
689
-
690
- async function startGeneration(event: Record<string, unknown>) {
691
- if (!agentState?.root) {
692
- return;
693
- }
694
-
695
- try {
696
- const key = getRequestKey(event, `generation-${++agentState.generationSeq}`);
697
- const payload = getProviderPayload(event);
698
- const model = String(event.model ?? event.modelId ?? currentModel ?? "");
699
- const provider = String(event.provider ?? currentProvider ?? "");
700
- const metadata = shapePayload({
701
- provider,
702
- requestId: key,
703
- url: event.url,
704
- method: event.method,
705
- }) as Record<string, unknown>;
706
-
707
- const generation = agentState.root.startObservation
708
- ? agentState.root.startObservation(
709
- "llm-generation",
710
- {
711
- input: shapePayload(payload),
712
- model: model || undefined,
713
- metadata,
714
- },
715
- { asType: "generation" },
716
- )
717
- : (await getRuntime()).startObservation(
718
- "llm-generation",
719
- {
720
- input: shapePayload(payload),
721
- model: model || undefined,
722
- metadata,
723
- },
724
- { asType: "generation" },
725
- );
726
-
727
- agentState.activeGenerations.set(key, {
728
- observation: generation,
729
- requestKey: key,
730
- ended: false,
731
- metadata,
732
- });
733
- agentState.generationOrder.push(key);
734
- } catch (e) {
735
- console.warn("📊 Langfuse: Failed to start generation", e);
736
- }
737
- }
738
-
739
- function updateGenerationMetadata(event: Record<string, unknown>) {
740
- if (!agentState) {
741
- return;
742
- }
743
-
744
- const key = getRequestKey(event, "");
745
- const metadata = extractResponseMetadata(event);
746
- if (!key) {
747
- const generation = getOpenGeneration();
748
- if (generation) {
749
- generation.metadata = { ...generation.metadata, ...metadata };
750
- generation.observation.update({ metadata: generation.metadata });
751
- }
752
- return;
753
- }
754
-
755
- const generation = agentState.activeGenerations.get(key) ?? getOpenGeneration();
756
- if (generation) {
757
- generation.metadata = { ...generation.metadata, ...metadata };
758
- generation.observation.update({ metadata: generation.metadata });
759
- }
760
- }
761
-
762
- async function finishGenerationFromMessage(event: Record<string, unknown>) {
763
- if (!agentState) {
764
- return;
765
- }
766
-
767
- const message = getMessageFromEvent(event);
768
- if (!message || message.role !== "assistant") {
769
- return;
770
- }
771
-
772
- const generation = getOpenGeneration();
773
- const output = extractAssistantOutput(message);
774
- agentState.latestAssistantOutput = output;
775
-
776
- if (!generation) {
777
- return;
778
- }
779
-
780
- const usageDetails = extractUsage({ ...event, message });
781
- const costDetails = extractCostDetails({ ...event, message });
782
- const model = String(message.model ?? event.model ?? currentModel ?? "");
783
- const update: ObservationUpdate = {
784
- output,
785
- model: model || undefined,
786
- usageDetails,
787
- costDetails,
788
- metadata: {
789
- ...generation.metadata,
790
- finishReason: message.finishReason ?? message.stopReason ?? event.finishReason,
791
- },
792
- };
793
-
794
- try {
795
- generation.observation.update(update).end();
796
- generation.ended = true;
797
- } catch (e) {
798
- console.warn("📊 Langfuse: Failed to finish generation", e);
799
- }
800
- }
801
-
802
- async function createFallbackGenerationFromTurn(event: Record<string, unknown>, message: Record<string, unknown>) {
803
- if (!agentState?.root || agentState.generationOrder.length > 0) {
804
- return;
805
- }
806
-
807
- try {
808
- const usageDetails = extractUsage({ ...event, message });
809
- const costDetails = extractCostDetails({ ...event, message });
810
- const model = String(message.model ?? event.model ?? currentModel ?? "");
811
- const generation = agentState.root.startObservation
812
- ? agentState.root.startObservation(
813
- "llm-generation",
814
- {
815
- input: agentState.promptInput,
816
- output: extractAssistantOutput(message),
817
- model: model || undefined,
818
- usageDetails,
819
- costDetails,
820
- metadata: {
821
- provider: currentProvider || undefined,
822
- sourceEvent: "turn_end",
823
- },
824
- },
825
- { asType: "generation" },
826
- )
827
- : (await getRuntime()).startObservation(
828
- "llm-generation",
829
- {
830
- input: agentState.promptInput,
831
- output: extractAssistantOutput(message),
832
- model: model || undefined,
833
- usageDetails,
834
- costDetails,
835
- metadata: {
836
- provider: currentProvider || undefined,
837
- sourceEvent: "turn_end",
838
- },
839
- },
840
- { asType: "generation" },
841
- );
842
-
843
- generation.end();
844
- agentState.generationOrder.push("turn-end-fallback");
845
- } catch (e) {
846
- console.warn("📊 Langfuse: Failed to create fallback generation", e);
847
- }
848
- }
849
-
850
- async function startToolObservation(event: Record<string, unknown>) {
851
- if (!agentState?.root) {
852
- return;
853
- }
854
-
855
- const toolCallId = getToolCallId(event);
856
- if (!toolCallId || agentState.activeTools.has(toolCallId)) {
857
- return;
858
- }
859
-
860
- try {
861
- const toolName = getToolName(event);
862
- const tool = agentState.root.startObservation
863
- ? agentState.root.startObservation(
864
- toolName,
865
- {
866
- input: shapePayload(getToolInput(event), { maxString: MAX_TOOL_PAYLOAD_LENGTH }),
867
- metadata: { toolName, toolCallId },
868
- },
869
- { asType: "tool" },
870
- )
871
- : (await getRuntime()).startObservation(
872
- toolName,
873
- {
874
- input: shapePayload(getToolInput(event), { maxString: MAX_TOOL_PAYLOAD_LENGTH }),
875
- metadata: { toolName, toolCallId },
876
- },
877
- { asType: "tool" },
878
- );
879
-
880
- toolCallCount++;
881
- agentState.activeTools.set(toolCallId, { observation: tool, toolName, ended: false });
882
- } catch (e) {
883
- console.warn("📊 Langfuse: Failed to start tool observation", e);
884
- }
885
- }
886
-
887
- async function finishToolObservation(event: Record<string, unknown>) {
888
- if (!agentState) {
889
- return;
890
- }
891
-
892
- const toolCallId = getToolCallId(event);
893
- if (!toolCallId) {
894
- return;
895
- }
896
-
897
- const state = agentState.activeTools.get(toolCallId);
898
- if (!state || state.ended) {
899
- return;
900
- }
901
-
902
- const isError = Boolean(event.isError ?? event.error ?? event.status === "error");
903
- const output =
904
- extractTextContent(event.content, MAX_TOOL_PAYLOAD_LENGTH) ??
905
- event.output ??
906
- event.result ??
907
- event.error ??
908
- event.content ??
909
- event;
910
-
911
- try {
912
- state.observation
913
- .update({
914
- output: shapePayload(output, { maxString: MAX_TOOL_PAYLOAD_LENGTH }),
915
- level: isError ? "ERROR" : "DEFAULT",
916
- statusMessage: isError ? truncate(String(event.error ?? output), 1_000) : undefined,
917
- metadata: {
918
- toolName: state.toolName,
919
- toolCallId,
920
- isError,
921
- },
922
- })
923
- .end();
924
- state.ended = true;
925
-
926
- if (isError) {
927
- errorCount++;
928
- await sendScore("tool_is_error", 1, {
929
- traceId: agentState.traceId,
930
- observationId: state.observation.id,
931
- });
932
- }
933
- } catch (e) {
934
- console.warn("📊 Langfuse: Failed to finish tool observation", e);
935
- } finally {
936
- agentState.activeTools.delete(toolCallId);
937
- }
938
- }
939
-
940
- function closeDanglingObservations(statusMessage: string) {
941
- if (!agentState) {
942
- return;
943
- }
944
-
945
- for (const state of agentState.activeTools.values()) {
946
- if (!state.ended) {
947
- state.observation
948
- .update({ level: "WARNING", statusMessage, metadata: { toolName: state.toolName, cancelled: true } })
949
- .end();
950
- state.ended = true;
951
- }
952
- }
953
-
954
- for (const state of agentState.activeGenerations.values()) {
955
- if (!state.ended) {
956
- state.observation.update({ level: "WARNING", statusMessage, metadata: { ...state.metadata, cancelled: true } }).end();
957
- state.ended = true;
958
- }
959
- }
960
-
961
- agentState.activeTools.clear();
962
- }
963
-
964
- async function finishAgentRun(event: Record<string, unknown> = {}) {
965
- if (!agentState?.root) {
966
- resetRunState();
967
- return;
968
- }
969
-
970
- const lastAssistant = extractFinalAssistant(event.messages);
971
- const output = lastAssistant ? extractAssistantOutput(lastAssistant) : agentState.latestAssistantOutput;
972
- const scores = computeEvaluationScores();
973
-
974
- closeDanglingObservations("Agent run ended before observation finalized");
975
-
976
- try {
977
- agentState.root
978
- .update({
979
- output,
980
- metadata: {
981
- cwd: agentState.cwd,
982
- completed: true,
983
- model: currentModel || undefined,
984
- provider: currentProvider || undefined,
985
- totalTools: toolCallCount,
986
- ...scores,
987
- },
988
- })
989
- .end();
990
- updateTraceIO(agentState.promptInput, output);
991
-
992
- await sendScore("tool_call_count", scores.tool_call_count, { traceId: agentState.traceId });
993
- await sendScore("turn_count", scores.turn_count, { traceId: agentState.traceId });
994
- await sendScore("total_tool_errors", scores.total_tool_errors, { traceId: agentState.traceId });
995
- await sendScore("tool_success_rate", scores.tool_success_rate, { traceId: agentState.traceId });
996
- await sendScore("session_had_errors", scores.session_had_errors, { traceId: agentState.traceId });
997
- } catch (e) {
998
- console.warn("📊 Langfuse: Failed to finish agent observation", e);
999
- } finally {
1000
- resetRunState();
1001
- }
1002
- }
13
+ import { state, resetRunState } from "./src/state.js";
14
+ import { ensureConfig, promptForConfig } from "./src/config.js";
15
+ import { shutdownRuntime } from "./src/langfuse.js";
16
+ import { getMessageFromEvent, extractAssistantOutput } from "./src/utils.js";
17
+ import { startAgentRun, finishAgentRun } from "./src/handlers/agent.js";
18
+ import { startTurnObservation, finishTurnObservation } from "./src/handlers/turn.js";
19
+ import {
20
+ startGeneration,
21
+ updateGenerationMetadata,
22
+ finishGenerationFromMessage,
23
+ createFallbackGenerationFromTurn,
24
+ recordTTFT,
25
+ } from "./src/handlers/generation.js";
26
+ import {
27
+ startToolObservation,
28
+ finishToolObservation,
29
+ closeDanglingObservations,
30
+ } from "./src/handlers/tool.js";
1003
31
 
1004
32
  // ============================================
1005
33
  // Extension
1006
34
  // ============================================
1007
35
 
1008
36
  export default async function (pi: ExtensionAPI) {
1009
- if (config) {
1010
- console.log("📊 Langfuse: Tracing enabled →", config.host);
37
+ if (state.config) {
38
+ console.log("📊 Langfuse: Tracing enabled →", state.config.host);
1011
39
  } else {
1012
40
  console.log("📊 Langfuse: Waiting for first-run setup");
1013
41
  }
@@ -1020,18 +48,18 @@ export default async function (pi: ExtensionAPI) {
1020
48
  });
1021
49
 
1022
50
  pi.on("session_start", async (_event, ctx) => {
1023
- setupAttemptedThisSession = false;
51
+ state.setupAttemptedThisSession = false;
1024
52
  await ensureConfig(ctx);
1025
53
  const sessionFile = ctx.sessionManager.getSessionFile();
1026
54
  if (sessionFile) {
1027
- currentSessionId = basename(sessionFile, ".jsonl");
55
+ state.currentSessionId = basename(sessionFile, ".jsonl");
1028
56
  }
1029
57
  resetRunState();
1030
58
  });
1031
59
 
1032
60
  pi.on("model_select", async (event) => {
1033
- currentModel = event.model?.id || "";
1034
- currentProvider = event.model?.provider || "";
61
+ state.currentModel = event.model?.id || "";
62
+ state.currentProvider = event.model?.provider || "";
1035
63
  });
1036
64
 
1037
65
  pi.on("before_agent_start", async (event, ctx) => {
@@ -1039,11 +67,15 @@ export default async function (pi: ExtensionAPI) {
1039
67
  });
1040
68
 
1041
69
  pi.on("agent_start", async (event, ctx) => {
1042
- if (!agentState?.root) {
70
+ if (!state.agentState?.root) {
1043
71
  await startAgentRun(event, ctx);
1044
72
  }
1045
73
  });
1046
74
 
75
+ pi.on("turn_start", async (event) => {
76
+ await startTurnObservation(event);
77
+ });
78
+
1047
79
  pi.on("before_provider_request", async (event) => {
1048
80
  await startGeneration(event);
1049
81
  });
@@ -1053,9 +85,10 @@ export default async function (pi: ExtensionAPI) {
1053
85
  });
1054
86
 
1055
87
  pi.on("message_update", async (event) => {
88
+ recordTTFT(event);
1056
89
  const message = getMessageFromEvent(event);
1057
- if (message?.role === "assistant" && agentState) {
1058
- agentState.latestAssistantOutput = extractAssistantOutput(message);
90
+ if (message?.role === "assistant" && state.agentState) {
91
+ state.agentState.latestAssistantOutput = extractAssistantOutput(message);
1059
92
  }
1060
93
  });
1061
94
 
@@ -1080,12 +113,13 @@ export default async function (pi: ExtensionAPI) {
1080
113
  });
1081
114
 
1082
115
  pi.on("turn_end", async (event) => {
1083
- turnCount++;
116
+ state.turnCount++;
1084
117
  const message = getMessageFromEvent(event);
1085
118
  if (message?.role === "assistant") {
1086
119
  await createFallbackGenerationFromTurn(event, message);
1087
120
  await finishGenerationFromMessage(event);
1088
121
  }
122
+ finishTurnObservation(event);
1089
123
  });
1090
124
 
1091
125
  pi.on("agent_end", async (event) => {
@@ -1093,12 +127,44 @@ export default async function (pi: ExtensionAPI) {
1093
127
  await shutdownRuntime();
1094
128
  });
1095
129
 
1096
- pi.on("session_shutdown", async () => {
1097
- if (agentState?.root) {
1098
- closeDanglingObservations("Session shutdown before agent completed");
1099
- agentState.root.update({ metadata: { completed: false, cancelled: true } }).end();
130
+ const handleSessionInterruption = (reason: string) => {
131
+ if (state.agentState?.root) {
132
+ closeDanglingObservations(reason);
133
+ state.agentState.root.update({ metadata: { completed: false, cancelled: true } }).end();
1100
134
  }
1101
135
  resetRunState();
136
+ };
137
+
138
+ pi.on("session_before_switch", async () => {
139
+ handleSessionInterruption("Session switched");
140
+ });
141
+
142
+ pi.on("session_before_fork", async () => {
143
+ handleSessionInterruption("Session forked");
144
+ });
145
+
146
+ pi.on("session_compact", async (event) => {
147
+ if (state.agentState?.root) {
148
+ const parent = state.agentState.activeTurn ?? state.agentState.root;
149
+ try {
150
+ const observation = parent.startObservation ? parent.startObservation(
151
+ "session_compact",
152
+ {
153
+ level: "DEFAULT",
154
+ statusMessage: "Context was compacted",
155
+ metadata: { ...event }
156
+ },
157
+ { asType: "span" }
158
+ ) : undefined;
159
+ observation?.end();
160
+ } catch (e) {
161
+ // ignore
162
+ }
163
+ }
164
+ });
165
+
166
+ pi.on("session_shutdown", async () => {
167
+ handleSessionInterruption("Session shutdown before agent completed");
1102
168
  await shutdownRuntime();
1103
169
  });
1104
170
  }