@librechat/agents 3.4.3 → 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/graphs/Graph.cjs +27 -14
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +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/instrumentation.cjs +3 -3
- package/dist/cjs/langfuse.cjs +3 -3
- package/dist/cjs/langfuseRuntimeScope.cjs +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +2 -2
- package/dist/cjs/main.cjs +11 -1
- package/dist/cjs/messages/assistantPhase.cjs +59 -0
- package/dist/cjs/messages/assistantPhase.cjs.map +1 -0
- package/dist/cjs/messages/index.cjs +1 -0
- package/dist/cjs/prompts/activityLabel.cjs +76 -0
- package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
- package/dist/cjs/run.cjs +200 -10
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +1 -1
- package/dist/cjs/stream.cjs +45 -8
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +3 -3
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +81 -6
- 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/callbacks.cjs +8 -0
- package/dist/cjs/utils/callbacks.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +27 -14
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +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/instrumentation.mjs +3 -3
- package/dist/esm/langfuse.mjs +3 -3
- package/dist/esm/langfuseRuntimeScope.mjs +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +2 -2
- package/dist/esm/main.mjs +5 -2
- package/dist/esm/messages/assistantPhase.mjs +57 -0
- package/dist/esm/messages/assistantPhase.mjs.map +1 -0
- package/dist/esm/messages/index.mjs +1 -0
- package/dist/esm/prompts/activityLabel.mjs +74 -1
- package/dist/esm/prompts/activityLabel.mjs.map +1 -1
- package/dist/esm/run.mjs +202 -12
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +1 -1
- package/dist/esm/stream.mjs +45 -8
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +3 -3
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +81 -6
- 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/callbacks.mjs +8 -1
- package/dist/esm/utils/callbacks.mjs.map +1 -1
- 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/messages/assistantPhase.d.ts +22 -0
- package/dist/types/messages/index.d.ts +1 -0
- package/dist/types/prompts/activityLabel.d.ts +21 -1
- package/dist/types/run.d.ts +15 -2
- package/dist/types/types/activityLabel.d.ts +63 -0
- package/dist/types/types/assistantPhase.d.ts +6 -0
- package/dist/types/types/graph.d.ts +8 -1
- package/dist/types/types/hitl.d.ts +31 -2
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/stream.d.ts +11 -0
- package/dist/types/utils/callbacks.d.ts +1 -0
- package/package.json +2 -1
- package/src/graphs/Graph.ts +33 -9
- package/src/graphs/__tests__/Graph.reasoning.test.ts +57 -0
- package/src/hitl/askUserQuestions.ts +126 -0
- package/src/hitl/askUserQuestionsInterrupt.ts +115 -0
- package/src/hitl/index.ts +6 -0
- package/src/messages/assistantPhase.test.ts +75 -0
- package/src/messages/assistantPhase.ts +91 -0
- package/src/messages/index.ts +1 -0
- package/src/prompts/activityLabel.ts +177 -1
- package/src/run.ts +403 -21
- package/src/specs/activity-label-prompt.test.ts +123 -1
- package/src/specs/activity-phase-label.test.ts +306 -0
- package/src/specs/ask-user-questions.live.test.ts +185 -0
- package/src/specs/ask-user-questions.test.ts +293 -0
- package/src/stream.ts +69 -12
- package/src/tools/__tests__/SubagentExecutor.test.ts +436 -0
- package/src/tools/subagent/SubagentExecutor.ts +160 -8
- package/src/types/activityLabel.ts +65 -0
- package/src/types/assistantPhase.ts +6 -0
- package/src/types/graph.ts +8 -0
- package/src/types/hitl.ts +36 -2
- package/src/types/index.ts +1 -0
- package/src/types/stream.ts +9 -0
- package/src/utils/callbacks.ts +21 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import { LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT } from '@/langfuseToolOutputTracing';
|
|
3
|
+
import { Providers } from '@/common';
|
|
4
|
+
import { Run } from '@/run';
|
|
5
|
+
|
|
6
|
+
const invoke = jest.fn();
|
|
7
|
+
|
|
8
|
+
jest.mock('@/llm/init', () => ({
|
|
9
|
+
initializeModel: jest.fn(() => ({ invoke })),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
async function createRun(): Promise<Run<never>> {
|
|
13
|
+
return Run.create({
|
|
14
|
+
runId: 'phase-run',
|
|
15
|
+
graphConfig: {
|
|
16
|
+
type: 'standard',
|
|
17
|
+
agents: [
|
|
18
|
+
{
|
|
19
|
+
agentId: 'agent-1',
|
|
20
|
+
name: 'Phase Agent',
|
|
21
|
+
provider: Providers.OPENAI,
|
|
22
|
+
clientOptions: { model: 'gpt-4.1-mini' },
|
|
23
|
+
tools: [],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('generateActivityPhaseLabel', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
invoke.mockReset();
|
|
33
|
+
invoke.mockResolvedValue(
|
|
34
|
+
new AIMessage('"Fixed session refresh handling and verified auth tests."')
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not spend a model call on one logical activity', async () => {
|
|
39
|
+
const run = await createRun();
|
|
40
|
+
|
|
41
|
+
await expect(
|
|
42
|
+
run.generateActivityPhaseLabel({
|
|
43
|
+
provider: Providers.OPENAI,
|
|
44
|
+
activities: [{ label: 'Inspected session refresh middleware' }],
|
|
45
|
+
})
|
|
46
|
+
).resolves.toEqual({});
|
|
47
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('summarizes two activities and normalizes the persisted row', async () => {
|
|
51
|
+
const run = await createRun();
|
|
52
|
+
if (run.Graph != null) {
|
|
53
|
+
run.Graph.messages = [
|
|
54
|
+
new HumanMessage({
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: 'input_text',
|
|
58
|
+
text: 'Why is session refresh failing?',
|
|
59
|
+
} as never,
|
|
60
|
+
],
|
|
61
|
+
}),
|
|
62
|
+
new HumanMessage({
|
|
63
|
+
content: 'Internal routing instructions',
|
|
64
|
+
additional_kwargs: {
|
|
65
|
+
role: 'user',
|
|
66
|
+
isMeta: true,
|
|
67
|
+
source: 'routing',
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
const handleChainStart = jest.fn();
|
|
73
|
+
const handleChainEnd = jest.fn();
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
run.generateActivityPhaseLabel({
|
|
77
|
+
provider: Providers.OPENAI,
|
|
78
|
+
activities: [
|
|
79
|
+
{ label: 'Inspected session refresh middleware' },
|
|
80
|
+
{ label: 'Fixed refresh token validation' },
|
|
81
|
+
],
|
|
82
|
+
assistantContext: ['I am checking the auth path.'],
|
|
83
|
+
closingTextPhase: 'final_answer',
|
|
84
|
+
chainOptions: {
|
|
85
|
+
callbacks: [{ handleChainStart, handleChainEnd }],
|
|
86
|
+
configurable: {
|
|
87
|
+
requestBody: { parentMessageId: 'parent-message-1' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
).resolves.toEqual({
|
|
92
|
+
label: 'Fixed session refresh handling and verified auth tests',
|
|
93
|
+
});
|
|
94
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
95
|
+
const messages = invoke.mock.calls[0][0] as AIMessage[];
|
|
96
|
+
expect(String(messages[1].content)).toContain(
|
|
97
|
+
'Inspected session refresh middleware'
|
|
98
|
+
);
|
|
99
|
+
expect(String(messages[1].content)).toContain(
|
|
100
|
+
'Fixed refresh token validation'
|
|
101
|
+
);
|
|
102
|
+
expect(String(messages[1].content)).not.toContain(
|
|
103
|
+
'Why is session refresh failing?'
|
|
104
|
+
);
|
|
105
|
+
const modelConfig = invoke.mock.calls[0][1] as {
|
|
106
|
+
callbacks?: { getParentRunId?: () => string | undefined };
|
|
107
|
+
tags?: string[];
|
|
108
|
+
metadata?: Record<string, unknown>;
|
|
109
|
+
};
|
|
110
|
+
expect(modelConfig.callbacks?.getParentRunId?.()).toEqual(
|
|
111
|
+
expect.any(String)
|
|
112
|
+
);
|
|
113
|
+
expect(modelConfig.tags).toEqual(
|
|
114
|
+
expect.arrayContaining(['activity-phase', 'agent'])
|
|
115
|
+
);
|
|
116
|
+
expect(modelConfig.metadata).toEqual(
|
|
117
|
+
expect.objectContaining({
|
|
118
|
+
agentId: 'agent-1',
|
|
119
|
+
agentName: 'Phase Agent',
|
|
120
|
+
parentMessageId: 'parent-message-1',
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
expect(handleChainStart.mock.calls[0]?.[1]).toEqual(
|
|
124
|
+
expect.objectContaining({
|
|
125
|
+
messages: expect.arrayContaining([
|
|
126
|
+
expect.objectContaining({
|
|
127
|
+
content: 'Why is session refresh failing?',
|
|
128
|
+
}),
|
|
129
|
+
]),
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
expect(JSON.stringify(handleChainStart.mock.calls[0]?.[1])).not.toContain(
|
|
133
|
+
'Internal routing instructions'
|
|
134
|
+
);
|
|
135
|
+
expect(handleChainEnd.mock.calls[0]?.[0]).toEqual(
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
messages: expect.arrayContaining([
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
content: 'Fixed session refresh handling and verified auth tests',
|
|
140
|
+
}),
|
|
141
|
+
]),
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('does not call the model when retained activities have no evidence', async () => {
|
|
147
|
+
const run = await createRun();
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
run.generateActivityPhaseLabel({
|
|
151
|
+
provider: Providers.OPENAI,
|
|
152
|
+
activities: [
|
|
153
|
+
...Array.from({ length: 12 }, () => ({ status: 'success' as const })),
|
|
154
|
+
{ label: 'Evidence beyond the prompt cap' },
|
|
155
|
+
],
|
|
156
|
+
})
|
|
157
|
+
).resolves.toEqual({});
|
|
158
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('preserves the phase parent when retrying without a failing callback', async () => {
|
|
162
|
+
invoke.mockRejectedValueOnce(new Error('event stream callback failed'));
|
|
163
|
+
const run = await createRun();
|
|
164
|
+
|
|
165
|
+
await expect(
|
|
166
|
+
run.generateActivityPhaseLabel({
|
|
167
|
+
provider: Providers.OPENAI,
|
|
168
|
+
activities: [
|
|
169
|
+
{ label: 'Inspected session refresh middleware' },
|
|
170
|
+
{ label: 'Fixed refresh token validation' },
|
|
171
|
+
],
|
|
172
|
+
chainOptions: {
|
|
173
|
+
callbacks: [{ handleChainStart: jest.fn() }],
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
).resolves.toEqual({
|
|
177
|
+
label: 'Fixed session refresh handling and verified auth tests',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(invoke).toHaveBeenCalledTimes(2);
|
|
181
|
+
const firstCallbacks = invoke.mock.calls[0][1].callbacks as {
|
|
182
|
+
getParentRunId: () => string | undefined;
|
|
183
|
+
handlers: unknown[];
|
|
184
|
+
};
|
|
185
|
+
const retryCallbacks = invoke.mock.calls[1][1].callbacks as {
|
|
186
|
+
getParentRunId: () => string | undefined;
|
|
187
|
+
handlers: unknown[];
|
|
188
|
+
};
|
|
189
|
+
expect(firstCallbacks.getParentRunId()).toEqual(expect.any(String));
|
|
190
|
+
expect(retryCallbacks.getParentRunId()).toBe(
|
|
191
|
+
firstCallbacks.getParentRunId()
|
|
192
|
+
);
|
|
193
|
+
expect(retryCallbacks.handlers).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('applies every agent redaction policy when any activity is unattributed', async () => {
|
|
197
|
+
const run = await Run.create({
|
|
198
|
+
runId: 'mixed-attribution-phase-run',
|
|
199
|
+
graphConfig: {
|
|
200
|
+
type: 'multi-agent',
|
|
201
|
+
agents: [
|
|
202
|
+
{
|
|
203
|
+
agentId: 'agent-1',
|
|
204
|
+
provider: Providers.OPENAI,
|
|
205
|
+
clientOptions: { model: 'gpt-4.1-mini' },
|
|
206
|
+
tools: [],
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
agentId: 'agent-2',
|
|
210
|
+
provider: Providers.OPENAI,
|
|
211
|
+
clientOptions: { model: 'gpt-4.1-mini' },
|
|
212
|
+
tools: [],
|
|
213
|
+
langfuse: {
|
|
214
|
+
toolOutputTracing: { redactedToolNames: ['secret_tool'] },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
edges: [],
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await run.generateActivityPhaseLabel({
|
|
223
|
+
provider: Providers.OPENAI,
|
|
224
|
+
activities: [
|
|
225
|
+
{ agentId: 'agent-1', label: 'Inspected public session behavior' },
|
|
226
|
+
{
|
|
227
|
+
entries: [
|
|
228
|
+
{
|
|
229
|
+
toolName: 'secret_tool',
|
|
230
|
+
toolInput: { key: 'public-key' },
|
|
231
|
+
toolOutput: 'STRICT_AGENT_SECRET',
|
|
232
|
+
status: 'success',
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const messages = invoke.mock.calls[0][0] as AIMessage[];
|
|
240
|
+
expect(String(messages[1].content)).not.toContain('STRICT_AGENT_SECRET');
|
|
241
|
+
expect(String(messages[1].content)).toContain(
|
|
242
|
+
LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('applies every policy when omitted activities have no complete agent list', async () => {
|
|
247
|
+
const run = await Run.create({
|
|
248
|
+
runId: 'omitted-attribution-phase-run',
|
|
249
|
+
graphConfig: {
|
|
250
|
+
type: 'multi-agent',
|
|
251
|
+
agents: [
|
|
252
|
+
{
|
|
253
|
+
agentId: 'agent-1',
|
|
254
|
+
provider: Providers.OPENAI,
|
|
255
|
+
clientOptions: { model: 'gpt-4.1-mini' },
|
|
256
|
+
tools: [],
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
agentId: 'agent-2',
|
|
260
|
+
provider: Providers.OPENAI,
|
|
261
|
+
clientOptions: { model: 'gpt-4.1-mini' },
|
|
262
|
+
tools: [],
|
|
263
|
+
langfuse: {
|
|
264
|
+
toolOutputTracing: { redactedToolNames: ['secret_tool'] },
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
edges: [],
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await run.generateActivityPhaseLabel({
|
|
273
|
+
provider: Providers.OPENAI,
|
|
274
|
+
activities: [
|
|
275
|
+
{
|
|
276
|
+
agentId: 'agent-1',
|
|
277
|
+
entries: [
|
|
278
|
+
{
|
|
279
|
+
toolName: 'public_lookup',
|
|
280
|
+
toolInput: { id: 'one' },
|
|
281
|
+
toolOutput: 'public-one',
|
|
282
|
+
status: 'success',
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
agentId: 'agent-1',
|
|
288
|
+
entries: [
|
|
289
|
+
{
|
|
290
|
+
toolName: 'public_lookup',
|
|
291
|
+
toolInput: { id: 'two' },
|
|
292
|
+
toolOutput: 'public-two',
|
|
293
|
+
status: 'success',
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
totalActivityCount: 3,
|
|
299
|
+
assistantContext: ['OMITTED_AGENT_SECRET'],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const messages = invoke.mock.calls[0][0] as AIMessage[];
|
|
303
|
+
expect(String(messages[1].content)).not.toContain('OMITTED_AGENT_SECRET');
|
|
304
|
+
expect(String(messages[1].content)).toContain('public-one');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -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
|
+
});
|