@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.
Files changed (57) hide show
  1. package/dist/cjs/graphs/MultiAgentGraph.cjs +6 -6
  2. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  3. package/dist/cjs/llm/anthropic/index.cjs +4 -3
  4. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  5. package/dist/cjs/llm/anthropic/utils/tools.cjs +2 -1
  6. package/dist/cjs/llm/anthropic/utils/tools.cjs.map +1 -1
  7. package/dist/cjs/llm/bedrock/cachePoints.cjs +28 -0
  8. package/dist/cjs/llm/bedrock/cachePoints.cjs.map +1 -0
  9. package/dist/cjs/llm/bedrock/index.cjs +10 -1
  10. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  11. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  12. package/dist/cjs/run.cjs +21 -3
  13. package/dist/cjs/run.cjs.map +1 -1
  14. package/dist/cjs/tools/ToolNode.cjs +26 -5
  15. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  16. package/dist/esm/graphs/MultiAgentGraph.mjs +7 -7
  17. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  18. package/dist/esm/llm/anthropic/index.mjs +4 -3
  19. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  20. package/dist/esm/llm/anthropic/utils/tools.mjs +2 -1
  21. package/dist/esm/llm/anthropic/utils/tools.mjs.map +1 -1
  22. package/dist/esm/llm/bedrock/cachePoints.mjs +28 -0
  23. package/dist/esm/llm/bedrock/cachePoints.mjs.map +1 -0
  24. package/dist/esm/llm/bedrock/index.mjs +10 -1
  25. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  26. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  27. package/dist/esm/run.mjs +21 -3
  28. package/dist/esm/run.mjs.map +1 -1
  29. package/dist/esm/tools/ToolNode.mjs +26 -5
  30. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  31. package/dist/types/llm/anthropic/utils/tools.d.ts +1 -1
  32. package/dist/types/llm/bedrock/cachePoints.d.ts +9 -0
  33. package/dist/types/run.d.ts +15 -1
  34. package/dist/types/tools/ToolNode.d.ts +9 -2
  35. package/package.json +16 -21
  36. package/src/graphs/MultiAgentGraph.ts +7 -12
  37. package/src/llm/anthropic/index.ts +13 -2
  38. package/src/llm/anthropic/inherited-content-utils.spec.ts +244 -0
  39. package/src/llm/anthropic/inherited-stream-events.spec.ts +752 -0
  40. package/src/llm/anthropic/inherited-strict.spec.ts +302 -0
  41. package/src/llm/anthropic/llm.spec.ts +65 -0
  42. package/src/llm/anthropic/utils/tools.ts +7 -1
  43. package/src/llm/bedrock/cachePoints.ts +86 -0
  44. package/src/llm/bedrock/index.ts +9 -0
  45. package/src/llm/bedrock/inherited-cache.spec.ts +144 -0
  46. package/src/llm/bedrock/inherited.spec.ts +724 -0
  47. package/src/llm/google/inherited-stream-events.spec.ts +350 -0
  48. package/src/llm/openai/inherited-deepseek.spec.ts +347 -0
  49. package/src/llm/openai/inherited-xai.spec.ts +416 -0
  50. package/src/llm/openai/llm.spec.ts +1568 -0
  51. package/src/llm/openrouter/index.ts +1 -3
  52. package/src/llm/vertexai/inherited-stream-events.spec.ts +271 -0
  53. package/src/run.ts +31 -3
  54. package/src/scripts/handoff-test.ts +5 -6
  55. package/src/tools/ToolNode.ts +43 -5
  56. package/src/tools/__tests__/ToolNode.runtimeState.test.ts +120 -0
  57. 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
- // @ts-expect-error - OpenRouter reasoning extends OpenAI Reasoning with additional
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 (currently only `Command({ resume })`) are
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({ resume: scopedResume }),
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 (_, config) => {
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: config.toolCall.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) },
@@ -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 { StructuredToolInterface } from '@langchain/core/tools';
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
- const output = await tool.invoke(invokeParams, config);
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
+ });