@librechat/agents 3.3.8 → 3.3.10

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 (156) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +4 -0
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +66 -13
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs +35 -2
  6. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  7. package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
  8. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  9. package/dist/cjs/instrumentation.cjs +18 -48
  10. package/dist/cjs/instrumentation.cjs.map +1 -1
  11. package/dist/cjs/langfuse.cjs +174 -29
  12. package/dist/cjs/langfuse.cjs.map +1 -1
  13. package/dist/cjs/langfuseConfig.cjs +12 -0
  14. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  15. package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
  16. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  17. package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
  18. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  19. package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
  20. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
  21. package/dist/cjs/langfuseTraceShaping.cjs +121 -4
  22. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  23. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
  24. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  25. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
  26. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  27. package/dist/cjs/llm/init.cjs +3 -3
  28. package/dist/cjs/llm/invoke.cjs +5 -5
  29. package/dist/cjs/llm/openai/index.cjs +1 -1
  30. package/dist/cjs/main.cjs +10 -10
  31. package/dist/cjs/messages/prune.cjs +13 -1
  32. package/dist/cjs/messages/prune.cjs.map +1 -1
  33. package/dist/cjs/prompts/activityLabel.cjs +24 -12
  34. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  35. package/dist/cjs/run.cjs +57 -22
  36. package/dist/cjs/run.cjs.map +1 -1
  37. package/dist/cjs/session/messageSerialization.cjs +6 -0
  38. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  39. package/dist/cjs/stream.cjs +21 -10
  40. package/dist/cjs/stream.cjs.map +1 -1
  41. package/dist/cjs/summarization/node.cjs +5 -0
  42. package/dist/cjs/summarization/node.cjs.map +1 -1
  43. package/dist/cjs/tools/ToolNode.cjs +253 -24
  44. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  45. package/dist/cjs/tools/handlers.cjs +1 -1
  46. package/dist/cjs/tools/search/tool.cjs +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  48. package/dist/cjs/utils/index.cjs +2 -2
  49. package/dist/esm/agents/AgentContext.mjs +4 -0
  50. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  51. package/dist/esm/graphs/Graph.mjs +67 -14
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs +35 -2
  54. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  55. package/dist/esm/hitl/askUserQuestion.mjs +3 -2
  56. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  57. package/dist/esm/instrumentation.mjs +18 -48
  58. package/dist/esm/instrumentation.mjs.map +1 -1
  59. package/dist/esm/langfuse.mjs +176 -28
  60. package/dist/esm/langfuse.mjs.map +1 -1
  61. package/dist/esm/langfuseConfig.mjs +10 -1
  62. package/dist/esm/langfuseConfig.mjs.map +1 -1
  63. package/dist/esm/langfuseRuntimeContext.mjs +21 -3
  64. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  65. package/dist/esm/langfuseRuntimeScope.mjs +39 -10
  66. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  67. package/dist/esm/langfuseSpanRegistry.mjs +91 -0
  68. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
  69. package/dist/esm/langfuseTraceShaping.mjs +121 -4
  70. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  71. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
  72. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  73. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
  74. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  75. package/dist/esm/llm/init.mjs +2 -2
  76. package/dist/esm/llm/invoke.mjs +5 -5
  77. package/dist/esm/llm/openai/index.mjs +1 -1
  78. package/dist/esm/main.mjs +8 -8
  79. package/dist/esm/messages/prune.mjs +13 -1
  80. package/dist/esm/messages/prune.mjs.map +1 -1
  81. package/dist/esm/prompts/activityLabel.mjs +24 -12
  82. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  83. package/dist/esm/run.mjs +57 -22
  84. package/dist/esm/run.mjs.map +1 -1
  85. package/dist/esm/session/messageSerialization.mjs +6 -0
  86. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  87. package/dist/esm/stream.mjs +21 -10
  88. package/dist/esm/stream.mjs.map +1 -1
  89. package/dist/esm/summarization/node.mjs +5 -0
  90. package/dist/esm/summarization/node.mjs.map +1 -1
  91. package/dist/esm/tools/ToolNode.mjs +254 -25
  92. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  93. package/dist/esm/tools/handlers.mjs +1 -1
  94. package/dist/esm/tools/search/tool.mjs +1 -1
  95. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  96. package/dist/esm/utils/index.mjs +2 -2
  97. package/dist/types/agents/AgentContext.d.ts +2 -0
  98. package/dist/types/graphs/Graph.d.ts +24 -0
  99. package/dist/types/hitl/askUserQuestion.d.ts +11 -1
  100. package/dist/types/langfuse.d.ts +16 -8
  101. package/dist/types/langfuseConfig.d.ts +6 -0
  102. package/dist/types/langfuseRuntimeContext.d.ts +27 -1
  103. package/dist/types/langfuseRuntimeScope.d.ts +17 -2
  104. package/dist/types/langfuseSpanRegistry.d.ts +17 -0
  105. package/dist/types/langfuseTraceShaping.d.ts +2 -1
  106. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
  107. package/dist/types/run.d.ts +7 -0
  108. package/dist/types/session/types.d.ts +1 -0
  109. package/dist/types/tools/ToolNode.d.ts +7 -1
  110. package/dist/types/types/hitl.d.ts +8 -0
  111. package/dist/types/types/tools.d.ts +30 -0
  112. package/package.json +7 -4
  113. package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
  114. package/src/agents/AgentContext.ts +5 -0
  115. package/src/graphs/Graph.ts +108 -20
  116. package/src/graphs/MultiAgentGraph.ts +56 -2
  117. package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
  118. package/src/hitl/askUserQuestion.ts +14 -1
  119. package/src/instrumentation.ts +35 -77
  120. package/src/langfuse.ts +320 -43
  121. package/src/langfuseConfig.ts +24 -0
  122. package/src/langfuseRuntimeContext.ts +43 -1
  123. package/src/langfuseRuntimeScope.ts +94 -21
  124. package/src/langfuseSpanRegistry.ts +131 -0
  125. package/src/langfuseTraceShaping.ts +194 -7
  126. package/src/llm/anthropic/utils/message_inputs.ts +70 -19
  127. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
  128. package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
  129. package/src/llm/bedrock/utils/message_inputs.ts +32 -7
  130. package/src/messages/prune.ts +12 -1
  131. package/src/prompts/activityLabel.ts +23 -6
  132. package/src/run.ts +91 -45
  133. package/src/scripts/activity-labels/captured.json +56 -0
  134. package/src/scripts/activity-labels/checks.cjs +205 -0
  135. package/src/scripts/activity-labels/corpus.cjs +473 -0
  136. package/src/scripts/activity-labels/report.cjs +203 -0
  137. package/src/scripts/activity-labels/rescore.cjs +102 -0
  138. package/src/scripts/activity-labels/run.ts +705 -0
  139. package/src/scripts/activity-labels/variants.ts +71 -0
  140. package/src/session/messageSerialization.ts +12 -1
  141. package/src/session/types.ts +1 -0
  142. package/src/specs/activity-label-prompt.test.ts +26 -10
  143. package/src/specs/agent-handoffs.test.ts +306 -0
  144. package/src/specs/discovered-tools.test.ts +217 -0
  145. package/src/specs/langfuse-callbacks.test.ts +456 -0
  146. package/src/specs/langfuse-routing.integration.test.ts +138 -1
  147. package/src/specs/langfuse-span-registry.test.ts +70 -0
  148. package/src/specs/langfuse-trace-shaping.test.ts +294 -0
  149. package/src/specs/prune.test.ts +38 -1
  150. package/src/stream.ts +70 -6
  151. package/src/summarization/node.ts +5 -0
  152. package/src/tools/ToolNode.ts +400 -9
  153. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
  154. package/src/tools/__tests__/hitl.test.ts +58 -0
  155. package/src/types/hitl.ts +8 -0
  156. package/src/types/tools.ts +35 -1
