@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.
- package/dist/cjs/agents/AgentContext.cjs +4 -0
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +66 -13
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +35 -2
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
- package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
- package/dist/cjs/instrumentation.cjs +18 -48
- package/dist/cjs/instrumentation.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +174 -29
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/langfuseConfig.cjs +12 -0
- package/dist/cjs/langfuseConfig.cjs.map +1 -1
- package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
- package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
- package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
- package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
- package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
- package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
- package/dist/cjs/langfuseTraceShaping.cjs +121 -4
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +3 -3
- package/dist/cjs/llm/invoke.cjs +5 -5
- package/dist/cjs/llm/openai/index.cjs +1 -1
- package/dist/cjs/main.cjs +10 -10
- package/dist/cjs/messages/prune.cjs +13 -1
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/prompts/activityLabel.cjs +24 -12
- package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
- package/dist/cjs/run.cjs +57 -22
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/messageSerialization.cjs +6 -0
- package/dist/cjs/session/messageSerialization.cjs.map +1 -1
- package/dist/cjs/stream.cjs +21 -10
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +5 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +253 -24
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +1 -1
- package/dist/cjs/tools/search/tool.cjs +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
- package/dist/cjs/utils/index.cjs +2 -2
- package/dist/esm/agents/AgentContext.mjs +4 -0
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +67 -14
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +35 -2
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestion.mjs +3 -2
- package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
- package/dist/esm/instrumentation.mjs +18 -48
- package/dist/esm/instrumentation.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +176 -28
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/langfuseConfig.mjs +10 -1
- package/dist/esm/langfuseConfig.mjs.map +1 -1
- package/dist/esm/langfuseRuntimeContext.mjs +21 -3
- package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
- package/dist/esm/langfuseRuntimeScope.mjs +39 -10
- package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
- package/dist/esm/langfuseSpanRegistry.mjs +91 -0
- package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
- package/dist/esm/langfuseTraceShaping.mjs +121 -4
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +2 -2
- package/dist/esm/llm/invoke.mjs +5 -5
- package/dist/esm/llm/openai/index.mjs +1 -1
- package/dist/esm/main.mjs +8 -8
- package/dist/esm/messages/prune.mjs +13 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/prompts/activityLabel.mjs +24 -12
- package/dist/esm/prompts/activityLabel.mjs.map +1 -1
- package/dist/esm/run.mjs +57 -22
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/messageSerialization.mjs +6 -0
- package/dist/esm/session/messageSerialization.mjs.map +1 -1
- package/dist/esm/stream.mjs +21 -10
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +5 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +254 -25
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +1 -1
- package/dist/esm/tools/search/tool.mjs +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
- package/dist/esm/utils/index.mjs +2 -2
- package/dist/types/agents/AgentContext.d.ts +2 -0
- package/dist/types/graphs/Graph.d.ts +24 -0
- package/dist/types/hitl/askUserQuestion.d.ts +11 -1
- package/dist/types/langfuse.d.ts +16 -8
- package/dist/types/langfuseConfig.d.ts +6 -0
- package/dist/types/langfuseRuntimeContext.d.ts +27 -1
- package/dist/types/langfuseRuntimeScope.d.ts +17 -2
- package/dist/types/langfuseSpanRegistry.d.ts +17 -0
- package/dist/types/langfuseTraceShaping.d.ts +2 -1
- package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
- package/dist/types/run.d.ts +7 -0
- package/dist/types/session/types.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +7 -1
- package/dist/types/types/hitl.d.ts +8 -0
- package/dist/types/types/tools.d.ts +30 -0
- package/package.json +7 -4
- package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
- package/src/agents/AgentContext.ts +5 -0
- package/src/graphs/Graph.ts +108 -20
- package/src/graphs/MultiAgentGraph.ts +56 -2
- package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
- package/src/hitl/askUserQuestion.ts +14 -1
- package/src/instrumentation.ts +35 -77
- package/src/langfuse.ts +320 -43
- package/src/langfuseConfig.ts +24 -0
- package/src/langfuseRuntimeContext.ts +43 -1
- package/src/langfuseRuntimeScope.ts +94 -21
- package/src/langfuseSpanRegistry.ts +131 -0
- package/src/langfuseTraceShaping.ts +194 -7
- package/src/llm/anthropic/utils/message_inputs.ts +70 -19
- package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
- package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
- package/src/llm/bedrock/utils/message_inputs.ts +32 -7
- package/src/messages/prune.ts +12 -1
- package/src/prompts/activityLabel.ts +23 -6
- package/src/run.ts +91 -45
- package/src/scripts/activity-labels/captured.json +56 -0
- package/src/scripts/activity-labels/checks.cjs +205 -0
- package/src/scripts/activity-labels/corpus.cjs +473 -0
- package/src/scripts/activity-labels/report.cjs +203 -0
- package/src/scripts/activity-labels/rescore.cjs +102 -0
- package/src/scripts/activity-labels/run.ts +705 -0
- package/src/scripts/activity-labels/variants.ts +71 -0
- package/src/session/messageSerialization.ts +12 -1
- package/src/session/types.ts +1 -0
- package/src/specs/activity-label-prompt.test.ts +26 -10
- package/src/specs/agent-handoffs.test.ts +306 -0
- package/src/specs/discovered-tools.test.ts +217 -0
- package/src/specs/langfuse-callbacks.test.ts +456 -0
- package/src/specs/langfuse-routing.integration.test.ts +138 -1
- package/src/specs/langfuse-span-registry.test.ts +70 -0
- package/src/specs/langfuse-trace-shaping.test.ts +294 -0
- package/src/specs/prune.test.ts +38 -1
- package/src/stream.ts +70 -6
- package/src/summarization/node.ts +5 -0
- package/src/tools/ToolNode.ts +400 -9
- package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
- package/src/tools/__tests__/hitl.test.ts +58 -0
- package/src/types/hitl.ts +8 -0
- 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
|
+
});
|