@librechat/agents 3.4.4 → 3.4.6
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/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +268 -14
- package/dist/cjs/graphs/Graph.cjs.map +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/langfuseTraceShaping.cjs +9 -6
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
- package/dist/cjs/main.cjs +6 -0
- package/dist/cjs/run.cjs +147 -14
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +93 -31
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/session/handlers.cjs +10 -0
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +17 -3
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +1 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +4 -2
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +1 -0
- package/dist/cjs/tools/handlers.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +28 -0
- 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/handlers.cjs +3 -0
- package/dist/cjs/utils/handlers.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +268 -14
- package/dist/esm/graphs/Graph.mjs.map +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/langfuseTraceShaping.mjs +9 -6
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -1
- package/dist/esm/run.mjs +147 -14
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +93 -31
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/session/handlers.mjs +10 -0
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +17 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +1 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +4 -2
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +1 -0
- package/dist/esm/tools/handlers.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +28 -0
- 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/handlers.mjs +3 -0
- package/dist/esm/utils/handlers.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +2 -0
- package/dist/types/graphs/Graph.d.ts +87 -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/langfuseTraceShaping.d.ts +4 -3
- package/dist/types/run.d.ts +9 -1
- package/dist/types/session/types.d.ts +1 -1
- package/dist/types/summarization/node.d.ts +6 -0
- package/dist/types/types/graph.d.ts +3 -3
- package/dist/types/types/hitl.d.ts +31 -2
- package/dist/types/types/stream.d.ts +57 -0
- package/dist/types/utils/handlers.d.ts +1 -0
- package/package.json +2 -1
- package/src/aggregator.test.ts +127 -0
- package/src/common/enum.ts +2 -0
- package/src/graphs/Graph.ts +365 -7
- package/src/graphs/__tests__/Graph.closeRunStep.test.ts +385 -0
- package/src/hitl/askUserQuestions.ts +126 -0
- package/src/hitl/askUserQuestionsInterrupt.ts +115 -0
- package/src/hitl/index.ts +6 -0
- package/src/langfuseTraceShaping.ts +14 -7
- package/src/run.ts +219 -41
- package/src/session/AgentSession.ts +105 -13
- package/src/session/handlers.ts +14 -2
- package/src/session/types.ts +1 -0
- package/src/specs/activity-label-observability.live.test.ts +262 -0
- package/src/specs/activity-label-observability.test.ts +167 -0
- package/src/specs/ask-user-questions.live.test.ts +185 -0
- package/src/specs/ask-user-questions.test.ts +293 -0
- package/src/specs/langfuse-trace-shaping.test.ts +32 -0
- package/src/specs/run-step-timestamps.test.ts +414 -0
- package/src/stream.ts +29 -6
- package/src/summarization/node.ts +11 -0
- package/src/tools/ToolNode.ts +2 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +52 -10
- package/src/tools/__tests__/handlers.test.ts +2 -0
- package/src/tools/handlers.ts +1 -0
- package/src/tools/subagent/SubagentExecutor.ts +46 -4
- package/src/types/graph.ts +9 -6
- package/src/types/hitl.ts +36 -2
- package/src/types/stream.ts +65 -12
- package/src/utils/handlers.ts +13 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live provider + Langfuse export verification for activity-label traces.
|
|
3
|
+
*
|
|
4
|
+
* Run with:
|
|
5
|
+
* RUN_ACTIVITY_LABEL_LANGFUSE_LIVE=1 OPENAI_API_KEY=... \
|
|
6
|
+
* LANGFUSE_PUBLIC_KEY=... LANGFUSE_SECRET_KEY=... LANGFUSE_BASE_URL=... \
|
|
7
|
+
* LANGFUSE_FORCE_FLUSH_ON_DISPOSE=true \
|
|
8
|
+
* npm test -- activity-label-observability.live.test.ts --runInBand
|
|
9
|
+
*/
|
|
10
|
+
import { config as dotenvConfig } from 'dotenv';
|
|
11
|
+
dotenvConfig();
|
|
12
|
+
|
|
13
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
14
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
15
|
+
import { Providers } from '@/common';
|
|
16
|
+
import { Run } from '@/run';
|
|
17
|
+
|
|
18
|
+
type LangfuseMetadata = {
|
|
19
|
+
sourceRunId?: string;
|
|
20
|
+
responseId?: string;
|
|
21
|
+
activityIndex?: string | number;
|
|
22
|
+
phaseIndex?: string | number;
|
|
23
|
+
activityCount?: string | number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type LangfuseObservation = {
|
|
27
|
+
id: string;
|
|
28
|
+
parentObservationId?: string | null;
|
|
29
|
+
type: string;
|
|
30
|
+
name: string;
|
|
31
|
+
model?: string | null;
|
|
32
|
+
usage?: { input?: number; output?: number; total?: number };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type LangfuseTrace = {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
tags?: string[];
|
|
39
|
+
metadata?: LangfuseMetadata;
|
|
40
|
+
observations?: LangfuseObservation[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type LangfuseTraceList = {
|
|
44
|
+
data: LangfuseTrace[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const shouldRunLive =
|
|
48
|
+
process.env.RUN_ACTIVITY_LABEL_LANGFUSE_LIVE === '1' &&
|
|
49
|
+
(process.env.OPENAI_API_KEY ?? '') !== '' &&
|
|
50
|
+
(process.env.LANGFUSE_PUBLIC_KEY ?? '') !== '' &&
|
|
51
|
+
(process.env.LANGFUSE_SECRET_KEY ?? '') !== '' &&
|
|
52
|
+
(process.env.LANGFUSE_BASE_URL ?? '') !== '';
|
|
53
|
+
|
|
54
|
+
const describeIfLive = shouldRunLive ? describe : describe.skip;
|
|
55
|
+
|
|
56
|
+
function langfuseHeaders(): HeadersInit {
|
|
57
|
+
const credentials = Buffer.from(
|
|
58
|
+
`${process.env.LANGFUSE_PUBLIC_KEY}:${process.env.LANGFUSE_SECRET_KEY}`
|
|
59
|
+
).toString('base64');
|
|
60
|
+
return { Authorization: `Basic ${credentials}` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function requestLangfuse(path: string): Promise<Response> {
|
|
64
|
+
const baseUrl = process.env.LANGFUSE_BASE_URL?.replace(/\/$/, '');
|
|
65
|
+
return fetch(`${baseUrl}${path}`, {
|
|
66
|
+
headers: langfuseHeaders(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function getJson<T>(path: string): Promise<T> {
|
|
71
|
+
const response = await requestLangfuse(path);
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error(`Langfuse request failed: ${response.status}`);
|
|
74
|
+
}
|
|
75
|
+
return (await response.json()) as T;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function getJsonIfPresent<T>(path: string): Promise<T | undefined> {
|
|
79
|
+
const response = await requestLangfuse(path);
|
|
80
|
+
if (response.status === 404) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
throw new Error(`Langfuse request failed: ${response.status}`);
|
|
85
|
+
}
|
|
86
|
+
return (await response.json()) as T;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function findExportedTraces(
|
|
90
|
+
sourceRunId: string,
|
|
91
|
+
fromTimestamp: string
|
|
92
|
+
): Promise<{ label: LangfuseTrace; phase: LangfuseTrace }> {
|
|
93
|
+
const query = new URLSearchParams({
|
|
94
|
+
limit: '100',
|
|
95
|
+
fromTimestamp,
|
|
96
|
+
});
|
|
97
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
98
|
+
const list = await getJson<LangfuseTraceList>(
|
|
99
|
+
`/api/public/traces?${query.toString()}`
|
|
100
|
+
);
|
|
101
|
+
const candidates = list.data.filter(
|
|
102
|
+
(trace) => trace.metadata?.sourceRunId === sourceRunId
|
|
103
|
+
);
|
|
104
|
+
const labelSummary = candidates.find(
|
|
105
|
+
(trace) => trace.tags?.includes('activity-label') === true
|
|
106
|
+
);
|
|
107
|
+
const phaseSummary = candidates.find(
|
|
108
|
+
(trace) => trace.tags?.includes('activity-phase') === true
|
|
109
|
+
);
|
|
110
|
+
if (labelSummary != null && phaseSummary != null) {
|
|
111
|
+
const [label, phase] = await Promise.all([
|
|
112
|
+
getJsonIfPresent<LangfuseTrace>(
|
|
113
|
+
`/api/public/traces/${labelSummary.id}`
|
|
114
|
+
),
|
|
115
|
+
getJsonIfPresent<LangfuseTrace>(
|
|
116
|
+
`/api/public/traces/${phaseSummary.id}`
|
|
117
|
+
),
|
|
118
|
+
]);
|
|
119
|
+
if (label != null && phase != null) {
|
|
120
|
+
const labelReady =
|
|
121
|
+
label.observations?.some(
|
|
122
|
+
(observation) => observation.type === 'GENERATION'
|
|
123
|
+
) === true;
|
|
124
|
+
const phaseRootReady =
|
|
125
|
+
phase.observations?.some(
|
|
126
|
+
(observation) =>
|
|
127
|
+
observation.parentObservationId == null &&
|
|
128
|
+
observation.type === 'CHAIN'
|
|
129
|
+
) === true;
|
|
130
|
+
const phaseGenerationReady =
|
|
131
|
+
phase.observations?.some(
|
|
132
|
+
(observation) => observation.type === 'GENERATION'
|
|
133
|
+
) === true;
|
|
134
|
+
if (labelReady && phaseRootReady && phaseGenerationReady) {
|
|
135
|
+
return { label, phase };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`Timed out waiting for activity traces from ${sourceRunId}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describeIfLive('activity label Langfuse export (live)', () => {
|
|
145
|
+
jest.setTimeout(120_000);
|
|
146
|
+
|
|
147
|
+
it('exports stable, correlated traces with correct observation types', async () => {
|
|
148
|
+
const startedAt = new Date(Date.now() - 5000).toISOString();
|
|
149
|
+
const sourceRunId = `activity-label-live-${Date.now()}`;
|
|
150
|
+
const sessionId = `${sourceRunId}-session`;
|
|
151
|
+
const model = process.env.ACTIVITY_LABEL_LIVE_MODEL ?? 'gpt-4.1-mini';
|
|
152
|
+
const run = await Run.create({
|
|
153
|
+
runId: sourceRunId,
|
|
154
|
+
graphConfig: {
|
|
155
|
+
type: 'standard',
|
|
156
|
+
agents: [
|
|
157
|
+
{
|
|
158
|
+
agentId: 'activity-label-live-agent',
|
|
159
|
+
name: 'Volatile Agent Display Name',
|
|
160
|
+
provider: Providers.OPENAI,
|
|
161
|
+
clientOptions: {
|
|
162
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
163
|
+
model,
|
|
164
|
+
},
|
|
165
|
+
tools: [],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
if (run.Graph != null) {
|
|
171
|
+
run.Graph.messages = [
|
|
172
|
+
new HumanMessage('Verify activity-label Langfuse trace semantics'),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
const chainOptions = {
|
|
176
|
+
configurable: {
|
|
177
|
+
thread_id: sessionId,
|
|
178
|
+
user_id: 'activity-label-live-user',
|
|
179
|
+
requestBody: { parentMessageId: 'activity-label-live-parent' },
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
await run.generateActivityLabel({
|
|
184
|
+
provider: Providers.OPENAI,
|
|
185
|
+
agentId: 'activity-label-live-agent',
|
|
186
|
+
clientOptions: {
|
|
187
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
188
|
+
model,
|
|
189
|
+
},
|
|
190
|
+
entries: [
|
|
191
|
+
{
|
|
192
|
+
toolName: 'inspect_runtime',
|
|
193
|
+
toolInput: { target: 'trace-shape' },
|
|
194
|
+
toolOutput: { status: 'verified' },
|
|
195
|
+
status: 'success',
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
chainOptions,
|
|
199
|
+
});
|
|
200
|
+
await run.generateActivityPhaseLabel({
|
|
201
|
+
provider: Providers.OPENAI,
|
|
202
|
+
clientOptions: {
|
|
203
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
204
|
+
model,
|
|
205
|
+
},
|
|
206
|
+
activities: [
|
|
207
|
+
{ label: 'Inspected the activity-label trace shape' },
|
|
208
|
+
{ label: 'Verified stable correlation metadata' },
|
|
209
|
+
],
|
|
210
|
+
sourceRunId,
|
|
211
|
+
responseId: sourceRunId,
|
|
212
|
+
phaseIndex: 0,
|
|
213
|
+
chainOptions,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const { label, phase } = await findExportedTraces(sourceRunId, startedAt);
|
|
217
|
+
expect(label).toMatchObject({
|
|
218
|
+
name: 'LibreChat Activity Label',
|
|
219
|
+
metadata: {
|
|
220
|
+
sourceRunId,
|
|
221
|
+
responseId: sourceRunId,
|
|
222
|
+
activityIndex: 0,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
expect(phase).toMatchObject({
|
|
226
|
+
name: 'LibreChat Activity Phase',
|
|
227
|
+
metadata: {
|
|
228
|
+
sourceRunId,
|
|
229
|
+
responseId: sourceRunId,
|
|
230
|
+
phaseIndex: 0,
|
|
231
|
+
activityCount: 2,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const labelGeneration = label.observations?.find(
|
|
236
|
+
(observation) => observation.type === 'GENERATION'
|
|
237
|
+
);
|
|
238
|
+
expect(labelGeneration).toMatchObject({
|
|
239
|
+
parentObservationId: null,
|
|
240
|
+
name: 'llm',
|
|
241
|
+
model: expect.stringContaining(model),
|
|
242
|
+
});
|
|
243
|
+
expect(labelGeneration?.usage?.total).toBeGreaterThan(0);
|
|
244
|
+
|
|
245
|
+
const phaseRoot = phase.observations?.find(
|
|
246
|
+
(observation) => observation.parentObservationId == null
|
|
247
|
+
);
|
|
248
|
+
expect(phaseRoot).toMatchObject({
|
|
249
|
+
type: 'CHAIN',
|
|
250
|
+
name: 'summarize-activity-phase',
|
|
251
|
+
});
|
|
252
|
+
const phaseGeneration = phase.observations?.find(
|
|
253
|
+
(observation) => observation.type === 'GENERATION'
|
|
254
|
+
);
|
|
255
|
+
expect(phaseGeneration).toMatchObject({
|
|
256
|
+
parentObservationId: phaseRoot?.id,
|
|
257
|
+
name: 'llm',
|
|
258
|
+
model: expect.stringContaining(model),
|
|
259
|
+
});
|
|
260
|
+
expect(phaseGeneration?.usage?.total).toBeGreaterThan(0);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { CallbackHandler } from '@langfuse/langchain';
|
|
2
|
+
import { propagateAttributes } from '@langfuse/tracing';
|
|
3
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
4
|
+
import { Providers } from '@/common';
|
|
5
|
+
import { Run } from '@/run';
|
|
6
|
+
|
|
7
|
+
const invoke = jest.fn();
|
|
8
|
+
|
|
9
|
+
jest.mock('@/llm/init', () => ({
|
|
10
|
+
initializeModel: jest.fn(() => ({ invoke })),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
jest.mock('@langfuse/langchain', () => ({
|
|
14
|
+
CallbackHandler: jest.fn().mockImplementation(() => ({})),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
jest.mock('@langfuse/tracing', () => ({
|
|
18
|
+
...jest.requireActual('@langfuse/tracing'),
|
|
19
|
+
propagateAttributes: jest.fn((_params, action: () => unknown) => action()),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const MockedCallbackHandler = CallbackHandler as jest.MockedClass<
|
|
23
|
+
typeof CallbackHandler
|
|
24
|
+
>;
|
|
25
|
+
const MockedPropagateAttributes = propagateAttributes as jest.MockedFunction<
|
|
26
|
+
typeof propagateAttributes
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
async function createRun(): Promise<Run<never>> {
|
|
30
|
+
const run = await Run.create({
|
|
31
|
+
runId: 'response-1',
|
|
32
|
+
graphConfig: {
|
|
33
|
+
type: 'standard',
|
|
34
|
+
agents: [
|
|
35
|
+
{
|
|
36
|
+
agentId: 'agent-1',
|
|
37
|
+
name: 'Changing Model Name',
|
|
38
|
+
provider: Providers.OPENAI,
|
|
39
|
+
clientOptions: { model: 'gpt-4.1-mini' },
|
|
40
|
+
tools: [],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (run.Graph != null) {
|
|
46
|
+
run.Graph.messages = [new HumanMessage('Why did the run fail?')];
|
|
47
|
+
}
|
|
48
|
+
return run;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('activity label observability', () => {
|
|
52
|
+
const originalEnv = process.env;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
jest.clearAllMocks();
|
|
56
|
+
invoke.mockResolvedValue(new AIMessage('Resolved the failing run'));
|
|
57
|
+
process.env = {
|
|
58
|
+
...originalEnv,
|
|
59
|
+
LANGFUSE_SECRET_KEY: 'sk-test',
|
|
60
|
+
LANGFUSE_PUBLIC_KEY: 'pk-test',
|
|
61
|
+
LANGFUSE_BASE_URL: 'https://langfuse.test',
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
process.env = originalEnv;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('uses a stable trace name and source-run metadata for batch labels', async () => {
|
|
70
|
+
const run = await createRun();
|
|
71
|
+
|
|
72
|
+
await run.generateActivityLabel({
|
|
73
|
+
provider: Providers.OPENAI,
|
|
74
|
+
entries: [
|
|
75
|
+
{
|
|
76
|
+
toolName: 'inspect_status',
|
|
77
|
+
toolInput: { id: 'one' },
|
|
78
|
+
toolOutput: { fixed: true },
|
|
79
|
+
status: 'success',
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
chainOptions: {
|
|
83
|
+
configurable: {
|
|
84
|
+
thread_id: 'thread-1',
|
|
85
|
+
user_id: 'user-1',
|
|
86
|
+
requestBody: { parentMessageId: 'parent-1' },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(MockedCallbackHandler).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(MockedCallbackHandler.mock.calls[0][0]).toMatchObject({
|
|
93
|
+
traceMetadata: {
|
|
94
|
+
messageId: 'activity-label-response-1',
|
|
95
|
+
parentMessageId: 'parent-1',
|
|
96
|
+
agentId: 'agent-1',
|
|
97
|
+
agentName: 'Changing Model Name',
|
|
98
|
+
sourceRunId: 'response-1',
|
|
99
|
+
responseId: 'response-1',
|
|
100
|
+
activityIndex: '0',
|
|
101
|
+
},
|
|
102
|
+
tags: ['librechat', 'activity-label'],
|
|
103
|
+
});
|
|
104
|
+
expect(MockedPropagateAttributes.mock.calls[0][0]).toMatchObject({
|
|
105
|
+
traceName: 'LibreChat Activity Label',
|
|
106
|
+
metadata: {
|
|
107
|
+
sourceRunId: 'response-1',
|
|
108
|
+
responseId: 'response-1',
|
|
109
|
+
activityIndex: '0',
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
expect(invoke.mock.calls[0][1]).toMatchObject({
|
|
113
|
+
runName: 'LibreChat Activity Label',
|
|
114
|
+
tags: ['librechat', 'activity-label'],
|
|
115
|
+
metadata: {
|
|
116
|
+
sourceRunId: 'response-1',
|
|
117
|
+
responseId: 'response-1',
|
|
118
|
+
activityIndex: 0,
|
|
119
|
+
parentMessageId: 'parent-1',
|
|
120
|
+
agentId: 'agent-1',
|
|
121
|
+
agentName: 'Changing Model Name',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('uses a stable trace name for phase summaries', async () => {
|
|
127
|
+
const run = await createRun();
|
|
128
|
+
|
|
129
|
+
await run.generateActivityPhaseLabel({
|
|
130
|
+
provider: Providers.OPENAI,
|
|
131
|
+
activities: [
|
|
132
|
+
{ label: 'Inspected session refresh behavior' },
|
|
133
|
+
{ label: 'Fixed refresh token validation' },
|
|
134
|
+
],
|
|
135
|
+
sourceRunId: 'response-1',
|
|
136
|
+
responseId: 'response-1',
|
|
137
|
+
phaseIndex: 0,
|
|
138
|
+
chainOptions: {
|
|
139
|
+
configurable: {
|
|
140
|
+
thread_id: 'thread-1',
|
|
141
|
+
user_id: 'user-1',
|
|
142
|
+
requestBody: { parentMessageId: 'parent-1' },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(MockedCallbackHandler).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(MockedCallbackHandler.mock.calls[0][0]).toMatchObject({
|
|
149
|
+
tags: ['librechat', 'activity-phase', 'agent-run-summary', 'agent'],
|
|
150
|
+
traceMetadata: {
|
|
151
|
+
sourceRunId: 'response-1',
|
|
152
|
+
responseId: 'response-1',
|
|
153
|
+
phaseIndex: '0',
|
|
154
|
+
activityCount: '2',
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
expect(MockedPropagateAttributes.mock.calls[0][0]).toMatchObject({
|
|
158
|
+
traceName: 'LibreChat Activity Phase',
|
|
159
|
+
metadata: {
|
|
160
|
+
sourceRunId: 'response-1',
|
|
161
|
+
responseId: 'response-1',
|
|
162
|
+
phaseIndex: '0',
|
|
163
|
+
activityCount: '2',
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -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
|
+
});
|