@librechat/agents 3.4.5 → 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 (81) 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/langfuseTraceShaping.cjs +9 -6
  6. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  7. package/dist/cjs/run.cjs +147 -14
  8. package/dist/cjs/run.cjs.map +1 -1
  9. package/dist/cjs/session/AgentSession.cjs +93 -31
  10. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  11. package/dist/cjs/session/handlers.cjs +10 -0
  12. package/dist/cjs/session/handlers.cjs.map +1 -1
  13. package/dist/cjs/stream.cjs +17 -3
  14. package/dist/cjs/stream.cjs.map +1 -1
  15. package/dist/cjs/summarization/node.cjs +1 -0
  16. package/dist/cjs/summarization/node.cjs.map +1 -1
  17. package/dist/cjs/tools/ToolNode.cjs +4 -2
  18. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  19. package/dist/cjs/tools/handlers.cjs +1 -0
  20. package/dist/cjs/tools/handlers.cjs.map +1 -1
  21. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +28 -0
  22. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  23. package/dist/cjs/utils/handlers.cjs +3 -0
  24. package/dist/cjs/utils/handlers.cjs.map +1 -1
  25. package/dist/esm/common/enum.mjs +2 -0
  26. package/dist/esm/common/enum.mjs.map +1 -1
  27. package/dist/esm/graphs/Graph.mjs +268 -14
  28. package/dist/esm/graphs/Graph.mjs.map +1 -1
  29. package/dist/esm/langfuseTraceShaping.mjs +9 -6
  30. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  31. package/dist/esm/run.mjs +147 -14
  32. package/dist/esm/run.mjs.map +1 -1
  33. package/dist/esm/session/AgentSession.mjs +93 -31
  34. package/dist/esm/session/AgentSession.mjs.map +1 -1
  35. package/dist/esm/session/handlers.mjs +10 -0
  36. package/dist/esm/session/handlers.mjs.map +1 -1
  37. package/dist/esm/stream.mjs +17 -3
  38. package/dist/esm/stream.mjs.map +1 -1
  39. package/dist/esm/summarization/node.mjs +1 -0
  40. package/dist/esm/summarization/node.mjs.map +1 -1
  41. package/dist/esm/tools/ToolNode.mjs +4 -2
  42. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  43. package/dist/esm/tools/handlers.mjs +1 -0
  44. package/dist/esm/tools/handlers.mjs.map +1 -1
  45. package/dist/esm/tools/subagent/SubagentExecutor.mjs +28 -0
  46. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  47. package/dist/esm/utils/handlers.mjs +3 -0
  48. package/dist/esm/utils/handlers.mjs.map +1 -1
  49. package/dist/types/common/enum.d.ts +2 -0
  50. package/dist/types/graphs/Graph.d.ts +87 -0
  51. package/dist/types/langfuseTraceShaping.d.ts +4 -3
  52. package/dist/types/run.d.ts +9 -1
  53. package/dist/types/session/types.d.ts +1 -1
  54. package/dist/types/summarization/node.d.ts +6 -0
  55. package/dist/types/types/graph.d.ts +3 -3
  56. package/dist/types/types/stream.d.ts +57 -0
  57. package/dist/types/utils/handlers.d.ts +1 -0
  58. package/package.json +1 -1
  59. package/src/aggregator.test.ts +127 -0
  60. package/src/common/enum.ts +2 -0
  61. package/src/graphs/Graph.ts +365 -7
  62. package/src/graphs/__tests__/Graph.closeRunStep.test.ts +385 -0
  63. package/src/langfuseTraceShaping.ts +14 -7
  64. package/src/run.ts +219 -41
  65. package/src/session/AgentSession.ts +105 -13
  66. package/src/session/handlers.ts +14 -2
  67. package/src/session/types.ts +1 -0
  68. package/src/specs/activity-label-observability.live.test.ts +262 -0
  69. package/src/specs/activity-label-observability.test.ts +167 -0
  70. package/src/specs/langfuse-trace-shaping.test.ts +32 -0
  71. package/src/specs/run-step-timestamps.test.ts +414 -0
  72. package/src/stream.ts +29 -6
  73. package/src/summarization/node.ts +11 -0
  74. package/src/tools/ToolNode.ts +2 -0
  75. package/src/tools/__tests__/SubagentExecutor.test.ts +52 -10
  76. package/src/tools/__tests__/handlers.test.ts +2 -0
  77. package/src/tools/handlers.ts +1 -0
  78. package/src/tools/subagent/SubagentExecutor.ts +46 -4
  79. package/src/types/graph.ts +9 -6
  80. package/src/types/stream.ts +65 -12
  81. package/src/utils/handlers.ts +13 -0
