@librechat/agents 3.1.81 → 3.1.83

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 (60) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +125 -36
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +13 -0
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/llm/openai/index.cjs +50 -13
  6. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  7. package/dist/cjs/llm/openrouter/index.cjs +17 -7
  8. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  9. package/dist/cjs/llm/openrouter/toolCache.cjs +55 -0
  10. package/dist/cjs/llm/openrouter/toolCache.cjs.map +1 -0
  11. package/dist/cjs/main.cjs +1 -0
  12. package/dist/cjs/main.cjs.map +1 -1
  13. package/dist/cjs/messages/cache.cjs +96 -0
  14. package/dist/cjs/messages/cache.cjs.map +1 -1
  15. package/dist/cjs/tools/ToolNode.cjs +70 -12
  16. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  17. package/dist/esm/agents/AgentContext.mjs +125 -36
  18. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  19. package/dist/esm/graphs/Graph.mjs +13 -0
  20. package/dist/esm/graphs/Graph.mjs.map +1 -1
  21. package/dist/esm/llm/openai/index.mjs +50 -14
  22. package/dist/esm/llm/openai/index.mjs.map +1 -1
  23. package/dist/esm/llm/openrouter/index.mjs +17 -7
  24. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  25. package/dist/esm/llm/openrouter/toolCache.mjs +53 -0
  26. package/dist/esm/llm/openrouter/toolCache.mjs.map +1 -0
  27. package/dist/esm/main.mjs +1 -1
  28. package/dist/esm/messages/cache.mjs +96 -1
  29. package/dist/esm/messages/cache.mjs.map +1 -1
  30. package/dist/esm/tools/ToolNode.mjs +70 -12
  31. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  32. package/dist/types/agents/AgentContext.d.ts +8 -1
  33. package/dist/types/agents/__tests__/promptCacheLiveHelpers.d.ts +6 -2
  34. package/dist/types/llm/openrouter/index.d.ts +1 -0
  35. package/dist/types/llm/openrouter/toolCache.d.ts +2 -0
  36. package/dist/types/messages/cache.d.ts +1 -0
  37. package/dist/types/tools/ToolNode.d.ts +5 -0
  38. package/dist/types/types/run.d.ts +2 -0
  39. package/package.json +2 -1
  40. package/src/agents/AgentContext.ts +191 -40
  41. package/src/agents/__tests__/AgentContext.anthropic.live.test.ts +0 -4
  42. package/src/agents/__tests__/AgentContext.openrouter.live.test.ts +128 -0
  43. package/src/agents/__tests__/AgentContext.test.ts +355 -18
  44. package/src/agents/__tests__/promptCacheLiveHelpers.ts +8 -2
  45. package/src/graphs/Graph.ts +24 -0
  46. package/src/llm/custom-chat-models.smoke.test.ts +76 -0
  47. package/src/llm/openai/deepseek.test.ts +14 -1
  48. package/src/llm/openai/index.ts +38 -12
  49. package/src/llm/openrouter/index.ts +22 -7
  50. package/src/llm/openrouter/reasoning.test.ts +33 -0
  51. package/src/llm/openrouter/toolCache.test.ts +83 -0
  52. package/src/llm/openrouter/toolCache.ts +89 -0
  53. package/src/messages/cache.test.ts +127 -0
  54. package/src/messages/cache.ts +143 -0
  55. package/src/scripts/openrouter_prompt_cache_live.ts +310 -0
  56. package/src/specs/agent-handoffs.live.test.ts +140 -0
  57. package/src/specs/agent-handoffs.test.ts +266 -2
  58. package/src/specs/openrouter.simple.test.ts +15 -8
  59. package/src/tools/ToolNode.ts +92 -13
  60. package/src/types/run.ts +2 -0
@@ -1,11 +1,13 @@
1
1
  // src/specs/agent-handoffs.test.ts
2
2
  import { DynamicStructuredTool } from '@langchain/core/tools';
