@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.
@@ -157,15 +157,35 @@ export interface AskUserQuestionRequest {
157
157
  */
158
158
  multiSelect?: boolean;
159
159
  }
160
+ /** One independently answerable question in a batched question request. */
161
+ export interface AskUserQuestionBatchItem extends AskUserQuestionRequest {
162
+ /** Batch-unique identifier (`[A-Za-z][A-Za-z0-9_-]{0,63}`). */
163
+ id: string;
164
+ /** Optional short heading rendered above the question. */
165
+ header?: string;
166
+ }
167
+ /** Input shape for one tool call that asks one to four questions together. */
168
+ export interface AskUserQuestionsRequest {
169
+ questions: AskUserQuestionBatchItem[];
170
+ }
160
171
  /**
161
172
  * Structured payload the SDK passes to `interrupt()` when an agent (or
162
173
  * a custom node) needs to ask the user a clarifying question. Mirrors
163
- * Claude Code's `AskUserQuestion` semantic. Resume value:
164
- * `AskUserQuestionResolution`.
174
+ * Claude Code's `AskUserQuestion` semantic. Resume value is
175
+ * `AskUserQuestionResolution` for a single question, or
176
+ * `AskUserQuestionsResolution` when `questions` is present.
165
177
  */
166
178
  export interface AskUserQuestionInterruptPayload {
167
179
  type: 'ask_user_question';
180
+ /**
181
+ * Single-question request, or the first question as a compatibility
182
+ * fallback when `questions` contains a batch. This lets existing hosts show
183
+ * a useful preview during a staged rollout, but they must support `questions`
184
+ * and `AskUserQuestionsResolution` before enabling a batched tool schema.
185
+ */
168
186
  question: AskUserQuestionRequest;
187
+ /** One to four questions collected by one `ask_user_question` tool call. */
188
+ questions?: AskUserQuestionsRequest['questions'];
169
189
  /**
170
190
  * The `tool_call_id` of the ask-tool call that raised this interrupt,
171
191
  * when the tool body supplied it (see `askUserQuestion`'s `options`).
@@ -175,6 +195,10 @@ export interface AskUserQuestionInterruptPayload {
175
195
  */
176
196
  tool_call_id?: string;
177
197
  }
198
+ /** Batch-specialized ask payload for hosts that render several questions. */
199
+ export interface AskUserQuestionsInterruptPayload extends AskUserQuestionInterruptPayload {
200
+ questions: AskUserQuestionsRequest['questions'];
201
+ }
178
202
  /**
179
203
  * Discriminated union of every interrupt payload the SDK raises. New
180
204
  * variants can be added without breaking existing handlers as long as
@@ -194,6 +218,11 @@ export interface AskUserQuestionResolution {
194
218
  */
195
219
  answer: string;
196
220
  }