@@ -0,0 +1,414 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { HumanMessage } from '@langchain/core/messages';
4
+ import { MemorySaver, Command } from '@langchain/langgraph';
5
+ import type { ToolCall } from '@langchain/core/messages/tool';
6
+ import type * as t from '@/types';
7
+ import { GraphEvents, StepTypes, Providers } from '@/common';
8
+ import { FakeChatModel } from '@/llm/fake';
9
+ import { askUserQuestion } from '@/hitl';
10
+ import { Run } from '@/run';
11
+
12
+ const askTool = tool(
13
+ async (input) => {
14
+ const { answer } = askUserQuestion(input as { question: string });
15
+ return answer;
16
+ },
17
+ {
18
+ name: 'ask_user_question',
19
+ description:
20
+ 'Ask the user a clarifying question and wait for their answer.',
21
+ schema: z.object({ question: z.string() }),
22
+ }
23
+ );
24
+
25
+ const llmConfig: t.LLMConfig = {
26
+ provider: Providers.OPENAI,
27
+ streaming: true,
28
+ streamUsage: false,
29
+ };
30
+
31
+ const echoTool = tool(
32
+ async ({ input }: { input: string }) => `echo: ${input}`,
33
+ {
34
+ name: 'echoTool',
35
+ description: 'Echoes the input back',
36
+ schema: z.object({ input: z.string() }),
37
+ }
38
+ );
39
+
40
+ const toolCalls: ToolCall[] = [
41
+ {
42
+ name: 'echoTool',
43
+ args: { input: 'ping' },
44
+ id: 'call_timestamps_1',
45
+ type: 'tool_call',
46
+ },
47
+ ];
48
+
49
+ const createStreamConfig = (
50
+ threadId: string,
51
+ signal?: AbortSignal
52
+ ): t.RunStreamConfig => ({
53
+ configurable: { thread_id: threadId },
54
+ version: 'v2',
55
+ ...(signal != null ? { signal } : {}),
56
+ });
57
+
58
+ type RecordedEvent = {
59
+ event: string;
60
+ data: unknown;
61
+ /** Lifecycle fields captured at receipt — dispatched RunStep objects are
62
+ * shared references that later closes mutate in place. */
63
+ statusAtReceipt?: t.RunStepStatus;
64
+ };
65
+
66
+ function createRecorder(): {
67
+ sequence: RecordedEvent[];
68
+ handlers: Record<string, t.EventHandler>;
69
+ } {
70
+ const sequence: RecordedEvent[] = [];
71
+ const record = (event: string): t.EventHandler => ({
72
+ handle: (_event, data): void => {
73
+ sequence.push({
74
+ event,
75
+ data,
76
+ ...(event === GraphEvents.ON_RUN_STEP
77
+ ? { statusAtReceipt: (data as t.RunStep).status }
78
+ : {}),
79
+ });
80
+ },
81
+ });
82
+ return {
83
+ sequence,
84
+ handlers: {
85
+ [GraphEvents.ON_RUN_STEP]: record(GraphEvents.ON_RUN_STEP),
86
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: record(
87
+ GraphEvents.ON_RUN_STEP_COMPLETED
88
+ ),
89
+ [GraphEvents.ON_RUN_STEP_CLOSED]: record(GraphEvents.ON_RUN_STEP_CLOSED),
90
+ },
91
+ };
92
+ }
93
+
94
+ function getSteps(sequence: RecordedEvent[]): t.RunStep[] {
95
+ return sequence
96
+ .filter((entry) => entry.event === GraphEvents.ON_RUN_STEP)
97
+ .map((entry) => entry.data as t.RunStep);
98
+ }
99
+
100
+ function getClosed(sequence: RecordedEvent[]): t.RunStepClosedEvent[] {
101
+ return sequence
102
+ .filter((entry) => entry.event === GraphEvents.ON_RUN_STEP_CLOSED)
103
+ .map((entry) => entry.data as t.RunStepClosedEvent);
104
+ }
105
+
106
+ describe('run step timestamps', () => {
107
+ jest.setTimeout(20000);
108
+
109
+ it('stamps starts and closes every step exactly once on a natural finish', async () => {
110
+ const { sequence, handlers } = createRecorder();
111
+ const run = await Run.create<t.IState>({
112
+ runId: 'test-run-step-timestamps',
113
+ graphConfig: {
114
+ type: 'standard',
115
+ llmConfig,
116
+ tools: [echoTool],
117
+ instructions: 'You are a helpful assistant.',
118
+ },
119
+ returnContent: true,
120
+ skipCleanup: true,
121
+ customHandlers: handlers,
122
+ });
123
+
124
+ run.Graph?.overrideTestModel(
125
+ ['Let me call the tool', 'The tool answered'],
126
+ 2,
127
+ toolCalls
128
+ );
129
+ const started = Date.now();
130
+ await run.processStream(
131
+ { messages: [new HumanMessage('hello')] },
132
+ createStreamConfig('run-step-timestamps')
133
+ );
134
+
135
+ const steps = getSteps(sequence);
136
+ const closed = getClosed(sequence);
137
+ expect(steps.length).toBeGreaterThanOrEqual(3);
138
+ const stepReceipts = sequence.filter(
139
+ (entry) => entry.event === GraphEvents.ON_RUN_STEP
140
+ );
141
+ for (const receipt of stepReceipts) {
142
+ expect(receipt.statusAtReceipt).toBe('in_progress');
143
+ }
144
+ for (const step of steps) {
145
+ expect(typeof step.created_at).toBe('number');
146
+ expect(step.created_at as number).toBeGreaterThanOrEqual(started);
147
+ }
148
+
149
+ const stepIds = steps.map((step) => step.id);
150
+ expect(closed.map((event) => event.id).sort()).toEqual([...stepIds].sort());
151
+ for (const event of closed) {
152
+ expect(event.status).toBe('completed');
153
+ expect(event.closed_at).toBeGreaterThanOrEqual(
154
+ event.created_at as number
155
+ );
156
+ }
157
+
158
+ const toolStep = steps.find((step) => step.type === StepTypes.TOOL_CALLS);
159
+ expect(toolStep).toBeDefined();
160
+ const toolStepId = (toolStep as t.RunStep).id;
161
+ const toolCompletedIndex = sequence.findIndex(
162
+ (entry) =>
163
+ entry.event === GraphEvents.ON_RUN_STEP_COMPLETED &&
164
+ (entry.data as { result?: { id?: string } }).result?.id === toolStepId
165
+ );
166
+ const toolClosedIndex = sequence.findIndex(
167
+ (entry) =>
168
+ entry.event === GraphEvents.ON_RUN_STEP_CLOSED &&
169
+ (entry.data as t.RunStepClosedEvent).id === toolStepId
170
+ );
171
+ expect(toolCompletedIndex).toBeGreaterThanOrEqual(0);
172
+ expect(toolClosedIndex).toBeGreaterThan(toolCompletedIndex);
173
+
174
+ const firstMessageStepId = steps[0].id;
175
+ const toolStepIndex = sequence.findIndex(
176
+ (entry) =>
177
+ entry.event === GraphEvents.ON_RUN_STEP &&
178
+ (entry.data as t.RunStep).id === toolStepId
179
+ );
180
+ const firstMessageClosedIndex = sequence.findIndex(
181
+ (entry) =>
182
+ entry.event === GraphEvents.ON_RUN_STEP_CLOSED &&
183
+ (entry.data as t.RunStepClosedEvent).id === firstMessageStepId
184
+ );
185
+ expect(firstMessageClosedIndex).toBeGreaterThanOrEqual(0);
186
+ expect(firstMessageClosedIndex).toBeLessThan(toolStepIndex);
187
+
188
+ const completedEvent = sequence
189
+ .filter((entry) => entry.event === GraphEvents.ON_RUN_STEP_COMPLETED)
190
+ .map((entry) => (entry.data as { result: t.ToolCompleteEvent }).result)
191
+ .find((result) => result.id === toolStepId);
192
+ expect(typeof completedEvent?.completed_at).toBe('number');
193
+
194
+ for (const step of run.Graph?.contentData ?? []) {
195
+ expect(step.status).toBe('completed');
196
+ expect(typeof step.completed_at).toBe('number');
197
+ }
198
+ });
199
+
200
+ it('changes nothing for hosts that only register the legacy handlers', async () => {
201
+ const received: string[] = [];
202
+ const legacyHandler = (event: string): t.EventHandler => ({
203
+ handle: (): void => {
204
+ received.push(event);
205
+ },
206
+ });
207
+ const run = await Run.create<t.IState>({
208
+ runId: 'test-run-step-timestamps-legacy',
209
+ graphConfig: {
210
+ type: 'standard',
211
+ llmConfig,
212
+ tools: [echoTool],
213
+ instructions: 'You are a helpful assistant.',
214
+ },
215
+ returnContent: true,
216
+ skipCleanup: true,
217
+ customHandlers: {
218
+ [GraphEvents.ON_RUN_STEP]: legacyHandler(GraphEvents.ON_RUN_STEP),
219
+ [GraphEvents.ON_RUN_STEP_COMPLETED]: legacyHandler(
220
+ GraphEvents.ON_RUN_STEP_COMPLETED
221
+ ),
222
+ [GraphEvents.ON_MESSAGE_DELTA]: legacyHandler(
223
+ GraphEvents.ON_MESSAGE_DELTA
224
+ ),
225
+ },
226
+ });
227
+
228
+ run.Graph?.overrideTestModel(
229
+ ['Let me call the tool', 'The tool answered'],
230
+ 2,
231
+ toolCalls
232
+ );
233
+ await run.processStream(
234
+ { messages: [new HumanMessage('hello')] },
235
+ createStreamConfig('run-step-timestamps-legacy')
236
+ );
237
+
238
+ expect(new Set(received)).toEqual(
239
+ new Set([
240
+ GraphEvents.ON_RUN_STEP,
241
+ GraphEvents.ON_RUN_STEP_COMPLETED,
242
+ GraphEvents.ON_MESSAGE_DELTA,
243
+ ])
244
+ );
245
+ for (const step of run.Graph?.contentData ?? []) {
246
+ expect(step.status).toBe('completed');
247
+ }
248
+ });
249
+
250
+ it('sweeps unfinished steps as cancelled when the caller aborts mid-stream', async () => {
251
+ const { sequence, handlers } = createRecorder();
252
+ const controller = new AbortController();
253
+ const run = await Run.create<t.IState>({
254
+ runId: 'test-run-step-timestamps-abort',
255
+ graphConfig: {
256
+ type: 'standard',
257
+ llmConfig,
258
+ instructions: 'You are a helpful assistant.',
259
+ },
260
+ returnContent: true,
261
+ skipCleanup: true,
262
+ customHandlers: {
263
+ ...handlers,
264
+ [GraphEvents.ON_MESSAGE_DELTA]: {
265
+ handle: (): void => {
266
+ controller.abort();
267
+ },
268
+ },
269
+ },
270
+ });
271
+
272
+ run.Graph?.overrideTestModel(
273
+ ['This response streams slowly enough to abort in flight'],
274
+ 25
275
+ );
276
+ await expect(
277
+ run.processStream(
278
+ { messages: [new HumanMessage('hello')] },
279
+ createStreamConfig('run-step-timestamps-abort', controller.signal)
280
+ )
281
+ ).rejects.toThrow();
282
+
283
+ const closed = getClosed(sequence);
284
+ expect(closed.length).toBeGreaterThanOrEqual(1);
285
+ expect(closed.every((event) => event.status === 'cancelled')).toBe(true);
286
+ for (const step of run.Graph?.contentData ?? []) {
287
+ expect(step.status).toBe('cancelled');
288
+ expect(typeof step.cancelled_at).toBe('number');
289
+ expect(step.cancelled_at as number).toBeGreaterThanOrEqual(
290
+ step.created_at as number
291
+ );
292
+ }
293
+ });
294
+
295
+ it('sweeps as failed when an AbortError surfaces with no signal aborted', async () => {
296
+ const { sequence, handlers } = createRecorder();
297
+ const run = await Run.create<t.IState>({
298
+ runId: 'test-run-step-timestamps-uncorroborated-abort',
299
+ graphConfig: {
300
+ type: 'standard',
301
+ llmConfig,
302
+ instructions: 'You are a helpful assistant.',
303
+ },
304
+ returnContent: true,
305
+ skipCleanup: true,
306
+ customHandlers: {
307
+ ...handlers,
308
+ [GraphEvents.ON_MESSAGE_DELTA]: {
309
+ handle: (): void => {
310
+ /** A provider/host rejection that merely borrows the name — no
311
+ * caller or construction signal was ever aborted. */
312
+ const error = new Error('provider stream aborted internally');
313
+ error.name = 'AbortError';
314
+ throw error;
315
+ },
316
+ },
317
+ },
318
+ });
319
+
320
+ run.Graph?.overrideTestModel(['some streamed content'], 2);
321
+ await expect(
322
+ run.processStream(
323
+ { messages: [new HumanMessage('hello')] },
324
+ createStreamConfig('run-step-timestamps-uncorroborated-abort')
325
+ )
326
+ ).rejects.toThrow();
327
+
328
+ const closed = getClosed(sequence);
329
+ expect(closed.length).toBeGreaterThanOrEqual(1);
330
+ expect(closed.every((event) => event.status === 'failed')).toBe(true);
331
+ for (const step of run.Graph?.contentData ?? []) {
332
+ expect(step.status).toBe('failed');
333
+ expect(typeof step.failed_at).toBe('number');
334
+ expect(step.cancelled_at).toBeUndefined();
335
+ }
336
+ });
337
+
338
+ it('keeps steps open across a HITL interrupt and closes them on resume with the original created_at', async () => {
339
+ jest.setTimeout(30000);
340
+ const { sequence, handlers } = createRecorder();
341
+ const saver = new MemorySaver();
342
+ const run = await Run.create<t.IState>({
343
+ runId: 'test-run-step-timestamps-hitl',
344
+ graphConfig: {
345
+ type: 'standard',
346
+ agents: [
347
+ {
348
+ agentId: 'timestamps-hitl-agent',
349
+ provider: Providers.OPENAI,
350
+ clientOptions: {
351
+ model: 'gpt-4o-mini',
352
+ streaming: true,
353
+ streamUsage: false,
354
+ },
355
+ instructions: 'You are a helpful assistant.',
356
+ maxContextTokens: 8000,
357
+ graphTools: [askTool],
358
+ },
359
+ ],
360
+ compileOptions: { checkpointer: saver },
361
+ },
362
+ returnContent: true,
363
+ skipCleanup: true,
364
+ customHandlers: handlers,
365
+ });
366
+ run.Graph!.overrideModel = new FakeChatModel({
367
+ responses: ['asking the user', 'done after resume'],
368
+ toolCalls: [
369
+ {
370
+ name: 'ask_user_question',
371
+ args: { question: 'pick one' },
372
+ id: 'call_ask_timestamps',
373
+ type: 'tool_call',
374
+ },
375
+ ],
376
+ });
377
+
378
+ const streamConfig = createStreamConfig('run-step-timestamps-hitl');
379
+ await run.processStream(
380
+ { messages: [new HumanMessage('go')] },
381
+ streamConfig
382
+ );
383
+
384
+ expect(run.getInterrupt()).toBeDefined();
385
+ const toolStep = run.Graph?.contentData.find(
386
+ (step) => step.type === StepTypes.TOOL_CALLS
387
+ ) as t.RunStep;
388
+ expect(toolStep).toBeDefined();
389
+ expect(toolStep.status).toBe('in_progress');
390
+ const createdAt = toolStep.created_at;
391
+ expect(typeof createdAt).toBe('number');
392
+ expect(getClosed(sequence).map((event) => event.id)).not.toContain(
393
+ toolStep.id
394
+ );
395
+
396
+ await run.processStream(
397
+ new Command({ resume: { answer: 'blue' } }) as unknown as t.IState,
398
+ streamConfig
399
+ );
400
+
401
+ expect(toolStep.status).toBe('completed');
402
+ expect(toolStep.created_at).toBe(createdAt);
403
+ const closedForTool = getClosed(sequence).filter(
404
+ (event) => event.id === toolStep.id
405
+ );
406
+ expect(closedForTool).toHaveLength(1);
407
+ expect(closedForTool[0].closed_at).toBeGreaterThanOrEqual(
408
+ createdAt as number
409
+ );
410
+ for (const step of run.Graph?.contentData ?? []) {
411
+ expect(step.status).toBe('completed');
412
+ }
413
+ });
414
+ });
package/src/stream.ts CHANGED
@@ -924,6 +924,7 @@ async function dispatchEagerToolCompletions(args: {
924
924
  progress: 1,
925
925
  ...(outcome != null && { outcome }),
926
926
  } as t.ProcessedToolCall,
927
+ completed_at: Date.now(),
927
928
  },
