pi-langfuse 1.5.18 → 1.6.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.
@@ -0,0 +1,90 @@
1
+ import { applyCapturePolicy } from "../capture-policy.js";
2
+ import { getRuntime } from "../langfuse.js";
3
+ import { startChildObservation } from "../observation.js";
4
+ import { state } from "../state.js";
5
+ import {
6
+ extractCostDetails,
7
+ extractUsage,
8
+ getCapturePolicy,
9
+ shapePayload,
10
+ } from "../utils.js";
11
+
12
+ type RecordLike = Record<string, unknown>;
13
+
14
+ function namesFromTools(value: unknown): string[] {
15
+ if (!Array.isArray(value)) return [];
16
+ return value.flatMap((tool) => {
17
+ if (typeof tool === "string") return [tool];
18
+ if (!tool || typeof tool !== "object") return [];
19
+ const name = (tool as RecordLike).name;
20
+ return typeof name === "string" ? [name] : [];
21
+ });
22
+ }
23
+
24
+ export async function recordSessionCompaction(event: RecordLike): Promise<void> {
25
+ const agent = state.agentState;
26
+ if (state.isTracingDisabled || !agent?.root) return;
27
+
28
+ const entry = event.compactionEntry && typeof event.compactionEntry === "object"
29
+ ? event.compactionEntry as RecordLike
30
+ : {};
31
+ const checkpoint = entry.systemMessage && typeof entry.systemMessage === "object"
32
+ ? entry.systemMessage as RecordLike
33
+ : undefined;
34
+ const checkpointTools = namesFromTools(checkpoint?.toolsAdded);
35
+ const checkpointSections = checkpoint?.sections && typeof checkpoint.sections === "object"
36
+ ? Object.keys(checkpoint.sections as RecordLike)
37
+ : [];
38
+ const captured = applyCapturePolicy(
39
+ {
40
+ input: shapePayload({ summary: entry.summary }),
41
+ metadata: {
42
+ reason: event.reason,
43
+ willRetry: event.willRetry,
44
+ fromExtension: event.fromExtension,
45
+ compactionEntryId: entry.id,
46
+ firstKeptEntryId: entry.firstKeptEntryId,
47
+ tokensBefore: entry.tokensBefore,
48
+ checkpointSectionNames: checkpointSections,
49
+ checkpointToolNames: checkpointTools,
50
+ checkpointToolCount: checkpointTools.length,
51
+ promptStateHash: agent.promptStateHash,
52
+ toolStateHash: agent.toolStateHash,
53
+ },
54
+ },
55
+ getCapturePolicy(),
56
+ );
57
+
58
+ try {
59
+ const parent = agent.activeTurn ?? agent.activeAttempt ?? agent.root;
60
+ const compaction = await startChildObservation({
61
+ parent,
62
+ runtime: getRuntime,
63
+ name: "session-compaction",
64
+ body: { input: captured.input, metadata: captured.metadata },
65
+ asType: "span",
66
+ });
67
+
68
+ if (entry.usage && typeof entry.usage === "object") {
69
+ const summary = applyCapturePolicy({ output: entry.summary }, getCapturePolicy());
70
+ const costDetails = extractCostDetails({ usage: entry.usage });
71
+ const generation = await startChildObservation({
72
+ parent: compaction,
73
+ runtime: getRuntime,
74
+ name: "compaction-summary",
75
+ body: {
76
+ output: summary.output,
77
+ model: state.currentModel || undefined,
78
+ usageDetails: extractUsage({ usage: entry.usage }),
79
+ ...(costDetails ? { costDetails } : {}),
80
+ metadata: { provider: state.currentProvider || undefined },
81
+ },
82
+ asType: "generation",
83
+ });
84
+ generation.end();
85
+ }
86
+ compaction.end();
87
+ } catch (error) {
88
+ console.warn("📊 Langfuse: Failed to record session compaction", error);
89
+ }
90
+ }
@@ -0,0 +1,186 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { applyCapturePolicy } from "../capture-policy.js";
4
+ import { getRuntime } from "../langfuse.js";
5
+ import { startChildObservation } from "../observation.js";
6
+ import { getSessionRunState, state } from "../state.js";
7
+ import { getCapturePolicy, shapePayload } from "../utils.js";
8
+
9
+ type RecordLike = Record<string, unknown>;
10
+
11
+ function fingerprint(value: string): string {
12
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
13
+ }
14
+
15
+ function toolName(tool: unknown): string | undefined {
16
+ if (typeof tool === "string") return tool;
17
+ if (!tool || typeof tool !== "object") return undefined;
18
+ const name = (tool as RecordLike).name;
19
+ return typeof name === "string" && name ? name : undefined;
20
+ }
21
+
22
+ function sortedUnique(values: Iterable<string>): string[] {
23
+ return [...new Set(values)].sort();
24
+ }
25
+
26
+ function readNewSystemMessages(ctx: any): Array<{ entryId: string; message: RecordLike }> {
27
+ const session = getSessionRunState();
28
+ let branch: unknown;
29
+ try {
30
+ branch = ctx?.sessionManager?.getBranch?.();
31
+ } catch {
32
+ return [];
33
+ }
34
+ if (!Array.isArray(branch)) return [];
35
+
36
+ const messages: Array<{ entryId: string; message: RecordLike }> = [];
37
+ for (const rawEntry of branch) {
38
+ if (!rawEntry || typeof rawEntry !== "object") continue;
39
+ const entry = rawEntry as RecordLike;
40
+ const message = entry.message;
41
+ if (entry.type !== "message" || !message || typeof message !== "object") continue;
42
+ if ((message as RecordLike).role !== "system") continue;
43
+ const entryId = typeof entry.id === "string" ? entry.id : "";
44
+ if (!entryId || session.seenSystemEntryIds.has(entryId)) continue;
45
+ session.seenSystemEntryIds.add(entryId);
46
+ messages.push({ entryId, message: message as RecordLike });
47
+ }
48
+ return messages;
49
+ }
50
+
51
+ /** Mark restored transcript state as already seen so the first trace emits one snapshot, not the full history. */
52
+ export function initializeSystemStateTracking(ctx: any): void {
53
+ const session = getSessionRunState();
54
+ try {
55
+ const branch = ctx?.sessionManager?.getBranch?.();
56
+ if (!Array.isArray(branch)) return;
57
+ for (const rawEntry of branch) {
58
+ if (!rawEntry || typeof rawEntry !== "object") continue;
59
+ const entry = rawEntry as RecordLike;
60
+ const message = entry.message;
61
+ if (
62
+ entry.type === "message" &&
63
+ typeof entry.id === "string" &&
64
+ message &&
65
+ typeof message === "object" &&
66
+ (message as RecordLike).role === "system"
67
+ ) {
68
+ session.seenSystemEntryIds.add(entry.id);
69
+ }
70
+ }
71
+ } catch {
72
+ // Session history is optional in SDK/headless hosts.
73
+ }
74
+ }
75
+
76
+ export async function recordSystemState(ctx: any, activeTools: string[]): Promise<void> {
77
+ const agent = state.agentState;
78
+ if (state.isTracingDisabled || !agent?.root) return;
79
+
80
+ let prompt = "";
81
+ try {
82
+ prompt = String((await ctx?.getSystemPrompt?.()) ?? "");
83
+ } catch {
84
+ // The tool state is still useful when a host cannot expose the rendered prompt.
85
+ }
86
+
87
+ const tools = sortedUnique(activeTools);
88
+ const promptStateHash = prompt ? fingerprint(prompt) : undefined;
89
+ const toolStateHash = fingerprint(JSON.stringify(tools));
90
+ const session = getSessionRunState();
91
+ const systemMessages = readNewSystemMessages(ctx);
92
+
93
+ const transcriptToolsAdded: string[] = [];
94
+ const transcriptToolsRemoved: string[] = [];
95
+ const sectionsChanged = new Set<string>();
96
+ const sectionsRemoved = new Set<string>();
97
+ const entryIds: string[] = [];
98
+ for (const { entryId, message } of systemMessages) {
99
+ entryIds.push(entryId);
100
+ const sections = message.sections;
101
+ if (sections && typeof sections === "object" && !Array.isArray(sections)) {
102
+ for (const [name, value] of Object.entries(sections as RecordLike)) {
103
+ (value === null ? sectionsRemoved : sectionsChanged).add(name);
104
+ }
105
+ }
106
+ if (Array.isArray(message.toolsAdded)) {
107
+ for (const tool of message.toolsAdded) {
108
+ const name = toolName(tool);
109
+ if (name) transcriptToolsAdded.push(name);
110
+ }
111
+ }
112
+ if (Array.isArray(message.toolsRemoved)) {
113
+ for (const tool of message.toolsRemoved) {
114
+ const name = toolName(tool);
115
+ if (name) transcriptToolsRemoved.push(name);
116
+ }
117
+ }
118
+ }
119
+
120
+ const previousTools = new Set(session.lastActiveTools);
121
+ const currentTools = new Set(tools);
122
+ const toolsAdded = sortedUnique([
123
+ ...tools.filter((name) => !previousTools.has(name)),
124
+ ...transcriptToolsAdded,
125
+ ]);
126
+ const toolsRemoved = sortedUnique([
127
+ ...session.lastActiveTools.filter((name) => !currentTools.has(name)),
128
+ ...transcriptToolsRemoved,
129
+ ]);
130
+ const promptChanged = session.lastPromptStateHash !== promptStateHash;
131
+ const toolsChanged = session.lastToolStateHash !== toolStateHash;
132
+ const isInitial = session.lastPromptStateHash === undefined && session.lastToolStateHash === undefined;
133
+ const changed = isInitial || promptChanged || toolsChanged || systemMessages.length > 0;
134
+ if (changed) session.systemStateSequence++;
135
+
136
+ const capturePolicy = getCapturePolicy();
137
+ const rawSystemUpdate = systemMessages.length > 0
138
+ ? systemMessages.map(({ entryId, message }) => ({ entryId, ...message }))
139
+ : { role: "system", content: prompt };
140
+ const captured = applyCapturePolicy(
141
+ {
142
+ systemPrompt: shapePayload(rawSystemUpdate),
143
+ metadata: {
144
+ stateSequence: session.systemStateSequence,
145
+ changeKind: isInitial ? "initial" : changed ? "updated" : "unchanged",
146
+ source: systemMessages.length > 0 ? "transcript" : "effective-snapshot",
147
+ transcriptEntryIds: entryIds,
148
+ promptStateHash,
149
+ toolStateHash,
150
+ promptChars: prompt.length,
151
+ activeToolCount: tools.length,
152
+ activeTools: tools,
153
+ toolsAdded,
154
+ toolsRemoved,
155
+ sectionsChanged: sortedUnique(sectionsChanged),
156
+ sectionsRemoved: sortedUnique(sectionsRemoved),
157
+ },
158
+ },
159
+ capturePolicy,
160
+ );
161
+
162
+ try {
163
+ const observation = await startChildObservation({
164
+ parent: agent.activeAttempt ?? agent.root,
165
+ runtime: getRuntime,
166
+ name: "system-state",
167
+ body: {
168
+ input: captured.systemPrompt,
169
+ metadata: captured.metadata,
170
+ },
171
+ asType: "event",
172
+ });
173
+ observation.end();
174
+ } catch (error) {
175
+ console.warn("📊 Langfuse: Failed to record system state", error);
176
+ }
177
+
178
+ if (changed) agent.systemStateChangeCount = (agent.systemStateChangeCount ?? 0) + 1;
179
+ agent.promptStateHash = promptStateHash;
180
+ agent.toolStateHash = toolStateHash;
181
+ agent.systemStateSequence = session.systemStateSequence;
182
+ agent.activeToolCount = tools.length;
183
+ session.lastPromptStateHash = promptStateHash;
184
+ session.lastToolStateHash = toolStateHash;
185
+ session.lastActiveTools = tools;
186
+ }
@@ -37,7 +37,7 @@ export async function startToolObservation(event: Record<string, unknown>) {
37
37
  getCapturePolicy(),
38
38
  );