3
- import { HumanMessage, ToolMessage } from '@langchain/core/messages';
3
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
4
4
  import type { ToolCall } from '@langchain/core/messages/tool';
5
5
  import type { RunnableConfig } from '@langchain/core/runnables';
6
6
  import type * as t from '@/types';
7
- import { Providers, Constants } from '@/common';
7
+ import { Providers, GraphEvents, Constants } from '@/common';
8
8
  import { StandardGraph } from '@/graphs/Graph';
9
+ import { ToolNode } from '@/tools/ToolNode';
10
+ import * as events from '@/utils/events';
9
11
  import { Run } from '@/run';
10
12
 
11
13
  /**
@@ -989,5 +991,267 @@ describe('Agent Handoffs Tests', () => {
989
991
  `${Constants.LC_TRANSFER_TO_}AgentWithCamelCase`
990
992
  );
991
993
  });
994
+
995
+ it('should return exact-name guidance for handoff names with extra suffixes', async () => {
996
+ const agents: t.AgentInputs[] = [
997
+ createBasicAgent('router', 'You are a router'),
998
+ createBasicAgent('data_analyst', 'You are a data analyst'),
999
+ ];
1000
+
1001
+ const edges: t.GraphEdge[] = [
1002
+ {
1003
+ from: 'router',
1004
+ to: 'data_analyst',
1005
+ edgeType: 'handoff',
1006
+ },
1007
+ ];
1008
+
1009
+ const run = await Run.create(createTestConfig(agents, edges));
1010
+ const correctName = `${Constants.LC_TRANSFER_TO_}data_analyst`;
1011
+ const wrongName = `${correctName}_analyst`;
1012
+
1013
+ run.Graph?.overrideTestModel(
1014
+ ['Trying to transfer', 'Stopping after invalid tool name'],
1015
+ 10,
1016
+ [
1017
+ {
1018
+ id: 'tool_call_wrong_handoff',
1019
+ name: wrongName,
1020
+ args: { instructions: 'Analyze the uploaded data' },
1021
+ } as ToolCall,
1022
+ ]
1023
+ );
1024
+
1025
+ const config: Partial<RunnableConfig> & {
1026
+ version: 'v1' | 'v2';
1027
+ streamMode: string;
1028
+ } = {
1029
+ configurable: {
1030
+ thread_id: 'test-wrong-handoff-name-thread',
1031
+ },
1032
+ streamMode: 'values',
1033
+ version: 'v2' as const,
1034
+ };
1035
+
1036
+ await run.processStream(
1037
+ { messages: [new HumanMessage('Please analyze my data')] },
1038
+ config
1039
+ );
1040
+
1041
+ const toolMessages = run
1042
+ .getRunMessages()!
1043
+ .filter((msg) => msg.getType() === 'tool') as ToolMessage[];
1044
+ const wrongNameMessage = toolMessages.find(
1045
+ (msg) => msg.name === wrongName
1046
+ );
1047
+
1048
+ expect(wrongNameMessage).toBeDefined();
1049
+ expect(wrongNameMessage?.status).toBe('error');
1050
+ expect(wrongNameMessage?.content).toContain(
1051
+ `Did you mean "${correctName}"`
1052
+ );
1053
+ });
1054
+
1055
+ it('should include toolMap handoffs when direct tool names are present', async () => {
1056
+ const correctName = `${Constants.LC_TRANSFER_TO_}data_analyst`;
1057
+ const wrongName = `${correctName}_analyst`;
1058
+ const handoffTool = new DynamicStructuredTool({
1059
+ name: correctName,
1060
+ description: 'Transfer to data analyst',
1061
+ schema: { type: 'object', properties: {}, required: [] },
1062
+ func: async (): Promise<string> => 'transferred',
1063
+ }) as t.GenericTool;
1064
+ const node = new ToolNode({
1065
+ tools: [handoffTool],
1066
+ directToolNames: new Set(['execute_code']),
1067
+ });
1068
+ const result = (await node.invoke({
1069
+ messages: [
1070
+ new AIMessage({
1071
+ content: '',
1072
+ tool_calls: [{ id: 'wrong_handoff', name: wrongName, args: {} }],
1073
+ }),
1074
+ ],
1075
+ })) as { messages: ToolMessage[] };
1076
+ const wrongNameMessage = result.messages.find(
1077
+ (msg) => msg.tool_call_id === 'wrong_handoff'
1078
+ );
1079
+
1080
+ expect(wrongNameMessage).toBeDefined();
1081
+ expect(wrongNameMessage?.status).toBe('error');
1082
+ expect(wrongNameMessage?.content).toContain(
1083
+ `Did you mean "${correctName}"`
1084
+ );
1085
+ });
1086
+
1087
+ it('should keep event-driven unknown handoffs local without direct tool names', async () => {
1088
+ const executedToolNames: string[] = [];
1089
+ const correctName = `${Constants.LC_TRANSFER_TO_}data_analyst`;
1090
+ const wrongName = `${correctName}_analyst`;
1091
+ const lookupName = 'lookup_sessions';
1092
+ const handoffTool = new DynamicStructuredTool({
1093
+ name: correctName,
1094
+ description: 'Transfer to data analyst',
1095
+ schema: { type: 'object', properties: {}, required: [] },
1096
+ func: async (): Promise<string> => 'transferred',
1097
+ }) as t.GenericTool;
1098
+ const lookupTool = new DynamicStructuredTool({
1099
+ name: lookupName,
1100
+ description: 'List upload sessions',
1101
+ schema: { type: 'object', properties: {}, required: [] },
1102
+ func: async (): Promise<string> => 'sessions',
1103
+ }) as t.GenericTool;
1104
+ const dispatchSpy = jest
1105
+ .spyOn(events, 'safeDispatchCustomEvent')
1106
+ .mockImplementation(async (event, data): Promise<void> => {
1107
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
1108
+ return;
1109
+ }
1110
+ const batch = data as t.ToolExecuteBatchRequest;
1111
+ executedToolNames.push(
1112
+ ...batch.toolCalls.map((toolCall) => toolCall.name)
1113
+ );
1114
+ batch.resolve(
1115
+ batch.toolCalls.map((toolCall) => ({
1116
+ toolCallId: toolCall.id,
1117
+ status: 'success' as const,
1118
+ content: `host result for ${toolCall.name}`,
1119
+ }))
1120
+ );
1121
+ });
1122
+ const node = new ToolNode({
1123
+ tools: [handoffTool, lookupTool],
1124
+ eventDrivenMode: true,
1125
+ toolCallStepIds: new Map([
1126
+ ['lookup_call', 'step_lookup'],
1127
+ ['wrong_handoff', 'step_wrong_handoff'],
1128
+ ]),
1129
+ });
1130
+
1131
+ try {
1132
+ const result = (await node.invoke({
1133
+ messages: [
1134
+ new AIMessage({
1135
+ content: '',
1136
+ tool_calls: [
1137
+ { id: 'lookup_call', name: lookupName, args: {} },
1138
+ { id: 'wrong_handoff', name: wrongName, args: {} },
1139
+ ],
1140
+ }),
1141
+ ],
1142
+ })) as { messages: ToolMessage[] };
1143
+ const wrongNameMessage = result.messages.find(
1144
+ (msg) => msg.tool_call_id === 'wrong_handoff'
1145
+ );
1146
+
1147
+ expect(executedToolNames).toEqual([lookupName]);
1148
+ expect(wrongNameMessage).toBeDefined();
1149
+ expect(wrongNameMessage?.status).toBe('error');
1150
+ expect(wrongNameMessage?.content).toContain(
1151
+ `Did you mean "${correctName}"`
1152
+ );
1153
+ } finally {
1154
+ dispatchSpy.mockRestore();
1155
+ }
1156
+ });
1157
+
1158
+ it('should not dispatch mistyped graph handoffs to event-driven tool hosts', async () => {
1159
+ const executedToolNames: string[] = [];
1160
+ const agents: t.AgentInputs[] = [
1161
+ {
1162
+ ...createBasicAgent('router', 'You are a router'),
1163
+ toolDefinitions: [
1164
+ {
1165
+ name: 'lookup_sessions',
1166
+ description: 'List upload sessions',
1167
+ parameters: {
1168
+ type: 'object',
1169
+ properties: {},
1170
+ required: [],
1171
+ },
1172
+ },
1173
+ ],
1174
+ },
1175
+ createBasicAgent('data_analyst', 'You are a data analyst'),
1176
+ ];
1177
+
1178
+ const edges: t.GraphEdge[] = [
1179
+ {
1180
+ from: 'router',
1181
+ to: 'data_analyst',
1182
+ edgeType: 'handoff',
1183
+ },
1184
+ ];
1185
+
1186
+ const run = await Run.create({
1187
+ ...createTestConfig(agents, edges),
1188
+ customHandlers: {
1189
+ [GraphEvents.ON_TOOL_EXECUTE]: {
1190
+ handle: (_event: string, data: t.StreamEventData): void => {
1191
+ const batch = data as t.ToolExecuteBatchRequest;
1192
+ executedToolNames.push(
1193
+ ...batch.toolCalls.map((toolCall) => toolCall.name)
1194
+ );
1195
+ batch.resolve(
1196
+ batch.toolCalls.map((toolCall) => ({
1197
+ toolCallId: toolCall.id,
1198
+ status: 'success' as const,
1199
+ content: `host result for ${toolCall.name}`,
1200
+ }))
1201
+ );
1202
+ },
1203
+ },
1204
+ },
1205
+ });
1206
+ const correctName = `${Constants.LC_TRANSFER_TO_}data_analyst`;
1207
+ const wrongName = `${correctName}_analyst`;
1208
+
1209
+ run.Graph?.overrideTestModel(
1210
+ ['Checking sessions and transferring', 'Handled invalid transfer'],
1211
+ 10,
1212
+ [
1213
+ {
1214
+ id: 'tool_call_lookup',
1215
+ name: 'lookup_sessions',
1216
+ args: {},
1217
+ } as ToolCall,
1218
+ {
1219
+ id: 'tool_call_wrong_handoff',
1220
+ name: wrongName,
1221
+ args: { instructions: 'Analyze the upload session data' },
1222
+ } as ToolCall,
1223
+ ]
1224
+ );
1225
+
1226
+ const config: Partial<RunnableConfig> & {
1227
+ version: 'v1' | 'v2';
1228
+ streamMode: string;
1229
+ } = {
1230
+ configurable: {
1231
+ thread_id: 'test-event-wrong-handoff-name-thread',
1232
+ },
1233
+ streamMode: 'values',
1234
+ version: 'v2' as const,
1235
+ };
1236
+
1237
+ await run.processStream(
1238
+ { messages: [new HumanMessage('Check my sessions and analyze them')] },
1239
+ config
1240
+ );
1241
+
1242
+ const toolMessages = run
1243
+ .getRunMessages()!
1244
+ .filter((msg) => msg.getType() === 'tool') as ToolMessage[];
1245
+ const wrongNameMessage = toolMessages.find(
1246
+ (msg) => msg.name === wrongName
1247
+ );
1248
+
1249
+ expect(executedToolNames).toEqual(['lookup_sessions']);
1250
+ expect(wrongNameMessage).toBeDefined();
1251
+ expect(wrongNameMessage?.status).toBe('error');
1252
+ expect(wrongNameMessage?.content).toContain(
1253
+ `Did you mean "${correctName}"`
1254
+ );
1255
+ });
992
1256
  });
993
1257
  });
@@ -21,6 +21,11 @@ const hasOpenRouter = (process.env.OPENROUTER_API_KEY ?? '').trim() !== '';
21
21
  const describeIf = hasOpenRouter ? describe : describe.skip;
22
22
 
23
23
  const provider = Providers.OPENROUTER;
24
+ const LIVE_MAX_TOKENS = 4096;
25
+ const GEMINI_REASONING_MODEL = 'google/gemini-3.1-pro-preview';
26
+ const ANTHROPIC_REASONING_MODEL = 'anthropic/claude-sonnet-4';
27
+ const ANTHROPIC_LATEST_REASONING_MODEL = 'anthropic/claude-sonnet-4.6';
28
+
24
29
  describeIf(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
25
30
  jest.setTimeout(60000);
26
31
  let run: Run<t.IState>;
@@ -75,6 +80,7 @@ describeIf(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
75
80
  const llmConfig = {
76
81
  ...baseWithoutReasoning,
77
82
  model: opts.model,
83
+ maxTokens: LIVE_MAX_TOKENS,
78
84
  ...(opts.reasoning != null ? { reasoning: opts.reasoning } : {}),
79
85
  } as t.LLMConfig;
80
86
  const customHandlers = setupCustomHandlers();
@@ -178,7 +184,8 @@ describeIf(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
178
184
  baseLLMConfig as unknown as Record<string, unknown>;
179
185
  const llmConfig = {
180
186
  ...baseWithoutReasoning,
181
- model: 'anthropic/claude-sonnet-4',
187
+ model: ANTHROPIC_REASONING_MODEL,
188
+ maxTokens: LIVE_MAX_TOKENS,
182
189
  } as t.LLMConfig;
183
190
  const customHandlers = setupCustomHandlers();
184
191
 
@@ -214,7 +221,7 @@ describeIf(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
214
221
 
215
222
  test(`${capitalizeFirstLetter(provider)}: Gemini 3 reasons by default (no config)`, async () => {
216
223
  await runReasoningTest({
217
- model: 'google/gemini-3-pro-preview',
224
+ model: GEMINI_REASONING_MODEL,
218
225
  reasoning: undefined,
219
226
  threadId: 'or-gemini-default-1',
220
227
  runId: 'or-gemini-default-1',
@@ -223,7 +230,7 @@ describeIf(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
223
230
 
224
231
  test(`${capitalizeFirstLetter(provider)}: Gemini reasoning with max_tokens`, async () => {
225
232
  await runReasoningTest({
226
- model: 'google/gemini-3-pro-preview',
233
+ model: GEMINI_REASONING_MODEL,
227
234
  reasoning: { max_tokens: 4000 },
228
235
  threadId: 'or-gemini-reasoning-1',
229
236
  runId: 'or-gemini-reasoning-1',
@@ -232,7 +239,7 @@ describeIf(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
232
239
 
233
240
  test(`${capitalizeFirstLetter(provider)}: Gemini reasoning with effort`, async () => {
234
241
  await runReasoningTest({
235
- model: 'google/gemini-3-flash-preview',
242
+ model: GEMINI_REASONING_MODEL,
236
243
  reasoning: { effort: 'low' },
237
244
  threadId: 'or-gemini-effort-1',
238
245
  runId: 'or-gemini-effort-1',
@@ -241,7 +248,7 @@ describeIf(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
241
248
 
242
249
  test(`${capitalizeFirstLetter(provider)}: Anthropic reasoning with max_tokens`, async () => {
243
250
  await runReasoningTest({
244
- model: 'anthropic/claude-sonnet-4',
251
+ model: ANTHROPIC_REASONING_MODEL,
245
252
  reasoning: { max_tokens: 4000 },
246
253
  threadId: 'or-anthropic-reasoning-1',
247
254
  runId: 'or-anthropic-reasoning-1',
@@ -250,16 +257,16 @@ describeIf(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
250
257
 
251
258
  test(`${capitalizeFirstLetter(provider)}: Anthropic sonnet-4 reasoning with effort`, async () => {
252
259
  await runReasoningTest({
253
- model: 'anthropic/claude-sonnet-4',
260
+ model: ANTHROPIC_REASONING_MODEL,
254
261
  reasoning: { effort: 'medium' },
255
262
  threadId: 'or-anthropic-effort-s4-1',
256
263
  runId: 'or-anthropic-effort-s4-1',
257
264
  });
258
265
  });
259
266
 
260
- test(`${capitalizeFirstLetter(provider)}: Anthropic sonnet-4-6 reasoning with effort`, async () => {
267
+ test(`${capitalizeFirstLetter(provider)}: Anthropic sonnet-4.6 reasoning with effort`, async () => {
261
268
  await runReasoningTest({
262
- model: 'anthropic/claude-sonnet-4-6',
269
+ model: ANTHROPIC_LATEST_REASONING_MODEL,
263
270
  reasoning: { effort: 'medium' },
264
271
  threadId: 'or-anthropic-effort-s46-1',
265
272
  runId: 'or-anthropic-effort-s46-1',
@@ -131,6 +131,10 @@ function isSend(value: unknown): value is Send {
131
131
  return value instanceof Send;
132
132
  }
133
133
 
134
+ function isHandoffToolName(name: string): boolean {
135
+ return name.startsWith(Constants.LC_TRANSFER_TO_);
136
+ }
137
+
134
138
  /**
135
139
  * Format a fail-closed diagnostic for malformed approval-decision
136
140
  * fields. Hosts deserialize resume payloads from untyped JSON, so
@@ -581,6 +585,72 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
581
585
  return this.fileCheckpointer;
582
586
  }
583
587
 
588
+ private *getRegisteredHandoffNames(): IterableIterator<string> {
589
+ if (this.directToolNames != null) {
590
+ for (const toolName of this.directToolNames) {
591
+ yield toolName;
592
+ }
593
+ }
594
+
595
+ for (const toolName of this.toolMap.keys()) {
596
+ if (this.directToolNames?.has(toolName) === true) {
597
+ continue;
598
+ }
599
+ yield toolName;
600
+ }
601
+ }
602
+
603
+ private hasRegisteredHandoffTool(): boolean {
604
+ for (const toolName of this.getRegisteredHandoffNames()) {
605
+ if (isHandoffToolName(toolName)) {
606
+ return true;
607
+ }
608
+ }
609
+ return false;
610
+ }
611
+
612
+ private getHandoffToolNameSuggestion(callName: string): string | undefined {
613
+ if (!isHandoffToolName(callName)) {
614
+ return undefined;
615
+ }
616
+
617
+ let suggestion: string | undefined;
618
+ for (const toolName of this.getRegisteredHandoffNames()) {
619
+ if (
620
+ !isHandoffToolName(toolName) ||
621
+ toolName.length >= callName.length ||
622
+ !callName.startsWith(toolName)
623
+ ) {
624
+ continue;
625
+ }
626
+ if (suggestion == null || toolName.length > suggestion.length) {
627
+ suggestion = toolName;
628
+ }
629
+ }
630
+ return suggestion;
631
+ }
632
+
633
+ private shouldHandleUnknownHandoffLocally(
634
+ callName: string,
635
+ hasRegisteredHandoffTool?: boolean
636
+ ): boolean {
637
+ if (!isHandoffToolName(callName) || this.toolMap.has(callName)) {
638
+ return false;
639
+ }
640
+ return hasRegisteredHandoffTool ?? this.hasRegisteredHandoffTool();
641
+ }
642
+
643
+ private getUnknownToolErrorMessage(callName: string): string {
644
+ const suggestion = this.getHandoffToolNameSuggestion(callName);
645
+ if (suggestion == null) {
646
+ return `Tool "${callName}" not found.`;
647
+ }
648
+ return (
649
+ `Tool "${callName}" not found. Did you mean "${suggestion}"? ` +
650
+ 'Handoff tool names must match exactly.'
651
+ );
652
+ }
653
+
584
654
  /**
585
655
  * Flush the per-Run direct-path turn cache. Called by the Graph at
586
656
  * end-of-Run via `clearHeavyState`. The map intentionally survives
@@ -699,7 +769,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
699
769
  batchScopeId ?? (config.configurable?.run_id as string | undefined);
700
770
  try {
701
771
  if (tool === undefined) {
702
- throw new Error(`Tool "${call.name}" not found.`);
772
+ throw new Error(this.getUnknownToolErrorMessage(call.name));
703
773
  }
704
774
  /**
705
775
  * `usageCount` is the per-tool-name invocation index that
@@ -2742,8 +2812,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2742
2812
  let outputs: (BaseMessage | Command)[];
2743
2813
 
2744
2814
  if (this.isSendInput(input)) {
2745
- const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
2746
- if (this.eventDrivenMode && isDirectTool !== true) {
2815
+ const isLocalTool =
2816
+ this.directToolNames?.has(input.lg_tool_call.name) === true ||
2817
+ this.shouldHandleUnknownHandoffLocally(input.lg_tool_call.name);
2818
+ if (this.eventDrivenMode && !isLocalTool) {
2747
2819
  return this.executeViaEvent([input.lg_tool_call], config, input, {
2748
2820
  batchIndices: [0],
2749
2821
  turn,
@@ -2848,28 +2920,35 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2848
2920
  }) ?? [];
2849
2921
 
2850
2922
  if (this.eventDrivenMode && filteredCalls.length > 0) {
2851
- const filteredIndices = filteredCalls.map((_, idx) => idx);
2852
-
2853
- if (!this.directToolNames || this.directToolNames.size === 0) {
2854
- return this.executeViaEvent(filteredCalls, config, input, {
2855
- batchIndices: filteredIndices,
2856
- turn,
2857
- batchScopeId,
2858
- });
2859
- }
2923
+ const directToolNames = this.directToolNames;
2924
+ const hasRegisteredHandoffTool = this.hasRegisteredHandoffTool();
2860
2925
 
2861
2926
  const directEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
2862
2927
  const eventEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
2863
2928
  for (let i = 0; i < filteredCalls.length; i++) {
2864
2929
  const call = filteredCalls[i];
2865
2930
  const entry = { call, batchIndex: i };
2866
- if (this.directToolNames!.has(call.name)) {
2931
+ if (
2932
+ directToolNames?.has(call.name) === true ||
2933
+ this.shouldHandleUnknownHandoffLocally(
2934
+ call.name,
2935
+ hasRegisteredHandoffTool
2936
+ )
2937
+ ) {
2867
2938
  directEntries.push(entry);
2868
2939
  } else {
2869
2940
  eventEntries.push(entry);
2870
2941
  }
2871
2942
  }
2872
2943
 
2944
+ if (directEntries.length === 0) {
2945
+ return this.executeViaEvent(filteredCalls, config, input, {
2946
+ batchIndices: eventEntries.map((entry) => entry.batchIndex),
2947
+ turn,
2948
+ batchScopeId,
2949
+ });
2950
+ }
2951
+
2873
2952
  const directCalls = directEntries.map((e) => e.call);
2874
2953
  const directIndices = directEntries.map((e) => e.batchIndex);
2875
2954
  const eventCalls = eventEntries.map((e) => e.call);
package/src/types/run.ts CHANGED
@@ -216,6 +216,8 @@ export type TokenBudgetBreakdown = {
216
216
  instructionTokens: number;
217
217
  /** Tokens from the system message text alone. */
218
218
  systemMessageTokens: number;
219
+ /** Tokens from instruction text emitted outside the system message. */
220
+ dynamicInstructionTokens: number;
219
221
  /** Tokens from tool schema definitions. */
220
222
  toolSchemaTokens: number;
221
223
  /** Tokens from the conversation summary. */