@librechat/agents 3.2.60 → 3.2.62

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 (116) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +32 -0
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/executeHooks.cjs +6 -0
  6. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  7. package/dist/cjs/hooks/index.cjs +12 -0
  8. package/dist/cjs/hooks/index.cjs.map +1 -0
  9. package/dist/cjs/hooks/types.cjs.map +1 -1
  10. package/dist/cjs/langfuse.cjs +62 -1
  11. package/dist/cjs/langfuse.cjs.map +1 -1
  12. package/dist/cjs/llm/bedrock/index.cjs +266 -43
  13. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  14. package/dist/cjs/llm/openai/index.cjs +274 -7
  15. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  16. package/dist/cjs/llm/openai/streamMetadata.cjs +69 -0
  17. package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -0
  18. package/dist/cjs/llm/openrouter/index.cjs +5 -6
  19. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  20. package/dist/cjs/main.cjs +6 -1
  21. package/dist/cjs/messages/format.cjs +61 -0
  22. package/dist/cjs/messages/format.cjs.map +1 -1
  23. package/dist/cjs/messages/prune.cjs +31 -19
  24. package/dist/cjs/messages/prune.cjs.map +1 -1
  25. package/dist/cjs/stream.cjs +13 -3
  26. package/dist/cjs/stream.cjs.map +1 -1
  27. package/dist/cjs/tools/ToolNode.cjs +75 -10
  28. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  29. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  30. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  31. package/dist/cjs/utils/tokens.cjs +111 -0
  32. package/dist/cjs/utils/tokens.cjs.map +1 -1
  33. package/dist/esm/common/enum.mjs +2 -0
  34. package/dist/esm/common/enum.mjs.map +1 -1
  35. package/dist/esm/hooks/HookRegistry.mjs +32 -0
  36. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  37. package/dist/esm/hooks/executeHooks.mjs +6 -0
  38. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  39. package/dist/esm/hooks/index.mjs +12 -1
  40. package/dist/esm/hooks/index.mjs.map +1 -0
  41. package/dist/esm/hooks/types.mjs.map +1 -1
  42. package/dist/esm/langfuse.mjs +62 -1
  43. package/dist/esm/langfuse.mjs.map +1 -1
  44. package/dist/esm/llm/bedrock/index.mjs +266 -43
  45. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  46. package/dist/esm/llm/openai/index.mjs +274 -7
  47. package/dist/esm/llm/openai/index.mjs.map +1 -1
  48. package/dist/esm/llm/openai/streamMetadata.mjs +69 -0
  49. package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -0
  50. package/dist/esm/llm/openrouter/index.mjs +5 -6
  51. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  52. package/dist/esm/main.mjs +3 -3
  53. package/dist/esm/messages/format.mjs +61 -0
  54. package/dist/esm/messages/format.mjs.map +1 -1
  55. package/dist/esm/messages/prune.mjs +31 -19
  56. package/dist/esm/messages/prune.mjs.map +1 -1
  57. package/dist/esm/stream.mjs +13 -3
  58. package/dist/esm/stream.mjs.map +1 -1
  59. package/dist/esm/tools/ToolNode.mjs +75 -10
  60. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  61. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  62. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  63. package/dist/esm/utils/tokens.mjs +108 -1
  64. package/dist/esm/utils/tokens.mjs.map +1 -1
  65. package/dist/types/common/enum.d.ts +3 -1
  66. package/dist/types/hooks/HookRegistry.d.ts +10 -0
  67. package/dist/types/hooks/index.d.ts +7 -0
  68. package/dist/types/hooks/types.d.ts +18 -1
  69. package/dist/types/llm/bedrock/index.d.ts +5 -0
  70. package/dist/types/llm/openai/index.d.ts +19 -0
  71. package/dist/types/llm/openai/streamMetadata.d.ts +16 -0
  72. package/dist/types/messages/prune.d.ts +4 -3
  73. package/dist/types/tools/ToolNode.d.ts +1 -0
  74. package/dist/types/types/llm.d.ts +12 -2
  75. package/dist/types/types/tools.d.ts +1 -1
  76. package/dist/types/utils/tokens.d.ts +30 -0
  77. package/package.json +9 -9
  78. package/src/__tests__/stream.eagerEventExecution.test.ts +100 -2
  79. package/src/common/enum.ts +2 -0
  80. package/src/hooks/HookRegistry.ts +45 -0
  81. package/src/hooks/__tests__/HookRegistry.test.ts +48 -0
  82. package/src/hooks/__tests__/executeHooks.test.ts +85 -2
  83. package/src/hooks/executeHooks.ts +15 -0
  84. package/src/hooks/index.ts +7 -0
  85. package/src/hooks/types.ts +18 -1
  86. package/src/langfuse.ts +134 -1
  87. package/src/llm/bedrock/index.ts +434 -83
  88. package/src/llm/bedrock/streamSealDispatch.test.ts +97 -0
  89. package/src/llm/custom-chat-models.smoke.test.ts +7 -0
  90. package/src/llm/openai/index.ts +604 -6
  91. package/src/llm/openai/managedRequests.test.ts +182 -0
  92. package/src/llm/openai/streamMetadata.spec.ts +86 -0
  93. package/src/llm/openai/streamMetadata.ts +95 -0
  94. package/src/llm/openai/streamMetadataDedup.spec.ts +166 -0
  95. package/src/llm/openrouter/index.ts +9 -5
  96. package/src/messages/format.ts +96 -3
  97. package/src/messages/formatAgentMessages.steer.test.ts +326 -0
  98. package/src/messages/labelContentByAgent.test.ts +75 -0
  99. package/src/messages/prune.ts +56 -30
  100. package/src/specs/anthropic.simple.test.ts +4 -2
  101. package/src/specs/cache.simple.test.ts +17 -4
  102. package/src/specs/langfuse-callbacks.test.ts +61 -0
  103. package/src/specs/openai.simple.test.ts +4 -2
  104. package/src/specs/spec.utils.ts +12 -0
  105. package/src/specs/summarization.test.ts +9 -13
  106. package/src/specs/token-accounting-pipeline.test.ts +130 -4
  107. package/src/specs/tokens.test.ts +214 -0
  108. package/src/stream.ts +22 -3
  109. package/src/tools/ToolNode.ts +107 -12
  110. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +554 -0
  111. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +49 -2
  112. package/src/tools/__tests__/hitl.test.ts +112 -0
  113. package/src/tools/subagent/SubagentExecutor.ts +2 -0
  114. package/src/types/llm.ts +12 -2
  115. package/src/types/tools.ts +1 -1
  116. package/src/utils/tokens.ts +181 -3
