pi-langfuse 1.0.0 → 1.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.
Files changed (5) hide show
  1. package/README.md +270 -59
  2. package/README_CN.md +273 -61
  3. package/image.png +0 -0
  4. package/index.ts +877 -325
  5. package/package.json +18 -3
package/index.ts CHANGED
@@ -1,16 +1,10 @@
1
1
  /**
2
2
  * Langfuse Observability Extension for Pi Coding Agent
3
- *
4
- * Sends traces to Langfuse for monitoring tokens, costs, latency, and tool calls.
5
- * Uses dynamic import to load the langfuse SDK properly.
6
- *
7
- * Scores tracked:
8
- * - tool_call_count: Total number of tool calls
9
- * - turn_count: Number of turns in the session
10
- * - total_tool_errors: Number of tools that returned errors
11
- * - tool_success_rate: Success rate of tool calls (0-1)
12
- * - session_had_errors: Boolean indicating if any tool error occurred
13
- * - tool_is_error: Per-tool score indicating if that specific tool call errored
3
+ *
4
+ * Sends one complete Langfuse trace per Pi agent run:
5
+ * - root agent observation for the user prompt and final assistant response
6
+ * - one generation observation per provider request
7
+ * - one tool observation per tool call, keyed by toolCallId
14
8
  */
15
9
 
16
10
  import { readFileSync, existsSync, writeFileSync } from "node:fs";
@@ -62,7 +56,7 @@ function loadConfigFromEnv(): Config | null {
62
56
  return {
63
57
  publicKey,
64
58
  secretKey,
65
- host: process.env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
59
+ host: process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
66
60
  };
67
61
  }
68
62
 
@@ -71,114 +65,281 @@ function saveConfig(config: Config) {
71
65
  }
72
66
 
73
67
  // ============================================
74
- // Langfuse Client (lazy-loaded via dynamic import)
68
+ // Langfuse SDK facade (lazy-loaded)
75
69
  // ============================================
76
70
 
77
- interface LangfuseSpan {
78
- id: string;
79
- end(body?: { metadata?: Record<string, unknown>; isError?: boolean; output?: unknown }): void;
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;
80
82
  }
81
83
 
82
- interface LangfuseGeneration {
83
- id: string;
84
- end(body?: {
85
- metadata?: Record<string, unknown>;
86
- usage?: unknown;
87
- output?: unknown;
88
- costDetails?: unknown;
89
- }): void;
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;
90
94
  }
91
95
 
92
- interface LangfuseClient {
93
- trace(body?: { name: string; metadata?: Record<string, unknown>; input?: unknown; output?: unknown; sessionId?: string }): {
94
- id: string;
95
- update(body?: { metadata?: Record<string, unknown>; output?: unknown; input?: unknown }): void;
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;
96
106
  };
97
- span(body: { name: string; traceId: string; metadata?: Record<string, unknown>; input?: unknown }): LangfuseSpan;
98
- generation(body: {
99
- name: string;
100
- traceId: string;
101
- metadata?: Record<string, unknown>;
102
- input?: unknown;
103
- output?: unknown;
104
- usage?: unknown;
105
- model?: string;
106
- costDetails?: unknown;
107
- }): LangfuseGeneration;
108
- score(body: { name: string; value: number; traceId?: string; observationId?: string }): void;
109
- shutdownAsync(): Promise<void>;
110
- }
111
-
112
- let client: LangfuseClient | null = null;
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;
113
132
  let config: Config | null = loadConfigFromFile() ?? loadConfigFromEnv();
114
133
  let setupAttemptedThisSession = false;
115
134
 
