@librechat/agents 3.4.5 → 3.4.7

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 (87) 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/llm/google/utils/common.cjs +10 -5
  8. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  9. package/dist/cjs/run.cjs +147 -14
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/session/AgentSession.cjs +93 -31
  12. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  13. package/dist/cjs/session/handlers.cjs +10 -0
  14. package/dist/cjs/session/handlers.cjs.map +1 -1
  15. package/dist/cjs/stream.cjs +17 -3
  16. package/dist/cjs/stream.cjs.map +1 -1
  17. package/dist/cjs/summarization/node.cjs +1 -0
  18. package/dist/cjs/summarization/node.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolNode.cjs +4 -2
  20. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  21. package/dist/cjs/tools/handlers.cjs +1 -0
  22. package/dist/cjs/tools/handlers.cjs.map +1 -1
  23. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +28 -0
  24. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  25. package/dist/cjs/utils/handlers.cjs +3 -0
  26. package/dist/cjs/utils/handlers.cjs.map +1 -1
  27. package/dist/esm/common/enum.mjs +2 -0
  28. package/dist/esm/common/enum.mjs.map +1 -1
  29. package/dist/esm/graphs/Graph.mjs +268 -14
  30. package/dist/esm/graphs/Graph.mjs.map +1 -1
  31. package/dist/esm/langfuseTraceShaping.mjs +9 -6
  32. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  33. package/dist/esm/llm/google/utils/common.mjs +10 -5
  34. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  35. package/dist/esm/run.mjs +147 -14
  36. package/dist/esm/run.mjs.map +1 -1
  37. package/dist/esm/session/AgentSession.mjs +93 -31
  38. package/dist/esm/session/AgentSession.mjs.map +1 -1
  39. package/dist/esm/session/handlers.mjs +10 -0
  40. package/dist/esm/session/handlers.mjs.map +1 -1
  41. package/dist/esm/stream.mjs +17 -3
  42. package/dist/esm/stream.mjs.map +1 -1
  43. package/dist/esm/summarization/node.mjs +1 -0
  44. package/dist/esm/summarization/node.mjs.map +1 -1
  45. package/dist/esm/tools/ToolNode.mjs +4 -2
  46. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  47. package/dist/esm/tools/handlers.mjs +1 -0
  48. package/dist/esm/tools/handlers.mjs.map +1 -1
  49. package/dist/esm/tools/subagent/SubagentExecutor.mjs +28 -0
  50. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  51. package/dist/esm/utils/handlers.mjs +3 -0
  52. package/dist/esm/utils/handlers.mjs.map +1 -1
  53. package/dist/types/common/enum.d.ts +2 -0
  54. package/dist/types/graphs/Graph.d.ts +87 -0
  55. package/dist/types/langfuseTraceShaping.d.ts +4 -3
  56. package/dist/types/run.d.ts +9 -1
  57. package/dist/types/session/types.d.ts +1 -1
  58. package/dist/types/summarization/node.d.ts +6 -0
  59. package/dist/types/types/graph.d.ts +3 -3
  60. package/dist/types/types/stream.d.ts +57 -0
  61. package/dist/types/utils/handlers.d.ts +1 -0
  62. package/package.json +1 -1
  63. package/src/aggregator.test.ts +127 -0
  64. package/src/common/enum.ts +2 -0
  65. package/src/graphs/Graph.ts +365 -7
  66. package/src/graphs/__tests__/Graph.closeRunStep.test.ts +385 -0
  67. package/src/langfuseTraceShaping.ts +14 -7
  68. package/src/llm/google/utils/common.test.ts +11 -0
  69. package/src/llm/google/utils/common.ts +6 -4
  70. package/src/run.ts +219 -41
  71. package/src/session/AgentSession.ts +105 -13
  72. package/src/session/handlers.ts +14 -2
  73. package/src/session/types.ts +1 -0
  74. package/src/specs/activity-label-observability.live.test.ts +262 -0
  75. package/src/specs/activity-label-observability.test.ts +167 -0
  76. package/src/specs/langfuse-trace-shaping.test.ts +32 -0
  77. package/src/specs/run-step-timestamps.test.ts +414 -0
  78. package/src/stream.ts +29 -6
  79. package/src/summarization/node.ts +11 -0
  80. package/src/tools/ToolNode.ts +2 -0
  81. package/src/tools/__tests__/SubagentExecutor.test.ts +52 -10
  82. package/src/tools/__tests__/handlers.test.ts +2 -0
  83. package/src/tools/handlers.ts +1 -0
  84. package/src/tools/subagent/SubagentExecutor.ts +46 -4
  85. package/src/types/graph.ts +9 -6
  86. package/src/types/stream.ts +65 -12
  87. 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
