@librechat/agents 3.2.56 → 3.2.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/agents/AgentContext.cjs +7 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +7 -2
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
- package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
- package/dist/cjs/langfuseTraceShaping.cjs +172 -0
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -0
- package/dist/cjs/run.cjs +6 -2
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/stream.cjs +3 -2
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +19 -5
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-search.cjs +68 -0
- package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
- package/dist/cjs/tools/search/rerankers.cjs +28 -11
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +30 -4
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +14 -6
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/title.cjs +9 -9
- package/dist/cjs/utils/title.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +7 -1
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +7 -2
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
- package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
- package/dist/esm/langfuseTraceShaping.mjs +171 -0
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
- package/dist/esm/run.mjs +6 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/stream.mjs +3 -2
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +19 -5
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-search.mjs +66 -0
- package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
- package/dist/esm/tools/search/rerankers.mjs +28 -11
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +30 -4
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +14 -6
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/title.mjs +9 -9
- package/dist/esm/utils/title.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +1 -0
- package/dist/types/langfuseTraceShaping.d.ts +20 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +11 -1
- package/dist/types/tools/search/keenable-search.d.ts +4 -0
- package/dist/types/tools/search/rerankers.d.ts +7 -2
- package/dist/types/tools/search/types.d.ts +38 -1
- package/dist/types/types/graph.d.ts +22 -0
- package/dist/types/types/run.d.ts +9 -0
- package/dist/types/types/tools.d.ts +6 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +44 -0
- package/src/agents/AgentContext.ts +9 -0
- package/src/agents/__tests__/AgentContext.test.ts +40 -0
- package/src/graphs/Graph.ts +31 -3
- package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
- package/src/langfuseToolOutputTracing.ts +11 -0
- package/src/langfuseTraceShaping.ts +280 -0
- package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
- package/src/run.ts +7 -3
- package/src/specs/langfuse-routing.integration.test.ts +1 -1
- package/src/specs/langfuse-trace-shaping.test.ts +194 -0
- package/src/stream.ts +10 -2
- package/src/tools/ToolNode.ts +28 -4
- package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
- package/src/tools/__tests__/ToolNode.session.test.ts +160 -0
- package/src/tools/__tests__/hitl.test.ts +85 -0
- package/src/tools/search/jina-reranker.test.ts +70 -1
- package/src/tools/search/keenable-search.ts +98 -0
- package/src/tools/search/keenable.test.ts +183 -0
- package/src/tools/search/rerankers.ts +41 -4
- package/src/tools/search/search.ts +58 -2
- package/src/tools/search/source-processing.test.ts +86 -0
- package/src/tools/search/tool.ts +29 -4
- package/src/tools/search/types.ts +42 -1
- package/src/tools/subagent/SubagentExecutor.ts +11 -0
- package/src/types/graph.ts +22 -0
- package/src/types/run.ts +9 -0
- package/src/types/tools.ts +6 -0
- package/src/utils/title.ts +9 -9
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
|
|
2
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
3
|
+
|
|
4
|
+
const LANGGRAPH_START_NODE = '__start__';
|
|
5
|
+
const ANONYMOUS_LAMBDA_NAME = 'RunnableLambda';
|
|
6
|
+
const LANGGRAPH_AGENT_NODE_PREFIX = 'agent=';
|
|
7
|
+
const LANGGRAPH_TOOL_NODE_PREFIX = 'tools=';
|
|
8
|
+
const TOOL_BATCH_RUN_NAME = 'tool_batch';
|
|
9
|
+
const AGENT_NODE_SPAN_NAME = 'agent';
|
|
10
|
+
|
|
11
|
+
type MutableSpan = ReadableSpan & {
|
|
12
|
+
name: string;
|
|
13
|
+
attributes: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type SerializedToolCall = {
|
|
17
|
+
name: string;
|
|
18
|
+
args: unknown;
|
|
19
|
+
id?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseAttributeValue(value: unknown): unknown {
|
|
27
|
+
if (typeof value !== 'string') {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(value) as unknown;
|
|
36
|
+
} catch {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getMessageArray(
|
|
42
|
+
value: unknown
|
|
43
|
+
): Record<string, unknown>[] | undefined {
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
const records = value.filter(isRecord);
|
|
46
|
+
return records.length > 0 ? records : undefined;
|
|
47
|
+
}
|
|
48
|
+
if (!isRecord(value)) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
getMessageArray(value.messages) ??
|
|
53
|
+
getMessageArray(value.input) ??
|
|
54
|
+
getMessageArray(value.output)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getMessageRole(message: Record<string, unknown>): string | undefined {
|
|
59
|
+
const id = message.id;
|
|
60
|
+
if (Array.isArray(id)) {
|
|
61
|
+
const className = id[id.length - 1];
|
|
62
|
+
if (typeof className === 'string') {
|
|
63
|
+
if (className.includes('Human')) {
|
|
64
|
+
return 'user';
|
|
65
|
+
}
|
|
66
|
+
if (className.includes('AI')) {
|
|
67
|
+
return 'assistant';
|
|
68
|
+
}
|
|
69
|
+
if (className.includes('System')) {
|
|
70
|
+
return 'system';
|
|
71
|
+
}
|
|
72
|
+
if (className.includes('Tool')) {
|
|
73
|
+
return 'tool';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const rawRole =
|
|
78
|
+
message.type ?? message._type ?? message.role ?? message.sender;
|
|
79
|
+
if (typeof rawRole !== 'string') {
|
|
80
|
+
const kwargs = message.kwargs;
|
|
81
|
+
return isRecord(kwargs) ? getMessageRole(kwargs) : undefined;
|
|
82
|
+
}
|
|
83
|
+
const normalized = rawRole.toLowerCase();
|
|
84
|
+
if (normalized === 'human') {
|
|
85
|
+
return 'user';
|
|
86
|
+
}
|
|
87
|
+
if (normalized === 'ai') {
|
|
88
|
+
return 'assistant';
|
|
89
|
+
}
|
|
90
|
+
return normalized;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getMessageText(message: Record<string, unknown>): string | undefined {
|
|
94
|
+
const content =
|
|
95
|
+
message.content ??
|
|
96
|
+
(isRecord(message.kwargs) ? message.kwargs.content : undefined) ??
|
|
97
|
+
(isRecord(message.data) ? message.data.content : undefined);
|
|
98
|
+
if (typeof content === 'string') {
|
|
99
|
+
return content;
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(content)) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const text = content
|
|
105
|
+
.filter(isRecord)
|
|
106
|
+
.map((part) => (typeof part.text === 'string' ? part.text : ''))
|
|
107
|
+
.join('');
|
|
108
|
+
return text === '' ? undefined : text;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function findLastMessageText(value: unknown, role: string): string | undefined {
|
|
112
|
+
const messages = getMessageArray(value);
|
|
113
|
+
if (messages == null) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
117
|
+
if (getMessageRole(messages[i]) !== role) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const text = getMessageText(messages[i]);
|
|
121
|
+
if (text != null && text.trim() !== '') {
|
|
122
|
+
return text;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeToolCall(value: unknown): SerializedToolCall | undefined {
|
|
129
|
+
if (!isRecord(value)) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const fn = value.function;
|
|
133
|
+
if (isRecord(fn) && typeof fn.name === 'string') {
|
|
134
|
+
return {
|
|
135
|
+
name: fn.name,
|
|
136
|
+
args: parseAttributeValue(fn.arguments),
|
|
137
|
+
...(typeof value.id === 'string' ? { id: value.id } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (typeof value.name !== 'string') {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
name: value.name,
|
|
145
|
+
args: value.args ?? {},
|
|
146
|
+
...(typeof value.id === 'string' ? { id: value.id } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getMessageToolCalls(
|
|
151
|
+
message: Record<string, unknown>
|
|
152
|
+
): SerializedToolCall[] {
|
|
153
|
+
const rawCalls =
|
|
154
|
+
message.tool_calls ??
|
|
155
|
+
(isRecord(message.kwargs) ? message.kwargs.tool_calls : undefined) ??
|
|
156
|
+
(isRecord(message.additional_kwargs)
|
|
157
|
+
? message.additional_kwargs.tool_calls
|
|
158
|
+
: undefined) ??
|
|
159
|
+
(isRecord(message.data) ? message.data.tool_calls : undefined);
|
|
160
|
+
if (!Array.isArray(rawCalls)) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
const calls: SerializedToolCall[] = [];
|
|
164
|
+
for (const rawCall of rawCalls) {
|
|
165
|
+
const call = normalizeToolCall(rawCall);
|
|
166
|
+
if (call != null) {
|
|
167
|
+
calls.push(call);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return calls;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Latest assistant turn's tool calls — the calls this tool node is executing. */
|
|
174
|
+
function findPendingToolCalls(value: unknown): SerializedToolCall[] {
|
|
175
|
+
const messages = getMessageArray(value);
|
|
176
|
+
if (messages == null) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
180
|
+
if (getMessageRole(messages[i]) !== 'assistant') {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const calls = getMessageToolCalls(messages[i]);
|
|
184
|
+
if (calls.length > 0) {
|
|
185
|
+
return calls;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getRootSpanParentId(span: ReadableSpan): string | undefined {
|
|
192
|
+
const legacyParent = (span as { parentSpanId?: string }).parentSpanId;
|
|
193
|
+
if (typeof legacyParent === 'string' && legacyParent !== '') {
|
|
194
|
+
return legacyParent;
|
|
195
|
+
}
|
|
196
|
+
const parentContext = (span as { parentSpanContext?: { spanId?: string } })
|
|
197
|
+
.parentSpanContext;
|
|
198
|
+
const spanId = parentContext?.spanId;
|
|
199
|
+
return typeof spanId === 'string' && spanId !== '' ? spanId : undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isRootSpan(span: ReadableSpan): boolean {
|
|
203
|
+
return getRootSpanParentId(span) == null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* LangGraph plumbing observations that add noise without information:
|
|
208
|
+
* the duplicated `__start__` channel-seed nodes and anonymous
|
|
209
|
+
* `RunnableLambda` pass-throughs (Langfuse team feedback items 4 & 5).
|
|
210
|
+
*/
|
|
211
|
+
export function shouldDropLangfuseSpan(spanName: string): boolean {
|
|
212
|
+
return (
|
|
213
|
+
spanName === LANGGRAPH_START_NODE || spanName === ANONYMOUS_LAMBDA_NAME
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function shapeToolNodeSpan(span: MutableSpan): void {
|
|
218
|
+
const inputKey = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
|
|
219
|
+
const calls = findPendingToolCalls(
|
|
220
|
+
parseAttributeValue(span.attributes[inputKey])
|
|
221
|
+
);
|
|
222
|
+
if (calls.length === 0) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
span.name = [...new Set(calls.map((call) => call.name))].join(', ');
|
|
226
|
+
span.attributes[inputKey] = JSON.stringify(
|
|
227
|
+
calls.map(({ name, args }) => ({ name, args }))
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function shapeRootSpan(span: MutableSpan): void {
|
|
232
|
+
const inputKey = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
|
|
233
|
+
const outputKey = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
|
|
234
|
+
const question = findLastMessageText(
|
|
235
|
+
parseAttributeValue(span.attributes[inputKey]),
|
|
236
|
+
'user'
|
|
237
|
+
);
|
|
238
|
+
const answer = findLastMessageText(
|
|
239
|
+
parseAttributeValue(span.attributes[outputKey]),
|
|
240
|
+
'assistant'
|
|
241
|
+
);
|
|
242
|
+
if (question != null) {
|
|
243
|
+
span.attributes[inputKey] = question;
|
|
244
|
+
span.attributes[LangfuseOtelSpanAttributes.TRACE_INPUT] = question;
|
|
245
|
+
}
|
|
246
|
+
if (answer != null) {
|
|
247
|
+
span.attributes[outputKey] = answer;
|
|
248
|
+
span.attributes[LangfuseOtelSpanAttributes.TRACE_OUTPUT] = answer;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Reshapes spans per Langfuse-team feedback before export:
|
|
254
|
+
* - `agent=<id>` / `tools=<id>` node names carry the ephemeral agent id
|
|
255
|
+
* (`provider__model`) — strip it so switching models doesn't break
|
|
256
|
+
* name-based logic (item 1).
|
|
257
|
+
* - Tool node spans are renamed to the actual tool name(s) and their
|
|
258
|
+
* input scoped to the pending tool-call args instead of the whole
|
|
259
|
+
* chat history (items 3 & 4).
|
|
260
|
+
* - The root span (and trace) input/output become the user question and
|
|
261
|
+
* assistant response so the session view reads as a conversation
|
|
262
|
+
* (item 2).
|
|
263
|
+
*/
|
|
264
|
+
export function shapeLangfuseSpan(span: ReadableSpan): void {
|
|
265
|
+
const mutable = span as MutableSpan;
|
|
266
|
+
if (mutable.name.startsWith(LANGGRAPH_AGENT_NODE_PREFIX)) {
|
|
267
|
+
mutable.name = AGENT_NODE_SPAN_NAME;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (
|
|
271
|
+
mutable.name.startsWith(LANGGRAPH_TOOL_NODE_PREFIX) ||
|
|
272
|
+
mutable.name === TOOL_BATCH_RUN_NAME
|
|
273
|
+
) {
|
|
274
|
+
shapeToolNodeSpan(mutable);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (isRootSpan(span)) {
|
|
278
|
+
shapeRootSpan(mutable);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -32,7 +32,6 @@ import { HumanMessage } from '@langchain/core/messages';
|
|
|
32
32
|
import { ChatModelStream } from '@langchain/core/language_models/stream';
|
|
33
33
|
import type { BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
|
|
34
34
|
import type { ChatModelStreamEvent } from '@langchain/core/language_models/event';
|
|
35
|
-
import type { Stream } from '@anthropic-ai/sdk/streaming';
|
|
36
35
|
import type {
|
|
37
36
|
AnthropicMessageStreamEvent,
|
|
38
37
|
AnthropicRequestOptions,
|
|
@@ -57,10 +56,14 @@ class MockStreamChatAnthropic extends ChatAnthropic {
|
|
|
57
56
|
this.mockEvents = mockEvents;
|
|
58
57
|
}
|
|
59
58
|
|
|
59
|
+
// Inherit the base method's own Stream identity instead of importing
|
|
60
|
+
// @anthropic-ai/sdk/streaming here: the SDK ships dual .d.ts/.d.mts types,
|
|
61
|
+
// and an independent import can resolve to the other format's identity
|
|
62
|
+
// depending on compile order (flaky TS2416 in sharded CI).
|
|
60
63
|
protected override async createStreamWithRetry(
|
|
61
64
|
request: AnthropicStreamingMessageCreateParams,
|
|
62
65
|
_options?: AnthropicRequestOptions
|
|
63
|
-
):
|
|
66
|
+
): ReturnType<ChatAnthropic['createStreamWithRetry']> {
|
|
64
67
|
this.capturedRequest = request;
|
|
65
68
|
const events = this.mockEvents;
|
|
66
69
|
return {
|
|
@@ -70,7 +73,7 @@ class MockStreamChatAnthropic extends ChatAnthropic {
|
|
|
70
73
|
yield event as AnthropicMessageStreamEvent;
|
|
71
74
|
}
|
|
72
75
|
},
|
|
73
|
-
} as unknown as
|
|
76
|
+
} as unknown as Awaited<ReturnType<ChatAnthropic['createStreamWithRetry']>>;
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
|
package/src/run.ts
CHANGED
|
@@ -127,6 +127,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
127
127
|
private langfuse?: t.LangfuseConfig;
|
|
128
128
|
private toolOutputReferences?: t.ToolOutputReferencesConfig;
|
|
129
129
|
private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
|
|
130
|
+
private codeSessionToolNames?: string[];
|
|
130
131
|
private toolExecution?: t.ToolExecutionConfig;
|
|
131
132
|
private subagentUsageSink?: t.SubagentUsageSink;
|
|
132
133
|
private indexTokenCountMap?: Record<string, number>;
|
|
@@ -182,6 +183,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
182
183
|
this.langfuse = config.langfuse;
|
|
183
184
|
this.toolOutputReferences = config.toolOutputReferences;
|
|
184
185
|
this.eagerEventToolExecution = config.eagerEventToolExecution;
|
|
186
|
+
this.codeSessionToolNames = config.codeSessionToolNames;
|
|
185
187
|
this.toolExecution = config.toolExecution;
|
|
186
188
|
this.subagentUsageSink = config.subagentUsageSink;
|
|
187
189
|
|
|
@@ -268,6 +270,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
268
270
|
standardGraph.humanInTheLoop = this.humanInTheLoop;
|
|
269
271
|
standardGraph.toolOutputReferences = this.toolOutputReferences;
|
|
270
272
|
standardGraph.eagerEventToolExecution = this.eagerEventToolExecution;
|
|
273
|
+
standardGraph.codeSessionToolNames = this.codeSessionToolNames;
|
|
271
274
|
standardGraph.toolExecution = this.toolExecution;
|
|
272
275
|
this.Graph = standardGraph;
|
|
273
276
|
return standardGraph.createWorkflow();
|
|
@@ -297,6 +300,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
297
300
|
multiAgentGraph.humanInTheLoop = this.humanInTheLoop;
|
|
298
301
|
multiAgentGraph.toolOutputReferences = this.toolOutputReferences;
|
|
299
302
|
multiAgentGraph.eagerEventToolExecution = this.eagerEventToolExecution;
|
|
303
|
+
multiAgentGraph.codeSessionToolNames = this.codeSessionToolNames;
|
|
300
304
|
multiAgentGraph.toolExecution = this.toolExecution;
|
|
301
305
|
this.Graph = multiAgentGraph;
|
|
302
306
|
return multiAgentGraph.createWorkflow();
|
|
@@ -1399,7 +1403,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
1399
1403
|
inputText,
|
|
1400
1404
|
skipLanguage,
|
|
1401
1405
|
}),
|
|
1402
|
-
}).withConfig({ runName: '
|
|
1406
|
+
}).withConfig({ runName: 'PrepareTitleInput' });
|
|
1403
1407
|
|
|
1404
1408
|
const titleChain =
|
|
1405
1409
|
titleMethod === TitleMethod.COMPLETION
|
|
@@ -1408,10 +1412,10 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
1408
1412
|
|
|
1409
1413
|
/** Pipes `convoTemplate` -> `transformer` -> `titleChain` */
|
|
1410
1414
|
const fullChain = convoTemplate
|
|
1411
|
-
.withConfig({ runName: '
|
|
1415
|
+
.withConfig({ runName: 'FormatConversation' })
|
|
1412
1416
|
.pipe(convoToTitleInput)
|
|
1413
1417
|
.pipe(titleChain)
|
|
1414
|
-
.withConfig({ runName: '
|
|
1418
|
+
.withConfig({ runName: 'GenerateConversationTitle' });
|
|
1415
1419
|
|
|
1416
1420
|
const invokeConfig = Object.assign({}, chainOptions, {
|
|
1417
1421
|
run_id: this.id,
|
|
@@ -469,7 +469,7 @@ describe('Langfuse per-run routing integration', () => {
|
|
|
469
469
|
expectNamedSpansUseTraceId({
|
|
470
470
|
starts,
|
|
471
471
|
traceId: titleTraceId,
|
|
472
|
-
names: [`LibreChat Title: Parent ${tenantId}`, '
|
|
472
|
+
names: [`LibreChat Title: Parent ${tenantId}`, 'GenerateTitle'],
|
|
473
473
|
});
|
|
474
474
|
expectNoCrossTenantTrace({
|
|
475
475
|
tenantId,
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
|
|
2
|
+
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
|
|
3
|
+
import {
|
|
4
|
+
shapeLangfuseSpan,
|
|
5
|
+
shouldDropLangfuseSpan,
|
|
6
|
+
} from '@/langfuseTraceShaping';
|
|
7
|
+
|
|
8
|
+
type TestSpan = ReadableSpan & {
|
|
9
|
+
name: string;
|
|
10
|
+
attributes: Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function createSpan(
|
|
14
|
+
name: string,
|
|
15
|
+
attributes: Record<string, unknown> = {},
|
|
16
|
+
parentSpanId?: string
|
|
17
|
+
): TestSpan {
|
|
18
|
+
return {
|
|
19
|
+
name,
|
|
20
|
+
attributes,
|
|
21
|
+
...(parentSpanId != null ? { parentSpanId } : {}),
|
|
22
|
+
} as unknown as TestSpan;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const INPUT = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
|
|
26
|
+
const OUTPUT = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
|
|
27
|
+
const TRACE_INPUT = LangfuseOtelSpanAttributes.TRACE_INPUT;
|
|
28
|
+
const TRACE_OUTPUT = LangfuseOtelSpanAttributes.TRACE_OUTPUT;
|
|
29
|
+
|
|
30
|
+
describe('shouldDropLangfuseSpan', () => {
|
|
31
|
+
it('drops langgraph __start__ seed spans', () => {
|
|
32
|
+
expect(shouldDropLangfuseSpan('__start__')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('drops anonymous RunnableLambda pass-throughs', () => {
|
|
36
|
+
expect(shouldDropLangfuseSpan('RunnableLambda')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('keeps named observations', () => {
|
|
40
|
+
expect(shouldDropLangfuseSpan('GenerateTitle')).toBe(false);
|
|
41
|
+
expect(shouldDropLangfuseSpan('agent=openAI__gpt-5.4')).toBe(false);
|
|
42
|
+
expect(shouldDropLangfuseSpan('ChatOpenAI')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('shapeLangfuseSpan', () => {
|
|
47
|
+
it('strips the ephemeral agent id (provider__model) from agent node names', () => {
|
|
48
|
+
const span = createSpan('agent=openAI__gpt-5.4', {}, 'parent-1');
|
|
49
|
+
shapeLangfuseSpan(span);
|
|
50
|
+
expect(span.name).toBe('agent');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renames tool node spans to the pending tool names and scopes input to args', () => {
|
|
54
|
+
const messages = [
|
|
55
|
+
{ type: 'human', content: 'hello' },
|
|
56
|
+
{
|
|
57
|
+
type: 'ai',
|
|
58
|
+
content: '',
|
|
59
|
+
tool_calls: [
|
|
60
|
+
{
|
|
61
|
+
name: 'get_service_details',
|
|
62
|
+
args: { path: 'organizations/1' },
|
|
63
|
+
id: 'call_1',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
const span = createSpan(
|
|
69
|
+
'tools=openAI__gpt-5.4',
|
|
70
|
+
{ [INPUT]: JSON.stringify({ messages }) },
|
|
71
|
+
'parent-1'
|
|
72
|
+
);
|
|
73
|
+
shapeLangfuseSpan(span);
|
|
74
|
+
expect(span.name).toBe('get_service_details');
|
|
75
|
+
expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
|
|
76
|
+
{ name: 'get_service_details', args: { path: 'organizations/1' } },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('joins multiple pending tool names and dedupes repeats', () => {
|
|
81
|
+
const messages = [
|
|
82
|
+
{
|
|
83
|
+
type: 'ai',
|
|
84
|
+
tool_calls: [
|
|
85
|
+
{ name: 'web_search', args: { q: 'a' }, id: '1' },
|
|
86
|
+
{ name: 'web_search', args: { q: 'b' }, id: '2' },
|
|
87
|
+
{ name: 'execute_code', args: { code: '1+1' }, id: '3' },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
const span = createSpan(
|
|
92
|
+
'tool_batch',
|
|
93
|
+
{ [INPUT]: JSON.stringify({ messages }) },
|
|
94
|
+
'parent-1'
|
|
95
|
+
);
|
|
96
|
+
shapeLangfuseSpan(span);
|
|
97
|
+
expect(span.name).toBe('web_search, execute_code');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('reads tool calls from serialized langchain message kwargs', () => {
|
|
101
|
+
const messages = [
|
|
102
|
+
{
|
|
103
|
+
lc: 1,
|
|
104
|
+
type: 'constructor',
|
|
105
|
+
id: ['langchain_core', 'messages', 'AIMessage'],
|
|
106
|
+
kwargs: {
|
|
107
|
+
content: '',
|
|
108
|
+
tool_calls: [{ name: 'lookup', args: { id: 7 }, id: 'call_7' }],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
const span = createSpan(
|
|
113
|
+
'tools=agent_abc',
|
|
114
|
+
{ [INPUT]: JSON.stringify({ messages }) },
|
|
115
|
+
'parent-1'
|
|
116
|
+
);
|
|
117
|
+
shapeLangfuseSpan(span);
|
|
118
|
+
expect(span.name).toBe('lookup');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('leaves tool node spans untouched when no tool calls are found', () => {
|
|
122
|
+
const original = JSON.stringify({
|
|
123
|
+
messages: [{ type: 'human', content: 'hi' }],
|
|
124
|
+
});
|
|
125
|
+
const span = createSpan(
|
|
126
|
+
'tools=agent_abc',
|
|
127
|
+
{ [INPUT]: original },
|
|
128
|
+
'parent-1'
|
|
129
|
+
);
|
|
130
|
+
shapeLangfuseSpan(span);
|
|
131
|
+
expect(span.name).toBe('tools=agent_abc');
|
|
132
|
+
expect(span.attributes[INPUT]).toBe(original);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('sets root span and trace input/output to the question and answer', () => {
|
|
136
|
+
const span = createSpan('LibreChat Agent', {
|
|
137
|
+
[INPUT]: JSON.stringify({
|
|
138
|
+
messages: [
|
|
139
|
+
{ type: 'system', content: 'You are helpful.' },
|
|
140
|
+
{ type: 'human', content: 'What is ClickHouse?' },
|
|
141
|
+
],
|
|
142
|
+
}),
|
|
143
|
+
[OUTPUT]: JSON.stringify({
|
|
144
|
+
messages: [
|
|
145
|
+
{ type: 'human', content: 'What is ClickHouse?' },
|
|
146
|
+
{ type: 'ai', content: 'A columnar OLAP database.' },
|
|
147
|
+
],
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
shapeLangfuseSpan(span);
|
|
151
|
+
expect(span.attributes[INPUT]).toBe('What is ClickHouse?');
|
|
152
|
+
expect(span.attributes[TRACE_INPUT]).toBe('What is ClickHouse?');
|
|
153
|
+
expect(span.attributes[OUTPUT]).toBe('A columnar OLAP database.');
|
|
154
|
+
expect(span.attributes[TRACE_OUTPUT]).toBe('A columnar OLAP database.');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('extracts answer text from content part arrays', () => {
|
|
158
|
+
const span = createSpan('LibreChat Agent', {
|
|
159
|
+
[INPUT]: JSON.stringify([{ type: 'human', content: 'hi' }]),
|
|
160
|
+
[OUTPUT]: JSON.stringify({
|
|
161
|
+
messages: [
|
|
162
|
+
{
|
|
163
|
+
id: ['langchain_core', 'messages', 'AIMessage'],
|
|
164
|
+
kwargs: {
|
|
165
|
+
content: [
|
|
166
|
+
{ type: 'text', text: 'Hello ' },
|
|
167
|
+
{ type: 'text', text: 'there.' },
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
shapeLangfuseSpan(span);
|
|
175
|
+
expect(span.attributes[INPUT]).toBe('hi');
|
|
176
|
+
expect(span.attributes[OUTPUT]).toBe('Hello there.');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does not rewrite non-root spans with message payloads', () => {
|
|
180
|
+
const original = JSON.stringify({
|
|
181
|
+
messages: [{ type: 'human', content: 'hi' }],
|
|
182
|
+
});
|
|
183
|
+
const span = createSpan('ChatOpenAI', { [INPUT]: original }, 'parent-1');
|
|
184
|
+
shapeLangfuseSpan(span);
|
|
185
|
+
expect(span.attributes[INPUT]).toBe(original);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('preserves root attributes when extraction finds nothing', () => {
|
|
189
|
+
const span = createSpan('LibreChat Agent', { [INPUT]: 'plain text' });
|
|
190
|
+
shapeLangfuseSpan(span);
|
|
191
|
+
expect(span.attributes[INPUT]).toBe('plain text');
|
|
192
|
+
expect(span.attributes[TRACE_INPUT]).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
});
|
package/src/stream.ts
CHANGED
|
@@ -135,7 +135,14 @@ function isEagerExecutionExcludedTool(
|
|
|
135
135
|
return false;
|
|
136
136
|
}
|
|
137
137
|
const excluded = graph.eagerEventToolExecution?.excludeToolNames;
|
|
138
|
-
|
|
138
|
+
if (excluded != null && excluded.includes(name)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
// A code-session participant writes to the shared sandbox, so it is
|
|
142
|
+
// side-effecting: never prestart it speculatively (a revised/superseded turn
|
|
143
|
+
// would leave the write applied). Implies exclusion without the host having
|
|
144
|
+
// to also list the name in excludeToolNames.
|
|
145
|
+
return graph.codeSessionToolNames?.includes(name) === true;
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
function isDirectGraphTool(
|
|
@@ -195,7 +202,8 @@ function getCodeSessionContext(
|
|
|
195
202
|
if (
|
|
196
203
|
!CODE_EXECUTION_TOOLS.has(name) &&
|
|
197
204
|
name !== Constants.SKILL_TOOL &&
|
|
198
|
-
name !== Constants.READ_FILE
|
|
205
|
+
name !== Constants.READ_FILE &&
|
|
206
|
+
graph.codeSessionToolNames?.includes(name) !== true
|
|
199
207
|
) {
|
|
200
208
|
return undefined;
|
|
201
209
|
}
|
package/src/tools/ToolNode.ts
CHANGED
|
@@ -439,6 +439,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
439
439
|
private eventDrivenMode: boolean = false;
|
|
440
440
|
/** Opt-in stream-layer prestart config for event-driven tools. */
|
|
441
441
|
private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
|
|
442
|
+
/** Host tools that write to the code sandbox and share its exec session. */
|
|
443
|
+
private codeSessionToolNames?: ReadonlySet<string>;
|
|
442
444
|
/** Shared per-run prestarted tool registry populated by ChatModelStreamHandler. */
|
|
443
445
|
private eagerEventToolExecutions?: Map<string, t.EagerEventToolExecution>;
|
|
444
446
|
/** Shared per-run per-tool turn counter used by eager and normal event dispatch. */
|
|
@@ -515,6 +517,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
515
517
|
agentId,
|
|
516
518
|
executingAgentId,
|
|
517
519
|
directToolNames,
|
|
520
|
+
codeSessionToolNames,
|
|
518
521
|
maxContextTokens,
|
|
519
522
|
maxToolResultChars,
|
|
520
523
|
hookRegistry,
|
|
@@ -552,6 +555,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
552
555
|
// existing agentId option) still get attribution without knowing the new option.
|
|
553
556
|
this.executingAgentId = executingAgentId ?? agentId;
|
|
554
557
|
this.directToolNames = directToolNames;
|
|
558
|
+
this.codeSessionToolNames =
|
|
559
|
+
codeSessionToolNames != null && codeSessionToolNames.length > 0
|
|
560
|
+
? new Set(codeSessionToolNames)
|
|
561
|
+
: undefined;
|
|
555
562
|
this.maxToolResultChars =
|
|
556
563
|
maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
|
|
557
564
|
this.hookRegistry = hookRegistry;
|
|
@@ -973,7 +980,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
973
980
|
* still need to travel through `_injected_files`; the legacy
|
|
974
981
|
* `/files/<session_id>` fallback was removed from the executors.
|
|
975
982
|
*/
|
|
976
|
-
if (
|
|
983
|
+
if (this.participatesInCodeSession(call.name)) {
|
|
977
984
|
const codeSession = this.sessions?.get(Constants.EXECUTE_CODE) as
|
|
978
985
|
| t.CodeSessionContext
|
|
979
986
|
| undefined;
|
|
@@ -1759,6 +1766,23 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1759
1766
|
* Extracts code execution session context from tool results and stores in Graph.sessions.
|
|
1760
1767
|
* Mirrors the session storage logic in handleRunToolCompletions for direct execution.
|
|
1761
1768
|
*/
|
|
1769
|
+
/**
|
|
1770
|
+
* True when a tool's successful result should fold its returned exec
|
|
1771
|
+
* `session_id` into the shared code session: built-in `CODE_EXECUTION_TOOLS`,
|
|
1772
|
+
* plus host-declared sandbox-writing tools (`codeSessionToolNames`, e.g.
|
|
1773
|
+
* create_file/edit_file). Kept name-scoped rather than a blanket artifact
|
|
1774
|
+
* opt-in so only host-declared tools can influence the shared session.
|
|
1775
|
+
*/
|
|
1776
|
+
private participatesInCodeSession(name: string): boolean {
|
|
1777
|
+
if (name === '') {
|
|
1778
|
+
return false;
|
|
1779
|
+
}
|
|
1780
|
+
return (
|
|
1781
|
+
CODE_EXECUTION_TOOLS.has(name) ||
|
|
1782
|
+
this.codeSessionToolNames?.has(name) === true
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1762
1786
|
private storeCodeSessionFromResults(
|
|
1763
1787
|
results: t.ToolExecuteResult[],
|
|
1764
1788
|
requestMap: Map<string, t.ToolCallRequest>
|
|
@@ -1777,7 +1801,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1777
1801
|
if (
|
|
1778
1802
|
request?.name == null ||
|
|
1779
1803
|
request.name === '' ||
|
|
1780
|
-
(!
|
|
1804
|
+
(!this.participatesInCodeSession(request.name) &&
|
|
1781
1805
|
request.name !== Constants.SKILL_TOOL)
|
|
1782
1806
|
) {
|
|
1783
1807
|
continue;
|
|
@@ -1830,7 +1854,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1830
1854
|
continue;
|
|
1831
1855
|
}
|
|
1832
1856
|
|
|
1833
|
-
if (this.sessions &&
|
|
1857
|
+
if (this.sessions && this.participatesInCodeSession(call.name)) {
|
|
1834
1858
|
const artifact = toolMessage.artifact as
|
|
1835
1859
|
| t.CodeExecutionArtifact
|
|
1836
1860
|
| undefined;
|
|
@@ -2463,7 +2487,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
2463
2487
|
const plan = buildToolExecutionRequestPlan({
|
|
2464
2488
|
toolCalls: approvedEntries.map((entry) => {
|
|
2465
2489
|
const codeSessionContext =
|
|
2466
|
-
|
|
2490
|
+
this.participatesInCodeSession(entry.call.name) ||
|
|
2467
2491
|
entry.call.name === Constants.SKILL_TOOL ||
|
|
2468
2492
|
entry.call.name === Constants.READ_FILE
|
|
2469
2493
|
? this.getCodeSessionContext()
|