116
- async function getClient(): Promise<LangfuseClient> {
135
+ async function getRuntime(): Promise<LangfuseRuntime> {
117
136
  if (!config) {
118
137
  throw new Error("Langfuse config is not set");
119
138
  }
120
139
 
121
- if (!client) {
122
- const lib = await import(`${EXT_DIR}/node_modules/langfuse/lib/index.mjs`) as {
123
- Langfuse: new (options: { publicKey: string; secretKey?: string; baseUrl?: string }) => LangfuseClient;
124
- };
125
- client = new lib.Langfuse({
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({
126
149
  publicKey: config.publicKey,
127
150
  secretKey: config.secretKey,
128
151
  baseUrl: config.host,
129
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;
130
187
  }
131
- return client;
132
188
  }
133
189
 
134
190
  // ============================================
135
191
  // State
136
192
  // ============================================
137
193
 
138
- interface TraceData {
139
- id: string;
140
- update?: (body?: { metadata?: Record<string, unknown>; output?: unknown; input?: unknown }) => void;
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;
141
205
  }
142
206
 
143
- interface SpanData {
144
- span: LangfuseSpan;
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>>;
145
218
  }
146
219
 
147
- let currentTrace: TraceData | null = null;
148
- let currentUserPrompt: string = "";
149
- let currentSessionId: string = "";
150
- let currentModel: string = "";
151
- let currentProvider: string = "";
152
- const activeSpans: Map<string, SpanData> = new Map();
220
+ let currentSessionId = "";
221
+ let currentModel = "";
222
+ let currentProvider = "";
223
+ let agentState: AgentState | null = null;
153
224
 
154
225
  // Evaluation tracking state
155
- let toolCallCount: number = 0;
156
- let errorCount: number = 0;
157
- let turnCount: number = 0;
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
+ }
158
279
 
159
- function truncate(value: string, maxLength: number): string {
160
- return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value;
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>());
161
317
  }
162
318
 
163
- function safeSerialize(value: unknown, maxLength: number): string {
319
+ function safeSerialize(value: unknown, maxLength = MAX_TOOL_PAYLOAD_LENGTH): string {
164
320
  try {
165
- return truncate(JSON.stringify(value, null, 2), maxLength);
321
+ return truncate(JSON.stringify(shapePayload(value, { maxString: maxLength }), null, 2), maxLength);
166
322
  } catch {
167
323
  return `[unserializable ${typeof value}]`;
168
324
  }
169
325
  }
170
326
 
171
- function extractTextContent(
172
- content: Array<{ type: string; text?: string; thinking?: string }> | undefined,
173
- maxLength?: number,
174
- ): string | undefined {
175
- if (!content?.length) {
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)) {
176
333
  return undefined;
177
334
  }
178
335
 
179
336
  const text = content
180
- .filter((item) => item.type === "text" && item.text)
181
- .map((item) => item.text)
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)
182
343
  .join("\n");
183
344
 
184
345
  if (!text) {
@@ -188,15 +349,203 @@ function extractTextContent(
188
349
  return maxLength ? truncate(text, maxLength) : text;
189
350
  }
190
351
 
191
- function resetSessionState() {
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;
192
517
  toolCallCount = 0;
193
518
  errorCount = 0;
194
519
  turnCount = 0;
195
- activeSpans.clear();
196
- currentTrace = null;
197
- currentUserPrompt = "";
198
- currentModel = "";
199
- currentProvider = "";
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
+ }
200
549
  }
201
550
 
202
551
  async function ensureConfig(ctx: any): Promise<boolean> {
@@ -210,7 +559,7 @@ async function ensureConfig(ctx: any): Promise<boolean> {
210
559
  setupAttemptedThisSession = true;
211
560
 
212
561
  if (!ctx.hasUI) {
213
- console.log("📊 Langfuse: Missing config. Run this extension in Pi UI to complete setup, or set LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_HOST.");
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.");
214
563
  return false;
215
564
  }
216
565
 
@@ -250,23 +599,406 @@ async function ensureConfig(ctx: any): Promise<boolean> {
250
599
  async function promptForConfig(ctx: any): Promise<boolean> {
251
600
  setupAttemptedThisSession = false;
252
601
  config = null;
253
- client = null;
602
+ await shutdownRuntime();
254
603
  return ensureConfig(ctx);
255
604
  }
256
605
 
257
- function computeEvaluationScores() {
258
- const toolSuccessRate = toolCallCount > 0
259
- ? (toolCallCount - errorCount) / toolCallCount
260
- : 1;
261
- const sessionHadErrors = errorCount > 0;
262
-
263
- return {
264
- tool_call_count: toolCallCount,
265
- turn_count: turnCount,
266
- total_tool_errors: errorCount,
267
- tool_success_rate: toolSuccessRate,
268
- session_had_errors: sessionHadErrors ? 1 : 0, // 1 for true, 0 for false
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
+ },
269
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
+ }
270
1002
  }
271
1003
 
272
1004
  // ============================================
@@ -287,266 +1019,86 @@ export default async function (pi: ExtensionAPI) {
287
1019
  },
288
1020
  });
289
1021
 
290
- // Capture session ID on session start
291
1022
  pi.on("session_start", async (_event, ctx) => {
292
1023
  setupAttemptedThisSession = false;
293
1024
  await ensureConfig(ctx);
294
1025
  const sessionFile = ctx.sessionManager.getSessionFile();
295
1026
  if (sessionFile) {
296
- // Extract session ID from file path (format: 2026-04-25T13-23-54-756Z_<uuid>.jsonl)
297
1027
  currentSessionId = basename(sessionFile, ".jsonl");
298
1028
  }
299
- // Reset state for new session
300
- resetSessionState();
1029
+ resetRunState();
301
1030
  });
302
1031
 
303
- // Capture model info on model select
304
- pi.on("model_select", async (event, ctx) => {
305
- currentModel = event.model?.id || '';
306
- currentProvider = event.model?.provider || '';
1032
+ pi.on("model_select", async (event) => {
1033
+ currentModel = event.model?.id || "";
1034
+ currentProvider = event.model?.provider || "";
307
1035
  });
308
1036
 
309
- // Use before_agent_start to capture user prompt and create trace
310
1037
  pi.on("before_agent_start", async (event, ctx) => {
311
- if (!(await ensureConfig(ctx))) {
312
- return;
313
- }
1038
+ await startAgentRun(event, ctx);
1039
+ });
314
1040
 
315
- try {
316
- const lf = await getClient();
317
- const cwd = event.systemPromptOptions?.cwd || process.cwd();
318
- currentUserPrompt = event.prompt;
319
-
320
- // Fallback to ctx.model if not captured via model_select
321
- if (!currentModel && ctx.model) {
322
- currentModel = ctx.model.id || '';
323
- currentProvider = ctx.model.provider || '';
324
- }
325
-
326
- // Create trace with user input and session ID
327
- currentTrace = lf.trace({
328
- name: "pi-agent",
329
- input: event.prompt,
330
- metadata: {
331
- cwd,
332
- model: currentModel,
333
- provider: currentProvider
334
- },
335
- sessionId: currentSessionId || undefined
336
- });
337
- } catch (e) {
338
- console.warn("📊 Langfuse: Failed to create trace", e);
1041
+ pi.on("agent_start", async (event, ctx) => {
1042
+ if (!agentState?.root) {
1043
+ await startAgentRun(event, ctx);
339
1044
  }
340
1045
  });
341
1046
 
342
- pi.on("agent_end", async (event) => {
343
- if (currentTrace) {
344
- // Get final response/output
345
- const eventData = event as unknown as {
346
- messages?: Array<{
347
- role: string;
348
- content: Array<{ type: string; text?: string; thinking?: string }>;
349
- }>;
350
- };
351
- const messages = eventData.messages || [];
352
- const lastAssistant = messages.filter(m => m.role === "assistant").pop();
353
-
354
- // Extract text from content array (filter out thinking blocks)
355
- const output = extractTextContent(lastAssistant?.content);
356
-
357
- // Compute evaluation scores
358
- const scores = computeEvaluationScores();
359
-
360
- currentTrace.update?.({
361
- output: output || undefined,
362
- metadata: {
363
- completed: true,
364
- totalTools: toolCallCount,
365
- model: currentModel,
366
- provider: currentProvider,
367
- ...scores
368
- }
369
- });
370
-
371
- // Send evaluation scores to Langfuse
372
- try {
373
- const lf = await getClient();
374
-
375
- // Trace-level evaluation scores
376
- lf.score({ name: "tool_call_count", value: scores.tool_call_count, traceId: currentTrace.id });
377
- lf.score({ name: "turn_count", value: scores.turn_count, traceId: currentTrace.id });
378
- lf.score({ name: "total_tool_errors", value: scores.total_tool_errors, traceId: currentTrace.id });
379
- lf.score({ name: "tool_success_rate", value: scores.tool_success_rate, traceId: currentTrace.id });
380
- lf.score({ name: "session_had_errors", value: scores.session_had_errors, traceId: currentTrace.id });
381
-
382
- console.log("📊 Langfuse: Evaluation scores sent:", scores);
383
- } catch (e) {
384
- console.warn("📊 Langfuse: Failed to send evaluation scores", e);
385
- }
386
-
387
- currentTrace = null;
388
- }
389
-
390
- // Reset for next session
391
- resetSessionState();
392
-
393
- if (client) {
394
- await client.shutdownAsync();
395
- client = null;
396
- }
1047
+ pi.on("before_provider_request", async (event) => {
1048
+ await startGeneration(event);
397
1049
  });
398
1050
 
399
- // Track tool calls and create spans
400
- pi.on("tool_call", async (event) => {
401
- if (!currentTrace) return;
1051
+ pi.on("after_provider_response", async (event) => {
1052
+ updateGenerationMetadata(event);
1053
+ });
402
1054
 
403
- try {
404
- const lf = await getClient();
405
-
406
- // Increment tool call counter
407
- toolCallCount++;
408
-
409
- // Format input nicely
410
- const inputStr = event.input ? safeSerialize(event.input, 1000) : "";
411
-
412
- const span = lf.span({
413
- name: `tool:${event.toolName}`,
414
- traceId: currentTrace.id,
415
- input: inputStr,
416
- metadata: { tool: event.toolName }
417
- });
418
-
419
- activeSpans.set(event.toolCallId, { span });
420
- } catch (e) {
421
- console.warn("📊 Langfuse: Failed to create span", e);
1055
+ pi.on("message_update", async (event) => {
1056
+ const message = getMessageFromEvent(event);
1057
+ if (message?.role === "assistant" && agentState) {
1058
+ agentState.latestAssistantOutput = extractAssistantOutput(message);
422
1059
  }
423
1060
  });
424
1061
 
425
- // Track tool results and errors
426
- pi.on("tool_result", async (event) => {
427
- const spanData = activeSpans.get(event.toolCallId);
428
- if (spanData) {
429
- const { span } = spanData;
430
-
431
- // Format output nicely
432
- const outputStr = extractTextContent(event.content, 2000);
433
-
434
- span.end({
435
- isError: event.isError,
436
- output: outputStr || undefined
437
- });
438
-
439
- // Track errors and send per-tool score
440
- if (event.isError) {
441
- errorCount++;
442
- try {
443
- const lf = await getClient();
444
- // Per-tool error score (observation level)
445
- lf.score({
446
- name: "tool_is_error",
447
- value: 1,
448
- traceId: currentTrace?.id,
449
- observationId: span.id,
450
- });
451
- } catch (e) {
452
- console.warn("📊 Langfuse: Failed to send tool error score", e);
453
- }
454
- }
455
-
456
- activeSpans.delete(event.toolCallId);
457
- }
1062
+ pi.on("message_end", async (event) => {
1063
+ await finishGenerationFromMessage(event);
458
1064
  });
459
1065
 
460
- // Track turns and generations
461
- pi.on("turn_end", async (event) => {
462
- if (!currentTrace) return;
1066
+ pi.on("tool_execution_start", async (event) => {
1067
+ await startToolObservation(event);
1068
+ });
463
1069
 
464
- // Increment turn counter
465
- turnCount++;
1070
+ pi.on("tool_call", async (event) => {
1071
+ await startToolObservation(event);
1072
+ });
466
1073
 
467
- const eventData = event as unknown as {
468
- message?: {
469
- role: string;
470
- content: Array<{ type: string; text?: string }>;
471
- model?: string;
472
- cost?: { input: number; output: number; total: number };
473
- usage?: {
474
- input: number;
475
- output: number;
476
- cacheRead: number;
477
- cacheWrite: number;
478
- totalTokens: number;
479
- cost?: { input: number; output: number; total: number };
480
- };
481
- };
482
- toolResults?: Array<{ toolName: string; toolCallId: string }>;
483
- };
1074
+ pi.on("tool_result", async (event) => {
1075
+ await finishToolObservation(event);
1076
+ });
484
1077
 
485
- const message = eventData.message;
486
- if (!message || message.role !== "assistant") return;
487
-
488
- const usage = message.usage;
489
- const modelId = message.model || currentModel;
490
- const provider = currentProvider;
491
- const cost = usage?.cost;
492
-
493
- if (usage) {
494
- try {
495
- const lf = await getClient();
496
-
497
- // Extract output text
498
- const outputText = extractTextContent(message.content)?.slice(0, 1000) || "";
499
-
500
- // Create generation for the LLM response with model info
501
- // Note: usage and costDetails go in the generation observation, NOT as scores
502
- const gen = lf.generation({
503
- name: "llm-response",
504
- traceId: currentTrace.id,
505
- input: currentUserPrompt.slice(0, 500),
506
- output: outputText,
507
- model: modelId,
508
- metadata: {
509
- provider: provider,
510
- inputTokens: usage.input || 0,
511
- outputTokens: usage.output || 0,
512
- cachedTokens: usage.cacheRead || 0,
513
- },
514
- usage: {
515
- input: usage.input || 0,
516
- output: usage.output || 0,
517
- total: usage.totalTokens || (usage.input || 0) + (usage.output || 0)
518
- },
519
- costDetails: cost ? { total: cost.total, input: cost.input, output: cost.output } : undefined
520
- });
521
- gen.end({
522
- costDetails: cost ? { total: cost.total, input: cost.input, output: cost.output } : undefined,
523
- usage: {
524
- input: usage.input || 0,
525
- output: usage.output || 0,
526
- total: usage.totalTokens || (usage.input || 0) + (usage.output || 0)
527
- }
528
- });
529
- } catch (e) {
530
- console.warn("📊 Langfuse: Failed to create generation", e);
531
- }
532
-
533
- // NOTE: We no longer send token counts or cost as scores
534
- // Those belong in usage/costDetails on the generation observation
535
- // Scores are for EVALUATION metrics (success rates, error counts, etc.)
1078
+ pi.on("tool_execution_end", async (event) => {
1079
+ await finishToolObservation(event);
1080
+ });
1081
+
1082
+ pi.on("turn_end", async (event) => {
1083
+ turnCount++;
1084
+ const message = getMessageFromEvent(event);
1085
+ if (message?.role === "assistant") {
1086
+ await createFallbackGenerationFromTurn(event, message);
1087
+ await finishGenerationFromMessage(event);
536
1088
  }
537
1089
  });
538
1090
 
1091
+ pi.on("agent_end", async (event) => {
1092
+ await finishAgentRun(event);
1093
+ await shutdownRuntime();
1094
+ });
1095
+
539
1096
  pi.on("session_shutdown", async () => {
540
- if (currentTrace) {
541
- if (currentTrace.update) {
542
- currentTrace.update({ metadata: { completed: true } });
543
- }
544
- currentTrace = null;
545
- }
546
- if (client) {
547
- await client.shutdownAsync();
548
- client = null;
1097
+ if (agentState?.root) {
1098
+ closeDanglingObservations("Session shutdown before agent completed");
1099
+ agentState.root.update({ metadata: { completed: false, cancelled: true } }).end();
549
1100
  }
550
- resetSessionState();
1101
+ resetRunState();
1102
+ await shutdownRuntime();
551
1103
  });
552
1104
  }