+ });
@@ -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;
@@ -116,9 +116,11 @@ describe('convertResponseContentToChatGenerationChunk seal metadata', () => {
116
116
 
117
117
  describe('rejectsModelTurnPrefill', () => {
118
118
  test('is true for models that reject a trailing model turn', () => {
119
+ expect(rejectsModelTurnPrefill('gemini-3.7-flash')).toBe(true);
119
120
  expect(rejectsModelTurnPrefill('gemini-3.6-flash')).toBe(true);
120
121
  expect(rejectsModelTurnPrefill('gemini-3.5-flash-lite')).toBe(true);
121
122
  expect(rejectsModelTurnPrefill('models/gemini-3.6-flash')).toBe(true);
123
+ expect(rejectsModelTurnPrefill('models/gemini-3.7-flash-latest')).toBe(true);
122
124
  expect(rejectsModelTurnPrefill('google/gemini-3.5-flash-lite-latest')).toBe(
123
125
  true
124
126
  );
@@ -149,6 +151,15 @@ describe('dropUnsupportedModelTurnPrefill', () => {
149
151
  expect(result).toEqual([userTurn]);
150
152
  });
151
153
 
154
+ test('drops a trailing model turn for Gemini 3.7 Flash', () => {
155
+ const contents: Content[] = [userTurn, modelTurn];
156
+ const result = dropUnsupportedModelTurnPrefill(
157
+ contents,
158
+ 'gemini-3.7-flash'
159
+ );
160
+ expect(result).toEqual([userTurn]);
161
+ });
162
+
152
163
  test('drops multiple consecutive trailing model turns but keeps one turn', () => {
153
164
  const contents: Content[] = [userTurn, modelTurn, modelTurn];
154
165
  const result = dropUnsupportedModelTurnPrefill(
@@ -660,13 +660,15 @@ export function convertBaseMessagesToContent(
660
660
 
661
661
  /**
662
662
  * Gemini models that reject a request whose `contents` end with a `model`-role
663
- * turn (a "prefill"). Google enforces this on newer generations (Gemini 3.6
664
- * Flash, Gemini 3.5 Flash-Lite) while older/sibling models still accept a
665
- * trailing model turn, so the rule is model-scoped rather than version-wide.
666
- * Extend this list as Google applies the restriction to further models.
663
+ * turn (a "prefill"). Google enforces this on newer generations (Gemini 3.7
664
+ * Flash, Gemini 3.6 Flash, Gemini 3.5 Flash-Lite) while older/sibling models
665
+ * still accept a trailing model turn, so the rule is model-scoped rather than
666
+ * version-wide. Extend this list as Google applies the restriction to further
667
+ * models.
667
668
  * @see https://ai.google.dev/gemini-api/docs/latest-model#api-changes-and-parameter-updates
668
669
  */
669
670
  const NO_PREFILL_GEMINI_MODELS = [
671
+ 'gemini-3.7-flash',
670
672
  'gemini-3.6-flash',
671
673
  'gemini-3.5-flash-lite',
672
674
  ] as const;