@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.
- 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/main.cjs +6 -0
- package/dist/cjs/types/hitl.cjs +13 -0
- package/dist/cjs/types/hitl.cjs.map +1 -0
- 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/main.mjs +3 -1
- package/dist/esm/types/hitl.mjs +13 -0
- package/dist/esm/types/hitl.mjs.map +1 -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/types/hitl.d.ts +31 -2
- package/package.json +2 -1
- package/src/hitl/askUserQuestions.ts +126 -0
- package/src/hitl/askUserQuestionsInterrupt.ts +115 -0
- package/src/hitl/index.ts +6 -0
- package/src/specs/ask-user-questions.live.test.ts +185 -0
- package/src/specs/ask-user-questions.test.ts +293 -0
- package/src/types/hitl.ts +36 -2
|
@@ -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
|