@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.
Files changed (107) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +268 -14
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hitl/askUserQuestions.cjs +66 -0
  6. package/dist/cjs/hitl/askUserQuestions.cjs.map +1 -0
  7. package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs +46 -0
  8. package/dist/cjs/hitl/askUserQuestionsInterrupt.cjs.map +1 -0
  9. package/dist/cjs/hitl/index.cjs +2 -0
  10. package/dist/cjs/langfuseTraceShaping.cjs +9 -6
  11. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  12. package/dist/cjs/main.cjs +6 -0
  13. package/dist/cjs/run.cjs +147 -14
  14. package/dist/cjs/run.cjs.map +1 -1
  15. package/dist/cjs/session/AgentSession.cjs +93 -31
  16. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  17. package/dist/cjs/session/handlers.cjs +10 -0
  18. package/dist/cjs/session/handlers.cjs.map +1 -1
  19. package/dist/cjs/stream.cjs +17 -3
  20. package/dist/cjs/stream.cjs.map +1 -1
  21. package/dist/cjs/summarization/node.cjs +1 -0
  22. package/dist/cjs/summarization/node.cjs.map +1 -1
  23. package/dist/cjs/tools/ToolNode.cjs +4 -2
  24. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  25. package/dist/cjs/tools/handlers.cjs +1 -0
  26. package/dist/cjs/tools/handlers.cjs.map +1 -1
  27. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +28 -0
  28. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  29. package/dist/cjs/types/hitl.cjs +13 -0
  30. package/dist/cjs/types/hitl.cjs.map +1 -0
  31. package/dist/cjs/utils/handlers.cjs +3 -0
  32. package/dist/cjs/utils/handlers.cjs.map +1 -1
  33. package/dist/esm/common/enum.mjs +2 -0
  34. package/dist/esm/common/enum.mjs.map +1 -1
  35. package/dist/esm/graphs/Graph.mjs +268 -14
  36. package/dist/esm/graphs/Graph.mjs.map +1 -1
  37. package/dist/esm/hitl/askUserQuestions.mjs +66 -0
  38. package/dist/esm/hitl/askUserQuestions.mjs.map +1 -0
  39. package/dist/esm/hitl/askUserQuestionsInterrupt.mjs +43 -0
  40. package/dist/esm/hitl/askUserQuestionsInterrupt.mjs.map +1 -0
  41. package/dist/esm/hitl/index.mjs +2 -0
  42. package/dist/esm/langfuseTraceShaping.mjs +9 -6
  43. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  44. package/dist/esm/main.mjs +3 -1
  45. package/dist/esm/run.mjs +147 -14
  46. package/dist/esm/run.mjs.map +1 -1
  47. package/dist/esm/session/AgentSession.mjs +93 -31
  48. package/dist/esm/session/AgentSession.mjs.map +1 -1
  49. package/dist/esm/session/handlers.mjs +10 -0
  50. package/dist/esm/session/handlers.mjs.map +1 -1
  51. package/dist/esm/stream.mjs +17 -3
  52. package/dist/esm/stream.mjs.map +1 -1
  53. package/dist/esm/summarization/node.mjs +1 -0
  54. package/dist/esm/summarization/node.mjs.map +1 -1
  55. package/dist/esm/tools/ToolNode.mjs +4 -2
  56. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  57. package/dist/esm/tools/handlers.mjs +1 -0
  58. package/dist/esm/tools/handlers.mjs.map +1 -1
  59. package/dist/esm/tools/subagent/SubagentExecutor.mjs +28 -0
  60. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  61. package/dist/esm/types/hitl.mjs +13 -0
  62. package/dist/esm/types/hitl.mjs.map +1 -0
  63. package/dist/esm/utils/handlers.mjs +3 -0
  64. package/dist/esm/utils/handlers.mjs.map +1 -1
  65. package/dist/types/common/enum.d.ts +2 -0
  66. package/dist/types/graphs/Graph.d.ts +87 -0
  67. package/dist/types/hitl/askUserQuestions.d.ts +24 -0
  68. package/dist/types/hitl/askUserQuestionsInterrupt.d.ts +11 -0
  69. package/dist/types/hitl/index.d.ts +2 -0
  70. package/dist/types/langfuseTraceShaping.d.ts +4 -3
  71. package/dist/types/run.d.ts +9 -1
  72. package/dist/types/session/types.d.ts +1 -1
  73. package/dist/types/summarization/node.d.ts +6 -0
  74. package/dist/types/types/graph.d.ts +3 -3
  75. package/dist/types/types/hitl.d.ts +31 -2
  76. package/dist/types/types/stream.d.ts +57 -0
  77. package/dist/types/utils/handlers.d.ts +1 -0
  78. package/package.json +2 -1
  79. package/src/aggregator.test.ts +127 -0
  80. package/src/common/enum.ts +2 -0
  81. package/src/graphs/Graph.ts +365 -7
  82. package/src/graphs/__tests__/Graph.closeRunStep.test.ts +385 -0
  83. package/src/hitl/askUserQuestions.ts +126 -0
  84. package/src/hitl/askUserQuestionsInterrupt.ts +115 -0
  85. package/src/hitl/index.ts +6 -0
  86. package/src/langfuseTraceShaping.ts +14 -7
  87. package/src/run.ts +219 -41
  88. package/src/session/AgentSession.ts +105 -13
  89. package/src/session/handlers.ts +14 -2
  90. package/src/session/types.ts +1 -0
  91. package/src/specs/activity-label-observability.live.test.ts +262 -0
  92. package/src/specs/activity-label-observability.test.ts +167 -0
  93. package/src/specs/ask-user-questions.live.test.ts +185 -0
  94. package/src/specs/ask-user-questions.test.ts +293 -0
  95. package/src/specs/langfuse-trace-shaping.test.ts +32 -0
  96. package/src/specs/run-step-timestamps.test.ts +414 -0
  97. package/src/stream.ts +29 -6
  98. package/src/summarization/node.ts +11 -0
  99. package/src/tools/ToolNode.ts +2 -0
  100. package/src/tools/__tests__/SubagentExecutor.test.ts +52 -10
  101. package/src/tools/__tests__/handlers.test.ts +2 -0
  102. package/src/tools/handlers.ts +1 -0
  103. package/src/tools/subagent/SubagentExecutor.ts +46 -4
  104. package/src/types/graph.ts +9 -6
  105. package/src/types/hitl.ts +36 -2
  106. package/src/types/stream.ts +65 -12
  107. package/src/utils/handlers.ts +13 -0