@@ -0,0 +1,757 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { Command, MemorySaver } from '@langchain/langgraph';
4
+ import { describe, it, expect } from '@jest/globals';
5
+ import {
6
+ AIMessage,
7
+ ToolMessage,
8
+ HumanMessage,
9
+ } from '@langchain/core/messages';
10
+ import type { BaseMessage } from '@langchain/core/messages';
11
+ import type { StructuredToolInterface } from '@langchain/core/tools';
12
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
13
+ import type * as t from '@/types';
14
+ import { _convertMessagesToOpenAIResponsesParams } from '@/llm/openai/utils';
15
+ import {
16
+ serializeMessage,
17
+ deserializeMessage,
18
+ } from '@/session/messageSerialization';
19
+ import { askUserQuestion } from '@/hitl/askUserQuestion';
20
+ import { FakeChatModel } from '@/llm/fake';
21
+ import { Providers } from '@/common';
22
+ import { ToolNode, toolsCondition } from '../ToolNode';
23
+ import { Run } from '@/run';
24
+
25
+ /**
26
+ * `invalid_tool_calls` coverage: a streamed tool call whose accumulated args
27
+ * never collapse into a JSON object is filed by `@langchain/core` under
28
+ * `invalid_tool_calls` (never `tool_calls`), yet its `tool_use` block still
29
+ * rides the AI message content the provider receives. ToolNode must
30
+ * synthesize an error `ToolMessage` for it — skipping it leaves a `tool_use`
31
+ * with no `tool_result` and the next model call is rejected (Anthropic 400
32
+ * INVALID_TOOL_RESULTS). Fatal on HITL resume, where the paused AI message
33
+ * is replayed from the checkpoint (observed with two parallel
34
+ * `ask_user_question` calls, one malformed).
35
+ */
36
+
37
+ function createEchoTool(name = 'echo'): StructuredToolInterface {
38
+ return tool(async (input) => `ran:${(input as { command: string }).command}`, {
39
+ name,
40
+ description: 'Echo test tool',
41
+ schema: z.object({ command: z.string() }),
42
+ }) as unknown as StructuredToolInterface;
43
+ }
44
+
45
+ function resultMessages(result: unknown): BaseMessage[] {
46
+ return Array.isArray(result)
47
+ ? result
48
+ : (result as { messages: BaseMessage[] }).messages;
49
+ }
50
+
51
+ function toToolMessages(result: unknown): ToolMessage[] {
52
+ return resultMessages(result).filter(
53
+ (msg): msg is ToolMessage => msg._getType() === 'tool'
54
+ );
55
+ }
56
+
57
+ function toPromotedAiMessage(result: unknown): AIMessage | undefined {
58
+ return resultMessages(result).find(
59
+ (msg): msg is AIMessage => msg._getType() === 'ai'
60
+ );
61
+ }
62
+
63
+ describe('ToolNode invalid_tool_calls handling', () => {
64
+ it('synthesizes an error ToolMessage for an invalid call alongside real results (direct batch)', async () => {
65
+ const node = new ToolNode({ tools: [createEchoTool()] });
66
+ const aiMsg = new AIMessage({
67
+ id: 'ai_mixed',
68
+ content: '',
69
+ tool_calls: [{ id: 'tc_valid', name: 'echo', args: { command: 'hi' } }],
70
+ invalid_tool_calls: [
71
+ {
72
+ id: 'tc_invalid',
73
+ name: 'echo',
74
+ args: '"not an object"',
75
+ error: 'Malformed args.',
76
+ type: 'invalid_tool_call',
77
+ },
78
+ ],
79
+ });
80
+
81
+ const result = await node.invoke(
82
+ { messages: [aiMsg] },
83
+ { configurable: { run_id: 'invalid-mixed' } }
84
+ );
85
+ const toolMessages = toToolMessages(result);
86
+
87
+ expect(toolMessages.map((m) => m.tool_call_id).sort()).toEqual([
88
+ 'tc_invalid',
89
+ 'tc_valid',
90
+ ]);
91
+ const invalidResult = toolMessages.find(
92
+ (m) => m.tool_call_id === 'tc_invalid'
93
+ )!;
94
+ expect(String(invalidResult.content)).toContain('Malformed args.');
95
+ expect(invalidResult.name).toBe('echo');
96
+
97
+ /** Replacement AI message (reducer upsert-by-id): the answered invalid
98
+ * call is promoted into tool_calls so provider converters that rebuild
99
+ * the call side from tool_calls emit it alongside its synthesized
100
+ * result. */
101
+ const promoted = toPromotedAiMessage(result);
102
+ expect(promoted?.id).toBe('ai_mixed');
103
+ expect(promoted?.tool_calls?.map((c) => c.id).sort()).toEqual([
104
+ 'tc_invalid',
105
+ 'tc_valid',
106
+ ]);
107
+ expect(promoted?.invalid_tool_calls).toHaveLength(0);
108
+ });
109
+
110
+ it('sanitizes the promoted call\'s Anthropic tool_use content block (raw string input → {})', async () => {
111
+ /**
112
+ * Anthropic formats array-content AI messages from the blocks verbatim; a
113
+ * call whose streamed input never parsed leaves `input` as the raw
114
+ * accumulated string, which the API rejects with "Input should be an
115
+ * object" on replay. The replacement message must normalize the block to
116
+ * match the promoted args; valid siblings' blocks stay untouched.
117
+ */
118
+ const node = new ToolNode({ tools: [createEchoTool()] });
119
+ const aiMsg = new AIMessage({
120
+ id: 'ai_blocks',
121
+ content: [
122
+ { type: 'text', text: 'Two calls.' },
123
+ { type: 'tool_use', id: 'tc_ok', name: 'echo', input: { command: 'hi' } },
124
+ { type: 'tool_use', id: 'tc_bad', name: 'echo', input: '"raw unparsed' },
125
+ ],
126
+ tool_calls: [{ id: 'tc_ok', name: 'echo', args: { command: 'hi' } }],
127
+ invalid_tool_calls: [
128
+ {
129
+ id: 'tc_bad',
130
+ name: 'echo',
131
+ args: '"raw unparsed',
132
+ error: 'Malformed args.',
133
+ type: 'invalid_tool_call',
134
+ },
135
+ ],
136
+ });
137
+
138
+ const result = await node.invoke(
139
+ { messages: [aiMsg] },
140
+ { configurable: { run_id: 'invalid-blocks' } }
141
+ );
142
+ const promoted = toPromotedAiMessage(result)!;
143
+ const blocks = promoted.content as Array<{
144
+ type?: string;
145
+ id?: string;
146
+ input?: unknown;
147
+ }>;
148
+ expect(blocks.find((b) => b.id === 'tc_bad')?.input).toEqual({});
149
+ expect(blocks.find((b) => b.id === 'tc_ok')?.input).toEqual({ command: 'hi' });
150
+ expect(blocks[0]).toEqual({ type: 'text', text: 'Two calls.' });
151
+ });
152
+
153
+ it('normalizes a nameless promoted call\'s tool_use block name (provider validation)', async () => {
154
+ const node = new ToolNode({ tools: [createEchoTool()] });
155
+ const aiMsg = new AIMessage({
156
+ id: 'ai_nameless_block',
157
+ content: [
158
+ { type: 'tool_use', id: 'tc_noname', input: '"raw unparsed' },
159
+ { type: 'tool_use', id: 'tc_emptyname', name: '', input: '"raw unparsed' },
160
+ ],
161
+ tool_calls: [],
162
+ invalid_tool_calls: [
163
+ {
164
+ id: 'tc_noname',
165
+ args: '"raw unparsed',
166
+ error: 'Malformed args.',
167
+ type: 'invalid_tool_call',
168
+ },
169
+ {
170
+ id: 'tc_emptyname',
171
+ name: '',
172
+ args: '"raw unparsed',
173
+ error: 'Malformed args.',
174
+ type: 'invalid_tool_call',
175
+ },
176
+ ],
177
+ });
178
+
179
+ const result = await node.invoke(
180
+ { messages: [aiMsg] },
181
+ { configurable: { run_id: 'invalid-nameless-block' } }
182
+ );
183
+ const promoted = toPromotedAiMessage(result)!;
184
+ const blocks = promoted.content as Array<{ id?: string; name?: string; input?: unknown }>;
185
+ /** Same fallback the promoted tool_calls entries use — missing AND
186
+ * empty-string names both fail provider validation on their own. */
187
+ for (const id of ['tc_noname', 'tc_emptyname']) {
188
+ const block = blocks.find((b) => b.id === id)!;
189
+ expect(block.name).toBe('unknown');
190
+ expect(block.input).toEqual({});
191
+ expect(promoted.tool_calls?.find((c) => c.id === id)).toMatchObject({
192
+ name: 'unknown',
193
+ });
194
+ const synthesized = toToolMessages(result).find(
195
+ (m) => m.tool_call_id === id
196
+ )!;
197
+ expect(synthesized.name).toBe('unknown');
198
+ }
199
+ });
200
+
201
+ it('carries the promotion into a handoff Command update (same-id state copy + missing result)', async () => {
202
+ /**
203
+ * A handoff tool snapshots `update.messages` from the PRE-promotion
204
+ * state with a filtered SAME-ID copy of the AI message, and commands
205
+ * apply after sibling reducer updates — un-patched, that stale copy
206
+ * overwrites the promotion and the child state omits the synthesized
207
+ * result, recreating the dangling pair inside the child graph.
208
+ */
209
+ const prePromotionCopy = () =>
210
+ new AIMessage({
211
+ id: 'ai_handoff',
212
+ content: [
213
+ {
214
+ type: 'tool_use',
215
+ id: 'tc_handoff',
216
+ name: 'transfer_to_agent_b',
217
+ input: {},
218
+ },
219
+ { type: 'tool_use', id: 'tc_bad', name: 'echo', input: '"raw unparsed' },
220
+ ],
221
+ tool_calls: [
222
+ { id: 'tc_handoff', name: 'transfer_to_agent_b', args: {} },
223
+ ],
224
+ });
225
+ const handoffTool = tool(
226
+ async () =>
227
+ new Command({
228
+ graph: Command.PARENT,
229
+ goto: 'agent_b',
230
+ update: {
231
+ messages: [
232
+ prePromotionCopy(),
233
+ new ToolMessage({
234
+ content: 'transferred',
235
+ tool_call_id: 'tc_handoff',
236
+ name: 'transfer_to_agent_b',
237
+ }),
238
+ ],
239
+ },
240
+ }),
241
+ {
242
+ name: 'transfer_to_agent_b',
243
+ description: 'handoff',
244
+ schema: z.object({}),
245
+ }
246
+ ) as unknown as StructuredToolInterface;
247
+
248
+ const node = new ToolNode({ tools: [handoffTool] });
249
+ const aiMsg = new AIMessage({
250
+ id: 'ai_handoff',
251
+ content: prePromotionCopy().content,
252
+ tool_calls: [{ id: 'tc_handoff', name: 'transfer_to_agent_b', args: {} }],
253
+ invalid_tool_calls: [
254
+ {
255
+ id: 'tc_bad',
256
+ name: 'echo',
257
+ args: '"raw unparsed',
258
+ error: 'Malformed args.',
259
+ type: 'invalid_tool_call',
260
+ },
261
+ ],
262
+ });
263
+
264
+ const result = (await node.invoke(
265
+ { messages: [aiMsg] },
266
+ { configurable: { run_id: 'invalid-handoff' } }
267
+ )) as Array<Command | { messages: BaseMessage[] }>;
268
+
269
+ const command = result.find((entry) => entry instanceof Command) as Command;
270
+ expect(command).toBeDefined();
271
+ const update = command.update as { messages: BaseMessage[] };
272
+ const patchedAi = update.messages.find(
273
+ (msg): msg is AIMessage => msg._getType() === 'ai'
274
+ )!;
275
+ expect(patchedAi.tool_calls?.map((c) => c.id).sort()).toEqual([
276
+ 'tc_bad',
277
+ 'tc_handoff',
278
+ ]);
279
+ expect(patchedAi.invalid_tool_calls).toHaveLength(0);
280
+ const patchedBlock = (patchedAi.content as Array<{ id?: string }>).find(
281
+ (b) => b.id === 'tc_bad'
282
+ ) as { input?: unknown };
283
+ expect(patchedBlock.input).toEqual({});
284
+ const resultIds = update.messages
285
+ .filter((msg): msg is ToolMessage => msg._getType() === 'tool')
286
+ .map((msg) => msg.tool_call_id)
287
+ .sort();
288
+ expect(resultIds).toEqual(['tc_bad', 'tc_handoff']);
289
+ });
290
+
291
+ it('session serialization round-trips invalid_tool_calls with the content blocks they repair', async () => {
292
+ /**
293
+ * The durable-session layer keeps the serialized content (including any
294
+ * raw malformed tool_use blocks) — dropping `invalid_tool_calls` there
295
+ * would strand those blocks without the entries ToolNode synthesizes
296
+ * results and promotions from on restore.
297
+ */
298
+ const original = new AIMessage({
299
+ id: 'ai_session_roundtrip',
300
+ content: [
301
+ { type: 'tool_use', id: 'tc_rt_bad', name: 'echo', input: '"raw unparsed' },
302
+ ],
303
+ tool_calls: [{ id: 'tc_rt_ok', name: 'echo', args: { command: 'hi' } }],
304
+ invalid_tool_calls: [
305
+ {
306
+ id: 'tc_rt_bad',
307
+ name: 'echo',
308
+ args: '"raw unparsed',
309
+ error: 'Malformed args.',
310
+ type: 'invalid_tool_call',
311
+ },
312
+ ],
313
+ });
314
+
315
+ const restored = deserializeMessage(serializeMessage(original)) as AIMessage;
316
+ expect(restored.tool_calls).toEqual(original.tool_calls);
317
+ expect(restored.invalid_tool_calls).toEqual(original.invalid_tool_calls);
318
+
319
+ const node = new ToolNode({ tools: [createEchoTool()] });
320
+ const result = await node.invoke(
321
+ { messages: [restored] },
322
+ { configurable: { run_id: 'invalid-session-roundtrip' } }
323
+ );
324
+ expect(toToolMessages(result).map((m) => m.tool_call_id).sort()).toEqual([
325
+ 'tc_rt_bad',
326
+ 'tc_rt_ok',
327
+ ]);
328
+ expect(toPromotedAiMessage(result)?.invalid_tool_calls).toHaveLength(0);
329
+ });
330
+
331
+ it('leaves BaseMessage[] (array-input) callers at the status quo — no synthesized results, no replacement', async () => {
332
+ /**
333
+ * The array input form returns a plain output LIST the caller appends to
334
+ * its own history: a replacement AI message would duplicate the assistant
335
+ * turn, and synthesized results would reference calls the caller's
336
+ * history formatting never emits. Both are reducer-shaped writes, so
337
+ * they only apply to the messages-state form.
338
+ */
339
+ const node = new ToolNode({ tools: [createEchoTool()] });
340
+ const aiMsg = new AIMessage({
341
+ id: 'ai_array_input',
342
+ content: '',
343
+ tool_calls: [{ id: 'tc_ok', name: 'echo', args: { command: 'hi' } }],
344
+ invalid_tool_calls: [
345
+ {
346
+ id: 'tc_bad',
347
+ name: 'echo',
348
+ args: 'garbage',
349
+ error: 'Malformed args.',
350
+ type: 'invalid_tool_call',
351
+ },
352
+ ],
353
+ });
354
+
355
+ const result = await node.invoke([aiMsg], {
356
+ configurable: { run_id: 'invalid-array-input' },
357
+ });
358
+
359
+ expect(toToolMessages(result).map((m) => m.tool_call_id)).toEqual(['tc_ok']);
360
+ expect(toPromotedAiMessage(result)).toBeUndefined();
361
+ });
362
+
363
+ it('toolsCondition routes a server-call + malformed-client-call mix to the tool node', () => {
364
+ /**
365
+ * `handleAnthropicSearchResults` marks completed server calls invoked, so
366
+ * the valid-calls branch declines; every valid call is `srvtoolu_`-
367
+ * prefixed (ToolNode's batch filter excludes those), so routing cannot
368
+ * re-execute anything and the malformed call gets its result. A valid
369
+ * NON-server call stays conservative: no routing, even when invoked.
370
+ */
371
+ const serverMix = new AIMessage({
372
+ id: 'ai_server_mix',
373
+ content: '',
374
+ tool_calls: [{ id: 'srvtoolu_abc', name: 'web_search', args: { q: 'x' } }],
375
+ invalid_tool_calls: [
376
+ {
377
+ id: 'tc_bad',
378
+ name: 'echo',
379
+ args: 'garbage',
380
+ error: 'Malformed args.',
381
+ type: 'invalid_tool_call',
382
+ },
383
+ ],
384
+ });
385
+ expect(
386
+ toolsCondition({ messages: [serverMix] }, 'tools', new Set(['srvtoolu_abc']))
387
+ ).toBe('tools');
388
+
389
+ const clientMix = new AIMessage({
390
+ id: 'ai_client_mix',
391
+ content: '',
392
+ tool_calls: [{ id: 'tc_regular', name: 'echo', args: { command: 'hi' } }],
393
+ invalid_tool_calls: serverMix.invalid_tool_calls,
394
+ });
395
+ expect(
396
+ toolsCondition({ messages: [clientMix] }, 'tools', new Set(['tc_regular']))
397
+ ).toBe('__end__');
398
+
399
+ /** Mirrors ToolNode's own gating: array-state graphs get a plain output
400
+ * list (invalid handling is skipped there), and an id-less message
401
+ * cannot take the replacement upsert — routing either would no-op. */
402
+ expect(toolsCondition([serverMix], 'tools', new Set(['srvtoolu_abc']))).toBe(
403
+ '__end__'
404
+ );
405
+ const noIdMix = new AIMessage({
406
+ content: '',
407
+ tool_calls: [],
408
+ invalid_tool_calls: serverMix.invalid_tool_calls,
409
+ });
410
+ expect(toolsCondition({ messages: [noIdMix] }, 'tools')).toBe('__end__');
411
+ });
412
+
413
+ it('keeps the full status quo when the AI message has no id (results and replacement are all-or-nothing)', async () => {
414
+ const node = new ToolNode({ tools: [createEchoTool()] });
415
+ const aiMsg = new AIMessage({
416
+ content: '',
417
+ tool_calls: [],
418
+ invalid_tool_calls: [
419
+ {
420
+ id: 'tc_no_promote',
421
+ name: 'echo',
422
+ args: 'garbage',
423
+ error: 'Malformed args.',
424
+ type: 'invalid_tool_call',
425
+ },
426
+ ],
427
+ });
428
+
429
+ const result = await node.invoke(
430
+ { messages: [aiMsg] },
431
+ { configurable: { run_id: 'invalid-no-id' } }
432
+ );
433
+
434
+ /** No replacement can upsert without an id, so the synthesized result is
435
+ * suppressed too — emitting it alone would strand an output whose call
436
+ * the provider converters never reconstruct. */
437
+ expect(toToolMessages(result)).toHaveLength(0);
438
+ expect(toPromotedAiMessage(result)).toBeUndefined();
439
+ });
440
+
441
+ it('synthesizes error ToolMessages when EVERY call in the batch is invalid', async () => {
442
+ const node = new ToolNode({ tools: [createEchoTool()] });
443
+ const aiMsg = new AIMessage({
444
+ id: 'ai_only_invalid',
445
+ content: '',
446
+ tool_calls: [],
447
+ invalid_tool_calls: [
448
+ {
449
+ id: 'tc_only_invalid',
450
+ name: 'echo',
451
+ args: 'garbage',
452
+ error: 'Malformed args.',
453
+ type: 'invalid_tool_call',
454
+ },
455
+ ],
456
+ });
457
+
458
+ const result = await node.invoke(
459
+ { messages: [aiMsg] },
460
+ { configurable: { run_id: 'invalid-only' } }
461
+ );
462
+ const toolMessages = toToolMessages(result);
463
+
464
+ expect(toolMessages).toHaveLength(1);
465
+ expect(toolMessages[0].tool_call_id).toBe('tc_only_invalid');
466
+ });
467
+
468
+ it('skips invalid calls that already have a ToolMessage or carry no id', async () => {
469
+ const node = new ToolNode({ tools: [createEchoTool()] });
470
+ const aiMsg = new AIMessage({
471
+ content: '',
472
+ tool_calls: [],
473
+ invalid_tool_calls: [
474
+ {
475
+ id: 'tc_answered',
476
+ name: 'echo',
477
+ args: 'garbage',
478
+ error: 'Malformed args.',
479
+ type: 'invalid_tool_call',
480
+ },
481
+ {
482
+ name: 'echo',
483
+ args: 'garbage-no-id',
484
+ error: 'Malformed args.',
485
+ type: 'invalid_tool_call',
486
+ },
487
+ ],
488
+ });
489
+ const priorResult = new ToolMessage({
490
+ content: 'already answered',
491
+ tool_call_id: 'tc_answered',
492
+ name: 'echo',
493
+ });
494
+
495
+ const result = await node.invoke(
496
+ { messages: [aiMsg, priorResult] },
497
+ { configurable: { run_id: 'invalid-skip' } }
498
+ );
499
+
500
+ expect(toToolMessages(result)).toHaveLength(0);
501
+ });
502
+
503
+ it('HITL resume regression: a malformed sibling of a paused ask_user_question gets a result instead of dangling', async () => {
504
+ const ASK_TOOL = 'ask_user_question';
505
+ const askTool = tool(
506
+ async (input) => {
507
+ const { answer } = askUserQuestion(
508
+ input as { question: string }
509
+ );
510
+ return answer;
511
+ },
512
+ {
513
+ name: ASK_TOOL,
514
+ description: 'Ask the user a question.',
515
+ schema: z.object({ question: z.string() }),
516
+ }
517
+ );
518
+
519
+ /** Model invocation capture: the post-resume call's message list is the
520
+ * payload the real provider would validate tool_use/tool_result pairing
521
+ * on. */
522
+ const modelInvocations: BaseMessage[][] = [];
523
+ const buildModel = (responses: string[], emitCalls: boolean) => {
524
+ const model = new FakeChatModel({
525
+ responses,
526
+ toolCalls: emitCalls
527
+ ? [
528
+ { name: ASK_TOOL, args: {}, id: 'tc_ask_invalid', type: 'tool_call' },
529
+ {
530
+ name: ASK_TOOL,
531
+ args: { question: 'Which one?' },
532
+ id: 'tc_ask_valid',
533
+ type: 'tool_call',
534
+ },
535
+ ]
536
+ : [],
537
+ });
538
+ const orig = model._streamResponseChunks.bind(model);
539
+ model._streamResponseChunks = async function* (
540
+ messages,
541
+ options,
542
+ runManager
543
+ ): AsyncGenerator<ChatGenerationChunk> {
544
+ modelInvocations.push(messages);
545
+ for await (const chunk of orig(messages, options, runManager)) {
546
+ /** Corrupt the first ask call's streamed args into a non-object
547
+ * JSON string so `collapseToolCallChunks` files it under
548
+ * `invalid_tool_calls` — the shape a malformed provider stream
549
+ * produces. */
550
+ const chunkMessage = chunk.message as unknown as {
551
+ tool_call_chunks?: Array<{ id?: string; args?: string }>;
552
+ };
553
+ for (const tc of chunkMessage.tool_call_chunks ?? []) {
554
+ if (tc.id === 'tc_ask_invalid') {
555
+ tc.args = '"malformed"';
556
+ }
557
+ }
558
+ yield chunk;
559
+ }
560
+ };
561
+ return model;
562
+ };
563
+
564
+ const saver = new MemorySaver();
565
+ const buildRun = async (responses: string[], emitCalls: boolean) => {
566
+ const run = await Run.create<t.IState>({
567
+ runId: 'invalid-ask-resume',
568
+ graphConfig: {
569
+ type: 'standard',
570
+ agents: [
571
+ {
572
+ agentId: 'agent-invalid-ask',
573
+ provider: Providers.OPENAI,
574
+ clientOptions: { model: 'gpt-4o-mini', streaming: true },
575
+ instructions: 'noop',
576
+ maxContextTokens: 8000,
577
+ graphTools: [askTool],
578
+ },
579
+ ],
580
+ compileOptions: { checkpointer: saver },
581
+ },
582
+ returnContent: true,
583
+ customHandlers: {},
584
+ tokenCounter: ((text: string) =>
585
+ String(text).length) as unknown as t.RunConfig['tokenCounter'],
586
+ indexTokenCountMap: {},
587
+ });
588
+ run.Graph!.overrideModel = buildModel(responses, emitCalls);
589
+ return run;
590
+ };
591
+ const config = {
592
+ configurable: { thread_id: 'invalid-ask-thread' },
593
+ streamMode: 'values' as const,
594
+ version: 'v2' as const,
595
+ };
596
+
597
+ const run = await buildRun(['Asking.'], true);
598
+ await run.processStream(
599
+ { messages: [new HumanMessage('go')] },
600
+ config
601
+ );
602
+ expect(run.getInterrupt()?.payload).toMatchObject({
603
+ type: 'ask_user_question',
604
+ question: { question: 'Which one?' },
605
+ });
606
+
607
+ const resumed = await buildRun(['Done.'], false);
608
+ await resumed.resume({ answer: 'the first one' }, config);
609
+ expect(resumed.getInterrupt()).toBeUndefined();
610
+
611
+ const finalCall = modelInvocations[modelInvocations.length - 1];
612
+ const resultIds = new Set(
613
+ finalCall
614
+ .filter((msg) => msg._getType() === 'tool')
615
+ .map((msg) => (msg as ToolMessage).tool_call_id)
616
+ );
617
+ /** Both tool_use blocks ride the paused AI message the provider
618
+ * replays; each must have a paired result or the call 400s. */
619
+ expect(resultIds.has('tc_ask_valid')).toBe(true);
620
+ expect(resultIds.has('tc_ask_invalid')).toBe(true);
621
+ });
622
+
623
+ it('routes an INVALID-ONLY turn through ToolNode at the graph level (toolsCondition)', async () => {
624
+ /**
625
+ * Codex P1: `toolsCondition` used to return END when `tool_calls` was
626
+ * empty, so a turn whose only call was malformed never entered ToolNode —
627
+ * the dangling `tool_use` was committed with no result and no promotion.
628
+ * This drives the REAL graph routing (agent → toolsCondition → toolNode →
629
+ * agent) via a scripted model, not a direct node.invoke.
630
+ */
631
+ const modelInvocations: BaseMessage[][] = [];
632
+ const buildModel = (responses: string[], emitCalls: boolean) => {
633
+ const model = new FakeChatModel({
634
+ responses,
635
+ toolCalls: emitCalls
636
+ ? [{ name: 'echo', args: {}, id: 'tc_solo_invalid', type: 'tool_call' }]
637
+ : [],
638
+ });
639
+ const orig = model._streamResponseChunks.bind(model);
640
+ model._streamResponseChunks = async function* (
641
+ messages,
642
+ options,
643
+ runManager
644
+ ): AsyncGenerator<ChatGenerationChunk> {
645
+ modelInvocations.push(messages);
646
+ for await (const chunk of orig(messages, options, runManager)) {
647
+ const chunkMessage = chunk.message as unknown as {
648
+ tool_call_chunks?: Array<{ id?: string; args?: string }>;
649
+ };
650
+ for (const tc of chunkMessage.tool_call_chunks ?? []) {
651
+ if (tc.id === 'tc_solo_invalid') {
652
+ tc.args = '"malformed"';
653
+ }
654
+ }
655
+ yield chunk;
656
+ }
657
+ };
658
+ return model;
659
+ };
660
+
661
+ const run = await Run.create<t.IState>({
662
+ runId: 'invalid-only-graph',
663
+ graphConfig: {
664
+ type: 'standard',
665
+ agents: [
666
+ {
667
+ agentId: 'agent-invalid-only',
668
+ provider: Providers.OPENAI,
669
+ clientOptions: { model: 'gpt-4o-mini', streaming: true },
670
+ instructions: 'noop',
671
+ maxContextTokens: 8000,
672
+ graphTools: [createEchoTool()],
673
+ },
674
+ ],
675
+ },
676
+ returnContent: true,
677
+ customHandlers: {},
678
+ tokenCounter: ((text: string) =>
679
+ String(text).length) as unknown as t.RunConfig['tokenCounter'],
680
+ indexTokenCountMap: {},
681
+ });
682
+ run.Graph!.overrideModel = buildModel(['Calling.', 'Recovered.'], true);
683
+
684
+ const invalidOnlyConfig = {
685
+ configurable: { thread_id: 'invalid-only-thread' },
686
+ streamMode: 'values' as const,
687
+ version: 'v2' as const,
688
+ };
689
+ await run.processStream(
690
+ { messages: [new HumanMessage('go')] },
691
+ invalidOnlyConfig
692
+ );
693
+
694
+ /** A second model call happened at all (END would have stopped after one),
695
+ * and it sees the promoted call paired with its synthesized result. */
696
+ expect(modelInvocations.length).toBeGreaterThan(1);
697
+ const followUp = modelInvocations[1];
698
+ const aiMsg = followUp.find(
699
+ (msg): msg is AIMessage => msg._getType() === 'ai'
700
+ )!;
701
+ expect(aiMsg.tool_calls?.map((c) => c.id)).toEqual(['tc_solo_invalid']);
702
+ expect(aiMsg.invalid_tool_calls).toHaveLength(0);
703
+ const toolMsg = followUp.find(
704
+ (msg): msg is ToolMessage => msg._getType() === 'tool'
705
+ )!;
706
+ expect(toolMsg.tool_call_id).toBe('tc_solo_invalid');
707
+ expect(String(toolMsg.content)).toContain('Malformed');
708
+ });
709
+
710
+ it('round-trips through the REAL OpenAI Responses outbound converter with call/output pairing intact', async () => {
711
+ /**
712
+ * Codex P1: `_convertMessagesToOpenAIResponsesParams` rebuilds
713
+ * `function_call` items from `tool_calls` only, so an un-promoted invalid
714
+ * call would vanish while its synthesized `function_call_output` remained
715
+ * — an output whose call_id has no matching call. The promotion keeps the
716
+ * two sides agreeing; this exercises the real outbound converter over the
717
+ * exact message shapes ToolNode emits for a mixed valid/invalid batch.
718
+ */
719
+ const node = new ToolNode({ tools: [createEchoTool()] });
720
+ const aiMsg = new AIMessage({
721
+ id: 'ai_responses',
722
+ content: 'Two calls.',
723
+ tool_calls: [{ id: 'tc_ok', name: 'echo', args: { command: 'hi' } }],
724
+ invalid_tool_calls: [
725
+ {
726
+ id: 'tc_bad',
727
+ name: 'echo',
728
+ args: '"malformed"',
729
+ error: 'Malformed args.',
730
+ type: 'invalid_tool_call',
731
+ },
732
+ ],
733
+ });
734
+ const result = await node.invoke(
735
+ { messages: [aiMsg] },
736
+ { configurable: { run_id: 'invalid-responses' } }
737
+ );
738
+ const promoted = toPromotedAiMessage(result)!;
739
+ const toolMessages = toToolMessages(result);
740
+
741
+ const items = _convertMessagesToOpenAIResponsesParams(
742
+ [new HumanMessage('go'), promoted, ...toolMessages],
743
+ 'gpt-4o-mini'
744
+ ) as Array<{ type?: string; call_id?: string }>;
745
+
746
+ const callIds = items
747
+ .filter((item) => item.type === 'function_call')
748
+ .map((item) => item.call_id)
749
+ .sort();
750
+ const outputIds = items
751
+ .filter((item) => item.type === 'function_call_output')
752
+ .map((item) => item.call_id)
753
+ .sort();
754
+ expect(callIds).toEqual(['tc_bad', 'tc_ok']);
755
+ expect(outputIds).toEqual(['tc_bad', 'tc_ok']);
756
+ });
757
+ });