pi-langfuse 1.1.0 → 1.3.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,39 @@
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, loadConfigFromEnv, loadConfigFromFile } 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
+ state.config = loadConfigFromEnv() || loadConfigFromFile();
39
+ }
40
+
41
+ if (state.config) {
42
+ console.log("📊 Langfuse: Tracing enabled →", state.config.host);
1011
43
  } else {
1012
44
  console.log("📊 Langfuse: Waiting for first-run setup");
1013
45
  }
@@ -1020,18 +52,18 @@ export default async function (pi: ExtensionAPI) {
1020
52
  });
1021
53
 
1022
54
  pi.on("session_start", async (_event, ctx) => {
1023
- setupAttemptedThisSession = false;
55
+ state.setupAttemptedThisSession = false;
1024
56
  await ensureConfig(ctx);
1025
57
  const sessionFile = ctx.sessionManager.getSessionFile();
1026
58
  if (sessionFile) {
1027
- currentSessionId = basename(sessionFile, ".jsonl");
59
+ state.currentSessionId = basename(sessionFile, ".jsonl");
1028
60
  }
1029
61
  resetRunState();
1030
62
  });
1031
63
 
1032
64
  pi.on("model_select", async (event) => {
1033
- currentModel = event.model?.id || "";
1034
- currentProvider = event.model?.provider || "";
65
+ state.currentModel = event.model?.id || "";
66
+ state.currentProvider = event.model?.provider || "";
1035
67
  });
1036
68
 
1037
69
  pi.on("before_agent_start", async (event, ctx) => {
@@ -1039,11 +71,15 @@ export default async function (pi: ExtensionAPI) {
1039
71
  });
1040
72
 
1041
73
  pi.on("agent_start", async (event, ctx) => {
1042
- if (!agentState?.root) {
74
+ if (!state.agentState?.root) {
1043
75
  await startAgentRun(event, ctx);
1044
76
  }
1045
77
  });
1046
78
 
79
+ pi.on("turn_start", async (event) => {
80
+ await startTurnObservation(event);
81
+ });
82
+
1047
83
  pi.on("before_provider_request", async (event) => {
1048
84
  await startGeneration(event);
1049
85
  });
@@ -1053,9 +89,10 @@ export default async function (pi: ExtensionAPI) {
1053
89
  });
1054
90
 
1055
91
  pi.on("message_update", async (event) => {
92
+ recordTTFT(event);
1056
93
  const message = getMessageFromEvent(event);
1057
- if (message?.role === "assistant" && agentState) {
1058
- agentState.latestAssistantOutput = extractAssistantOutput(message);
94
+ if (message?.role === "assistant" && state.agentState) {
95
+ state.agentState.latestAssistantOutput = extractAssistantOutput(message);
1059
96
  }
1060
97
  });
1061
98
 
@@ -1080,12 +117,13 @@ export default async function (pi: ExtensionAPI) {
1080
117
  });
1081
118
 
1082
119
  pi.on("turn_end", async (event) => {
1083
- turnCount++;
120
+ state.turnCount++;
1084
121
  const message = getMessageFromEvent(event);
1085
122
  if (message?.role === "assistant") {
1086
123
  await createFallbackGenerationFromTurn(event, message);
1087
124
  await finishGenerationFromMessage(event);
1088
125
  }
126
+ finishTurnObservation(event);
1089
127
  });
1090
128
 
1091
129
  pi.on("agent_end", async (event) => {
@@ -1093,12 +131,44 @@ export default async function (pi: ExtensionAPI) {
1093
131
  await shutdownRuntime();
1094
132
  });
1095
133
 
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();
134
+ const handleSessionInterruption = (reason: string) => {
135
+ if (state.agentState?.root) {
136
+ closeDanglingObservations(reason);
137
+ state.agentState.root.update({ metadata: { completed: false, cancelled: true } }).end();
1100
138
  }
1101
139
  resetRunState();
140
+ };
141
+
142
+ pi.on("session_before_switch", async () => {
143
+ handleSessionInterruption("Session switched");
144
+ });
145
+
146
+ pi.on("session_before_fork", async () => {
147
+ handleSessionInterruption("Session forked");
148
+ });
149
+
150
+ pi.on("session_compact", async (event) => {
151
+ if (state.agentState?.root) {
152
+ const parent = state.agentState.activeTurn ?? state.agentState.root;
153
+ try {
154
+ const observation = parent.startObservation ? parent.startObservation(
155
+ "session_compact",
156
+ {
157
+ level: "DEFAULT",
158
+ statusMessage: "Context was compacted",
159
+ metadata: { ...event }
160
+ },
161
+ { asType: "span" }
162
+ ) : undefined;
163
+ observation?.end();
164
+ } catch (e) {
165
+ // ignore
166
+ }
167
+ }
168
+ });
169
+
170
+ pi.on("session_shutdown", async () => {
171
+ handleSessionInterruption("Session shutdown before agent completed");
1102
172
  await shutdownRuntime();
1103
173
  });
1104
174
  }