@librechat/agents 3.3.7 → 3.3.9

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 (168) hide show
  1. package/dist/cjs/graphs/Graph.cjs +47 -13
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +56 -6
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
  6. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  7. package/dist/cjs/instrumentation.cjs +18 -48
  8. package/dist/cjs/instrumentation.cjs.map +1 -1
  9. package/dist/cjs/langfuse.cjs +174 -29
  10. package/dist/cjs/langfuse.cjs.map +1 -1
  11. package/dist/cjs/langfuseConfig.cjs +12 -0
  12. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  13. package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
  14. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  15. package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
  16. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  17. package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
  18. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
  19. package/dist/cjs/langfuseTraceShaping.cjs +121 -4
  20. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  21. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
  22. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  23. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
  24. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  25. package/dist/cjs/llm/init.cjs +3 -3
  26. package/dist/cjs/llm/invoke.cjs +5 -5
  27. package/dist/cjs/llm/openai/index.cjs +1 -1
  28. package/dist/cjs/main.cjs +10 -10
  29. package/dist/cjs/messages/format.cjs +124 -15
  30. package/dist/cjs/messages/format.cjs.map +1 -1
  31. package/dist/cjs/messages/injected.cjs +10 -1
  32. package/dist/cjs/messages/injected.cjs.map +1 -1
  33. package/dist/cjs/messages/prune.cjs +13 -1
  34. package/dist/cjs/messages/prune.cjs.map +1 -1
  35. package/dist/cjs/prompts/activityLabel.cjs +51 -11
  36. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  37. package/dist/cjs/run.cjs +54 -24
  38. package/dist/cjs/run.cjs.map +1 -1
  39. package/dist/cjs/session/messageSerialization.cjs +6 -0
  40. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  41. package/dist/cjs/stream.cjs +21 -10
  42. package/dist/cjs/stream.cjs.map +1 -1
  43. package/dist/cjs/summarization/node.cjs +60 -0
  44. package/dist/cjs/summarization/node.cjs.map +1 -1
  45. package/dist/cjs/tools/ToolNode.cjs +253 -24
  46. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  47. package/dist/cjs/tools/handlers.cjs +1 -1
  48. package/dist/cjs/tools/search/tool.cjs +1 -1
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  50. package/dist/cjs/utils/index.cjs +2 -2
  51. package/dist/esm/graphs/Graph.mjs +48 -14
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs +56 -6
  54. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  55. package/dist/esm/hitl/askUserQuestion.mjs +3 -2
  56. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  57. package/dist/esm/instrumentation.mjs +18 -48
  58. package/dist/esm/instrumentation.mjs.map +1 -1
  59. package/dist/esm/langfuse.mjs +176 -28
  60. package/dist/esm/langfuse.mjs.map +1 -1
  61. package/dist/esm/langfuseConfig.mjs +10 -1
  62. package/dist/esm/langfuseConfig.mjs.map +1 -1
  63. package/dist/esm/langfuseRuntimeContext.mjs +21 -3
  64. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  65. package/dist/esm/langfuseRuntimeScope.mjs +39 -10
  66. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  67. package/dist/esm/langfuseSpanRegistry.mjs +91 -0
  68. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
  69. package/dist/esm/langfuseTraceShaping.mjs +121 -4
  70. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  71. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
  72. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  73. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
  74. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  75. package/dist/esm/llm/init.mjs +2 -2
  76. package/dist/esm/llm/invoke.mjs +5 -5
  77. package/dist/esm/llm/openai/index.mjs +1 -1
  78. package/dist/esm/main.mjs +8 -8
  79. package/dist/esm/messages/format.mjs +124 -15
  80. package/dist/esm/messages/format.mjs.map +1 -1
  81. package/dist/esm/messages/injected.mjs +10 -1
  82. package/dist/esm/messages/injected.mjs.map +1 -1
  83. package/dist/esm/messages/prune.mjs +13 -1
  84. package/dist/esm/messages/prune.mjs.map +1 -1
  85. package/dist/esm/prompts/activityLabel.mjs +51 -11
  86. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  87. package/dist/esm/run.mjs +54 -24
  88. package/dist/esm/run.mjs.map +1 -1
  89. package/dist/esm/session/messageSerialization.mjs +6 -0
  90. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  91. package/dist/esm/stream.mjs +21 -10
  92. package/dist/esm/stream.mjs.map +1 -1
  93. package/dist/esm/summarization/node.mjs +60 -0
  94. package/dist/esm/summarization/node.mjs.map +1 -1
  95. package/dist/esm/tools/ToolNode.mjs +254 -25
  96. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  97. package/dist/esm/tools/handlers.mjs +1 -1
  98. package/dist/esm/tools/search/tool.mjs +1 -1
  99. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  100. package/dist/esm/utils/index.mjs +2 -2
  101. package/dist/types/graphs/Graph.d.ts +19 -0
  102. package/dist/types/hitl/askUserQuestion.d.ts +11 -1
  103. package/dist/types/langfuse.d.ts +16 -8
  104. package/dist/types/langfuseConfig.d.ts +6 -0
  105. package/dist/types/langfuseRuntimeContext.d.ts +27 -1
  106. package/dist/types/langfuseRuntimeScope.d.ts +17 -2
  107. package/dist/types/langfuseSpanRegistry.d.ts +17 -0
  108. package/dist/types/langfuseTraceShaping.d.ts +2 -1
  109. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
  110. package/dist/types/messages/format.d.ts +9 -8
  111. package/dist/types/prompts/activityLabel.d.ts +8 -1
  112. package/dist/types/run.d.ts +1 -1
  113. package/dist/types/session/types.d.ts +1 -0
  114. package/dist/types/tools/ToolNode.d.ts +7 -1
  115. package/dist/types/types/activityLabel.d.ts +8 -0
  116. package/dist/types/types/hitl.d.ts +8 -0
  117. package/dist/types/types/stream.d.ts +19 -0
  118. package/dist/types/types/tools.d.ts +30 -0
  119. package/package.json +7 -4
  120. package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
  121. package/src/graphs/Graph.ts +69 -20
  122. package/src/graphs/MultiAgentGraph.ts +74 -6
  123. package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
  124. package/src/hitl/askUserQuestion.ts +14 -1
  125. package/src/instrumentation.ts +35 -77
  126. package/src/langfuse.ts +320 -43
  127. package/src/langfuseConfig.ts +24 -0
  128. package/src/langfuseRuntimeContext.ts +43 -1
  129. package/src/langfuseRuntimeScope.ts +94 -21
  130. package/src/langfuseSpanRegistry.ts +131 -0
  131. package/src/langfuseTraceShaping.ts +194 -7
  132. package/src/llm/anthropic/utils/message_inputs.ts +70 -19
  133. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
  134. package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
  135. package/src/llm/bedrock/utils/message_inputs.ts +32 -7
  136. package/src/messages/format.ts +222 -50
  137. package/src/messages/formatAgentMessages.test.ts +308 -6
  138. package/src/messages/injected.test.ts +18 -1
  139. package/src/messages/injected.ts +8 -1
  140. package/src/messages/prune.ts +12 -1
  141. package/src/prompts/activityLabel.ts +67 -2
  142. package/src/run.ts +86 -46
  143. package/src/scripts/activity-labels/captured.json +56 -0
  144. package/src/scripts/activity-labels/checks.cjs +205 -0
  145. package/src/scripts/activity-labels/corpus.cjs +473 -0
  146. package/src/scripts/activity-labels/report.cjs +203 -0
  147. package/src/scripts/activity-labels/rescore.cjs +102 -0
  148. package/src/scripts/activity-labels/run.ts +705 -0
  149. package/src/scripts/activity-labels/variants.ts +71 -0
  150. package/src/session/messageSerialization.ts +12 -1
  151. package/src/session/types.ts +1 -0
  152. package/src/specs/activity-label-prompt.test.ts +109 -0
  153. package/src/specs/agent-handoffs.test.ts +306 -0
  154. package/src/specs/langfuse-callbacks.test.ts +456 -0
  155. package/src/specs/langfuse-routing.integration.test.ts +138 -1
  156. package/src/specs/langfuse-span-registry.test.ts +70 -0
  157. package/src/specs/langfuse-trace-shaping.test.ts +294 -0
  158. package/src/specs/prune.test.ts +38 -1
  159. package/src/stream.ts +70 -6
  160. package/src/summarization/__tests__/node.test.ts +188 -0
  161. package/src/summarization/node.ts +72 -0
  162. package/src/tools/ToolNode.ts +400 -9
  163. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
  164. package/src/tools/__tests__/hitl.test.ts +58 -0
  165. package/src/types/activityLabel.ts +8 -0
  166. package/src/types/hitl.ts +8 -0
  167. package/src/types/stream.ts +20 -0
  168. package/src/types/tools.ts +35 -1