928
929
  },
929
930
  graph.config
@@ -1836,9 +1837,7 @@ export class ChatModelStreamHandler implements t.EventHandler {
1836
1837
  contentGroups[0]
1837
1838
  ).phase;
1838
1839
  const phaseChanged =
1839
- currentPhase != null &&
1840
- nextPhase != null &&
1841
- currentPhase !== nextPhase;
1840
+ currentPhase != null && nextPhase != null && currentPhase !== nextPhase;
1842
1841
  if (contentGroups.length > 1 || phaseChanged) {
1843
1842
  for (const contentGroup of contentGroups) {
1844
1843
  const currentStepId = await dispatchMessageCreationStep({
@@ -2299,20 +2298,44 @@ export function createContentAggregator(): t.ContentAggregatorResult {
2299
2298
  return;
2300
2299
  }
2301
2300
 
2301
+ const incomingText =
2302
+ ContentTypes.TEXT in contentPart && typeof contentPart.text === 'string'
2303
+ ? contentPart.text
2304
+ : undefined;
2305
+ /**
2306
+ * Anthropic emits a search turn's citations as their own `citations_delta`,
2307
+ * which arrives here as a text part carrying `citations` and no `text`.
2308
+ */
2309
+ const { citations } = contentPart as {
2310
+ citations?: t.MessageDeltaUpdate['citations'];
2311
+ };
2312
+ const incomingCitations = Array.isArray(citations) ? citations : undefined;
2313
+
2302
2314
  if (
2303
2315
  partType.startsWith(ContentTypes.TEXT) &&
2304
- ContentTypes.TEXT in contentPart &&
2305
- typeof contentPart.text === 'string'
2316
+ (incomingText !== undefined || incomingCitations !== undefined)
2306
2317
  ) {
2307
2318
  // TODO: update this!!
2308
2319
  const currentContent = contentParts[index] as t.MessageDeltaUpdate;
2309
2320
  const update: t.MessageDeltaUpdate = {
2310
2321
  type: ContentTypes.TEXT,
2311
- text: (currentContent.text || '') + contentPart.text,
2322
+ text: (currentContent.text || '') + (incomingText ?? ''),
2312
2323
  };
2313
2324
 
2314
2325
  if (contentPart.tool_call_ids) {
2315
2326
  update.tool_call_ids = contentPart.tool_call_ids;
2327
+ } else if (incomingText === undefined && currentContent.tool_call_ids) {
2328
+ /** A citations-only delta must not drop ids already accumulated */
2329
+ update.tool_call_ids = currentContent.tool_call_ids;
2330
+ }
2331
+
2332
+ if (incomingCitations !== undefined) {
2333
+ update.citations =
2334
+ currentContent.citations !== undefined
2335
+ ? [...currentContent.citations, ...incomingCitations]
2336
+ : [...incomingCitations];
2337
+ } else if (currentContent.citations !== undefined) {
2338
+ update.citations = currentContent.citations;
2316
2339
  }
2317
2340
  contentParts[index] = update;
2318
2341
  } else if (
@@ -908,6 +908,16 @@ interface CreateSummarizeNodeParams {
908
908
  result: t.StepCompleted,
909
909
  config?: RunnableConfig
910
910
  ) => Promise<void>;
911
+ /**
912
+ * Terminal close for a summary step that ends without a completion —
913
+ * an errored or empty summary would otherwise stay `in_progress` until
914
+ * the run-end sweep reported it as `completed`.
915
+ */
916
+ closeRunStep?: (
917
+ stepId: string,
918
+ status: Exclude<t.RunStepStatus, 'in_progress'>,
919
+ config?: RunnableConfig
920
+ ) => Promise<void>;
911
921
  /** The run's shared breaker signal, composed into every summarization
912
922
  * model attempt so a sibling branch tripping a stream limit also
913
923
  * cancels in-flight summaries. */
@@ -1342,6 +1352,7 @@ export function createSummarizeNode({
1342
1352
  runnableConfig
1343
1353
  );
1344
1354
  }
1355
+ await graph.closeRunStep?.(stepId, 'failed', runnableConfig);
1345
1356
  return { summarizationRequest: undefined };
1346
1357
  }
1347
1358
 
@@ -2561,6 +2561,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2561
2561
  index: turn,
2562
2562
  type: 'tool_call' as const,
2563
2563
  tool_call,
2564
+ completed_at: Date.now(),
2564
2565
  },
2565
2566
  },
2566
2567
  config
@@ -3965,6 +3966,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
3965
3966
  progress: 1,
3966
3967
  ...(outcome != null && { outcome }),
3967
3968
  } as t.ProcessedToolCall,
3969
+ completed_at: Date.now(),
3968
3970
  },