@@ -634,4 +634,558 @@ describe('ToolNode eager event tool execution', () => {
634
634
  ]);
635
635
  expect(eagerUsageCount.size).toBe(0);
636
636
  });
637
+
638
+ it('consumes a prestarted eager result when the registry only has observation hooks', async () => {
639
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
640
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
641
+ const eagerUsageCount = new Map<string, number>([['weather', 1]]);
642
+ const request: t.ToolCallRequest = {
643
+ id: 'call_weather',
644
+ name: 'weather',
645
+ args: { city: 'NYC' },
646
+ stepId: 'step_weather',
647
+ turn: 0,
648
+ };
649
+ eagerExecutions.set('call_weather', {
650
+ toolCallId: 'call_weather',
651
+ toolName: 'weather',
652
+ args: { city: 'NYC' },
653
+ request,
654
+ promise: Promise.resolve({
655
+ results: [
656
+ {
657
+ toolCallId: 'call_weather',
658
+ status: 'success',
659
+ content: 'eager result',
660
+ },
661
+ ],
662
+ }),
663
+ });
664
+
665
+ const hookRegistry = new HookRegistry();
666
+ hookRegistry.register('PostToolBatch', {
667
+ hooks: [async () => ({})],
668
+ });
669
+ const toolNode = new ToolNode({
670
+ tools: [createDummyTool('weather')],
671
+ eventDrivenMode: true,
672
+ eagerEventToolExecution: { enabled: true },
673
+ eagerEventToolExecutions: eagerExecutions,
674
+ eagerEventToolUsageCount: eagerUsageCount,
675
+ hookRegistry,
676
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
677
+ });
678
+
679
+ const result = (await toolNode.invoke({
680
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
681
+ })) as { messages: ToolMessage[] };
682
+
683
+ expect(toolExecuteCalls).toHaveLength(0);
684
+ expect(eagerExecutions.has('call_weather')).toBe(false);
685
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
686
+ expect(toolMessage?.content).toBe('eager result');
687
+ });
688
+
689
+ it('ignores another run session-scoped result-altering hooks when consuming', async () => {
690
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
691
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
692
+ const request: t.ToolCallRequest = {
693
+ id: 'call_weather',
694
+ name: 'weather',
695
+ args: { city: 'NYC' },
696
+ stepId: 'step_weather',
697
+ turn: 0,
698
+ };
699
+ eagerExecutions.set('call_weather', {
700
+ toolCallId: 'call_weather',
701
+ toolName: 'weather',
702
+ args: { city: 'NYC' },
703
+ request,
704
+ promise: Promise.resolve({
705
+ results: [
706
+ {
707
+ toolCallId: 'call_weather',
708
+ status: 'success',
709
+ content: 'eager result',
710
+ },
711
+ ],
712
+ }),
713
+ });
714
+
715
+ const hookRegistry = new HookRegistry();
716
+ hookRegistry.registerSession('some-other-run', 'PreToolUse', {
717
+ hooks: [async () => ({ decision: 'allow' as const })],
718
+ });
719
+ const toolNode = new ToolNode({
720
+ tools: [createDummyTool('weather')],
721
+ eventDrivenMode: true,
722
+ eagerEventToolExecution: { enabled: true },
723
+ eagerEventToolExecutions: eagerExecutions,
724
+ eagerEventToolUsageCount: new Map(),
725
+ hookRegistry,
726
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
727
+ });
728
+
729
+ const result = (await toolNode.invoke({
730
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
731
+ })) as { messages: ToolMessage[] };
732
+
733
+ expect(toolExecuteCalls).toHaveLength(0);
734
+ expect(eagerExecutions.has('call_weather')).toBe(false);
735
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
736
+ expect(toolMessage?.content).toBe('eager result');
737
+ });
738
+
739
+ it('discards a prestarted eager record when a PreToolUse hook blocks the call', async () => {
740
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
741
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
742
+ const request: t.ToolCallRequest = {
743
+ id: 'call_weather',
744
+ name: 'weather',
745
+ args: { city: 'NYC' },
746
+ stepId: 'step_weather',
747
+ turn: 0,
748
+ };
749
+ eagerExecutions.set('call_weather', {
750
+ toolCallId: 'call_weather',
751
+ toolName: 'weather',
752
+ args: { city: 'NYC' },
753
+ request,
754
+ promise: Promise.resolve({
755
+ results: [
756
+ {
757
+ toolCallId: 'call_weather',
758
+ status: 'success',
759
+ content: 'eager result',
760
+ },
761
+ ],
762
+ }),
763
+ });
764
+
765
+ const hookRegistry = new HookRegistry();
766
+ hookRegistry.register('PreToolUse', {
767
+ hooks: [
768
+ async () => ({ decision: 'deny' as const, reason: 'not allowed' }),
769
+ ],
770
+ });
771
+ const toolNode = new ToolNode({
772
+ tools: [createDummyTool('weather')],
773
+ eventDrivenMode: true,
774
+ eagerEventToolExecution: { enabled: true },
775
+ eagerEventToolExecutions: eagerExecutions,
776
+ eagerEventToolUsageCount: new Map(),
777
+ hookRegistry,
778
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
779
+ });
780
+
781
+ const result = (await toolNode.invoke({
782
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
783
+ })) as { messages: ToolMessage[] };
784
+
785
+ // The record must be gone so the prestarted promise can never emit a
786
+ // successful completion for a call the run reported as blocked.
787
+ expect(eagerExecutions.has('call_weather')).toBe(false);
788
+ expect(toolExecuteCalls).toHaveLength(0);
789
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
790
+ expect(String(toolMessage?.content)).toContain('Blocked');
791
+ });
792
+
793
+ it('skips post hooks registered while the batch is in flight (snapshot semantics)', async () => {
794
+ const hookRegistry = new HookRegistry();
795
+ hookRegistry.register('PostToolBatch', { hooks: [async () => ({})] });
796
+
797
+ jest
798
+ .spyOn(events, 'safeDispatchCustomEvent')
799
+ .mockImplementation(async (event, data): Promise<void> => {
800
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
801
+ return;
802
+ }
803
+ const batch = data as t.ToolExecuteBatchRequest;
804
+ // A result-altering hook lands while the host tool is executing: the
805
+ // whole batch keeps the gate snapshot from dispatch start, so the
806
+ // rewrite applies from the NEXT batch and cannot diverge from any
807
+ // already-emitted early completion.
808
+ hookRegistry.register('PostToolUse', {
809
+ hooks: [async () => ({ updatedOutput: 'REWRITTEN' })],
810
+ });
811
+ batch.resolve(
812
+ batch.toolCalls.map((toolCall) => ({
813
+ toolCallId: toolCall.id,
814
+ status: 'success' as const,
815
+ content: 'raw output',
816
+ }))
817
+ );
818
+ });
819
+
820
+ const toolNode = new ToolNode({
821
+ tools: [createDummyTool('weather')],
822
+ eventDrivenMode: true,
823
+ hookRegistry,
824
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
825
+ });
826
+
827
+ const result = (await toolNode.invoke({
828
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
829
+ })) as { messages: ToolMessage[] };
830
+
831
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
832
+ expect(toolMessage?.content).toBe('raw output');
833
+ });
834
+
835
+ it('removes prestarted records while PreToolUse hooks are in flight', async () => {
836
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
837
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
838
+ const request: t.ToolCallRequest = {
839
+ id: 'call_weather',
840
+ name: 'weather',
841
+ args: { city: 'NYC' },
842
+ stepId: 'step_weather',
843
+ turn: 0,
844
+ };
845
+ eagerExecutions.set('call_weather', {
846
+ toolCallId: 'call_weather',
847
+ toolName: 'weather',
848
+ args: { city: 'NYC' },
849
+ request,
850
+ promise: Promise.resolve({
851
+ results: [
852
+ {
853
+ toolCallId: 'call_weather',
854
+ status: 'success',
855
+ content: 'eager result',
856
+ },
857
+ ],
858
+ }),
859
+ });
860
+
861
+ const seenDuringHook: boolean[] = [];
862
+ const hookRegistry = new HookRegistry();
863
+ hookRegistry.register('PreToolUse', {
864
+ hooks: [
865
+ async () => {
866
+ // The record must be OUT of the map while this hook is pending, so
867
+ // a resolving eager promise cannot emit a successful completion for
868
+ // a call this hook is about to deny.
869
+ seenDuringHook.push(eagerExecutions.has('call_weather'));
870
+ return { decision: 'deny' as const, reason: 'no' };
871
+ },
872
+ ],
873
+ });
874
+ const toolNode = new ToolNode({
875
+ tools: [createDummyTool('weather')],
876
+ eventDrivenMode: true,
877
+ eagerEventToolExecution: { enabled: true },
878
+ eagerEventToolExecutions: eagerExecutions,
879
+ eagerEventToolUsageCount: new Map(),
880
+ hookRegistry,
881
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
882
+ });
883
+
884
+ const result = (await toolNode.invoke({
885
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
886
+ })) as { messages: ToolMessage[] };
887
+
888
+ expect(seenDuringHook).toEqual([false]);
889
+ expect(eagerExecutions.has('call_weather')).toBe(false);
890
+ expect(toolExecuteCalls).toHaveLength(0);
891
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
892
+ expect(String(toolMessage?.content)).toContain('Blocked');
893
+ });
894
+
895
+ it('restores preempted records for allowed calls and consumes them', async () => {
896
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
897
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
898
+ const request: t.ToolCallRequest = {
899
+ id: 'call_weather',
900
+ name: 'weather',
901
+ args: { city: 'NYC' },
902
+ stepId: 'step_weather',
903
+ turn: 0,
904
+ };
905
+ eagerExecutions.set('call_weather', {
906
+ toolCallId: 'call_weather',
907
+ toolName: 'weather',
908
+ args: { city: 'NYC' },
909
+ request,
910
+ promise: Promise.resolve({
911
+ results: [
912
+ {
913
+ toolCallId: 'call_weather',
914
+ status: 'success',
915
+ content: 'eager result',
916
+ },
917
+ ],
918
+ }),
919
+ });
920
+
921
+ const hookRegistry = new HookRegistry();
922
+ hookRegistry.register('PreToolUse', {
923
+ hooks: [async () => ({ decision: 'allow' as const })],
924
+ });
925
+ const toolNode = new ToolNode({
926
+ tools: [createDummyTool('weather')],
927
+ eventDrivenMode: true,
928
+ eagerEventToolExecution: { enabled: true },
929
+ eagerEventToolExecutions: eagerExecutions,
930
+ eagerEventToolUsageCount: new Map(),
931
+ hookRegistry,
932
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
933
+ });
934
+
935
+ const result = (await toolNode.invoke({
936
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
937
+ })) as { messages: ToolMessage[] };
938
+
939
+ expect(toolExecuteCalls).toHaveLength(0);
940
+ expect(eagerExecutions.has('call_weather')).toBe(false);
941
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
942
+ expect(toolMessage?.content).toBe('eager result');
943
+ });
944
+
945
+ it('does not duplicate completions for hooks that cannot rewrite the result', async () => {
946
+ const stepCompletions: Array<{
947
+ result?: { tool_call?: { output?: string } };
948
+ }> = [];
949
+ jest
950
+ .spyOn(events, 'safeDispatchCustomEvent')
951
+ .mockImplementation(async (event, data): Promise<boolean | void> => {
952
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
953
+ stepCompletions.push(data as (typeof stepCompletions)[number]);
954
+ return true;
955
+ }
956
+ });
957
+
958
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
959
+ const request: t.ToolCallRequest = {
960
+ id: 'call_weather',
961
+ name: 'weather',
962
+ args: { city: 'NYC' },
963
+ stepId: 'step_weather',
964
+ turn: 0,
965
+ };
966
+ eagerExecutions.set('call_weather', {
967
+ toolCallId: 'call_weather',
968
+ toolName: 'weather',
969
+ args: { city: 'NYC' },
970
+ request,
971
+ completionDispatched: true,
972
+ promise: Promise.resolve({
973
+ results: [
974
+ {
975
+ toolCallId: 'call_weather',
976
+ status: 'success',
977
+ content: 'raw eager output',
978
+ },
979
+ ],
980
+ }),
981
+ });
982
+
983
+ // A failure hook cannot replace a SUCCESS result's output — the already
984
+ // emitted eager completion is final, so no batch re-emission may fire.
985
+ const hookRegistry = new HookRegistry();
986
+ hookRegistry.register('PostToolUseFailure', {
987
+ hooks: [async () => ({})],
988
+ });
989
+ const toolNode = new ToolNode({
990
+ tools: [createDummyTool('weather')],
991
+ eventDrivenMode: true,
992
+ eagerEventToolExecution: { enabled: true },
993
+ eagerEventToolExecutions: eagerExecutions,
994
+ eagerEventToolUsageCount: new Map(),
995
+ hookRegistry,
996
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
997
+ });
998
+
999
+ const result = (await toolNode.invoke({
1000
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
1001
+ })) as { messages: ToolMessage[] };
1002
+
1003
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
1004
+ expect(toolMessage?.content).toBe('raw eager output');
1005
+ expect(stepCompletions).toHaveLength(0);
1006
+ });
1007
+
1008
+ it('keeps the original emission final for observe-only PostToolUse hooks', async () => {
1009
+ const stepCompletions: Array<{
1010
+ result?: { tool_call?: { output?: string } };
1011
+ }> = [];
1012
+ jest
1013
+ .spyOn(events, 'safeDispatchCustomEvent')
1014
+ .mockImplementation(async (event, data): Promise<boolean | void> => {
1015
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
1016
+ stepCompletions.push(data as (typeof stepCompletions)[number]);
1017
+ return true;
1018
+ }
1019
+ });
1020
+
1021
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
1022
+ const request: t.ToolCallRequest = {
1023
+ id: 'call_weather',
1024
+ name: 'weather',
1025
+ args: { city: 'NYC' },
1026
+ stepId: 'step_weather',
1027
+ turn: 0,
1028
+ };
1029
+ eagerExecutions.set('call_weather', {
1030
+ toolCallId: 'call_weather',
1031
+ toolName: 'weather',
1032
+ args: { city: 'NYC' },
1033
+ request,
1034
+ completionDispatched: true,
1035
+ promise: Promise.resolve({
1036
+ results: [
1037
+ {
1038
+ toolCallId: 'call_weather',
1039
+ status: 'success',
1040
+ content: 'raw eager output',
1041
+ },
1042
+ ],
1043
+ }),
1044
+ });
1045
+
1046
+ // The hook runs but returns no updatedOutput — the already-emitted
1047
+ // eager completion is final and must not be duplicated.
1048
+ const hookRegistry = new HookRegistry();
1049
+ hookRegistry.register('PostToolUse', {
1050
+ hooks: [async () => ({})],
1051
+ });
1052
+ const toolNode = new ToolNode({
1053
+ tools: [createDummyTool('weather')],
1054
+ eventDrivenMode: true,
1055
+ eagerEventToolExecution: { enabled: true },
1056
+ eagerEventToolExecutions: eagerExecutions,
1057
+ eagerEventToolUsageCount: new Map(),
1058
+ hookRegistry,
1059
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
1060
+ });
1061
+
1062
+ const result = (await toolNode.invoke({
1063
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
1064
+ })) as { messages: ToolMessage[] };
1065
+
1066
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
1067
+ expect(toolMessage?.content).toBe('raw eager output');
1068
+ expect(stepCompletions).toHaveLength(0);
1069
+ });
1070
+
1071
+ it('re-emits a corrected completion when hooks rewrite a consumed eager result', async () => {
1072
+ const stepCompletions: Array<{
1073
+ result?: { tool_call?: { output?: string } };
1074
+ }> = [];
1075
+ jest
1076
+ .spyOn(events, 'safeDispatchCustomEvent')
1077
+ .mockImplementation(async (event, data): Promise<boolean | void> => {
1078
+ if (event === GraphEvents.ON_RUN_STEP_COMPLETED) {
1079
+ stepCompletions.push(data as (typeof stepCompletions)[number]);
1080
+ return true;
1081
+ }
1082
+ });
1083
+
1084
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
1085
+ const request: t.ToolCallRequest = {
1086
+ id: 'call_weather',
1087
+ name: 'weather',
1088
+ args: { city: 'NYC' },
1089
+ stepId: 'step_weather',
1090
+ turn: 0,
1091
+ };
1092
+ // The stream already emitted the RAW eager completion before the hook
1093
+ // below registered; the batch must re-emit with the rewritten output so
1094
+ // host/UI state converges to the ToolMessage.
1095
+ eagerExecutions.set('call_weather', {
1096
+ toolCallId: 'call_weather',
1097
+ toolName: 'weather',
1098
+ args: { city: 'NYC' },
1099
+ request,
1100
+ completionDispatched: true,
1101
+ promise: Promise.resolve({
1102
+ results: [
1103
+ {
1104
+ toolCallId: 'call_weather',
1105
+ status: 'success',
1106
+ content: 'raw eager output',
1107
+ },
1108
+ ],
1109
+ }),
1110
+ });
1111
+
1112
+ const hookRegistry = new HookRegistry();
1113
+ hookRegistry.register('PostToolUse', {
1114
+ hooks: [async () => ({ updatedOutput: 'REWRITTEN' })],
1115
+ });
1116
+ const toolNode = new ToolNode({
1117
+ tools: [createDummyTool('weather')],
1118
+ eventDrivenMode: true,
1119
+ eagerEventToolExecution: { enabled: true },
1120
+ eagerEventToolExecutions: eagerExecutions,
1121
+ eagerEventToolUsageCount: new Map(),
1122
+ hookRegistry,
1123
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
1124
+ });
1125
+
1126
+ const result = (await toolNode.invoke({
1127
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
1128
+ })) as { messages: ToolMessage[] };
1129
+
1130
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
1131
+ expect(toolMessage?.content).toBe('REWRITTEN');
1132
+ expect(
1133
+ stepCompletions.some(
1134
+ (completion) => completion.result?.tool_call?.output === 'REWRITTEN'
1135
+ )
1136
+ ).toBe(true);
1137
+ });
1138
+
1139
+ it('consumes a prestarted eager result even after a result-altering hook registers mid-run', async () => {
1140
+ const { toolExecuteCalls } = installToolExecuteResponder('redispatched');
1141
+ const eagerExecutions = new Map<string, t.EagerEventToolExecution>();
1142
+ const request: t.ToolCallRequest = {
1143
+ id: 'call_weather',
1144
+ name: 'weather',
1145
+ args: { city: 'NYC' },
1146
+ stepId: 'step_weather',
1147
+ turn: 0,
1148
+ };
1149
+ // The record exists => the host already dispatched this execution before
1150
+ // the hook below was registered. Declining to consume would re-dispatch
1151
+ // the same call and run the tool's side effects twice.
1152
+ eagerExecutions.set('call_weather', {
1153
+ toolCallId: 'call_weather',
1154
+ toolName: 'weather',
1155
+ args: { city: 'NYC' },
1156
+ request,
1157
+ promise: Promise.resolve({
1158
+ results: [
1159
+ {
1160
+ toolCallId: 'call_weather',
1161
+ status: 'success',
1162
+ content: 'eager result',
1163
+ },
1164
+ ],
1165
+ }),
1166
+ });
1167
+
1168
+ const hookRegistry = new HookRegistry();
1169
+ hookRegistry.register('PreToolUse', {
1170
+ hooks: [async () => ({ decision: 'allow' as const })],
1171
+ });
1172
+ const toolNode = new ToolNode({
1173
+ tools: [createDummyTool('weather')],
1174
+ eventDrivenMode: true,
1175
+ eagerEventToolExecution: { enabled: true },
1176
+ eagerEventToolExecutions: eagerExecutions,
1177
+ eagerEventToolUsageCount: new Map(),
1178
+ hookRegistry,
1179
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
1180
+ });
1181
+
1182
+ const result = (await toolNode.invoke({
1183
+ messages: [createAIMessage('call_weather', 'weather', { city: 'NYC' })],
1184
+ })) as { messages: ToolMessage[] };
1185
+
1186
+ expect(toolExecuteCalls).toHaveLength(0);
1187
+ expect(eagerExecutions.has('call_weather')).toBe(false);
1188
+ const toolMessage = result.messages.find((m) => m instanceof ToolMessage);
1189
+ expect(toolMessage?.content).toBe('eager result');
1190
+ });
637
1191
  });