@@ -8,8 +8,11 @@ import type * as t from '@/types';
8
8
  import {
9
9
  getLangfuseRuntimeConfig,
10
10
  getLangfuseRuntimeToolOutputTracingConfig,
11
+ getLangfuseScopeAgentId,
12
+ getLangfuseScopeRunId,
11
13
  getTraceIdSeed,
12
14
  hasLangfuseRuntimeContextValue,
15
+ replaceLangfuseRuntimeContext,
13
16
  runWithLangfuseRuntimeContext,
14
17
  } from '@/langfuseRuntimeContext';
15
18
  import {
@@ -24,6 +27,16 @@ export type ResolveLangfuseRuntimeScopeParams = {
24
27
  runLangfuse?: t.LangfuseConfig;
25
28
  langfuseOverlay?: t.LangfuseConfig;
26
29
  traceIdSeed?: string;
30
+ runId?: string;
31
+ agentId?: string;
32
+ };
33
+
34
+ export type LangfuseRuntimeScopeOptions = {
35
+ /** Replace the surrounding scope entirely instead of merging with it:
36
+ * fields absent from `scope` are CLEARED rather than inherited. Required
37
+ * when rejecting a foreign concurrent run's scope, whose explicit values
38
+ * must not survive merge inheritance. */
39
+ replace?: boolean;
27
40
  };
28
41
 
29
42
  const langfuseToolOutputTracingConfigKey = createContextKey(
@@ -33,6 +46,8 @@ const langfuseConfigKey = createContextKey('librechat.langfuse.config');
33
46
  const langfuseTraceIdSeedKey = createContextKey(
34
47
  'librechat.langfuse.trace-id-seed'
35
48
  );
49
+ const langfuseScopeRunIdKey = createContextKey('librechat.langfuse.run-id');
50
+ const langfuseScopeAgentIdKey = createContextKey('librechat.langfuse.agent-id');
36
51
 
37
52
  function isRecord(value: unknown): value is Record<string, unknown> {
38
53
  return value != null && typeof value === 'object' && !Array.isArray(value);
@@ -75,6 +90,32 @@ export function resolveTraceIdSeedForSpan(
75
90
  return getTraceIdSeed() ?? getOtelTraceIdSeed(activeContext);
76
91
  }
77
92
 
93
+ function getOtelScopeRunId(activeContext: Context): string | undefined {
94
+ const value = activeContext.getValue(langfuseScopeRunIdKey);
95
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
96
+ }
97
+
98
+ function getOtelScopeAgentId(activeContext: Context): string | undefined {
99
+ const value = activeContext.getValue(langfuseScopeAgentIdKey);
100
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
101
+ }
102
+
103
+ /** The run identity the active scope belongs to (see
104
+ * `LangfuseRuntimeContext.runId`), or `undefined` for unstamped scopes. */
105
+ export function resolveLangfuseScopeRunId(
106
+ activeContext: Context
107
+ ): string | undefined {
108
+ return getLangfuseScopeRunId() ?? getOtelScopeRunId(activeContext);
109
+ }
110
+
111
+ /** The agent identity of a per-agent overlay scope (see
112
+ * `LangfuseRuntimeContext.agentId`), or `undefined` for run-level scopes. */
113
+ export function resolveLangfuseScopeAgentId(
114
+ activeContext: Context
115
+ ): string | undefined {
116
+ return getLangfuseScopeAgentId() ?? getOtelScopeAgentId(activeContext);
117
+ }
118
+
78
119
  export function resolveToolOutputTracingConfigForSpan(
79
120
  activeContext: Context
80
121
  ): ResolvedLangfuseToolOutputTracingConfig | undefined {
@@ -84,45 +125,77 @@ export function resolveToolOutputTracingConfigForSpan(
84
125
  );
85
126
  }
86
127
 
128
+ function setOrClearContextValue(
129
+ activeContext: Context,
130
+ key: Parameters<Context['setValue']>[0],
131
+ value: unknown,
132
+ replace: boolean
133
+ ): Context {
134
+ if (value != null) {
135
+ return activeContext.setValue(key, value);
136
+ }
137
+ return replace ? activeContext.deleteValue(key) : activeContext;
138
+ }
139
+
87
140
  export function withLangfuseRuntimeScope<T>(
88
141
  scope: LangfuseRuntimeScope,
89
- action: () => T
142
+ action: () => T,
143
+ options?: LangfuseRuntimeScopeOptions
90
144
  ): T {
91
- if (!hasLangfuseRuntimeContextValue(scope)) {
145
+ const replace = options?.replace === true;
146
+ if (!replace && !hasLangfuseRuntimeContextValue(scope)) {
92
147
  return action();
93
148
  }
94
149
 
95
150
  let activeContext = context.active();
96
- if (scope.langfuse != null) {
97
- activeContext = activeContext.setValue(langfuseConfigKey, scope.langfuse);
98
- }
99
- if (scope.toolOutputTracing != null) {
100
- activeContext = activeContext.setValue(
101
- langfuseToolOutputTracingConfigKey,
102
- scope.toolOutputTracing
103
- );
104
- }
105
- if (hasText(scope.traceIdSeed)) {
106
- activeContext = activeContext.setValue(
107
- langfuseTraceIdSeedKey,
108
- scope.traceIdSeed
109
- );
110
- }
151
+ activeContext = setOrClearContextValue(
152
+ activeContext,
153
+ langfuseConfigKey,
154
+ scope.langfuse,
155
+ replace
156
+ );
157
+ activeContext = setOrClearContextValue(
158
+ activeContext,
159
+ langfuseToolOutputTracingConfigKey,
160
+ scope.toolOutputTracing,
161
+ replace
162
+ );
163
+ activeContext = setOrClearContextValue(
164
+ activeContext,
165
+ langfuseTraceIdSeedKey,
166
+ hasText(scope.traceIdSeed) ? scope.traceIdSeed : undefined,
167
+ replace
168
+ );
169
+ activeContext = setOrClearContextValue(
170
+ activeContext,
171
+ langfuseScopeRunIdKey,
172
+ hasText(scope.runId) ? scope.runId : undefined,
173
+ replace
174
+ );
175
+ activeContext = setOrClearContextValue(
176
+ activeContext,
177
+ langfuseScopeAgentIdKey,
178
+ hasText(scope.agentId) ? scope.agentId : undefined,
179
+ replace
180
+ );
111
181
 
112
182
  // Span processors receive the OTel parent context in `onStart`, while
113
183
  // LangChain callback handlers may run outside that context and need ALS.
114
184
  // The trace id generator reads the seed from ALS or OTel context so SDK
115
185
  // callbacks that preserve only one of those contexts still keep trace/score
116
186
  // cohesion.
117
- return runWithLangfuseRuntimeContext(scope, () =>
118
- context.with(activeContext, action)
119
- );
187
+ const runScoped = replace
188
+ ? replaceLangfuseRuntimeContext
189
+ : runWithLangfuseRuntimeContext;
190
+ return runScoped(scope, () => context.with(activeContext, action));
120
191
  }
121
192
 
122
193
  export function resolveLangfuseRuntimeScope({
123
194
  runLangfuse,
124
195
  langfuseOverlay,
125
196
  traceIdSeed,
197
+ runId,
198
+ agentId,
126
199
  }: ResolveLangfuseRuntimeScopeParams): LangfuseRuntimeScope {
127
200
  const langfuse = resolveLangfuseConfig(runLangfuse, langfuseOverlay);
128
201
  const toolOutputTracing = !hasToolOutputTracingConfig(
@@ -131,5 +204,5 @@ export function resolveLangfuseRuntimeScope({
131
204
  )
132
205
  ? undefined
133
206
  : resolveToolOutputTracingConfig(runLangfuse, langfuseOverlay);
134
- return { langfuse, traceIdSeed, toolOutputTracing };
207
+ return { langfuse, traceIdSeed, toolOutputTracing, runId, agentId };
135
208
  }
@@ -0,0 +1,131 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { LangfuseSpanProcessorParams } from '@langfuse/otel';
3
+ import type { Span } from '@opentelemetry/api';
4
+ import type * as t from '@/types';
5
+ import {
6
+ hasLangfuseConfigCredentials,
7
+ hasLangfuseEnvCredentials,
8
+ hasLangfuseEnvConfig,
9
+ } from '@/langfuseConfig';
10
+ import { isPresent } from '@/utils/misc';
11
+
12
+ /**
13
+ * Spans created through the Langfuse tracer provider, keyed by the export
14
+ * destination they were routed to. Callback handlers use this to distinguish
15
+ * safe ambient parents from spans a root observation must not inherit:
16
+ *
17
+ * - Foreign spans (a host's own OpenTelemetry instrumentation, e.g. HTTP
18
+ * server spans on the global provider) are never exported to Langfuse —
19
+ * inheriting one orphans the trace root (its root/trace input-output
20
+ * shaping is skipped), collapses concurrent runs inside one request
21
+ * context into a single merged trace, and bypasses the seeded
22
+ * deterministic trace id generator.
23
+ * - Langfuse-managed spans bound to a *different* destination (another
24
+ * tenant's project) would leave the new trace dangling in its own
25
+ * destination while inheriting the other tenant's trace id.
26
+ *
27
+ * Only a managed span whose destination matches the starting run's resolved
28
+ * destination is a safe parent — that is the sanctioned way for hosts to
29
+ * group runs under their own Langfuse observations.
30
+ */
31
+ const managedSpanDestinations = new WeakMap<Span, string>();
32
+
33
+ export function registerLangfuseManagedSpan(
34
+ span: Span,
35
+ destinationKey: string
36
+ ): void {
37
+ managedSpanDestinations.set(span, destinationKey);
38
+ }
39
+
40
+ export function getLangfuseManagedSpanDestination(
41
+ span: Span
42
+ ): string | undefined {
43
+ return managedSpanDestinations.get(span);
44
+ }
45
+
46
+ function resolveLangfuseEnvironment(
47
+ langfuse?: t.LangfuseConfig
48
+ ): string | undefined {
49
+ const candidates = [
50
+ langfuse?.environment,
51
+ process.env.LANGFUSE_TRACING_ENVIRONMENT,
52
+ process.env.NODE_ENV,
53
+ ];
54
+ for (const candidate of candidates) {
55
+ if (candidate != null && candidate.trim() !== '') {
56
+ return candidate.trim();
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ export function getLangfuseSpanProcessorParams(
63
+ langfuse?: t.LangfuseConfig
64
+ ): LangfuseSpanProcessorParams | undefined {
65
+ if (langfuse?.enabled === false) {
66
+ return undefined;
67
+ }
68
+ const environment = resolveLangfuseEnvironment(langfuse);
69
+ if (hasLangfuseConfigCredentials(langfuse)) {
70
+ return {
71
+ publicKey: langfuse.publicKey,
72
+ secretKey: langfuse.secretKey,
73
+ ...(isPresent(langfuse.baseUrl) ? { baseUrl: langfuse.baseUrl } : {}),
74
+ ...(isPresent(environment) ? { environment } : {}),
75
+ };
76
+ }
77
+ if (hasLangfuseEnvConfig()) {
78
+ const baseUrl =
79
+ langfuse?.baseUrl ??
80
+ process.env.LANGFUSE_BASE_URL ??
81
+ process.env.LANGFUSE_BASEURL;
82
+ return {
83
+ publicKey: process.env.LANGFUSE_PUBLIC_KEY as string,
84
+ secretKey: process.env.LANGFUSE_SECRET_KEY as string,
85
+ ...(isPresent(baseUrl) ? { baseUrl } : {}),
86
+ ...(isPresent(environment) ? { environment } : {}),
87
+ };
88
+ }
89
+ if (isPresent(langfuse?.baseUrl) && hasLangfuseEnvCredentials()) {
90
+ return {
91
+ publicKey: process.env.LANGFUSE_PUBLIC_KEY as string,
92
+ secretKey: process.env.LANGFUSE_SECRET_KEY as string,
93
+ baseUrl: langfuse.baseUrl,
94
+ ...(isPresent(environment) ? { environment } : {}),
95
+ };
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ function hashCacheKeyValue(value: string | undefined): string | undefined {
101
+ return isPresent(value)
102
+ ? createHash('sha256').update(value, 'utf8').digest('hex')
103
+ : undefined;
104
+ }
105
+
106
+ /**
107
+ * Identity of an export destination (project credentials + endpoint +
108
+ * environment) only. Processor-level policies like `toolOutputTracing` are
109
+ * deliberately excluded: two spans exporting to the same project under
110
+ * different redaction settings still share a destination and may parent one
111
+ * another.
112
+ */
113
+ export function getLangfuseDestinationKey(
114
+ params: LangfuseSpanProcessorParams
115
+ ): string {
116
+ return JSON.stringify({
117
+ publicKey: params.publicKey,
118
+ secretKeyHash: hashCacheKeyValue(params.secretKey),
119
+ baseUrl: params.baseUrl,
120
+ environment: params.environment,
121
+ });
122
+ }
123
+
124
+ /** The export destination a run with this config resolves to, or `undefined`
125
+ * when no Langfuse destination is configured. */
126
+ export function resolveLangfuseDestinationKey(
127
+ langfuse?: t.LangfuseConfig
128
+ ): string | undefined {
129
+ const params = getLangfuseSpanProcessorParams(langfuse);
130
+ return params == null ? undefined : getLangfuseDestinationKey(params);
131
+ }
@@ -1,5 +1,6 @@
1
1
  import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
2
2
  import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
3
+ import { Constants } from '@/common';
3
4
 
4
5
  const LANGGRAPH_START_NODE = '__start__';
5
6
  const ANONYMOUS_LAMBDA_NAME = 'RunnableLambda';
@@ -10,8 +11,12 @@ const TOOL_DISPATCH_SPAN_NAME = 'tool-dispatch';
10
11
  const GENERATION_SPAN_NAME = 'llm';
11
12
  const ROOT_OBSERVATION_TYPE = 'agent';
12
13
  const CHAIN_OBSERVATION_TYPE = 'chain';
14
+ const TOOL_OBSERVATION_TYPE = 'tool';
13
15
  const AGENT_TRACE_TAG = 'agent';
14
16
  const TITLE_TRACE_TAG = 'title';
17
+ const EPHEMERAL_AGENT_SENDER_SEPARATOR = '___';
18
+ const EPHEMERAL_AGENT_INDEX_SEPARATOR = '____';
19
+ const OBSERVATION_METADATA_LANGGRAPH_NODE = `${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langgraph_node`;
15
20
 
16
21
  type MutableSpan = ReadableSpan & {
17
22
  name: string;
@@ -175,17 +180,112 @@ function getMessageToolCalls(
175
180
  return calls;
176
181
  }
177
182
 
183
+ /**
184
+ * Id-bearing `invalid_tool_calls` on the assistant turn. ToolNode pairs these
185
+ * with synthesized error results (and an invalid-only turn is routed on them
186
+ * alone), so the tool-dispatch span must count them as part of the executing
187
+ * batch — otherwise an invalid-only dispatch finds zero calls and the span
188
+ * keeps the full serialized graph state as its input, and a mixed dispatch
189
+ * silently omits the malformed call. `args` stays the raw unparsed string.
190
+ */
191
+ /** Tool-result ids present in the serialized state — ToolNode's
192
+ * `!toolMessageIds.has(id)` execution filter, mirrored for the span. */
193
+ function getToolResultIds(
194
+ messages: Record<string, unknown>[]
195
+ ): Set<string> {
196
+ const ids = new Set<string>();
197
+ for (const message of messages) {
198
+ if (getMessageRole(message) !== 'tool') {
199
+ continue;
200
+ }
201
+ const rawId =
202
+ message.tool_call_id ??
203
+ (isRecord(message.kwargs) ? message.kwargs.tool_call_id : undefined) ??
204
+ (isRecord(message.data) ? message.data.tool_call_id : undefined);
205
+ if (typeof rawId === 'string' && rawId !== '') {
206
+ ids.add(rawId);
207
+ }
208
+ }
209
+ return ids;
210
+ }
211
+
212
+ function getMessageInvalidToolCalls(
213
+ message: Record<string, unknown>,
214
+ answeredIds: ReadonlySet<string>
215
+ ): SerializedToolCall[] {
216
+ const rawCalls =
217
+ message.invalid_tool_calls ??
218
+ (isRecord(message.kwargs) ? message.kwargs.invalid_tool_calls : undefined) ??
219
+ (isRecord(message.data) ? message.data.invalid_tool_calls : undefined);
220
+ if (!Array.isArray(rawCalls)) {
221
+ return [];
222
+ }
223
+ const calls: SerializedToolCall[] = [];
224
+ for (const rawCall of rawCalls) {
225
+ // Same attribution predicate ToolNode executes with (id-bearing,
226
+ // non-server) so the span never claims calls the node deliberately skips.
227
+ if (
228
+ !isRecord(rawCall) ||
229
+ typeof rawCall.id !== 'string' ||
230
+ rawCall.id === '' ||
231
+ answeredIds.has(rawCall.id) ||
232
+ rawCall.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
233
+ ) {
234
+ continue;
235
+ }
236
+ // Same name fallback ToolNode synthesizes with — a nameless attributable
237
+ // call still gets a result, so it must still count in the span input.
238
+ const call = normalizeToolCall(
239
+ typeof rawCall.name === 'string' && rawCall.name !== ''
240
+ ? rawCall
241
+ : { ...rawCall, name: 'unknown' }
242
+ );
243
+ if (call != null) {
244
+ calls.push(call);
245
+ }
246
+ }
247
+ return calls;
248
+ }
249
+
250
+ /** The serialized message's own id (uuid), NOT the LC-serialization type id
251
+ * array that `message.id` carries in constructor dumps. */
252
+ function getSerializedMessageId(
253
+ message: Record<string, unknown>
254
+ ): string | undefined {
255
+ const kwargsId = isRecord(message.kwargs) ? message.kwargs.id : undefined;
256
+ const dataId = isRecord(message.data) ? message.data.id : undefined;
257
+ const rawId = message.id;
258
+ const id = kwargsId ?? dataId ?? rawId;
259
+ return typeof id === 'string' && id !== '' ? id : undefined;
260
+ }
261
+
178
262
  /** Latest assistant turn's tool calls — the calls this tool node is executing. */
179
263
  function findPendingToolCalls(value: unknown): SerializedToolCall[] {
180
264
  const messages = getMessageArray(value);
181
265
  if (messages == null) {
182
266
  return [];
183
267
  }
268
+ /**
269
+ * Invalid calls count only where ToolNode's own gate lets them execute:
270
+ * the messages-state form (a bare-array state means the node returns a
271
+ * plain output list and skips invalid handling) with an id-bearing
272
+ * assistant message (no id, no reducer upsert). Mirrors
273
+ * `canPromoteInvalidCalls` so the span never reports skipped calls.
274
+ */
275
+ const invalidCallsApply = !Array.isArray(value);
276
+ const answeredIds = invalidCallsApply
277
+ ? getToolResultIds(messages)
278
+ : undefined;
184
279
  for (let i = messages.length - 1; i >= 0; i--) {
185
280
  if (getMessageRole(messages[i]) !== 'assistant') {
186
281
  continue;
187
282
  }
188
- const calls = getMessageToolCalls(messages[i]);
283
+ const calls = [
284
+ ...getMessageToolCalls(messages[i]),
285
+ ...(invalidCallsApply && getSerializedMessageId(messages[i]) != null
286
+ ? getMessageInvalidToolCalls(messages[i], answeredIds!)
287
+ : []),
288
+ ];
189
289
  if (calls.length > 0) {
190
290
  return calls;
191
291
  }
@@ -254,11 +354,20 @@ function shapeRootSpan(span: MutableSpan): void {
254
354
  parseAttributeValue(span.attributes[outputKey]),
255
355
  'assistant'
256
356
  );
257
- if (question != null) {
258
- span.attributes[inputKey] = question;
259
- }
260
- if (answer != null) {
261
- span.attributes[outputKey] = answer;
357
+ /** A generation that IS the trace root — a bare `model.invoke` with no
358
+ * wrapping chain, i.e. the activity-label path — is also the only
359
+ * observation carrying its own prompt: reducing its observation input
360
+ * would discard the SystemMessage from the one place it is traced.
361
+ * Chain/agent roots keep the full reduction; their child generations
362
+ * still record the complete prompt. Trace-level input/output reduce
363
+ * either way, so the trace list keeps showing question and answer. */
364
+ if (!isGenerationSpan(span)) {
365
+ if (question != null) {
366
+ span.attributes[inputKey] = question;
367
+ }
368
+ if (answer != null) {
369
+ span.attributes[outputKey] = answer;
370
+ }
262
371
  }
263
372
  const traceInput = question ?? span.attributes[inputKey];
264
373
  const traceOutput = answer ?? span.attributes[outputKey];
@@ -275,6 +384,81 @@ function isGenerationSpan(span: MutableSpan): boolean {
275
384
  return typeof type === 'string' && type.toLowerCase() === 'generation';
276
385
  }
277
386
 
387
+ function isToolSpan(span: MutableSpan): boolean {
388
+ const type = span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE];
389
+ return (
390
+ typeof type === 'string' && type.toLowerCase() === TOOL_OBSERVATION_TYPE
391
+ );
392
+ }
393
+
394
+ /**
395
+ * Whether the span is a LangGraph node execution whose node id is the span
396
+ * name — the shape of the outer workflow-agent node. `@langfuse/tracing`
397
+ * flattens object metadata to per-key attributes with string values stored
398
+ * raw, so LangGraph's `langgraph_node` arrives directly comparable.
399
+ */
400
+ function isWorkflowNodeSpan(span: MutableSpan): boolean {
401
+ return span.attributes[OBSERVATION_METADATA_LANGGRAPH_NODE] === span.name;
402
+ }
403
+
404
+ /**
405
+ * LibreChat ephemeral agents are identified as
406
+ * `endpoint__model___sender[____index]` (`__` encodes `:`; see LibreChat's
407
+ * `encodeEphemeralAgentId`), and the outer workflow node carries that id as
408
+ * its span name — so switching models renames the span (item 1). Returns the
409
+ * stable human `sender` segment only when the name matches that encoding:
410
+ * the `endpoint__model` prefix must contain both segments and — unlike
411
+ * display names such as `LibreChat Agent: Ops___EU` — never contains
412
+ * whitespace, so legitimate names that merely embed `___` are left alone.
413
+ */
414
+ function extractEphemeralAgentSender(name: string): string | undefined {
415
+ let workingId = name;
416
+ const indexSeparator = workingId.lastIndexOf(EPHEMERAL_AGENT_INDEX_SEPARATOR);
417
+ if (
418
+ indexSeparator !== -1 &&
419
+ /^\d+$/.test(
420
+ workingId.slice(indexSeparator + EPHEMERAL_AGENT_INDEX_SEPARATOR.length)
421
+ )
422
+ ) {
423
+ workingId = workingId.slice(0, indexSeparator);
424
+ }
425
+ const senderSeparator = workingId.indexOf(EPHEMERAL_AGENT_SENDER_SEPARATOR);
426
+ if (senderSeparator <= 0) {
427
+ return undefined;
428
+ }
429
+ const encodedPrefix = workingId.slice(0, senderSeparator);
430
+ if (/\s/.test(encodedPrefix)) {
431
+ return undefined;
432
+ }
433
+ const prefixParts = encodedPrefix.split('__');
434
+ if (prefixParts.length < 2 || prefixParts.some((part) => part === '')) {
435
+ return undefined;
436
+ }
437
+ const sender = workingId
438
+ .slice(senderSeparator + EPHEMERAL_AGENT_SENDER_SEPARATOR.length)
439
+ .replace(/__/g, ':');
440
+ return sender === '' ? undefined : sender;
441
+ }
442
+
443
+ /** Workflow-agent node spans are always children of the run's root chain and
444
+ * always carry `langgraph_node` metadata equal to their name, so host-named
445
+ * run roots (`LibreChat Agent: <name>`) and ordinary chains that merely look
446
+ * like encoded ids (`pipeline__stage___EU`) are never rename candidates.
447
+ * Successful decodes become `agent` observations, matching the inner
448
+ * `agent=<id>` node shaping. */
449
+ function shapeEphemeralAgentNodeSpan(span: MutableSpan): void {
450
+ if (isToolSpan(span) || isRootSpan(span) || !isWorkflowNodeSpan(span)) {
451
+ return;
452
+ }
453
+ const sender = extractEphemeralAgentSender(span.name);
454
+ if (sender == null) {
455
+ return;
456
+ }
457
+ span.name = sender;
458
+ span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
459
+ ROOT_OBSERVATION_TYPE;
460
+ }
461
+
278
462
  function hasTraceTag(span: MutableSpan, expectedTag: string): boolean {
279
463
  const tags = parseAttributeValue(
280
464
  span.attributes[LangfuseOtelSpanAttributes.TRACE_TAGS]
@@ -304,7 +488,8 @@ function shapeRootObservationType(span: MutableSpan): void {
304
488
  * Reshapes spans per Langfuse-team feedback before export:
305
489
  * - `agent=<id>` / `tools=<id>` node names carry the ephemeral agent id
306
490
  * (`provider__model`) — strip it so switching models doesn't break
307
- * name-based logic (item 1).
491
+ * name-based logic (item 1). The outer workflow node is named with the
492
+ * bare agent id; ephemeral ids reduce to their stable sender name.
308
493
  * - LLM generation spans keep the provider client class name (`ChatOpenAI`,
309
494
  * `AzureChatOpenAI`, …); rename them to a provider-agnostic `llm` so the
310
495
  * name reflects the operation, not the model (the model stays on the
@@ -324,6 +509,8 @@ export function shapeLangfuseSpan(span: ReadableSpan): void {
324
509
  shapeToolNodeSpan(mutable);
325
510
  } else if (isGenerationSpan(mutable)) {
326
511
  mutable.name = GENERATION_SPAN_NAME;
512
+ } else {
513
+ shapeEphemeralAgentNodeSpan(mutable);
327
514
  }
328
515
  if (!isRootSpan(span)) {
329
516
  return;
@@ -388,6 +388,44 @@ function _ensureMessageContents(
388
388
  return updatedMsgs as (SystemMessage | HumanMessage | AIMessage)[];
389
389
  }
390
390
 
391
+ /**
392
+ * Anthropic requires `tool_use.input` to be a JSON object; anything else is
393
+ * rejected with a 400 (`tool_use.input: Input should be an object`).
394
+ * History can carry non-object inputs: streaming leaves the raw partial-JSON
395
+ * string on the content block, and upstream context projections can leave
396
+ * `null` (observed live on a summarization-retained tool call replayed after
397
+ * compaction). A string is parsed when it forms complete JSON; every other
398
+ * shape degrades to `{}` — the call already executed, so the replayed input
399
+ * is informational and an empty object is the safe representation.
400
+ */
401
+ /** True for `{}` — no own enumerable keys on a non-array, non-null object. */
402
+ function isEmptyPlainObject(value: unknown): boolean {
403
+ return (
404
+ typeof value === 'object' &&
405
+ value !== null &&
406
+ !Array.isArray(value) &&
407
+ Object.keys(value).length === 0
408
+ );
409
+ }
410
+
411
+ export function coerceAnthropicToolUseInput(
412
+ input: unknown
413
+ ): Record<string, unknown> {
414
+ let candidate: unknown = input;
415
+ if (typeof candidate === 'string') {
416
+ try {
417
+ candidate = JSON.parse(candidate);
418
+ } catch {
419
+ return {};
420
+ }
421
+ }
422
+ return typeof candidate === 'object' &&
423
+ candidate !== null &&
424
+ !Array.isArray(candidate)
425
+ ? (candidate as Record<string, unknown>)
426
+ : {};
427
+ }
428
+
391
429
  export function _convertLangChainToolCallToAnthropic(
392
430
  toolCall: ToolCall
393
431
  ): AnthropicToolResponse {
@@ -401,7 +439,7 @@ export function _convertLangChainToolCallToAnthropic(
401
439
  type: isServerTool ? 'server_tool_use' : 'tool_use',
402
440
  id: isServerTool ? toolCall.id : normalizeAnthropicToolCallId(toolCall.id),
403
441
  name: toolCall.name,
404
- input: toolCall.args,
442
+ input: coerceAnthropicToolUseInput(toolCall.args),
405
443
  };
406
444
  }
407
445
 
@@ -649,19 +687,11 @@ function _formatContent(message: BaseMessage) {
649
687
  'name' in contentPart
650
688
  ) {
651
689
  const rawPart = contentPart as Record<string, unknown>;
652
- let input = rawPart.input ?? rawPart.args;
653
- if (typeof input === 'string') {
654
- try {
655
- input = JSON.parse(input);
656
- } catch {
657
- input = {};
658
- }
659
- }
660
690
  const corrected: AnthropicServerToolUseBlockParam = {
661
691
  type: 'server_tool_use',
662
692
  id: rawPart.id as string,
663
693
  name: (rawPart.name ?? 'web_search') as 'web_search',
664
- input: (input ?? {}) as Record<string, unknown>,
694
+ input: coerceAnthropicToolUseInput(rawPart.input ?? rawPart.args),
665
695
  };
666
696
  return corrected;
667
697
  }
@@ -916,10 +946,16 @@ function _formatContent(message: BaseMessage) {
916
946
  // Core's streaming aggregation can leave the inline tool_use input empty
917
947
  // (the assembled arguments live in `message.tool_calls` or, for persisted
918
948
  // messages, in sibling input_json_delta blocks). Restore it when missing.
949
+ // An empty plain object counts as missing too: context-pressure
950
+ // truncation degrades an inline input to `{}` while a small `args`
951
+ // mirror survives intact, and a genuinely empty call's mirror is also
952
+ // `{}`, so preferring the mirror never loses information.
919
953
  if (
920
954
  contentPartCopy.type === 'tool_use' &&
921
955
  typeof contentPartCopy.id === 'string' &&
922
- (contentPartCopy.input === '' || contentPartCopy.input == null)
956
+ (contentPartCopy.input === '' ||
957
+ contentPartCopy.input == null ||
958
+ isEmptyPlainObject(contentPartCopy.input))
923
959
  ) {
924
960
  const matchingToolCall = isAIMessage(message)
925
961
  ? message.tool_calls?.find(
@@ -949,14 +985,29 @@ function _formatContent(message: BaseMessage) {
949
985
  }
950
986
  }
951
987
 
952
- if ('input' in contentPartCopy) {
953
- // Anthropic tool use inputs should be valid objects, when applicable.
954
- if (typeof contentPartCopy.input === 'string') {
955
- try {
956
- contentPartCopy.input = JSON.parse(contentPartCopy.input);
957
- } catch {
958
- contentPartCopy.input = {};
959
- }
988
+ if (
989
+ contentPartCopy.type === 'tool_use' ||
990
+ contentPartCopy.type === 'server_tool_use'
991
+ ) {
992
+ /**
993
+ * The API requires `input` to be a JSON object. Streaming leaves the
994
+ * raw partial-JSON string here, and context-pressure truncation can
995
+ * leave `null` on BOTH the block and its `tool_calls` mirror — so
996
+ * the restore above may itself restore a non-object. Coerce last,
997
+ * after every restore path has run.
998
+ */
999
+ contentPartCopy.input = coerceAnthropicToolUseInput(
1000
+ 'input' in contentPartCopy ? contentPartCopy.input : undefined
1001
+ );
1002
+ } else if (
1003
+ 'input' in contentPartCopy &&
1004
+ typeof contentPartCopy.input === 'string'
1005
+ ) {
1006
+ // Non-tool_use tool blocks keep their legacy string handling.
1007
+ try {
1008
+ contentPartCopy.input = JSON.parse(contentPartCopy.input);
1009
+ } catch {
1010
+ contentPartCopy.input = {};
960
1011
  }
961
1012
  }
962
1013