3969
3971
  },
3970
3972
  config
@@ -1563,17 +1563,13 @@ describe('SubagentExecutor', () => {
1563
1563
  const selected = makeConfig('selected', {
1564
1564
  agentInputs: {
1565
1565
  ...makeChildInputs('selected-agent'),
1566
- initialSessions: new Map([
1567
- [Constants.EXECUTE_CODE, selectedSession],
1568
- ]),
1566
+ initialSessions: new Map([[Constants.EXECUTE_CODE, selectedSession]]),
1569
1567
  },
1570
1568
  });
1571
1569
  const sibling = makeConfig('sibling', {
1572
1570
  agentInputs: {
1573
1571
  ...makeChildInputs('sibling-agent'),
1574
- initialSessions: new Map([
1575
- [Constants.EXECUTE_CODE, siblingSession],
1576
- ]),
1572
+ initialSessions: new Map([[Constants.EXECUTE_CODE, siblingSession]]),
1577
1573
  },
1578
1574
  });
1579
1575
  const childSessions: ToolSessionMap = new Map();
@@ -1613,7 +1609,10 @@ describe('SubagentExecutor', () => {
1613
1609
  });
1614
1610
 
1615
1611
  it('combines member session seeds inside a selected graph child', async () => {
1616
- const makeSession = (storageSessionId: string, includeFileSession = true) => ({
1612
+ const makeSession = (
1613
+ storageSessionId: string,
1614
+ includeFileSession = true
1615
+ ) => ({
1617
1616
  session_id: storageSessionId,
1618
1617
  files: [
1619
1618
  {
@@ -1696,9 +1695,7 @@ describe('SubagentExecutor', () => {
1696
1695
  const hitlConfig = makeConfig('hitl-child', {
1697
1696
  agentInputs: {
1698
1697
  ...makeChildInputs('hitl-child'),
1699
- initialSessions: new Map([
1700
- [Constants.EXECUTE_CODE, seededSession],
1701
- ]),
1698
+ initialSessions: new Map([[Constants.EXECUTE_CODE, seededSession]]),
1702
1699
  },
1703
1700
  });
1704
1701
  const childSessions: ToolSessionMap = new Map();
@@ -3625,6 +3622,51 @@ describe('sanitizeForwardedSubagentUpdateData', () => {
3625
3622
  expect(serialized).not.toContain('refresh-secret');
3626
3623
  });
3627
3624
 
3625
+ it('forwards run step lifecycle stamps and closed events via allowlists', () => {
3626
+ const sanitizedStep = sanitizeForwardedSubagentUpdateData(
3627
+ GraphEvents.ON_RUN_STEP,
3628
+ {
3629
+ id: 'step_1',
3630
+ type: StepTypes.MESSAGE_CREATION,
3631
+ index: 0,
3632
+ created_at: 1_000,
3633
+ status: 'in_progress',
3634
+ stepDetails: {
3635
+ type: StepTypes.MESSAGE_CREATION,
3636
+ message_creation: { message_id: 'message_1' },
3637
+ },
3638
+ }
3639
+ ) as { created_at?: number; status?: string };
3640
+ expect(sanitizedStep.created_at).toBe(1_000);
3641
+ expect(sanitizedStep.status).toBe('in_progress');
3642
+
3643
+ const sanitizedClosed = sanitizeForwardedSubagentUpdateData(
3644
+ GraphEvents.ON_RUN_STEP_CLOSED,
3645
+ {
3646
+ id: 'step_1',
3647
+ index: 0,
3648
+ type: StepTypes.MESSAGE_CREATION,
3649
+ status: 'completed',
3650
+ created_at: 1_000,
3651
+ closed_at: 2_000,
3652
+ runId: 'run_1',
3653
+ agentId: 'researcher',
3654
+ futureSecret: 'top-level-secret',
3655
+ }
3656
+ );
3657
+ expect(sanitizedClosed).toEqual({
3658
+ id: 'step_1',
3659
+ index: 0,
3660
+ type: StepTypes.MESSAGE_CREATION,
3661
+ status: 'completed',
3662
+ created_at: 1_000,
3663
+ closed_at: 2_000,
3664
+ runId: 'run_1',
3665
+ agentId: 'researcher',
3666
+ });
3667
+ expect(JSON.stringify(sanitizedClosed)).not.toContain('top-level-secret');
3668
+ });
3669
+
3628
3670
  it('keeps completed tool output while stripping operational fields', () => {
3629
3671
  const output = 'x'.repeat(10_000);
3630
3672
  const sanitized = sanitizeForwardedSubagentUpdateData(
@@ -16,6 +16,7 @@ type MockGraph = {
16
16
  getRunStep: jest.Mock;
17
17
  dispatchRunStep: jest.Mock;
18
18
  dispatchRunStepDelta: jest.Mock;
19
+ registerPendingToolCall: jest.Mock;
19
20
  toolCallStepIds: Map<string, string>;
20
21
  messageStepHasToolCalls: Map<string, boolean>;
21
22
  messageIdsByStepKey: Map<string, string>;
@@ -40,6 +41,7 @@ function createMockGraph(overrides?: Partial<MockGraph>): MockGraph {
40
41
  dispatchRunStepDelta: jest
41
42
  .fn<() => Promise<void>>()
42
43
  .mockResolvedValue(undefined),
44
+ registerPendingToolCall: jest.fn<() => void>(),
43
45
  toolCallStepIds: new Map(),
44
46
  messageStepHasToolCalls: new Map(),
45
47
  messageIdsByStepKey: new Map(),
@@ -181,6 +181,7 @@ export const handleToolCalls = async (
181
181
  const isEmpty = !details.tool_calls || details.tool_calls.length === 0;
182
182
  if (isEmpty && prevStepId !== reusedChunkStepId) {
183
183
  graph.toolCallStepIds.set(toolCallId, prevStepId);
184
+ graph.registerPendingToolCall(toolCallId, prevStepId);
184
185
  reusedChunkStepId = prevStepId;
185
186
  continue;
186
187
  }