@@ -0,0 +1,385 @@
1
+ // src/graphs/__tests__/Graph.closeRunStep.test.ts
2
+ import type * as t from '@/types';
3
+ import { GraphEvents, StepTypes, Providers } from '@/common';
4
+ import { HandlerRegistry } from '@/events';
5
+ import { StandardGraph } from '../Graph';
6
+
7
+ const makeAgent = (agentId: string): t.AgentInputs => ({
8
+ agentId,
9
+ provider: Providers.OPENAI,
10
+ instructions: `You are ${agentId}.`,
11
+ });
12
+
13
+ function createGraph(): {
14
+ graph: StandardGraph;
15
+ closed: t.RunStepClosedEvent[];
16
+ } {
17
+ const graph = new StandardGraph({
18
+ runId: 'run_1',
19
+ agents: [makeAgent('agent')],
20
+ });
21
+ const closed: t.RunStepClosedEvent[] = [];
22
+ const registry = new HandlerRegistry();
23
+ registry.register(GraphEvents.ON_RUN_STEP_CLOSED, {
24
+ handle: (_event, data): void => {
25
+ closed.push(data as t.RunStepClosedEvent);
26
+ },
27
+ });
28
+ graph.handlerRegistry = registry;
29
+ return { graph, closed };
30
+ }
31
+
32
+ function seedStep(
33
+ graph: StandardGraph,
34
+ id: string,
35
+ type: StepTypes = StepTypes.MESSAGE_CREATION
36
+ ): t.RunStep {
37
+ const index = graph.contentData.length;
38
+ const stepDetails: t.StepDetails =
39
+ type === StepTypes.TOOL_CALLS
40
+ ? { type: StepTypes.TOOL_CALLS, tool_calls: [] }
41
+ : {
42
+ type: StepTypes.MESSAGE_CREATION,
43
+ message_creation: { message_id: `msg_${id}` },
44
+ };
45
+ const step: t.RunStep = {
46
+ id,
47
+ type,
48
+ index,
49
+ stepIndex: index,
50
+ stepDetails,
51
+ usage: null,
52
+ created_at: 1_000,
53
+ status: 'in_progress',
54
+ };
55
+ graph.contentData.push(step);
56
+ graph.contentIndexMap.set(id, index);
57
+ return step;
58
+ }
59
+
60
+ describe('StandardGraph.closeRunStep', () => {
61
+ it('stamps the terminal status + timestamp and emits ON_RUN_STEP_CLOSED once', async () => {
62
+ const { graph, closed } = createGraph();
63
+ const step = seedStep(graph, 'step_a');
64
+
65
+ const first = await graph.closeRunStep('step_a', 'completed', {
66
+ at: 2_000,
67
+ });
68
+ expect(first).toBe(true);
69
+ expect(step.status).toBe('completed');
70
+ expect(step.completed_at).toBe(2_000);
71
+ expect(closed).toHaveLength(1);
72
+ expect(closed[0]).toMatchObject({
73
+ id: 'step_a',
74
+ index: 0,
75
+ type: StepTypes.MESSAGE_CREATION,
76
+ status: 'completed',
77
+ created_at: 1_000,
78
+ closed_at: 2_000,
79
+ });
80
+
81
+ const second = await graph.closeRunStep('step_a', 'completed', {
82
+ at: 3_000,
83
+ });
84
+ expect(second).toBe(false);
85
+ expect(step.completed_at).toBe(2_000);
86
+ expect(closed).toHaveLength(1);
87
+ });
88
+
89
+ it('keeps a terminal status immutable across later close attempts', async () => {
90
+ const { graph, closed } = createGraph();
91
+ const step = seedStep(graph, 'step_a', StepTypes.TOOL_CALLS);
92
+
93
+ await graph.closeRunStep('step_a', 'cancelled', { at: 2_000 });
94
+ const reclosed = await graph.closeRunStep('step_a', 'completed', {
95
+ at: 3_000,
96
+ });
97
+ expect(reclosed).toBe(false);
98
+ expect(step.status).toBe('cancelled');
99
+ expect(step.cancelled_at).toBe(2_000);
100
+ expect(step.completed_at).toBeUndefined();
101
+ expect(closed).toHaveLength(1);
102
+ });
103
+
104
+ it('keeps the stored stamp and the emitted event in agreement', async () => {
105
+ const { graph, closed } = createGraph();
106
+ const step = seedStep(graph, 'step_a', StepTypes.TOOL_CALLS);
107
+
108
+ await graph.closeRunStep('step_a', 'completed', { at: 2_000 });
109
+ const reclosed = await graph.closeRunStep('step_a', 'completed', {
110
+ at: 3_000,
111
+ });
112
+
113
+ /** No silent correction: RunStep.completed_at can never disagree with the
114
+ * closed_at already delivered to subscribers. */
115
+ expect(reclosed).toBe(false);
116
+ expect(step.completed_at).toBe(2_000);
117
+ expect(closed).toHaveLength(1);
118
+ expect(closed[0].closed_at).toBe(2_000);
119
+ });
120
+
121
+ it('tolerates empty and unknown step ids', async () => {
122
+ const { graph, closed } = createGraph();
123
+ expect(await graph.closeRunStep('', 'completed')).toBe(false);
124
+ expect(await graph.closeRunStep('step_missing', 'completed')).toBe(false);
125
+ expect(closed).toHaveLength(0);
126
+ });
127
+ });
128
+
129
+ describe('StandardGraph.recordStepCompletion', () => {
130
+ it('closes a TOOL_CALLS step only after every registered call completes', async () => {
131
+ const { graph, closed } = createGraph();
132
+ const step = seedStep(graph, 'step_a', StepTypes.TOOL_CALLS);
133
+ graph.registerPendingToolCall('call_1', 'step_a');
134
+ graph.registerPendingToolCall('call_2', 'step_a');
135
+
136
+ await graph.recordStepCompletion('step_a', { toolCallId: 'call_1' });
137
+ expect(step.status).toBe('in_progress');
138
+ expect(closed).toHaveLength(0);
139
+
140
+ await graph.recordStepCompletion('step_a', { toolCallId: 'call_2' });
141
+ expect(step.status).toBe('completed');
142
+ expect(typeof step.completed_at).toBe('number');
143
+ expect(closed).toHaveLength(1);
144
+ expect(closed[0].status).toBe('completed');
145
+ });
146
+
147
+ it('closes steps without pending tracking on the first completion', async () => {
148
+ const { graph, closed } = createGraph();
149
+ const step = seedStep(graph, 'step_a');
150
+
151
+ await graph.recordStepCompletion('step_a');
152
+ expect(step.status).toBe('completed');
153
+ expect(closed).toHaveLength(1);
154
+ });
155
+
156
+ it('absorbs duplicate completions without re-emitting', async () => {
157
+ const { graph, closed } = createGraph();
158
+ seedStep(graph, 'step_a', StepTypes.TOOL_CALLS);
159
+ graph.registerPendingToolCall('call_1', 'step_a');
160
+
161
+ await graph.recordStepCompletion('step_a', { toolCallId: 'call_1' });
162
+ await graph.recordStepCompletion('step_a', { toolCallId: 'call_1' });
163
+ expect(closed).toHaveLength(1);
164
+ });
165
+
166
+ it('leaves a closed step untouched if a late call registers and completes', async () => {
167
+ const { graph, closed } = createGraph();
168
+ const step = seedStep(graph, 'step_a', StepTypes.TOOL_CALLS);
169
+ graph.registerPendingToolCall('call_1', 'step_a');
170
+
171
+ await graph.recordStepCompletion('step_a', {
172
+ toolCallId: 'call_1',
173
+ at: 2_000,
174
+ });
175
+ expect(closed).toHaveLength(1);
176
+
177
+ graph.registerPendingToolCall('call_2', 'step_a');
178
+ await graph.recordStepCompletion('step_a', {
179
+ toolCallId: 'call_2',
180
+ at: 3_000,
181
+ });
182
+ /** First close wins outright — no second event and no divergence between
183
+ * the stored stamp and what subscribers were told. */
184
+ expect(closed).toHaveLength(1);
185
+ expect(step.completed_at).toBe(2_000);
186
+ expect(closed[0].closed_at).toBe(2_000);
187
+ });
188
+ it('closes a multi-call step at the latest producer timestamp', async () => {
189
+ const { graph, closed } = createGraph();
190
+ const step = seedStep(graph, 'step_a', StepTypes.TOOL_CALLS);
191
+ graph.registerPendingToolCall('call_1', 'step_a');
192
+ graph.registerPendingToolCall('call_2', 'step_a');
193
+
194
+ /** call_2 finished later, but its host handler was faster, so the
195
+ * earlier-finishing call_1 is what drains the pending set. */
196
+ await graph.recordStepCompletion('step_a', {
197
+ toolCallId: 'call_2',
198
+ at: 5_000,
199
+ });
200
+ await graph.recordStepCompletion('step_a', {
201
+ toolCallId: 'call_1',
202
+ at: 4_000,
203
+ });
204
+
205
+ expect(step.completed_at).toBe(5_000);
206
+ expect(closed).toHaveLength(1);
207
+ expect(closed[0].closed_at).toBe(5_000);
208
+ });
209
+
210
+ it('uses the producer\'s completion timestamp when one is supplied', async () => {
211
+ const { graph, closed } = createGraph();
212
+ const step = seedStep(graph, 'step_a', StepTypes.TOOL_CALLS);
213
+ graph.registerPendingToolCall('call_1', 'step_a');
214
+
215
+ await graph.recordStepCompletion('step_a', {
216
+ toolCallId: 'call_1',
217
+ at: 4_000,
218
+ });
219
+
220
+ /** A slow host handler must not inflate the recorded duration. */
221
+ expect(step.completed_at).toBe(4_000);
222
+ expect(closed).toHaveLength(1);
223
+ expect(closed[0].closed_at).toBe(4_000);
224
+ });
225
+ });
226
+
227
+ describe('StandardGraph.closeUnfinishedRunSteps', () => {
228
+ it('closes only non-terminal steps with the sweep status', async () => {
229
+ const { graph, closed } = createGraph();
230
+ const done = seedStep(graph, 'step_done', StepTypes.TOOL_CALLS);
231
+ const openMessage = seedStep(graph, 'step_msg');
232
+ const openTool = seedStep(graph, 'step_tool', StepTypes.TOOL_CALLS);
233
+ await graph.closeRunStep('step_done', 'completed', { at: 2_000 });
234
+ closed.length = 0;
235
+
236
+ await graph.closeUnfinishedRunSteps('cancelled', 5_000);
237
+
238
+ expect(done.completed_at).toBe(2_000);
239
+ expect(openMessage.status).toBe('cancelled');
240
+ expect(openMessage.cancelled_at).toBe(5_000);
241
+ expect(openTool.status).toBe('cancelled');
242
+ expect(openTool.cancelled_at).toBe(5_000);
243
+ expect(closed.map((event) => event.id)).toEqual(['step_msg', 'step_tool']);
244
+ expect(closed.every((event) => event.status === 'cancelled')).toBe(true);
245
+ expect(graph.pendingToolCallsByStep.size).toBe(0);
246
+ expect(graph.openMessageStepByAgent.size).toBe(0);
247
+ });
248
+
249
+ it('stamps failed_at for failed sweeps', async () => {
250
+ const { graph } = createGraph();
251
+ const step = seedStep(graph, 'step_a');
252
+
253
+ await graph.closeUnfinishedRunSteps('failed', 5_000);
254
+ expect(step.status).toBe('failed');
255
+ expect(step.failed_at).toBe(5_000);
256
+ expect(step.cancelled_at).toBeUndefined();
257
+ expect(step.completed_at).toBeUndefined();
258
+ });
259
+ it('keeps sweeping when a closure handler throws', async () => {
260
+ const graph = new StandardGraph({
261
+ runId: 'run_1',
262
+ agents: [makeAgent('agent')],
263
+ });
264
+ const seen: string[] = [];
265
+ const registry = new HandlerRegistry();
266
+ registry.register(GraphEvents.ON_RUN_STEP_CLOSED, {
267
+ handle: (_event, data): void => {
268
+ const event = data as t.RunStepClosedEvent;
269
+ seen.push(event.id);
270
+ if (event.id === 'step_1') {
271
+ throw new Error('host handler blew up');
272
+ }
273
+ },
274
+ });
275
+ graph.handlerRegistry = registry;
276
+ const first = seedStep(graph, 'step_1');
277
+ const second = seedStep(graph, 'step_2');
278
+ const third = seedStep(graph, 'step_3');
279
+
280
+ await graph.closeUnfinishedRunSteps('cancelled', 5_000);
281
+
282
+ expect(seen).toEqual(['step_1', 'step_2', 'step_3']);
283
+ for (const step of [first, second, third]) {
284
+ expect(step.status).toBe('cancelled');
285
+ expect(step.cancelled_at).toBe(5_000);
286
+ }
287
+ });
288
+ });
289
+
290
+ describe('StandardGraph.trackDispatchedRunStep', () => {
291
+ it('reserves distinct content indexes when parallel lanes dispatch concurrently', async () => {
292
+ const graph = new StandardGraph({
293
+ runId: 'run_1',
294
+ agents: [makeAgent('agent')],
295
+ });
296
+ /** A host handler that yields — the window in which two lanes could
297
+ * otherwise both read the same contentData.length. */
298
+ const registry = new HandlerRegistry();
299
+ registry.register(GraphEvents.ON_RUN_STEP_CLOSED, {
300
+ handle: async (): Promise<void> => {
301
+ await new Promise((resolve) => setTimeout(resolve, 5));
302
+ },
303
+ });
304
+ graph.handlerRegistry = registry;
305
+
306
+ const openA = seedStep(graph, 'open_a');
307
+ openA.agentId = 'agent_a';
308
+ const openB = seedStep(graph, 'open_b');
309
+ openB.agentId = 'agent_b';
310
+ graph.openMessageStepByAgent.set('agent_a', 'open_a');
311
+ graph.openMessageStepByAgent.set('agent_b', 'open_b');
312
+
313
+ const makeSuccessor = (id: string, agentId: string): t.RunStep => ({
314
+ id,
315
+ agentId,
316
+ type: StepTypes.MESSAGE_CREATION,
317
+ /** Both lanes computed the same stale index before dispatching. */
318
+ index: graph.contentData.length,
319
+ stepDetails: {
320
+ type: StepTypes.MESSAGE_CREATION,
321
+ message_creation: { message_id: `msg_${id}` },
322
+ },
323
+ usage: null,
324
+ created_at: 1_000,
325
+ status: 'in_progress',
326
+ });
327
+ const successorA = makeSuccessor('next_a', 'agent_a');
328
+ const successorB = makeSuccessor('next_b', 'agent_b');
329
+
330
+ const track = (
331
+ graph as unknown as {
332
+ trackDispatchedRunStep: (step: t.RunStep) => Promise<void>;
333
+ }
334
+ ).trackDispatchedRunStep.bind(graph);
335
+ await Promise.all([track(successorA), track(successorB)]);
336
+
337
+ expect(successorA.index).not.toBe(successorB.index);
338
+ expect(graph.getRunStep('next_a')).toBe(successorA);
339
+ expect(graph.getRunStep('next_b')).toBe(successorB);
340
+ expect(graph.contentData.filter((s) => s.id === 'next_a')).toHaveLength(1);
341
+ const indexes = graph.contentData.map((step) => step.index);
342
+ expect(new Set(indexes).size).toBe(indexes.length);
343
+ });
344
+ it('stamps created_at after the predecessor closure is delivered', async () => {
345
+ const graph = new StandardGraph({
346
+ runId: 'run_1',
347
+ agents: [makeAgent('agent')],
348
+ });
349
+ let predecessorClosedAt = 0;
350
+ const registry = new HandlerRegistry();
351
+ registry.register(GraphEvents.ON_RUN_STEP_CLOSED, {
352
+ handle: async (): Promise<void> => {
353
+ /** A slow predecessor handler must not be charged to the successor. */
354
+ await new Promise((resolve) => setTimeout(resolve, 20));
355
+ predecessorClosedAt = Date.now();
356
+ },
357
+ });
358
+ graph.handlerRegistry = registry;
359
+
360
+ seedStep(graph, 'open_a');
361
+ graph.openMessageStepByAgent.set('', 'open_a');
362
+
363
+ const successor: t.RunStep = {
364
+ id: 'next_a',
365
+ type: StepTypes.MESSAGE_CREATION,
366
+ index: 0,
367
+ stepDetails: {
368
+ type: StepTypes.MESSAGE_CREATION,
369
+ message_creation: { message_id: 'msg_next_a' },
370
+ },
371
+ usage: null,
372
+ status: 'in_progress',
373
+ };
374
+ await (
375
+ graph as unknown as {
376
+ trackDispatchedRunStep: (step: t.RunStep) => Promise<void>;
377
+ }
378
+ ).trackDispatchedRunStep.bind(graph)(successor);
379
+
380
+ expect(predecessorClosedAt).toBeGreaterThan(0);
381
+ expect(successor.created_at as number).toBeGreaterThanOrEqual(
382
+ predecessorClosedAt
383
+ );
384
+ });
385
+ });
@@ -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';
@@ -14,6 +14,8 @@ const CHAIN_OBSERVATION_TYPE = 'chain';
14
14
  const TOOL_OBSERVATION_TYPE = 'tool';
