@librechat/agents 3.4.4 → 3.4.5

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,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
+ });
package/src/types/hitl.ts CHANGED
@@ -165,15 +165,37 @@ export interface AskUserQuestionRequest {
165
165
  multiSelect?: boolean;
166
166
  }
167
167
 
168
+ /** One independently answerable question in a batched question request. */
169
+ export interface AskUserQuestionBatchItem extends AskUserQuestionRequest {
170
+ /** Batch-unique identifier (`[A-Za-z][A-Za-z0-9_-]{0,63}`). */
171
+ id: string;
172
+ /** Optional short heading rendered above the question. */
173
+ header?: string;
174
+ }
175
+
176
+ /** Input shape for one tool call that asks one to four questions together. */
177
+ export interface AskUserQuestionsRequest {
178
+ questions: AskUserQuestionBatchItem[];
179
+ }
180
+
168
181
  /**
169
182
  * Structured payload the SDK passes to `interrupt()` when an agent (or
170
183
  * a custom node) needs to ask the user a clarifying question. Mirrors
171
- * Claude Code's `AskUserQuestion` semantic. Resume value:
172
- * `AskUserQuestionResolution`.
184
+ * Claude Code's `AskUserQuestion` semantic. Resume value is
185
+ * `AskUserQuestionResolution` for a single question, or
186
+ * `AskUserQuestionsResolution` when `questions` is present.
173
187
  */
174
188
  export interface AskUserQuestionInterruptPayload {
175
189
  type: 'ask_user_question';
190
+ /**
191
+ * Single-question request, or the first question as a compatibility
192
+ * fallback when `questions` contains a batch. This lets existing hosts show
193
+ * a useful preview during a staged rollout, but they must support `questions`
194
+ * and `AskUserQuestionsResolution` before enabling a batched tool schema.
195
+ */
176
196
  question: AskUserQuestionRequest;
197
+ /** One to four questions collected by one `ask_user_question` tool call. */
198
+ questions?: AskUserQuestionsRequest['questions'];
177
199
  /**
178
200
  * The `tool_call_id` of the ask-tool call that raised this interrupt,
179
201
  * when the tool body supplied it (see `askUserQuestion`'s `options`).
@@ -184,6 +206,12 @@ export interface AskUserQuestionInterruptPayload {
184
206
  tool_call_id?: string;
185
207
  }
186
208
 
209
+ /** Batch-specialized ask payload for hosts that render several questions. */
210
+ export interface AskUserQuestionsInterruptPayload
211
+ extends AskUserQuestionInterruptPayload {
212
+ questions: AskUserQuestionsRequest['questions'];
213
+ }
214
+
187
215
  /**
188
216
  * Discriminated union of every interrupt payload the SDK raises. New
189
217
  * variants can be added without breaking existing handlers as long as
@@ -207,6 +235,12 @@ export interface AskUserQuestionResolution {
207
235
  answer: string;
208
236
  }
209
237
 
238
+ /** Resume value for a batched `ask_user_question` interrupt. */
239
+ export interface AskUserQuestionsResolution {
240
+ /** Human answers keyed by each `AskUserQuestionBatchItem.id`. */
241
+ answers: Record<string, string>;
242
+ }
243
+
210
244
  /**
211
245
  * Type guard narrowing an arbitrary value to a `ToolApprovalInterruptPayload`.
212
246
  * Accepts `unknown` (not just `HumanInterruptPayload`) because hosts can