@librechat/agents 3.2.46 → 3.2.52
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/graphs/MultiAgentGraph.cjs +6 -6
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/index.cjs +4 -3
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/tools.cjs +2 -1
- package/dist/cjs/llm/anthropic/utils/tools.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/cachePoints.cjs +28 -0
- package/dist/cjs/llm/bedrock/cachePoints.cjs.map +1 -0
- package/dist/cjs/llm/bedrock/index.cjs +10 -1
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/run.cjs +21 -3
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +26 -5
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +7 -7
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/llm/anthropic/index.mjs +4 -3
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/tools.mjs +2 -1
- package/dist/esm/llm/anthropic/utils/tools.mjs.map +1 -1
- package/dist/esm/llm/bedrock/cachePoints.mjs +28 -0
- package/dist/esm/llm/bedrock/cachePoints.mjs.map +1 -0
- package/dist/esm/llm/bedrock/index.mjs +10 -1
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/run.mjs +21 -3
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +26 -5
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/types/llm/anthropic/utils/tools.d.ts +1 -1
- package/dist/types/llm/bedrock/cachePoints.d.ts +9 -0
- package/dist/types/run.d.ts +15 -1
- package/dist/types/tools/ToolNode.d.ts +9 -2
- package/package.json +16 -21
- package/src/graphs/MultiAgentGraph.ts +7 -12
- package/src/llm/anthropic/index.ts +13 -2
- package/src/llm/anthropic/inherited-content-utils.spec.ts +244 -0
- package/src/llm/anthropic/inherited-stream-events.spec.ts +752 -0
- package/src/llm/anthropic/inherited-strict.spec.ts +302 -0
- package/src/llm/anthropic/llm.spec.ts +65 -0
- package/src/llm/anthropic/utils/tools.ts +7 -1
- package/src/llm/bedrock/cachePoints.ts +86 -0
- package/src/llm/bedrock/index.ts +9 -0
- package/src/llm/bedrock/inherited-cache.spec.ts +144 -0
- package/src/llm/bedrock/inherited.spec.ts +724 -0
- package/src/llm/google/inherited-stream-events.spec.ts +350 -0
- package/src/llm/openai/inherited-deepseek.spec.ts +347 -0
- package/src/llm/openai/inherited-xai.spec.ts +416 -0
- package/src/llm/openai/llm.spec.ts +1568 -0
- package/src/llm/openrouter/index.ts +1 -3
- package/src/llm/vertexai/inherited-stream-events.spec.ts +271 -0
- package/src/run.ts +31 -3
- package/src/scripts/handoff-test.ts +5 -6
- package/src/tools/ToolNode.ts +43 -5
- package/src/tools/__tests__/ToolNode.runtimeState.test.ts +120 -0
- package/src/tools/__tests__/hitl.test.ts +162 -0
|
@@ -161,9 +161,7 @@ export class ChatOpenRouter extends ChatOpenAI {
|
|
|
161
161
|
return 'LibreChatOpenRouter';
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
//
|
|
165
|
-
// effort levels ('xhigh' | 'none' | 'minimal') not in ReasoningEffort.
|
|
166
|
-
// The parent's generic conditional return type cannot be widened in an override.
|
|
164
|
+
// OpenRouter widens OpenAI reasoning with extra effort levels ('xhigh' | 'none' | 'minimal').
|
|
167
165
|
override invocationParams(
|
|
168
166
|
options?: this['ParsedCallOptions'],
|
|
169
167
|
extra?: InvocationParamsExtra
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Inherited stream-events specs for the LibreChat Vertex fork, ported from two
|
|
2
|
+
// upstream @langchain/google-common@2.2.0 suites:
|
|
3
|
+
//
|
|
4
|
+
// 1. utils/tests/stream_events.test.ts (the original prompt references it as
|
|
5
|
+
// google-common_stream_events.test.ts) — unit tests for the pure converter
|
|
6
|
+
// `convertGoogleGeminiStream`, which turns Gemini-style stream responses
|
|
7
|
+
// into LangChain `ChatModelStreamEvent`s. Our fork's `ChatVertexAI` does
|
|
8
|
+
// not re-implement this converter; it is re-exported from
|
|
9
|
+
// `@langchain/google-common` and exercised verbatim here as a pure unit.
|
|
10
|
+
//
|
|
11
|
+
// 2. chat_models/tests/chat_models_stream_events.test.ts (the prompt references
|
|
12
|
+
// it as google-common_chat_models_stream_events.test.ts) — tests
|
|
13
|
+
// `ChatGoogle.streamEvents()`. Upstream drove a `TestChatGoogle` helper whose
|
|
14
|
+
// `authOptions.resultFile` replayed recorded mock JSON and asserted via
|
|
15
|
+
// vitest custom matchers (`toHaveStreamText` / `toHaveStreamReasoning` /
|
|
16
|
+
// `toHaveStreamToolCalls`). Those fixture files and matchers are not
|
|
17
|
+
// available in this repo, so each case is routed through OUR fork:
|
|
18
|
+
// `new ChatVertexAI({...})` (which extends ChatGoogle and inherits the
|
|
19
|
+
// native `_streamChatModelEvents` path) with the google transport mocked at
|
|
20
|
+
// the `streamedConnection.request` boundary — it returns `{ data: <json
|
|
21
|
+
// stream> }`, the exact shape `_streamChatModelEvents` consumes. The matchers
|
|
22
|
+
// are re-expressed against the public `ChatModelStream` sub-streams
|
|
23
|
+
// (`.text` / `.reasoning` / `.toolCalls`) that those matchers wrap.
|
|
24
|
+
//
|
|
25
|
+
// All cases are deterministic and run against a mocked transport — no live
|
|
26
|
+
// Vertex access. vitest -> jest, `zod/v3` -> n/a (no zod here).
|
|
27
|
+
|
|
28
|
+
import { describe, it, test, expect } from '@jest/globals';
|
|
29
|
+
import { convertGoogleGeminiStream } from '@langchain/google-common';
|
|
30
|
+
import { ChatModelStream } from '@langchain/core/language_models/stream';
|
|
31
|
+
import type { BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
|
|
32
|
+
import type { ChatModelStreamEvent } from '@langchain/core/language_models/event';
|
|
33
|
+
import { ChatVertexAI } from '@/llm/vertexai';
|
|
34
|
+
|
|
35
|
+
type GeminiChunk = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
// --- Converter unit-test harness (upstream source 1) -----------------------
|
|
38
|
+
|
|
39
|
+
async function* asAsyncIterable(
|
|
40
|
+
chunks: GeminiChunk[]
|
|
41
|
+
): AsyncGenerator<GeminiChunk> {
|
|
42
|
+
for (const chunk of chunks) {
|
|
43
|
+
yield chunk;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function collectEvents(
|
|
48
|
+
chunks: GeminiChunk[]
|
|
49
|
+
): Promise<ChatModelStreamEvent[]> {
|
|
50
|
+
const out: ChatModelStreamEvent[] = [];
|
|
51
|
+
for await (const event of convertGoogleGeminiStream(
|
|
52
|
+
asAsyncIterable(chunks)
|
|
53
|
+
)) {
|
|
54
|
+
out.push(event);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Class streamEvents harness (upstream source 2, routed through fork) ----
|
|
60
|
+
|
|
61
|
+
interface MockJsonStream {
|
|
62
|
+
readonly streamDone: boolean;
|
|
63
|
+
nextChunk(): Promise<GeminiChunk | null>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeJsonStream(chunks: GeminiChunk[]): MockJsonStream {
|
|
67
|
+
let i = 0;
|
|
68
|
+
return {
|
|
69
|
+
get streamDone(): boolean {
|
|
70
|
+
return i >= chunks.length;
|
|
71
|
+
},
|
|
72
|
+
async nextChunk(): Promise<GeminiChunk | null> {
|
|
73
|
+
if (i >= chunks.length) return null;
|
|
74
|
+
const chunk = chunks[i];
|
|
75
|
+
i += 1;
|
|
76
|
+
return chunk;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface StreamedConnectionModel {
|
|
82
|
+
streamedConnection: {
|
|
83
|
+
request: () => Promise<{ data: MockJsonStream }>;
|
|
84
|
+
};
|
|
85
|
+
_streamChatModelEvents: (
|
|
86
|
+
messages: unknown[],
|
|
87
|
+
options: BaseChatModelCallOptions
|
|
88
|
+
) => AsyncGenerator<ChatModelStreamEvent>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createStreamModel(chunks: GeminiChunk[]): ChatVertexAI {
|
|
92
|
+
const model = new ChatVertexAI({
|
|
93
|
+
model: 'gemini-2.0-flash',
|
|
94
|
+
authOptions: {
|
|
95
|
+
credentials: { client_email: 'test@example.com', private_key: 'test' },
|
|
96
|
+
projectId: 'test-project',
|
|
97
|
+
},
|
|
98
|
+
} as ConstructorParameters<typeof ChatVertexAI>[0]);
|
|
99
|
+
(model as unknown as StreamedConnectionModel).streamedConnection.request =
|
|
100
|
+
async () => ({ data: makeJsonStream(chunks) });
|
|
101
|
+
return model;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function streamEvents(
|
|
105
|
+
model: ChatVertexAI,
|
|
106
|
+
options: BaseChatModelCallOptions = {} as BaseChatModelCallOptions
|
|
107
|
+
): AsyncGenerator<ChatModelStreamEvent> {
|
|
108
|
+
return (model as unknown as StreamedConnectionModel)._streamChatModelEvents(
|
|
109
|
+
[],
|
|
110
|
+
options
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Source 1: convertGoogleGeminiStream (pure converter, re-exported from
|
|
116
|
+
// @langchain/google-common and inherited unchanged by the fork)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
describe('convertGoogleGeminiStream (inherited from @langchain/google-common)', () => {
|
|
120
|
+
test('text-only streaming', async () => {
|
|
121
|
+
const events = await collectEvents([
|
|
122
|
+
{ candidates: [{ content: { parts: [{ text: 'Hello' }] } }] },
|
|
123
|
+
{ candidates: [{ content: { parts: [{ text: ' world' }] } }] },
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const textDeltas = events.filter(
|
|
127
|
+
(e) =>
|
|
128
|
+
e.event === 'content-block-delta' &&
|
|
129
|
+
(e as { delta: { type: string } }).delta.type === 'text-delta'
|
|
130
|
+
);
|
|
131
|
+
expect(textDeltas).toHaveLength(2);
|
|
132
|
+
|
|
133
|
+
expect(
|
|
134
|
+
events.find((e) => e.event === 'content-block-finish')
|
|
135
|
+
).toMatchObject({
|
|
136
|
+
content: { text: 'Hello world' },
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('maps Gemini finish reasons', async () => {
|
|
141
|
+
const lengthEvents = await collectEvents([
|
|
142
|
+
{
|
|
143
|
+
candidates: [
|
|
144
|
+
{
|
|
145
|
+
content: { parts: [{ text: 'Hello' }] },
|
|
146
|
+
finishReason: 'MAX_TOKENS',
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
]);
|
|
151
|
+
const lengthFinish = lengthEvents.find((e) => e.event === 'message-finish');
|
|
152
|
+
expect(lengthFinish).toMatchObject({ reason: 'length' });
|
|
153
|
+
|
|
154
|
+
const filterEvents = await collectEvents([
|
|
155
|
+
{
|
|
156
|
+
candidates: [
|
|
157
|
+
{
|
|
158
|
+
content: { parts: [{ text: 'Hello' }] },
|
|
159
|
+
finishReason: 'SAFETY',
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
]);
|
|
164
|
+
const filterFinish = filterEvents.find((e) => e.event === 'message-finish');
|
|
165
|
+
expect(filterFinish).toMatchObject({ reason: 'content_filter' });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('thinking parts map to reasoning', async () => {
|
|
169
|
+
const events = await collectEvents([
|
|
170
|
+
{
|
|
171
|
+
candidates: [
|
|
172
|
+
{
|
|
173
|
+
content: { parts: [{ text: 'Let me think', thought: true }] },
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
events.find(
|
|
181
|
+
(e) =>
|
|
182
|
+
e.event === 'content-block-finish' &&
|
|
183
|
+
(e as { content: { type: string } }).content.type === 'reasoning'
|
|
184
|
+
)
|
|
185
|
+
).toMatchObject({
|
|
186
|
+
content: { reasoning: 'Let me think' },
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('usage snapshots', async () => {
|
|
191
|
+
const events = await collectEvents([
|
|
192
|
+
{
|
|
193
|
+
usageMetadata: {
|
|
194
|
+
promptTokenCount: 10,
|
|
195
|
+
candidatesTokenCount: 4,
|
|
196
|
+
totalTokenCount: 14,
|
|
197
|
+
},
|
|
198
|
+
candidates: [{ content: { parts: [{ text: 'Hi' }] } }],
|
|
199
|
+
},
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
expect(events.filter((e) => e.event === 'usage').length).toBe(1);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Source 2: ChatGoogle.streamEvents — inherited native streamEvents path on
|
|
208
|
+
// the fork (ChatVertexAI does not override _streamChatModelEvents). Re-expressed
|
|
209
|
+
// against the public ChatModelStream sub-streams that the upstream vitest
|
|
210
|
+
// matchers wrapped.
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
describe('ChatVertexAI.streamEvents (inherited native path)', () => {
|
|
214
|
+
test('streams text', async () => {
|
|
215
|
+
const model = createStreamModel([
|
|
216
|
+
{ candidates: [{ content: { parts: [{ text: 'Hello' }] } }] },
|
|
217
|
+
{
|
|
218
|
+
candidates: [
|
|
219
|
+
{ content: { parts: [{ text: ' world' }] }, finishReason: 'STOP' },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
]);
|
|
223
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
224
|
+
expect(await stream.text).toBe('Hello world');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('streams reasoning', async () => {
|
|
228
|
+
const model = createStreamModel([
|
|
229
|
+
{
|
|
230
|
+
candidates: [
|
|
231
|
+
{
|
|
232
|
+
content: { parts: [{ text: 'Let me reason...', thought: true }] },
|
|
233
|
+
finishReason: 'STOP',
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
]);
|
|
238
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
239
|
+
expect(await stream.reasoning).toBe('Let me reason...');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('streams tool calls', async () => {
|
|
243
|
+
const model = createStreamModel([
|
|
244
|
+
{
|
|
245
|
+
candidates: [
|
|
246
|
+
{
|
|
247
|
+
content: {
|
|
248
|
+
parts: [
|
|
249
|
+
{
|
|
250
|
+
functionCall: {
|
|
251
|
+
name: 'web_search',
|
|
252
|
+
args: { query: 'weather' },
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
finishReason: 'STOP',
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
]);
|
|
262
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
263
|
+
const calls = await stream.toolCalls;
|
|
264
|
+
expect(calls).toEqual([
|
|
265
|
+
expect.objectContaining({
|
|
266
|
+
name: 'web_search',
|
|
267
|
+
args: { query: 'weather' },
|
|
268
|
+
}),
|
|
269
|
+
]);
|
|
270
|
+
});
|
|
271
|
+
});
|
package/src/run.ts
CHANGED
|
@@ -667,7 +667,7 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
667
667
|
const graph = this.Graph;
|
|
668
668
|
|
|
669
669
|
/**
|
|
670
|
-
* `Command` inputs (
|
|
670
|
+
* `Command` inputs (`Command({ resume, update?, goto? })`) are
|
|
671
671
|
* resume-mode invocations: LangGraph rebuilds graph state from the
|
|
672
672
|
* checkpointer, so we skip RunStart / UserPromptSubmit hooks (no
|
|
673
673
|
* new prompt to evaluate) and read run-state from the Graph wrapper
|
|
@@ -1139,13 +1139,31 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
1139
1139
|
return cp == null ? 0 : cp.rewind();
|
|
1140
1140
|
}
|
|
1141
1141
|
|
|
1142
|
+
/**
|
|
1143
|
+
* Resume an interrupted run. `commandOptions` forwards langgraph 1.4.5
|
|
1144
|
+
* `Command` fields applied together with `resume` in one superstep:
|
|
1145
|
+
* - `update`: channel updates committed at the resume point. On a *rebuilt*
|
|
1146
|
+
* Run (new instance + durable checkpointer), `update.messages` are the first
|
|
1147
|
+
* write the fresh wrapper sees, so they seed the `getRunMessages()` /
|
|
1148
|
+
* `returnContent` baseline and are excluded from them (still committed to the
|
|
1149
|
+
* checkpoint). Hosts that rebuild + inject messages should persist them
|
|
1150
|
+
* directly or read from `getState`. Unreachable without a durable checkpointer.
|
|
1151
|
+
* - `goto`: a *dynamic* edge that does not cancel static `addEdge` routes. On
|
|
1152
|
+
* the built-in standard graph the fixed `toolNode -> agentNode/END` edge still
|
|
1153
|
+
* fires, so `goto` adds rather than replaces (e.g. `goto: END` will not stop a
|
|
1154
|
+
* tool-node resume). Intended for custom, Command-routed graphs.
|
|
1155
|
+
*/
|
|
1142
1156
|
async resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(
|
|
1143
1157
|
resumeValue: TResume,
|
|
1144
1158
|
callerConfig: Partial<RunnableConfig> & {
|
|
1145
1159
|
version: 'v1' | 'v2';
|
|
1146
1160
|
run_id?: string;
|
|
1147
1161
|
},
|
|
1148
|
-
streamOptions?: t.EventStreamOptions
|
|
1162
|
+
streamOptions?: t.EventStreamOptions,
|
|
1163
|
+
commandOptions?: Pick<
|
|
1164
|
+
ConstructorParameters<typeof Command>[0],
|
|
1165
|
+
'update' | 'goto'
|
|
1166
|
+
>
|
|
1149
1167
|
): Promise<MessageContentComplex[] | undefined> {
|
|
1150
1168
|
const interruptId = this._interrupt?.interruptId;
|
|
1151
1169
|
const scopedResume =
|
|
@@ -1155,8 +1173,18 @@ export class Run<_T extends t.BaseGraphState> {
|
|
|
1155
1173
|
? { [interruptId]: resumeValue }
|
|
1156
1174
|
: resumeValue;
|
|
1157
1175
|
const resumeConfig = await this.resolveInterruptResumeConfig(callerConfig);
|
|
1176
|
+
// langgraph 1.4.5 applies resume + state update + reroute in one superstep
|
|
1177
|
+
// (single checkpoint). `update`/`goto` are omitted unless the caller sets them.
|
|
1158
1178
|
return this.processStream(
|
|
1159
|
-
new Command({
|
|
1179
|
+
new Command({
|
|
1180
|
+
resume: scopedResume,
|
|
1181
|
+
...(commandOptions?.update !== undefined
|
|
1182
|
+
? { update: commandOptions.update }
|
|
1183
|
+
: {}),
|
|
1184
|
+
...(commandOptions?.goto !== undefined
|
|
1185
|
+
? { goto: commandOptions.goto }
|
|
1186
|
+
: {}),
|
|
1187
|
+
}),
|
|
1160
1188
|
resumeConfig,
|
|
1161
1189
|
streamOptions
|
|
1162
1190
|
);
|
|
@@ -10,10 +10,10 @@ import {
|
|
|
10
10
|
MessagesAnnotation,
|
|
11
11
|
Command,
|
|
12
12
|
START,
|
|
13
|
-
getCurrentTaskInput,
|
|
14
13
|
END,
|
|
15
14
|
} from '@langchain/langgraph';
|
|
16
15
|
import { ToolMessage } from '@langchain/core/messages';
|
|
16
|
+
import type { ToolRuntime } from '@langchain/core/tools';
|
|
17
17
|
|
|
18
18
|
interface CreateHandoffToolParams {
|
|
19
19
|
agentName: string;
|
|
@@ -28,16 +28,15 @@ const createHandoffTool = ({
|
|
|
28
28
|
const toolDescription = description || `Ask agent '${agentName}' for help`;
|
|
29
29
|
|
|
30
30
|
const handoffTool = tool(
|
|
31
|
-
async (_,
|
|
31
|
+
async (_, runtime: ToolRuntime) => {
|
|
32
32
|
const toolMessage = new ToolMessage({
|
|
33
33
|
content: `Successfully transferred to ${agentName}`,
|
|
34
34
|
name: toolName,
|
|
35
|
-
tool_call_id:
|
|
35
|
+
tool_call_id: runtime.toolCallId,
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
// inject the current agent state
|
|
39
|
-
const state =
|
|
40
|
-
getCurrentTaskInput() as (typeof MessagesAnnotation)['State'];
|
|
38
|
+
// inject the current agent state via langgraph 1.4 `runtime.state`
|
|
39
|
+
const state = runtime.state as (typeof MessagesAnnotation)['State'];
|
|
41
40
|
return new Command({
|
|
42
41
|
goto: agentName,
|
|
43
42
|
update: { messages: state.messages.concat(toolMessage) },
|
package/src/tools/ToolNode.ts
CHANGED
|
@@ -19,8 +19,12 @@ import type {
|
|
|
19
19
|
RunnableConfig,
|
|
20
20
|
RunnableToolLike,
|
|
21
21
|
} from '@langchain/core/runnables';
|
|
22
|
+
import type {
|
|
23
|
+
ToolRuntime,
|
|
24
|
+
StructuredToolInterface,
|
|
25
|
+
} from '@langchain/core/tools';
|
|
22
26
|
import type { BaseMessage, AIMessage } from '@langchain/core/messages';
|
|
23
|
-
import type {
|
|
27
|
+
import type { LangGraphRunnableConfig } from '@langchain/langgraph';
|
|
24
28
|
import type {
|
|
25
29
|
ToolOutputResolveView,
|
|
26
30
|
PreResolvedArgsMap,
|
|
@@ -65,7 +69,7 @@ import { executeHooks } from '@/hooks';
|
|
|
65
69
|
* batch-scoped value the method needs so the signature stays at
|
|
66
70
|
* three positional parameters even as new context fields are added.
|
|
67
71
|
*/
|
|
68
|
-
type RunToolBatchContext = {
|
|
72
|
+
type RunToolBatchContext<T = unknown> = {
|
|
69
73
|
/** Position of this call within the parent ToolNode batch. */
|
|
70
74
|
batchIndex?: number;
|
|
71
75
|
/** Batch turn shared across every call in the batch. */
|
|
@@ -104,6 +108,13 @@ type RunToolBatchContext = {
|
|
|
104
108
|
* contract for hosts relying on it for policy / recovery guidance.
|
|
105
109
|
*/
|
|
106
110
|
additionalContextsSink?: string[];
|
|
111
|
+
/**
|
|
112
|
+
* Graph state the ToolNode was invoked with, threaded from `run()`
|
|
113
|
+
* so `tool.invoke` can forward it as langgraph 1.4's `runtime.state`
|
|
114
|
+
* (the deprecation-free replacement for `getCurrentTaskInput()`,
|
|
115
|
+
* which relies on `node:async_hooks` and is browser-incompatible).
|
|
116
|
+
*/
|
|
117
|
+
runInput?: T;
|
|
107
118
|
};
|
|
108
119
|
|
|
109
120
|
const TOOL_NODE_RUN_NAME = 'tool_batch';
|
|
@@ -798,7 +809,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
798
809
|
protected async runTool(
|
|
799
810
|
call: ToolCall,
|
|
800
811
|
config: RunnableConfig,
|
|
801
|
-
batchContext: RunToolBatchContext = {}
|
|
812
|
+
batchContext: RunToolBatchContext<T> = {}
|
|
802
813
|
): Promise<BaseMessage | Command> {
|
|
803
814
|
const {
|
|
804
815
|
batchIndex,
|
|
@@ -806,6 +817,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
806
817
|
batchScopeId,
|
|
807
818
|
resolvedArgsByCallId,
|
|
808
819
|
preBatchSnapshot,
|
|
820
|
+
runInput,
|
|
809
821
|
} = batchContext;
|
|
810
822
|
const tool = this.toolMap.get(call.name);
|
|
811
823
|
const registry = this.toolOutputRegistry;
|
|
@@ -980,7 +992,27 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
980
992
|
}
|
|
981
993
|
}
|
|
982
994
|
|
|
983
|
-
|
|
995
|
+
/**
|
|
996
|
+
* Forward the graph state as langgraph 1.4's `runtime.state` so
|
|
997
|
+
* tools can read it off their second argument instead of the
|
|
998
|
+
* deprecated `getCurrentTaskInput()` (which relies on
|
|
999
|
+
* `node:async_hooks` and is browser-incompatible). Shape mirrors
|
|
1000
|
+
* langgraph's prebuilt ToolNode runtime exactly.
|
|
1001
|
+
*/
|
|
1002
|
+
const lgConfig = config as LangGraphRunnableConfig;
|
|
1003
|
+
const runtime: ToolRuntime<T> = {
|
|
1004
|
+
...config,
|
|
1005
|
+
state: runInput as ToolRuntime<T>['state'],
|
|
1006
|
+
toolCallId: call.id ?? '',
|
|
1007
|
+
config,
|
|
1008
|
+
context: lgConfig.context as ToolRuntime<T>['context'],
|
|
1009
|
+
store: (lgConfig.store ?? null) as ToolRuntime<T>['store'],
|
|
1010
|
+
writer:
|
|
1011
|
+
lgConfig.writer ??
|
|
1012
|
+
(config.configurable?.writer as ToolRuntime<T>['writer']) ??
|
|
1013
|
+
null,
|
|
1014
|
+
};
|
|
1015
|
+
const output = await tool.invoke(invokeParams, runtime);
|
|
984
1016
|
if (isCommand(output)) {
|
|
985
1017
|
return output;
|
|
986
1018
|
}
|
|
@@ -1192,7 +1224,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
1192
1224
|
private async runDirectToolWithLifecycleHooks(
|
|
1193
1225
|
call: ToolCall,
|
|
1194
1226
|
config: RunnableConfig,
|
|
1195
|
-
batchContext: RunToolBatchContext = {}
|
|
1227
|
+
batchContext: RunToolBatchContext<T> = {}
|
|
1196
1228
|
): Promise<BaseMessage | Command> {
|
|
1197
1229
|
const runId = (config.configurable?.run_id as string | undefined) ?? '';
|
|
1198
1230
|
const hookRegistry = this.hookRegistry;
|
|
@@ -3162,6 +3194,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3162
3194
|
// branch, so direct tools dispatched via Send (a supported
|
|
3163
3195
|
// input shape) still silently dropped hook context.
|
|
3164
3196
|
const directAdditionalContexts: string[] = [];
|
|
3197
|
+
// Mirror langgraph's prebuilt ToolNode: the Send-input state is
|
|
3198
|
+
// the input minus the `lg_tool_call` envelope key.
|
|
3199
|
+
const { lg_tool_call: _sendToolCall, ...sendState } = input;
|
|
3165
3200
|
const sendOutput = await this.runDirectToolWithLifecycleHooks(
|
|
3166
3201
|
input.lg_tool_call,
|
|
3167
3202
|
config,
|
|
@@ -3171,6 +3206,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3171
3206
|
batchScopeId,
|
|
3172
3207
|
resolvedArgsByCallId,
|
|
3173
3208
|
additionalContextsSink: directAdditionalContexts,
|
|
3209
|
+
runInput: sendState as T,
|
|
3174
3210
|
}
|
|
3175
3211
|
);
|
|
3176
3212
|
outputs =
|
|
@@ -3343,6 +3379,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3343
3379
|
resolvedArgsByCallId,
|
|
3344
3380
|
preBatchSnapshot,
|
|
3345
3381
|
additionalContextsSink: directAdditionalContexts,
|
|
3382
|
+
runInput: input as T,
|
|
3346
3383
|
})
|
|
3347
3384
|
)
|
|
3348
3385
|
)
|
|
@@ -3407,6 +3444,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3407
3444
|
resolvedArgsByCallId,
|
|
3408
3445
|
preBatchSnapshot,
|
|
3409
3446
|
additionalContextsSink: directAdditionalContexts,
|
|
3447
|
+
runInput: input as T,
|
|
3410
3448
|
})
|
|
3411
3449
|
)
|
|
3412
3450
|
);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
3
|
+
import { describe, it, expect } from '@jest/globals';
|
|
4
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
5
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
6
|
+
import type { ToolRuntime } from '@langchain/core/tools';
|
|
7
|
+
import { ToolNode } from '../ToolNode';
|
|
8
|
+
|
|
9
|
+
type CapturedRuntime = {
|
|
10
|
+
state: unknown;
|
|
11
|
+
toolCallId: string;
|
|
12
|
+
toolCallIdFromConfig: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tool that records the langgraph runtime it received as its second
|
|
17
|
+
* argument. Proves our forked ToolNode forwards `runtime.state` (the
|
|
18
|
+
* deprecation-free replacement for `getCurrentTaskInput()`).
|
|
19
|
+
*/
|
|
20
|
+
function createRuntimeProbeTool(captured: CapturedRuntime[]) {
|
|
21
|
+
return tool(
|
|
22
|
+
async (_input, runtime: ToolRuntime) => {
|
|
23
|
+
captured.push({
|
|
24
|
+
state: runtime.state,
|
|
25
|
+
toolCallId: runtime.toolCallId,
|
|
26
|
+
toolCallIdFromConfig: runtime.toolCall?.id,
|
|
27
|
+
});
|
|
28
|
+
return 'ok';
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'probe_runtime',
|
|
32
|
+
description: 'Records the runtime forwarded by the ToolNode',
|
|
33
|
+
schema: z.object({ value: z.string() }),
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function aiMessageWithProbeCall(callId: string): AIMessage {
|
|
39
|
+
return new AIMessage({
|
|
40
|
+
content: '',
|
|
41
|
+
tool_calls: [
|
|
42
|
+
{
|
|
43
|
+
id: callId,
|
|
44
|
+
name: 'probe_runtime',
|
|
45
|
+
args: { value: 'hello' },
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('ToolNode runtime.state forwarding (langgraph 1.4)', () => {
|
|
52
|
+
it('forwards the message-state input as runtime.state', async () => {
|
|
53
|
+
const captured: CapturedRuntime[] = [];
|
|
54
|
+
const toolNode = new ToolNode({
|
|
55
|
+
tools: [createRuntimeProbeTool(captured)],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const messages: BaseMessage[] = [
|
|
59
|
+
new HumanMessage('start'),
|
|
60
|
+
aiMessageWithProbeCall('call_1'),
|
|
61
|
+
];
|
|
62
|
+
const input = { messages };
|
|
63
|
+
|
|
64
|
+
await toolNode.invoke(input);
|
|
65
|
+
|
|
66
|
+
expect(captured).toHaveLength(1);
|
|
67
|
+
/* state is the exact run input object the ToolNode was invoked with */
|
|
68
|
+
expect(captured[0].state).toBe(input);
|
|
69
|
+
expect((captured[0].state as { messages: BaseMessage[] }).messages).toBe(
|
|
70
|
+
messages
|
|
71
|
+
);
|
|
72
|
+
/* toolCallId and the langchain-extracted toolCall both resolve */
|
|
73
|
+
expect(captured[0].toolCallId).toBe('call_1');
|
|
74
|
+
expect(captured[0].toolCallIdFromConfig).toBe('call_1');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('forwards the array-shaped input as runtime.state', async () => {
|
|
78
|
+
const captured: CapturedRuntime[] = [];
|
|
79
|
+
const toolNode = new ToolNode({
|
|
80
|
+
tools: [createRuntimeProbeTool(captured)],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const input: BaseMessage[] = [
|
|
84
|
+
new HumanMessage('start'),
|
|
85
|
+
aiMessageWithProbeCall('call_2'),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
await toolNode.invoke(input);
|
|
89
|
+
|
|
90
|
+
expect(captured).toHaveLength(1);
|
|
91
|
+
expect(captured[0].state).toBe(input);
|
|
92
|
+
expect(captured[0].toolCallId).toBe('call_2');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('forwards the de-enveloped state for Send-shaped input', async () => {
|
|
96
|
+
const captured: CapturedRuntime[] = [];
|
|
97
|
+
const toolNode = new ToolNode({
|
|
98
|
+
tools: [createRuntimeProbeTool(captured)],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const sidecar = { tenant: 'acme' };
|
|
102
|
+
await toolNode.invoke({
|
|
103
|
+
lg_tool_call: {
|
|
104
|
+
id: 'call_3',
|
|
105
|
+
name: 'probe_runtime',
|
|
106
|
+
args: { value: 'hi' },
|
|
107
|
+
type: 'tool_call',
|
|
108
|
+
},
|
|
109
|
+
...sidecar,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(captured).toHaveLength(1);
|
|
113
|
+
/* The `lg_tool_call` envelope key is stripped, the rest is state */
|
|
114
|
+
expect(captured[0].state).toEqual(sidecar);
|
|
115
|
+
expect(
|
|
116
|
+
(captured[0].state as Record<string, unknown>).lg_tool_call
|
|
117
|
+
).toBeUndefined();
|
|
118
|
+
expect(captured[0].toolCallId).toBe('call_3');
|
|
119
|
+
});
|
|
120
|
+
});
|