39
39
  const inputBytes = estimatePayloadBytes(captured.toolInput, getLimits().maxToolPayload);
40
- const parent = state.agentState.activeTurn ?? state.agentState.root;
40
+ const parent = state.agentState.activeTurn ?? state.agentState.activeAttempt ?? state.agentState.root;
41
41
  const tool = await startChildObservation({
42
42
  parent,
43
43
  runtime: getRuntime,
@@ -25,7 +25,7 @@ export async function startTurnObservation(event: Record<string, unknown>) {
25
25
  getCapturePolicy(),
26
26
  );
27
27
  const observation = await startChildObservation({
28
- parent: state.agentState.root,
28
+ parent: state.agentState.activeAttempt ?? state.agentState.root,
29
29
  runtime: getRuntime,
30
30
  name: "turn",
31
31
  body: {
package/src/langfuse.ts CHANGED
@@ -463,25 +463,73 @@ function observationType(asType?: string): FallbackObservationType {
463
463
  * `LangfuseSpanProcessor.onStart(span, parentContext)`, reading them from the
464
464
  * OTel context that was active when the span was created. `propagateAttributes`
465
465
  * only seeds that context for the duration of its callback, so a child created
466
- * later — from a separate event handler, outside the callback — is written with
467
- * an empty `session.id`.
466
+ * later — from a separate event handler, outside the callback — is written
467
+ * without `session.id`, `langfuse.trace.name`, or `langfuse.trace.metadata.*`.
468
468
  *
469
- * That is invisible in the legacy data model, where the session lives on the
470
- * trace, but Langfuse v4 stores `session_id` per event row and aggregates a
471
- * session with `WHERE session_id != ''`. Unstamped children drop out of
472
- * `sumMap(cost_details)` and `sumMap(usage_details)`, which is why such
473
- * sessions report their trace count correctly but no cost and no usage at all.
469
+ * That is invisible in the legacy data model, where those live on the trace,
470
+ * but Langfuse v4 stores them per event row and filters and aggregates over
471
+ * those rows: a session is `WHERE session_id != ''`, and cost by trace name
472
+ * matches `trace_name` on the generation itself. Unstamped children drop out
473
+ * of `sumMap(cost_details)` and `sumMap(usage_details)`, which is why such
474
+ * sessions and trace-name filters report a correct trace count but no cost
475
+ * and no usage at all.
474
476
  *
475
- * Re-entering the propagated context for every child keeps the whole tree in
476
- * the session. The id is read back off the parent span rather than from
477
- * `state.currentSessionId`, so a child created outside an active session scope
478
- * still inherits whatever its parent was actually stamped with.
477
+ * Re-entering the propagated context for every child keeps the whole tree
478
+ * queryable. The attributes are read back off the parent span rather than
479
+ * from state, so a child created outside an active session scope still
480
+ * inherits whatever its parent was actually stamped with, and grandchildren
481
+ * inherit transitively.
482
+ *
483
+ * The re-entry runs on the OTel root context, not the ambient one.
484
+ * `propagateAttributes` starts from `context.active()`, merges `metadata`
485
+ * and `tags` with whatever that context already carries, and stamps the
486
+ * active span if there is one. Any attributes propagated by the caller —
487
+ * another instrumentation, a foreign `propagateAttributes` scope — would
488
+ * otherwise leak into the child or onto an unrelated span. The parent is
489
+ * still explicit: `observation.startObservation` passes the parent span
490
+ * context to the child, so a clean ambient context changes nothing else.
479
491
  */
480
- const OTEL_SESSION_ID_ATTRIBUTE = "session.id";
492
+ type PropagatedAttributes = Parameters<LangfuseRuntime["propagateAttributes"]>[0];
493
+
494
+ const PROPAGATED_STRING_ATTRIBUTES = {
495
+ sessionId: "session.id",
496
+ userId: "user.id",
497
+ traceName: "langfuse.trace.name",
498
+ version: "langfuse.version",
499
+ } as const;
500
+ const OTEL_TRACE_TAGS_ATTRIBUTE = "langfuse.trace.tags";
501
+ const OTEL_TRACE_METADATA_PREFIX = "langfuse.trace.metadata.";
502
+
503
+ function readPropagatedAttributes(observation: any): PropagatedAttributes {
504
+ const attributes: Record<string, unknown> = observation?.otelSpan?.attributes ?? {};
505
+ const propagated: PropagatedAttributes = {};
506
+
507
+ for (const [key, attribute] of Object.entries(PROPAGATED_STRING_ATTRIBUTES)) {
508
+ const value = attributes[attribute];
509
+ if (typeof value === "string" && value) {
510
+ propagated[key as keyof typeof PROPAGATED_STRING_ATTRIBUTES] = value;
511
+ }
512
+ }
513
+
514
+ const tags = attributes[OTEL_TRACE_TAGS_ATTRIBUTE];
515
+ if (Array.isArray(tags)) {
516
+ const validTags = tags.filter((tag): tag is string => typeof tag === "string" && tag.length > 0);
517
+ if (validTags.length > 0) {
518
+ propagated.tags = validTags;
519
+ }
520
+ }
481
521
 
482
- function readSessionId(observation: any): string | undefined {
483
- const value = observation?.otelSpan?.attributes?.[OTEL_SESSION_ID_ATTRIBUTE];
484
- return typeof value === "string" && value ? value : undefined;
522
+ const metadata: Record<string, string> = {};
523
+ for (const [key, value] of Object.entries(attributes)) {
524
+ if (key.startsWith(OTEL_TRACE_METADATA_PREFIX) && typeof value === "string") {
525
+ metadata[key.slice(OTEL_TRACE_METADATA_PREFIX.length)] = value;
526
+ }
527
+ }
528
+ if (Object.keys(metadata).length > 0) {
529
+ propagated.metadata = metadata;
530
+ }
531
+
532
+ return propagated;
485
533
  }
486
534
 
487
535
  function wrapObservation(
@@ -543,12 +591,19 @@ function wrapObservation(
543
591
  return observation.end();
544
592
  },
545
593
  startObservation(childName: string, childBody?: Record<string, unknown>, options?: { asType?: string }) {
546
- const inheritedSessionId = readSessionId(observation) ?? state.currentSessionId ?? undefined;
594
+ const inherited = readPropagatedAttributes(observation);
595
+ const fallbackSessionId = state.currentSessionId?.slice(0, 200);
596
+ if (!inherited.sessionId && fallbackSessionId) {
597
+ inherited.sessionId = fallbackSessionId;
598
+ }
547
599
  const propagate = runtime?.propagateAttributes;
548
600
  const createChild = () => observation.startObservation(childName, childBody, options);
549
- const child = inheritedSessionId && propagate
550
- ? propagate({ sessionId: inheritedSessionId.slice(0, 200) }, createChild)
551
- : createChild();
601
+ const createInheritingChild = propagate && Object.keys(inherited).length > 0
602
+ ? () => propagate(inherited, createChild)
603
+ : createChild;
604
+ const child = runtime?.withRootContext
605
+ ? runtime.withRootContext(createInheritingChild)
606
+ : createInheritingChild();
552
607
  return wrapObservation(child, store, childName, childBody, options?.asType, id);
553
608
  },
554
609
  setTraceIO(traceBody?: { input?: unknown; output?: unknown }) {
@@ -777,7 +832,7 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
777
832
  const [
778
833
  { BasicTracerProvider },
779
834
  { resources },
780
- { context },
835
+ { context, ROOT_CONTEXT },
781
836
  { AsyncHooksContextManager },
782
837
  { LangfuseSpanProcessor },
783
838
  tracing,
@@ -822,6 +877,7 @@ export async function getRuntime(): Promise<LangfuseRuntime> {
822
877
  return wrapObservation(observation, restFallback, name, body, options?.asType);
823
878
  }) as unknown as LangfuseRuntime["startObservation"],
824
879
  propagateAttributes: tracing.propagateAttributes as unknown as LangfuseRuntime["propagateAttributes"],
880
+ withRootContext: (fn) => context.with(ROOT_CONTEXT, fn),
825
881
  scoreClient: new LangfuseClient({
826
882
  publicKey: state.config.publicKey,
827
883
  secretKey: state.config.secretKey,
@@ -11,7 +11,7 @@ export async function startChildObservation({
11
11
  runtime: () => Promise<LangfuseRuntime>;
12
12
  name: string;
13
13
  body?: ObservationUpdate;
14
- asType: "generation" | "tool" | "span";
14
+ asType: "event" | "generation" | "tool" | "span";
15
15
  }): Promise<LangfuseObservation> {
16
16
  if (parent.startObservation) {
17
17
  return parent.startObservation(name, body, { asType });
package/src/state.ts CHANGED
@@ -10,6 +10,12 @@ export interface SessionRunState {
10
10
  turnCount: number;
11
11
  tracingDisabled: boolean;
12
12
  setupAttemptedThisSession: boolean;
13
+ lastPromptStateHash?: string;
14
+ lastToolStateHash?: string;
15
+ lastActiveTools: string[];
16
+ systemStateSequence: number;
17
+ seenSystemEntryIds: Set<string>;
18
+ seenUsageEntryIds: Set<string>;
13
19
  }
14
20
 
15
21
  const DEFAULT_SESSION_ID = "__pi_langfuse_default_session__";
@@ -27,6 +33,10 @@ function createSessionRunState(): SessionRunState {
27
33
  turnCount: 0,
28
34
  tracingDisabled: false,
29
35
  setupAttemptedThisSession: false,
36
+ lastActiveTools: [],
37
+ systemStateSequence: 0,
38
+ seenSystemEntryIds: new Set(),
39
+ seenUsageEntryIds: new Set(),
30
40
  };
31
41
  }
32
42
 
@@ -130,11 +140,16 @@ export const state = {
130
140
 
131
141
  export function resetRunState(sessionId = getActiveSessionId()) {
132
142
  const normalizedSessionId = normalizeSessionId(sessionId);
133
- const setupAttemptedThisSession =
134
- state.sessionStates.get(normalizedSessionId)?.setupAttemptedThisSession ?? false;
143
+ const previous = state.sessionStates.get(normalizedSessionId);
135
144
  state.sessionStates.set(normalizedSessionId, {
136
145
  ...createSessionRunState(),
137
- setupAttemptedThisSession,
146
+ setupAttemptedThisSession: previous?.setupAttemptedThisSession ?? false,
147
+ lastPromptStateHash: previous?.lastPromptStateHash,
148
+ lastToolStateHash: previous?.lastToolStateHash,
149
+ lastActiveTools: previous?.lastActiveTools ?? [],
150
+ systemStateSequence: previous?.systemStateSequence ?? 0,
151
+ seenSystemEntryIds: previous?.seenSystemEntryIds ?? new Set(),
152
+ seenUsageEntryIds: previous?.seenUsageEntryIds ?? new Set(),
138
153
  });
139
154
  }
140
155
 
package/src/types.ts CHANGED
@@ -19,7 +19,7 @@ export interface LangfuseObservation {
19
19
  startObservation?(
20
20
  name: string,
21
21
  body?: ObservationUpdate,
22
- options?: { asType?: "agent" | "generation" | "tool" | "span" },
22
+ options?: { asType?: "agent" | "event" | "generation" | "tool" | "span" },
23
23
  ): LangfuseObservation;
24
24
  setTraceIO?(body?: { input?: unknown; output?: unknown }): void;
25
25
  }
@@ -82,17 +82,25 @@ export interface LangfuseRuntime {
82
82
  startObservation: (
83
83
  name: string,
84
84
  body?: ObservationUpdate,
85
- options?: { asType?: "agent" | "generation" | "tool" | "span" },
85
+ options?: { asType?: "agent" | "event" | "generation" | "tool" | "span" },
86
86
  ) => LangfuseObservation;
87
87
  propagateAttributes: (
88
88
  params: {
89
89
  sessionId?: string;
90
+ userId?: string;
90
91
  traceName?: string;
92
+ version?: string;
91
93
  metadata?: Record<string, string>;
92
94
  tags?: string[];
93
95
  },
94
96
  fn: () => LangfuseObservation,
95
97
  ) => LangfuseObservation;
98
+ /**
99
+ * Runs `fn` on the OTel root context, so the observations it creates take
100
+ * their propagated attributes only from what is passed explicitly and not
101
+ * from whatever the caller's ambient context happens to carry.
102
+ */
103
+ withRootContext?: <T>(fn: () => T) => T;
96
104
  scoreClient: LangfuseScoreClient;
97
105
  spanProcessor?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };
98
106
  tracerProvider?: { forceFlush?: () => Promise<void>; shutdown?: () => Promise<void> };
@@ -128,6 +136,7 @@ export interface ToolState {
128
136
 
129
137
  export interface AgentState {
130
138
  root?: LangfuseObservation;
139
+ activeAttempt?: LangfuseObservation;
131
140
  activeTurn?: LangfuseObservation;
132
141
  traceId?: string;
133
142
  promptInput?: unknown;
@@ -139,4 +148,14 @@ export interface AgentState {
139
148
  latestAssistantOutput?: unknown;
140
149
  sourceMetadata?: Record<string, unknown>;
141
150
  providerMetadataByRequest: Map<string, Record<string, unknown>>;
151
+ attemptCount: number;
152
+ lastAgentEndEvent?: Record<string, unknown>;
153
+ promptStateHash?: string;
154
+ toolStateHash?: string;
155
+ systemStateSequence?: number;
156
+ activeToolCount?: number;
157
+ systemStateChangeCount: number;
158
+ cacheReadTokens: number;
159
+ cacheWriteTokens: number;
160
+ uncachedInputTokens: number;
142
161
  }
package/src/utils.ts CHANGED
@@ -334,6 +334,55 @@ export function getProviderPayload(event: Record<string, unknown>): unknown {
334
334
  return event.request ?? event.payload ?? event.body ?? event.providerPayload ?? event.messages ?? event;
335
335
  }
336
336
 
337
+ export type ToolUpdateTransport =
338
+ | "anthropic-native"
339
+ | "openai-additional-tools"
340
+ | "openai-tool-search"
341
+ | "mid-conversation-system"
342
+ | "collapsed-leading-system";
343
+
344
+ /** Classify the provider payload observed by this hook rather than guessing from the model name alone. */
345
+ export function inferToolUpdateTransport(payload: unknown, hasPriorSystemState: boolean): ToolUpdateTransport | undefined {
346
+ let sawToolChangeBlock = false;
347
+ let sawAdditionalTools = false;
348
+ let sawToolSearch = false;
349
+ let sawTopLevelTools = false;
350
+ let sawMidConversationSystem = false;
351
+ let visited = 0;
352
+
353
+ const walk = (value: unknown, key?: string) => {
354
+ if (++visited > 2_000 || value === null || value === undefined) return;
355
+ if (key === "additional_tools") sawAdditionalTools = true;
356
+ if (key === "tools") sawTopLevelTools = true;
357
+ if (key === "tool_search" || key === "tool_search_output") sawToolSearch = true;
358
+ if (typeof value !== "object") return;
359
+ if (Array.isArray(value)) {
360
+ let sawNonSystem = false;
361
+ for (const item of value) {
362
+ if (item && typeof item === "object" && !Array.isArray(item)) {
363
+ const role = (item as Record<string, unknown>).role;
364
+ if (role === "system" && sawNonSystem) sawMidConversationSystem = true;
365
+ if (role && role !== "system") sawNonSystem = true;
366
+ }
367
+ walk(item);
368
+ }
369
+ return;
370
+ }
371
+ const record = value as Record<string, unknown>;
372
+ const type = record.type;
373
+ if (type === "tool_addition" || type === "tool_removal") sawToolChangeBlock = true;
374
+ for (const [childKey, child] of Object.entries(record)) walk(child, childKey);
375
+ };
376
+
377
+ walk(payload);
378
+ if (sawToolChangeBlock) return "anthropic-native";
379
+ if (sawAdditionalTools) return "openai-additional-tools";
380
+ if (sawToolSearch) return "openai-tool-search";
381
+ if (sawMidConversationSystem) return "mid-conversation-system";
382
+ if (hasPriorSystemState && sawTopLevelTools) return "collapsed-leading-system";
383
+ return undefined;
384
+ }
385
+
337
386
  export function extractModelParameters(payload: unknown): Record<string, string | number> | undefined {
338
387
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
339
388
  return undefined;
@@ -487,6 +536,32 @@ export function extractUsage(
487
536
  };
488
537
  }
489
538
 
539
+ export function extractCacheMetrics(messageOrEvent: Record<string, unknown>): {
540
+ cacheReadTokens: number;
541
+ cacheWriteTokens: number;
542
+ uncachedInputTokens: number;
543
+ cacheHitRatio?: number;
544
+ } | undefined {
545
+ const usage = (messageOrEvent.usage ??
546
+ (messageOrEvent.message && typeof messageOrEvent.message === "object"
547
+ ? (messageOrEvent.message as Record<string, unknown>).usage
548
+ : undefined)) as Record<string, unknown> | undefined;
549
+ if (!usage || typeof usage !== "object") return undefined;
550
+
551
+ const uncachedInputTokens = Number(
552
+ usage.input ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens ?? 0,
553
+ );
554
+ const cacheReadTokens = Number(usage.cacheRead ?? usage.cache_read ?? usage.cachedTokens ?? 0);
555
+ const cacheWriteTokens = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
556
+ const denominator = uncachedInputTokens + cacheReadTokens;
557
+ return {
558
+ cacheReadTokens,
559
+ cacheWriteTokens,
560
+ uncachedInputTokens,
561
+ ...(denominator > 0 ? { cacheHitRatio: cacheReadTokens / denominator } : {}),
562
+ };
563
+ }
564
+
490
565
  export function extractCostDetails(messageOrEvent: Record<string, unknown>): Record<string, number> | undefined {
491
566
  const usage = (messageOrEvent.usage ??
492
567
  (messageOrEvent.message && typeof messageOrEvent.message === "object"
@@ -1,49 +0,0 @@
1
- declare module "@opentelemetry/sdk-trace-base" {
2
- export class BasicTracerProvider {
3
- constructor(options?: { resource?: unknown; spanProcessors?: unknown[] });
4
- forceFlush?(): Promise<void>;
5
- shutdown?(): Promise<void>;
6
- }
7
- }
8
-
9
- declare module "@langfuse/otel" {
10
- export class LangfuseSpanProcessor {
11
- constructor(options: {
12
- publicKey: string;
13
- secretKey: string;
14
- baseUrl: string;
15
- });
16
- forceFlush?(): Promise<void>;
17
- shutdown?(): Promise<void>;
18
- }
19
- }
20
-
21
- declare module "@langfuse/tracing" {
22
- export function setLangfuseTracerProvider(provider: unknown): void;
23
-
24
- export function startObservation(
25
- name: string,
26
- body?: Record<string, unknown>,
27
- options?: { asType?: string },
28
- ): unknown;
29
-
30
- export function propagateAttributes<T>(
31
- params: {
32
- sessionId?: string;
33
- traceName?: string;
34
- metadata?: Record<string, string>;
35
- tags?: string[];
36
- },
37
- fn: () => T,
38
- ): T;
39
- }
40
-
41
- declare module "@langfuse/client" {
42
- export class LangfuseClient {
43
- constructor(options: {
44
- publicKey: string;
45
- secretKey: string;
46
- baseUrl: string;
47
- });
48
- }
49
- }