221
+ /** Resume value for a batched `ask_user_question` interrupt. */
222
+ export interface AskUserQuestionsResolution {
223
+ /** Human answers keyed by each `AskUserQuestionBatchItem.id`. */
224
+ answers: Record<string, string>;
225
+ }
197
226
  /**
198
227
  * Type guard narrowing an arbitrary value to a `ToolApprovalInterruptPayload`.
199
228
  * Accepts `unknown` (not just `HumanInterruptPayload`) because hosts can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.4.4",
3
+ "version": "3.4.5",
4
4
  "reova": {
5
5
  "enabled": true,
6
6
  "endpoint": "https://telemetry.reo.dev/data"
@@ -199,6 +199,7 @@
199
199
  "supervised": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/supervised.ts --provider anthropic --name Jo --location \"New York, NY\"",
200
200
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
201
201
  "test:live:handoffs": "RUN_HANDOFF_LIVE_TESTS=1 NODE_OPTIONS='--experimental-vm-modules' jest src/specs/agent-handoffs.live.test.ts --runInBand",
202
+ "test:live:ask-user-questions": "RUN_ASK_USER_QUESTIONS_LIVE_TESTS=1 NODE_OPTIONS='--experimental-vm-modules' jest src/specs/ask-user-questions.live.test.ts --runInBand",
202
203
  "test:memory": "NODE_OPTIONS='--expose-gc' npx jest src/specs/title.memory-leak.test.ts",
203
204
  "test:all": "npm test -- --testPathIgnorePatterns=title.memory-leak.test.ts && npm run test:memory",
204
205
  "reinstall": "npm run clean && npm ci && rm -rf ./dist && npm run build",
@@ -0,0 +1,126 @@
1
+ import { interrupt } from '@langchain/langgraph';
2
+ import type {
3
+ AskUserQuestionBatchItem,
4
+ AskUserQuestionRequest,
5
+ AskUserQuestionsInterruptPayload,
6
+ AskUserQuestionsRequest,
7
+ AskUserQuestionsResolution,
8
+ } from '@/types/hitl';
9
+ import {
10
+ ASK_USER_QUESTION_ID_PATTERN,
11
+ isAskUserQuestionRequest,
12
+ MAX_ASK_USER_QUESTIONS,
13
+ } from './askUserQuestionsInterrupt';
14
+
15
+ function validateQuestions(
16
+ questions: readonly AskUserQuestionBatchItem[]
17
+ ): AskUserQuestionBatchItem {
18
+ if (questions.length === 0) {
19
+ throw new RangeError('askUserQuestions requires at least one question.');
20
+ }
21
+ if (questions.length > MAX_ASK_USER_QUESTIONS) {
22
+ throw new RangeError(
23
+ `askUserQuestions accepts at most ${MAX_ASK_USER_QUESTIONS} questions.`
24
+ );
25
+ }
26
+
27
+ const ids = new Set<string>();
28
+ for (const question of questions) {
29
+ if (!isAskUserQuestionRequest(question)) {
30
+ throw new TypeError(
31
+ 'askUserQuestions requires each question and option to have valid string fields.'
32
+ );
33
+ }
34
+ if (!ASK_USER_QUESTION_ID_PATTERN.test(question.id)) {
35
+ throw new Error(
36
+ 'askUserQuestions requires each question id to match [A-Za-z][A-Za-z0-9_-]{0,63}.'
37
+ );
38
+ }
39
+ if (ids.has(question.id)) {
40
+ throw new Error(
41
+ `askUserQuestions requires unique question ids; received "${question.id}" more than once.`
42
+ );
43
+ }
44
+ ids.add(question.id);
45
+ }
46
+ return questions[0];
47
+ }
48
+
49
+ interface AskUserQuestionsResolutionCandidate {
50
+ answers?: unknown;
51
+ }
52
+
53
+ function validateResolution(
54
+ value: unknown,
55
+ questions: readonly AskUserQuestionBatchItem[]
56
+ ): AskUserQuestionsResolution {
57
+ if (typeof value !== 'object' || value === null) {
58
+ throw new TypeError('askUserQuestions requires an answers object.');
59
+ }
60
+ const answers = (value as AskUserQuestionsResolutionCandidate).answers;
61
+ if (
62
+ typeof answers !== 'object' ||
63
+ answers === null ||
64
+ Array.isArray(answers)
65
+ ) {
66
+ throw new TypeError('askUserQuestions requires an answers object.');
67
+ }
68
+
69
+ const validated: Record<string, string> = {};
70
+ for (const question of questions) {
71
+ const descriptor = Object.getOwnPropertyDescriptor(answers, question.id);
72
+ const answer: unknown = descriptor?.value;
73
+ if (descriptor == null || typeof answer !== 'string') {
74
+ throw new TypeError(
75
+ `askUserQuestions requires a string answer for question id "${question.id}".`
76
+ );
77
+ }
78
+ validated[question.id] = answer;
79
+ }
80
+ return { answers: validated };
81
+ }
82
+
83
+ /**
84
+ * Suspend once to collect answers to several related questions. The first
85
+ * question is also included in the legacy `question` field so existing hosts
86
+ * can render a useful fallback during a staged rollout.
87
+ *
88
+ * Question ids must be non-empty and unique within the batch. The helper
89
+ * accepts at most four questions so hosts can render the interaction as one
90
+ * focused decision surface rather than an unbounded form.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * const { answers } = askUserQuestions({
95
+ * questions: [
96
+ * { id: 'environment', question: 'Which environment?' },
97
+ * { id: 'region', question: 'Which region?' },
98
+ * ],
99
+ * });
100
+ * return `Deploy to ${answers.environment} in ${answers.region}`;
101
+ * ```
102
+ */
103
+ export function askUserQuestions(
104
+ request: AskUserQuestionsRequest,
105
+ options?: { toolCallId?: string }
106
+ ): AskUserQuestionsResolution {
107
+ const first = validateQuestions(request.questions);
108
+ const fallback: AskUserQuestionRequest = {
109
+ question: first.question,
110
+ ...(first.description != null && { description: first.description }),
111
+ ...(first.options != null && { options: first.options }),
112
+ ...(first.multiSelect != null && { multiSelect: first.multiSelect }),
113
+ };
114
+ const payload: AskUserQuestionsInterruptPayload = {
115
+ type: 'ask_user_question',
116
+ question: fallback,
117
+ questions: request.questions,
118
+ ...(options?.toolCallId != null &&
119
+ options.toolCallId !== '' && { tool_call_id: options.toolCallId }),
120
+ };
121
+
122
+ const resolution = interrupt<AskUserQuestionsInterruptPayload, unknown>(
123
+ payload
124
+ );
125
+ return validateResolution(resolution, request.questions);
126
+ }
@@ -0,0 +1,115 @@
1
+ import type {
2
+ AskUserQuestionBatchItem,
3
+ AskUserQuestionOption,
4
+ AskUserQuestionRequest,
5
+ AskUserQuestionsInterruptPayload,
6
+ } from '@/types/hitl';
7
+ import { isAskUserQuestionInterrupt } from '@/types/hitl';
8
+
9
+ /** Maximum questions supported by one batched clarification interaction. */
10
+ export const MAX_ASK_USER_QUESTIONS = 4;
11
+
12
+ /** Safe identifier format for answer-map keys in a batched question. */
13
+ export const ASK_USER_QUESTION_ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
14
+
15
+ interface AskUserQuestionOptionCandidate {
16
+ label?: unknown;
17
+ value?: unknown;
18
+ }
19
+
20
+ interface AskUserQuestionCandidate {
21
+ question?: unknown;
22
+ description?: unknown;
23
+ options?: unknown;
24
+ multiSelect?: unknown;
25
+ }
26
+
27
+ interface AskUserQuestionBatchItemCandidate extends AskUserQuestionCandidate {
28
+ id?: unknown;
29
+ header?: unknown;
30
+ }
31
+
32
+ function isAskUserQuestionOption(
33
+ value: unknown
34
+ ): value is AskUserQuestionOption {
35
+ if (typeof value !== 'object' || value === null) {
36
+ return false;
37
+ }
38
+ const option = value as AskUserQuestionOptionCandidate;
39
+ return typeof option.label === 'string' && typeof option.value === 'string';
40
+ }
41
+
42
+ function isAskUserQuestionOptions(
43
+ value: unknown
44
+ ): value is AskUserQuestionOption[] {
45
+ if (!Array.isArray(value)) {
46
+ return false;
47
+ }
48
+ for (let index = 0; index < value.length; index++) {
49
+ if (!Object.hasOwn(value, index) || !isAskUserQuestionOption(value[index])) {
50
+ return false;
51
+ }
52
+ }
53
+ return true;
54
+ }
55
+
56
+ export function isAskUserQuestionRequest(
57
+ value: unknown
58
+ ): value is AskUserQuestionRequest {
59
+ if (typeof value !== 'object' || value === null) {
60
+ return false;
61
+ }
62
+ const question = value as AskUserQuestionCandidate;
63
+ return (
64
+ typeof question.question === 'string' &&
65
+ (question.description === undefined ||
66
+ typeof question.description === 'string') &&
67
+ (question.options === undefined ||
68
+ isAskUserQuestionOptions(question.options)) &&
69
+ (question.multiSelect === undefined ||
70
+ typeof question.multiSelect === 'boolean')
71
+ );
72
+ }
73
+
74
+ function isAskUserQuestionBatchItem(
75
+ value: unknown
76
+ ): value is AskUserQuestionBatchItem {
77
+ if (!isAskUserQuestionRequest(value)) {
78
+ return false;
79
+ }
80
+ const question = value as AskUserQuestionBatchItemCandidate;
81
+ return (
82
+ typeof question.id === 'string' &&
83
+ ASK_USER_QUESTION_ID_PATTERN.test(question.id) &&
84
+ (question.header === undefined || typeof question.header === 'string')
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Type guard for the batched form of an `ask_user_question` interrupt. Hosts
90
+ * use this to select the multi-question UI and `AskUserQuestionsResolution`.
91
+ */
92
+ export function isAskUserQuestionsInterrupt(
93
+ payload: unknown
94
+ ): payload is AskUserQuestionsInterruptPayload {
95
+ if (
96
+ !isAskUserQuestionInterrupt(payload) ||
97
+ !isAskUserQuestionRequest(payload.question) ||
98
+ (payload.tool_call_id !== undefined &&
99
+ typeof payload.tool_call_id !== 'string') ||
100
+ !Array.isArray(payload.questions) ||
101
+ payload.questions.length === 0 ||
102
+ payload.questions.length > MAX_ASK_USER_QUESTIONS
103
+ ) {
104
+ return false;
105
+ }
106
+
107
+ const ids = new Set<string>();
108
+ for (const question of payload.questions) {
109
+ if (!isAskUserQuestionBatchItem(question) || ids.has(question.id)) {
110
+ return false;
111
+ }
112
+ ids.add(question.id);
113
+ }
114
+ return true;
115
+ }
package/src/hitl/index.ts CHANGED
@@ -5,3 +5,9 @@
5
5
  */
6
6
 
7
7
  export { askUserQuestion } from './askUserQuestion';
8
+ export { askUserQuestions } from './askUserQuestions';
9
+ export {
10
+ ASK_USER_QUESTION_ID_PATTERN,
11
+ isAskUserQuestionsInterrupt,
12
+ MAX_ASK_USER_QUESTIONS,
13
+ } from './askUserQuestionsInterrupt';
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Live proof that Anthropic can produce one tool call containing several
3
+ * questions, pause once, and continue from one keyed batch resolution.
4
+ *
5
+ * Run with:
6
+ * RUN_ASK_USER_QUESTIONS_LIVE_TESTS=1 ANTHROPIC_API_KEY=... npm test -- ask-user-questions.live.test.ts --runInBand
7
+ */
8
+ import { config as dotenvConfig } from 'dotenv';
9
+ dotenvConfig(
10
+ process.env.DOTENV_CONFIG_PATH != null
11
+ ? { path: process.env.DOTENV_CONFIG_PATH }
12
+ : undefined
13
+ );
14
+
15
+ import { z } from 'zod';
16
+ import { tool } from '@langchain/core/tools';
17
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
18
+ import { MemorySaver } from '@langchain/langgraph';
19
+ import { describe, expect, it, jest } from '@jest/globals';
20
+ import type { BaseMessage } from '@langchain/core/messages';
21
+ import type { RunnableConfig } from '@langchain/core/runnables';
22
+ import type * as t from '@/types';
23
+ import { Providers } from '@/common';
24
+ import { askUserQuestions } from '@/hitl';
25
+ import { Run } from '@/run';
26
+
27
+ const shouldRunLive =
28
+ process.env.RUN_ASK_USER_QUESTIONS_LIVE_TESTS === '1' &&
29
+ process.env.ANTHROPIC_API_KEY != null &&
30
+ process.env.ANTHROPIC_API_KEY !== '';
31
+ const describeIfLive = shouldRunLive ? describe : describe.skip;
32
+ const modelName =
33
+ process.env.ANTHROPIC_BATCH_QUESTIONS_LIVE_MODEL ?? 'claude-sonnet-5';
34
+
35
+ const questionSchema = z.object({
36
+ id: z.enum(['metric', 'window']),
37
+ header: z.string().max(20),
38
+ question: z.string(),
39
+ options: z
40
+ .array(z.object({ label: z.string().max(120), value: z.string() }))
41
+ .min(2)
42
+ .max(3),
43
+ multiSelect: z.boolean(),
44
+ });
45
+ const askUserQuestionsSchema = z.object({
46
+ questions: z.array(questionSchema).length(2),
47
+ });
48
+ type AskUserQuestionsInput = z.infer<typeof askUserQuestionsSchema>;
49
+
50
+ const askTool = tool(
51
+ async (input: AskUserQuestionsInput, config) => {
52
+ const resolution = askUserQuestions(input, {
53
+ toolCallId: config.toolCall?.id,
54
+ });
55
+ return JSON.stringify(resolution);
56
+ },
57
+ {
58
+ name: 'ask_user_question',
59
+ description:
60
+ 'Ask the user one to four related questions in one interaction. Put every question in this single tool call.',
61
+ schema: askUserQuestionsSchema,
62
+ }
63
+ );
64
+
65
+ type LiveStreamConfig = Partial<RunnableConfig> & {
66
+ version: 'v1' | 'v2';
67
+ streamMode: string;
68
+ };
69
+
70
+ function streamConfig(threadId: string): LiveStreamConfig {
71
+ return {
72
+ configurable: { thread_id: threadId },
73
+ streamMode: 'values',
74
+ version: 'v2',
75
+ };
76
+ }
77
+
78
+ function messageText(message: BaseMessage): string {
79
+ if (typeof message.content === 'string') {
80
+ return message.content;
81
+ }
82
+ if (!Array.isArray(message.content)) {
83
+ return '';
84
+ }
85
+ return message.content
86
+ .map((part) =>
87
+ typeof part === 'object' &&
88
+ 'text' in part &&
89
+ typeof part.text === 'string'
90
+ ? part.text
91
+ : ''
92
+ )
93
+ .join('');
94
+ }
95
+
96
+ describeIfLive('askUserQuestions live Anthropic integration', () => {
97
+ jest.setTimeout(120_000);
98
+
99
+ it('uses one batched call and continues after one composite answer', async () => {
100
+ const nonce = `batch-questions-${Date.now()}`;
101
+ const saver = new MemorySaver();
102
+ const run = await Run.create<t.IState>({
103
+ runId: `${nonce}-run`,
104
+ graphConfig: {
105
+ type: 'standard',
106
+ agents: [
107
+ {
108
+ agentId: 'clarifier',
109
+ provider: Providers.ANTHROPIC,
110
+ clientOptions: {
111
+ modelName,
112
+ apiKey: process.env.ANTHROPIC_API_KEY,
113
+ maxTokens: 512,
114
+ streaming: true,
115
+ },
116
+ instructions: `You are testing a batched clarification tool.
117
+ On the first turn, call ask_user_question exactly once. In that one call, ask exactly two questions:
118
+ - id "metric": whether to analyze "workload" or "website"
119
+ - id "window": whether to analyze "24h" or "7d"
120
+ Do not emit two tool calls and do not answer in prose before the tool result.
121
+ After the tool returns, reply exactly: LIVE_BATCH_OK metric=<metric>; window=<window>`,
122
+ maxContextTokens: 8000,
123
+ graphTools: [askTool],
124
+ },
125
+ ],
126
+ compileOptions: { checkpointer: saver },
127
+ },
128
+ returnContent: true,
129
+ skipCleanup: true,
130
+ interruptingToolNames: ['ask_user_question'],
131
+ });
132
+ const config = streamConfig(`${nonce}-thread`);
133
+
134
+ await run.processStream(
135
+ {
136
+ messages: [
137
+ new HumanMessage(
138
+ 'Clarify both dimensions before doing any analysis.'
139
+ ),
140
+ ],
141
+ },
142
+ config
143
+ );
144
+
145
+ const pending = run.getInterrupt();
146
+ expect(pending?.payload.type).toBe('ask_user_question');
147
+ if (pending?.payload.type !== 'ask_user_question') {
148
+ throw new Error('expected ask_user_question interrupt');
149
+ }
150
+ expect(pending.payload.questions).toHaveLength(2);
151
+ expect(pending.payload.questions?.map(({ id }) => id)).toEqual([
152
+ 'metric',
153
+ 'window',
154
+ ]);
155
+
156
+ await run.resume<t.AskUserQuestionsResolution>(
157
+ { answers: { metric: 'workload', window: '7d' } },
158
+ config
159
+ );
160
+
161
+ expect(run.getInterrupt()).toBeUndefined();
162
+ const messages = run.getRunMessages() ?? [];
163
+ const askCalls = messages.flatMap((message) => {
164
+ if (message.getType() !== 'ai') {
165
+ return [];
166
+ }
167
+ return ((message as AIMessage).tool_calls ?? []).filter(
168
+ ({ name }) => name === 'ask_user_question'
169
+ );
170
+ });
171
+ expect(askCalls).toHaveLength(1);
172
+ expect(askCalls[0].args).toMatchObject({
173
+ questions: expect.arrayContaining([
174
+ expect.objectContaining({ id: 'metric' }),
175
+ expect.objectContaining({ id: 'window' }),
176
+ ]),
177
+ });
178
+
179
+ const finalText = messages
180
+ .filter((message) => message.getType() === 'ai')
181
+ .map(messageText)
182
+ .join('\n');
183
+ expect(finalText).toContain('LIVE_BATCH_OK metric=workload; window=7d');
184
+ });
185
+ });