@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
|
@@ -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.
|
|
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
|
@@ -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
|
+
});
|