@librechat/agents 3.4.4 → 3.4.6
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/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +268 -14
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestions.cjs +66 -0
- package/dist/cjs/hitl/askUserQuestions.cjs.map +1 -0
- package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs +46 -0
- package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs.map +1 -0
- package/dist/cjs/hitl/index.cjs +2 -0
- package/dist/cjs/langfuseTraceShaping.cjs +9 -6
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
- package/dist/cjs/main.cjs +6 -0
- package/dist/cjs/run.cjs +147 -14
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +93 -31
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/session/handlers.cjs +10 -0
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +17 -3
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +1 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +4 -2
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +1 -0
- package/dist/cjs/tools/handlers.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +28 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/types/hitl.cjs +13 -0
- package/dist/cjs/types/hitl.cjs.map +1 -0
- package/dist/cjs/utils/handlers.cjs +3 -0
- package/dist/cjs/utils/handlers.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +268 -14
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestions.mjs +66 -0
- package/dist/esm/hitl/askUserQuestions.mjs.map +1 -0
- package/dist/esm/hitl/askUserQuestionsInterrupt.mjs +43 -0
- package/dist/esm/hitl/askUserQuestionsInterrupt.mjs.map +1 -0
- package/dist/esm/hitl/index.mjs +2 -0
- package/dist/esm/langfuseTraceShaping.mjs +9 -6
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -1
- package/dist/esm/run.mjs +147 -14
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +93 -31
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/session/handlers.mjs +10 -0
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +17 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +1 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +4 -2
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +1 -0
- package/dist/esm/tools/handlers.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +28 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/types/hitl.mjs +13 -0
- package/dist/esm/types/hitl.mjs.map +1 -0
- package/dist/esm/utils/handlers.mjs +3 -0
- package/dist/esm/utils/handlers.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +2 -0
- package/dist/types/graphs/Graph.d.ts +87 -0
- package/dist/types/hitl/askUserQuestions.d.ts +24 -0
- package/dist/types/hitl/askUserQuestionsInterrupt.d.ts +11 -0
- package/dist/types/hitl/index.d.ts +2 -0
- package/dist/types/langfuseTraceShaping.d.ts +4 -3
- package/dist/types/run.d.ts +9 -1
- package/dist/types/session/types.d.ts +1 -1
- package/dist/types/summarization/node.d.ts +6 -0
- package/dist/types/types/graph.d.ts +3 -3
- package/dist/types/types/hitl.d.ts +31 -2
- package/dist/types/types/stream.d.ts +57 -0
- package/dist/types/utils/handlers.d.ts +1 -0
- package/package.json +2 -1
- package/src/aggregator.test.ts +127 -0
- package/src/common/enum.ts +2 -0
- package/src/graphs/Graph.ts +365 -7
- package/src/graphs/__tests__/Graph.closeRunStep.test.ts +385 -0
- package/src/hitl/askUserQuestions.ts +126 -0
- package/src/hitl/askUserQuestionsInterrupt.ts +115 -0
- package/src/hitl/index.ts +6 -0
- package/src/langfuseTraceShaping.ts +14 -7
- package/src/run.ts +219 -41
- package/src/session/AgentSession.ts +105 -13
- package/src/session/handlers.ts +14 -2
- package/src/session/types.ts +1 -0
- package/src/specs/activity-label-observability.live.test.ts +262 -0
- package/src/specs/activity-label-observability.test.ts +167 -0
- package/src/specs/ask-user-questions.live.test.ts +185 -0
- package/src/specs/ask-user-questions.test.ts +293 -0
- package/src/specs/langfuse-trace-shaping.test.ts +32 -0
- package/src/specs/run-step-timestamps.test.ts +414 -0
- package/src/stream.ts +29 -6
- package/src/summarization/node.ts +11 -0
- package/src/tools/ToolNode.ts +2 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +52 -10
- package/src/tools/__tests__/handlers.test.ts +2 -0
- package/src/tools/handlers.ts +1 -0
- package/src/tools/subagent/SubagentExecutor.ts +46 -4
- package/src/types/graph.ts +9 -6
- package/src/types/hitl.ts +36 -2
- package/src/types/stream.ts +65 -12
- package/src/utils/handlers.ts +13 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
3
|
+
import { describe, expect, it } from '@jest/globals';
|
|
4
|
+
import { AIMessage, ToolMessage } from '@langchain/core/messages';
|
|
5
|
+
import {
|
|
6
|
+
END,
|
|
7
|
+
START,
|
|
8
|
+
Command,
|
|
9
|
+
StateGraph,
|
|
10
|
+
MemorySaver,
|
|
11
|
+
isInterrupted,
|
|
12
|
+
MessagesAnnotation,
|
|
13
|
+
} from '@langchain/langgraph';
|
|
14
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
15
|
+
import type * as t from '@/types';
|
|
16
|
+
import {
|
|
17
|
+
ASK_USER_QUESTION_ID_PATTERN,
|
|
18
|
+
askUserQuestions,
|
|
19
|
+
isAskUserQuestionsInterrupt,
|
|
20
|
+
MAX_ASK_USER_QUESTIONS,
|
|
21
|
+
} from '@/hitl';
|
|
22
|
+
import { ToolNode } from '@/tools/ToolNode';
|
|
23
|
+
|
|
24
|
+
type MessagesUpdate = { messages: BaseMessage[] };
|
|
25
|
+
const questions = [
|
|
26
|
+
{
|
|
27
|
+
id: 'metric',
|
|
28
|
+
header: 'Metric',
|
|
29
|
+
question: 'Which performance cost should be analyzed?',
|
|
30
|
+
options: [
|
|
31
|
+
{ label: 'ClickHouse workload', value: 'workload' },
|
|
32
|
+
{ label: 'Website experience', value: 'website' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'window',
|
|
37
|
+
header: 'Window',
|
|
38
|
+
question: 'Which time window should be used?',
|
|
39
|
+
options: [
|
|
40
|
+
{ label: 'Last 24 hours', value: '24h' },
|
|
41
|
+
{ label: 'Last 7 days', value: '7d' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
] satisfies t.AskUserQuestionBatchItem[];
|
|
45
|
+
|
|
46
|
+
const questionSchema = z.object({
|
|
47
|
+
id: z.string().regex(ASK_USER_QUESTION_ID_PATTERN),
|
|
48
|
+
header: z.string().optional(),
|
|
49
|
+
question: z.string(),
|
|
50
|
+
options: z
|
|
51
|
+
.array(z.object({ label: z.string(), value: z.string() }))
|
|
52
|
+
.optional(),
|
|
53
|
+
});
|
|
54
|
+
const askUserQuestionsSchema = z.object({
|
|
55
|
+
questions: z.array(questionSchema).min(1).max(MAX_ASK_USER_QUESTIONS),
|
|
56
|
+
});
|
|
57
|
+
type AskUserQuestionsInput = z.infer<typeof askUserQuestionsSchema>;
|
|
58
|
+
|
|
59
|
+
function buildGraph() {
|
|
60
|
+
const askTool = tool(
|
|
61
|
+
async (input: AskUserQuestionsInput, config) => {
|
|
62
|
+
const resolution = askUserQuestions(input, {
|
|
63
|
+
toolCallId: config.toolCall?.id,
|
|
64
|
+
});
|
|
65
|
+
return JSON.stringify(resolution);
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'ask_user_question',
|
|
69
|
+
description: 'Ask several related questions in one interaction.',
|
|
70
|
+
schema: askUserQuestionsSchema,
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
const node = new ToolNode({
|
|
74
|
+
tools: [askTool],
|
|
75
|
+
directToolNames: new Set(['ask_user_question']),
|
|
76
|
+
interruptingToolNames: new Set(['ask_user_question']),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return new StateGraph(MessagesAnnotation)
|
|
80
|
+
.addNode(
|
|
81
|
+
'agent',
|
|
82
|
+
(): MessagesUpdate => ({
|
|
83
|
+
messages: [
|
|
84
|
+
new AIMessage({
|
|
85
|
+
content: '',
|
|
86
|
+
tool_calls: [
|
|
87
|
+
{
|
|
88
|
+
id: 'batched-ask-call',
|
|
89
|
+
name: 'ask_user_question',
|
|
90
|
+
args: { questions },
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}),
|
|
94
|
+
],
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
.addNode('tools', node)
|
|
98
|
+
.addEdge(START, 'agent')
|
|
99
|
+
.addEdge('agent', 'tools')
|
|
100
|
+
.addEdge('tools', END)
|
|
101
|
+
.compile({
|
|
102
|
+
checkpointer: new MemorySaver(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('askUserQuestions', () => {
|
|
107
|
+
it('pauses once for a batch and resumes with keyed answers', async () => {
|
|
108
|
+
const graph = buildGraph();
|
|
109
|
+
const config = { configurable: { thread_id: 'batched-questions' } };
|
|
110
|
+
|
|
111
|
+
const first = await graph.invoke({ messages: [] }, config);
|
|
112
|
+
expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
|
|
113
|
+
if (!isInterrupted<t.HumanInterruptPayload>(first)) {
|
|
114
|
+
throw new Error('expected batched question interrupt');
|
|
115
|
+
}
|
|
116
|
+
expect(first.__interrupt__).toHaveLength(1);
|
|
117
|
+
const payload = first.__interrupt__[0].value;
|
|
118
|
+
expect(isAskUserQuestionsInterrupt(payload)).toBe(true);
|
|
119
|
+
expect(payload).toMatchObject({
|
|
120
|
+
type: 'ask_user_question',
|
|
121
|
+
tool_call_id: 'batched-ask-call',
|
|
122
|
+
question: { question: questions[0].question },
|
|
123
|
+
questions,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const answers: t.AskUserQuestionsResolution = {
|
|
127
|
+
answers: { metric: 'workload', window: '7d' },
|
|
128
|
+
};
|
|
129
|
+
const second = (await graph.invoke(
|
|
130
|
+
new Command({ resume: answers }),
|
|
131
|
+
config
|
|
132
|
+
)) as MessagesUpdate;
|
|
133
|
+
const result = second.messages.find(
|
|
134
|
+
(message): message is ToolMessage =>
|
|
135
|
+
message.getType() === 'tool' &&
|
|
136
|
+
(message as ToolMessage).name === 'ask_user_question'
|
|
137
|
+
);
|
|
138
|
+
expect(result).toBeDefined();
|
|
139
|
+
expect(JSON.parse(String(result!.content))).toEqual(answers);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects duplicate question ids before raising an interrupt', () => {
|
|
143
|
+
const request: t.AskUserQuestionsRequest = {
|
|
144
|
+
questions: [questions[0], { ...questions[1], id: questions[0].id }],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
expect(() => askUserQuestions(request)).toThrow(
|
|
148
|
+
'requires unique question ids'
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('distinguishes singular ask payloads from batched payloads', () => {
|
|
153
|
+
const sparseOptions: unknown[] = [];
|
|
154
|
+
sparseOptions.length = 2;
|
|
155
|
+
|
|
156
|
+
expect(
|
|
157
|
+
isAskUserQuestionsInterrupt({
|
|
158
|
+
type: 'ask_user_question',
|
|
159
|
+
question: { question: 'Proceed?' },
|
|
160
|
+
})
|
|
161
|
+
).toBe(false);
|
|
162
|
+
expect(
|
|
163
|
+
isAskUserQuestionsInterrupt({
|
|
164
|
+
type: 'ask_user_question',
|
|
165
|
+
question: { question: 'Proceed?' },
|
|
166
|
+
questions: [],
|
|
167
|
+
})
|
|
168
|
+
).toBe(false);
|
|
169
|
+
expect(
|
|
170
|
+
isAskUserQuestionsInterrupt({
|
|
171
|
+
type: 'ask_user_question',
|
|
172
|
+
question: { question: 'Proceed?' },
|
|
173
|
+
questions: [null],
|
|
174
|
+
})
|
|
175
|
+
).toBe(false);
|
|
176
|
+
expect(
|
|
177
|
+
isAskUserQuestionsInterrupt({
|
|
178
|
+
type: 'ask_user_question',
|
|
179
|
+
question: { question: 'Proceed?' },
|
|
180
|
+
questions: [{ id: '__proto__', question: 'Unsafe key?' }],
|
|
181
|
+
})
|
|
182
|
+
).toBe(false);
|
|
183
|
+
expect(
|
|
184
|
+
isAskUserQuestionsInterrupt({
|
|
185
|
+
type: 'ask_user_question',
|
|
186
|
+
question: { question: 'Proceed?' },
|
|
187
|
+
questions: Array.from(
|
|
188
|
+
{ length: MAX_ASK_USER_QUESTIONS + 1 },
|
|
189
|
+
(_, index) => ({
|
|
190
|
+
id: `question-${index}`,
|
|
191
|
+
question: `Question ${index}?`,
|
|
192
|
+
})
|
|
193
|
+
),
|
|
194
|
+
})
|
|
195
|
+
).toBe(false);
|
|
196
|
+
expect(
|
|
197
|
+
isAskUserQuestionsInterrupt({
|
|
198
|
+
type: 'ask_user_question',
|
|
199
|
+
question: { question: 'Proceed?' },
|
|
200
|
+
questions: [
|
|
201
|
+
{
|
|
202
|
+
id: 'choice',
|
|
203
|
+
question: 'Choose?',
|
|
204
|
+
options: [{ label: 'Missing value' }],
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
})
|
|
208
|
+
).toBe(false);
|
|
209
|
+
expect(
|
|
210
|
+
isAskUserQuestionsInterrupt({
|
|
211
|
+
type: 'ask_user_question',
|
|
212
|
+
question: { question: 'Proceed?' },
|
|
213
|
+
questions: [
|
|
214
|
+
{
|
|
215
|
+
id: 'choice',
|
|
216
|
+
question: 'Choose?',
|
|
217
|
+
options: sparseOptions,
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
})
|
|
221
|
+
).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('returns a tool error when a resumed batch omits an answer', async () => {
|
|
225
|
+
const graph = buildGraph();
|
|
226
|
+
const config = { configurable: { thread_id: 'incomplete-answers' } };
|
|
227
|
+
|
|
228
|
+
await graph.invoke({ messages: [] }, config);
|
|
229
|
+
const resumed = (await graph.invoke(
|
|
230
|
+
new Command({ resume: { answers: { metric: 'workload' } } }),
|
|
231
|
+
config
|
|
232
|
+
)) as MessagesUpdate;
|
|
233
|
+
const result = resumed.messages.find(
|
|
234
|
+
(message): message is ToolMessage =>
|
|
235
|
+
message.getType() === 'tool' &&
|
|
236
|
+
(message as ToolMessage).name === 'ask_user_question'
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
expect(result?.status).toBe('error');
|
|
240
|
+
expect(String(result?.content)).toContain(
|
|
241
|
+
'requires a string answer for question id "window"'
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('rejects empty question ids before raising an interrupt', () => {
|
|
246
|
+
const request: t.AskUserQuestionsRequest = {
|
|
247
|
+
questions: [{ ...questions[0], id: ' ' }],
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
expect(() => askUserQuestions(request)).toThrow(
|
|
251
|
+
'requires each question id to match'
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('rejects unsafe answer-map keys before raising an interrupt', () => {
|
|
256
|
+
const request: t.AskUserQuestionsRequest = {
|
|
257
|
+
questions: [{ ...questions[0], id: '__proto__' }],
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
expect(() => askUserQuestions(request)).toThrow(
|
|
261
|
+
'requires each question id to match'
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('rejects sparse option arrays before raising an interrupt', () => {
|
|
266
|
+
const sparseOptions: t.AskUserQuestionOption[] = [];
|
|
267
|
+
sparseOptions.length = 2;
|
|
268
|
+
const request: t.AskUserQuestionsRequest = {
|
|
269
|
+
questions: [{ ...questions[0], options: sparseOptions }],
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
expect(() => askUserQuestions(request)).toThrow(
|
|
273
|
+
'requires each question and option to have valid string fields'
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('rejects batches larger than four questions', () => {
|
|
278
|
+
expect(MAX_ASK_USER_QUESTIONS).toBe(4);
|
|
279
|
+
const request: t.AskUserQuestionsRequest = {
|
|
280
|
+
questions: [
|
|
281
|
+
questions[0],
|
|
282
|
+
questions[1],
|
|
283
|
+
{ ...questions[0], id: 'third' },
|
|
284
|
+
{ ...questions[0], id: 'fourth' },
|
|
285
|
+
{ ...questions[0], id: 'fifth' },
|
|
286
|
+
],
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
expect(() => askUserQuestions(request)).toThrow(
|
|
290
|
+
'accepts at most 4 questions'
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -486,6 +486,38 @@ describe('shapeLangfuseSpan', () => {
|
|
|
486
486
|
expect(span.attributes[TRACE_OUTPUT]).toBe('Conversation title');
|
|
487
487
|
});
|
|
488
488
|
|
|
489
|
+
it('marks activity-phase roots as chains even when tagged as agent work', () => {
|
|
490
|
+
const span = createSpan('summarize-activity-phase', {
|
|
491
|
+
[TRACE_TAGS]: JSON.stringify([
|
|
492
|
+
'librechat',
|
|
493
|
+
'activity-phase',
|
|
494
|
+
'agent-run-summary',
|
|
495
|
+
'agent',
|
|
496
|
+
]),
|
|
497
|
+
[INPUT]: 'What changed?',
|
|
498
|
+
[OUTPUT]: 'Reconciled the implementation and verified the fix',
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
shapeLangfuseSpan(span);
|
|
502
|
+
|
|
503
|
+
expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
|
|
504
|
+
expect(span.attributes[TRACE_INPUT]).toBe('What changed?');
|
|
505
|
+
expect(span.attributes[TRACE_OUTPUT]).toBe(
|
|
506
|
+
'Reconciled the implementation and verified the fix'
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('does not treat a user-supplied activity-phase tag as an operation type', () => {
|
|
511
|
+
const span = createSpan('LibreChat Agent', {
|
|
512
|
+
[TRACE_TAGS]: JSON.stringify(['librechat', 'agent', 'activity-phase']),
|
|
513
|
+
[INPUT]: 'Run an ordinary agent',
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
shapeLangfuseSpan(span);
|
|
517
|
+
|
|
518
|
+
expect(span.attributes[OBSERVATION_TYPE]).toBe('agent');
|
|
519
|
+
});
|
|
520
|
+
|
|
489
521
|
it('does not classify untagged root spans as agents', () => {
|
|
490
522
|
const span = createSpan('Custom root', { [INPUT]: 'input' });
|
|
491
523
|
|