15
15
  const AGENT_TRACE_TAG = 'agent';
16
16
  const TITLE_TRACE_TAG = 'title';
17
+ const ACTIVITY_PHASE_TRACE_TAG = 'activity-phase';
18
+ const ACTIVITY_PHASE_ROOT_NAME = 'summarize-activity-phase';
17
19
  const EPHEMERAL_AGENT_SENDER_SEPARATOR = '___';
18
20
  const EPHEMERAL_AGENT_INDEX_SEPARATOR = '____';
19
21
  const OBSERVATION_METADATA_LANGGRAPH_NODE = `${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langgraph_node`;
@@ -473,14 +475,18 @@ function shapeRootObservationType(span: MutableSpan): void {
473
475
  if (isGenerationSpan(span)) {
474
476
  return;
475
477
  }
476
- if (hasTraceTag(span, AGENT_TRACE_TAG)) {
478
+ if (
479
+ hasTraceTag(span, TITLE_TRACE_TAG) ||
480
+ (span.name === ACTIVITY_PHASE_ROOT_NAME &&
481
+ hasTraceTag(span, ACTIVITY_PHASE_TRACE_TAG))
482
+ ) {
477
483
  span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
478
- ROOT_OBSERVATION_TYPE;
484
+ CHAIN_OBSERVATION_TYPE;
479
485
  return;
480
486
  }
481
- if (hasTraceTag(span, TITLE_TRACE_TAG)) {
487
+ if (hasTraceTag(span, AGENT_TRACE_TAG)) {
482
488
  span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
483
- CHAIN_OBSERVATION_TYPE;
489
+ ROOT_OBSERVATION_TYPE;
484
490
  }
485
491
  }
486
492
 
@@ -497,9 +503,10 @@ function shapeRootObservationType(span: MutableSpan): void {
497
503
  * - Agent nodes become `agent` observations, while tool-dispatch nodes become
498
504
  * stable `chain` observations whose input is scoped to the pending calls.
499
505
  * Individual child calls remain `tool` observations (items 3 & 4).
500
- * - Agent trace roots become `agent` observations and title trace roots become
501
- * `chain` observations. Root and trace input/output are reduced to the user
502
- * question and assistant response when chat messages are available (item 2).
506
+ * - Agent trace roots become `agent` observations, while title and activity
507
+ * summary roots become `chain` observations. Root and trace input/output are
508
+ * reduced to the user question and assistant response when chat messages are
509
+ * available (item 2).
503
510
  */
504
511
  export function shapeLangfuseSpan(span: ReadableSpan): void {
505
512
  const mutable = span as MutableSpan;