@@ -182,7 +182,7 @@ describe('ToolNode per-call onResult completion emission', () => {
182
182
  expect(byId.filter((id) => id === 'call_unknown')).toHaveLength(0);
183
183
  });
184
184
 
185
- it('does not offer onResult when batch-sensitive hooks are configured', async () => {
185
+ it('does not offer onResult when result-altering hooks are configured', async () => {
186
186
  let observedBatch: t.ToolExecuteBatchRequest | undefined;
187
187
 
188
188
  jest
@@ -202,10 +202,14 @@ describe('ToolNode per-call onResult completion emission', () => {
202
202
  ]);
203
203
  });
204
204
 
205
+ const hookRegistry = new HookRegistry();
206
+ hookRegistry.register('PostToolUse', {
207
+ hooks: [async () => ({})],
208
+ });
205
209
  const toolNode = new ToolNode({
206
210
  tools: [createDummyTool('weather')],
207
211
  eventDrivenMode: true,
208
- hookRegistry: new HookRegistry(),
212
+ hookRegistry,
209
213
  toolCallStepIds: new Map([['call_weather', 'step_weather']]),
210
214
  });
211
215
 
@@ -221,6 +225,49 @@ describe('ToolNode per-call onResult completion emission', () => {
221
225
  expect(observedBatch?.onResult).toBeUndefined();
222
226
  });
223
227
 
228
+ it('offers onResult when the registry only has observation hooks (PostToolBatch)', async () => {
229
+ let observedBatch: t.ToolExecuteBatchRequest | undefined;
230
+
231
+ jest
232
+ .spyOn(events, 'safeDispatchCustomEvent')
233
+ .mockImplementation(async (event, data): Promise<void> => {
234
+ if (event !== GraphEvents.ON_TOOL_EXECUTE) {
235
+ return;
236
+ }
237
+ const batch = data as t.ToolExecuteBatchRequest;
238
+ observedBatch = batch;
239
+ batch.resolve([
240
+ {
241
+ toolCallId: 'call_weather',
242
+ status: 'success',
243
+ content: 'sunny',
244
+ },
245
+ ]);
246
+ });
247
+
248
+ const hookRegistry = new HookRegistry();
249
+ hookRegistry.register('PostToolBatch', {
250
+ hooks: [async () => ({})],
251
+ });
252
+ const toolNode = new ToolNode({
253
+ tools: [createDummyTool('weather')],
254
+ eventDrivenMode: true,
255
+ hookRegistry,
256
+ toolCallStepIds: new Map([['call_weather', 'step_weather']]),
257
+ });
258
+
259
+ await toolNode.invoke({
260
+ messages: [
261
+ createAIMessageWithToolCalls([
262
+ { id: 'call_weather', name: 'weather', args: { city: 'NYC' } },
263
+ ]),
264
+ ],
265
+ });
266
+
267
+ expect(observedBatch).toBeDefined();
268
+ expect(observedBatch?.onResult).toBeDefined();
269
+ });
270
+
224
271
  it('does not offer onResult when human-in-the-loop is enabled', async () => {
225
272
  let observedBatch: t.ToolExecuteBatchRequest | undefined;
226
273