@librechat/agents 3.2.61 → 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.
- package/dist/cjs/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +32 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/executeHooks.cjs +6 -0
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +62 -1
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +266 -43
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +274 -7
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/streamMetadata.cjs +69 -0
- package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -0
- package/dist/cjs/llm/openrouter/index.cjs +5 -6
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +6 -1
- package/dist/cjs/messages/format.cjs +61 -0
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +31 -19
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/stream.cjs +13 -3
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +75 -10
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +111 -0
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +32 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/executeHooks.mjs +6 -0
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +62 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +266 -43
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +274 -7
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/streamMetadata.mjs +69 -0
- package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -0
- package/dist/esm/llm/openrouter/index.mjs +5 -6
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/messages/format.mjs +61 -0
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +31 -19
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/stream.mjs +13 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +75 -10
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +108 -1
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +3 -1
- package/dist/types/hooks/HookRegistry.d.ts +10 -0
- package/dist/types/hooks/index.d.ts +7 -0
- package/dist/types/hooks/types.d.ts +18 -1
- package/dist/types/llm/bedrock/index.d.ts +5 -0
- package/dist/types/llm/openai/index.d.ts +19 -0
- package/dist/types/llm/openai/streamMetadata.d.ts +16 -0
- package/dist/types/messages/prune.d.ts +4 -3
- package/dist/types/tools/ToolNode.d.ts +1 -0
- package/dist/types/types/llm.d.ts +12 -2
- package/dist/types/types/tools.d.ts +1 -1
- package/dist/types/utils/tokens.d.ts +30 -0
- package/package.json +6 -6
- package/src/__tests__/stream.eagerEventExecution.test.ts +100 -2
- package/src/common/enum.ts +2 -0
- package/src/hooks/HookRegistry.ts +45 -0
- package/src/hooks/__tests__/HookRegistry.test.ts +48 -0
- package/src/hooks/__tests__/executeHooks.test.ts +85 -2
- package/src/hooks/executeHooks.ts +15 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/types.ts +18 -1
- package/src/langfuse.ts +134 -1
- package/src/llm/bedrock/index.ts +434 -83
- package/src/llm/bedrock/streamSealDispatch.test.ts +97 -0
- package/src/llm/custom-chat-models.smoke.test.ts +7 -0
- package/src/llm/openai/index.ts +604 -6
- package/src/llm/openai/managedRequests.test.ts +182 -0
- package/src/llm/openai/streamMetadata.spec.ts +86 -0
- package/src/llm/openai/streamMetadata.ts +95 -0
- package/src/llm/openai/streamMetadataDedup.spec.ts +166 -0
- package/src/llm/openrouter/index.ts +9 -5
- package/src/messages/format.ts +96 -3
- package/src/messages/formatAgentMessages.steer.test.ts +326 -0
- package/src/messages/labelContentByAgent.test.ts +75 -0
- package/src/messages/prune.ts +56 -30
- package/src/specs/anthropic.simple.test.ts +4 -2
- package/src/specs/cache.simple.test.ts +17 -4
- package/src/specs/langfuse-callbacks.test.ts +61 -0
- package/src/specs/openai.simple.test.ts +4 -2
- package/src/specs/spec.utils.ts +12 -0
- package/src/specs/summarization.test.ts +9 -13
- package/src/specs/token-accounting-pipeline.test.ts +130 -4
- package/src/specs/tokens.test.ts +214 -0
- package/src/stream.ts +22 -3
- package/src/tools/ToolNode.ts +107 -12
- package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +554 -0
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +49 -2
- package/src/tools/__tests__/hitl.test.ts +112 -0
- package/src/tools/subagent/SubagentExecutor.ts +2 -0
- package/src/types/llm.ts +12 -2
- package/src/types/tools.ts +1 -1
- 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
|
|
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
|
|
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
|
|