@librechat/agents 3.1.67-dev.0 → 3.1.67-dev.4
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/graphs/Graph.cjs +19 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/main.cjs +1 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +267 -17
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +19 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/main.mjs +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +267 -18
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +2 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +56 -2
- package/dist/types/tools/subagent/index.d.ts +1 -1
- package/dist/types/types/graph.d.ts +36 -2
- package/package.json +4 -1
- package/src/common/enum.ts +2 -0
- package/src/graphs/Graph.ts +21 -0
- package/src/scripts/subagent-event-driven-debug.ts +190 -0
- package/src/scripts/subagent-tools-debug.ts +160 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +534 -1
- package/src/tools/subagent/SubagentExecutor.ts +349 -17
- package/src/tools/subagent/index.ts +1 -0
- package/src/types/graph.ts +53 -1
|
@@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
|
2
2
|
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
3
3
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
4
4
|
import { HookRegistry } from '@/hooks/HookRegistry';
|
|
5
|
-
import { Providers } from '@/common';
|
|
5
|
+
import { Providers, GraphEvents } from '@/common';
|
|
6
|
+
import { HandlerRegistry } from '@/events';
|
|
6
7
|
import { AgentContext } from '@/agents/AgentContext';
|
|
7
8
|
import type { AgentInputs, ResolvedSubagentConfig } from '@/types';
|
|
8
9
|
import {
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
filterSubagentResult,
|
|
11
12
|
resolveSubagentConfigs,
|
|
12
13
|
buildChildInputs,
|
|
14
|
+
summarizeEvent,
|
|
13
15
|
} from '../subagent';
|
|
14
16
|
import type { StandardGraph } from '@/graphs/Graph';
|
|
15
17
|
|
|
@@ -281,6 +283,29 @@ describe('buildChildInputs', () => {
|
|
|
281
283
|
expect(result.toolDefinitions).toBeUndefined();
|
|
282
284
|
});
|
|
283
285
|
|
|
286
|
+
it('strips parent-run-scoped initialSummary and discoveredTools from child inputs', () => {
|
|
287
|
+
/**
|
|
288
|
+
* Codex P1: a child inheriting `initialSummary` or `discoveredTools` from
|
|
289
|
+
* the parent's shallow-spread AgentInputs leaks unrelated conversation
|
|
290
|
+
* context / prior tool-search state into an isolated subagent run,
|
|
291
|
+
* defeating the context-isolation contract. Both fields must be cleared.
|
|
292
|
+
*/
|
|
293
|
+
const inputsWithRunContext: AgentInputs = {
|
|
294
|
+
...parentAgentInputs,
|
|
295
|
+
initialSummary: { text: 'prior conversation summary', tokenCount: 42 },
|
|
296
|
+
discoveredTools: ['prior_tool_a', 'prior_tool_b'],
|
|
297
|
+
};
|
|
298
|
+
const config: ResolvedSubagentConfig = {
|
|
299
|
+
type: 'researcher',
|
|
300
|
+
name: 'R',
|
|
301
|
+
description: 'd',
|
|
302
|
+
agentInputs: inputsWithRunContext,
|
|
303
|
+
};
|
|
304
|
+
const result = buildChildInputs(config, 'child', 3);
|
|
305
|
+
expect(result.initialSummary).toBeUndefined();
|
|
306
|
+
expect(result.discoveredTools).toBeUndefined();
|
|
307
|
+
});
|
|
308
|
+
|
|
284
309
|
it('overrides agentId with the passed childAgentId', () => {
|
|
285
310
|
const config: ResolvedSubagentConfig = {
|
|
286
311
|
type: 'researcher',
|
|
@@ -612,4 +637,512 @@ describe('SubagentExecutor', () => {
|
|
|
612
637
|
expect(result.messages).toEqual([]);
|
|
613
638
|
});
|
|
614
639
|
});
|
|
640
|
+
|
|
641
|
+
describe('event forwarding', () => {
|
|
642
|
+
it('emits start/stop ON_SUBAGENT_UPDATE envelopes when parentHandlerRegistry is provided', async () => {
|
|
643
|
+
const events: unknown[] = [];
|
|
644
|
+
const registry = new HandlerRegistry();
|
|
645
|
+
registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
646
|
+
handle: (_event, data): void => {
|
|
647
|
+
events.push(data);
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
const { factory } = makeStubGraphFactory({
|
|
652
|
+
messages: [new AIMessage('done')],
|
|
653
|
+
});
|
|
654
|
+
const executor = createExecutor({
|
|
655
|
+
createChildGraph: factory,
|
|
656
|
+
parentHandlerRegistry: registry,
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
await executor.execute({
|
|
660
|
+
description: 'Test task',
|
|
661
|
+
subagentType: 'researcher',
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const phases = events.map((e) => (e as { phase: string }).phase);
|
|
665
|
+
expect(phases[0]).toBe('start');
|
|
666
|
+
expect(phases[phases.length - 1]).toBe('stop');
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('keeps toolDefinitions on child when registry has ON_TOOL_EXECUTE handler', async () => {
|
|
670
|
+
const registry = new HandlerRegistry();
|
|
671
|
+
registry.register(GraphEvents.ON_TOOL_EXECUTE, {
|
|
672
|
+
handle: (): void => {},
|
|
673
|
+
});
|
|
674
|
+
let observedChildInputs: AgentInputs | undefined;
|
|
675
|
+
const configWithDefs: ResolvedSubagentConfig = {
|
|
676
|
+
type: 'researcher',
|
|
677
|
+
name: 'Research Specialist',
|
|
678
|
+
description: 'Researches topics',
|
|
679
|
+
agentInputs: {
|
|
680
|
+
agentId: 'researcher',
|
|
681
|
+
provider: Providers.OPENAI,
|
|
682
|
+
toolDefinitions: [
|
|
683
|
+
{ name: 'web', description: 'search', parameters: {} },
|
|
684
|
+
],
|
|
685
|
+
} as AgentInputs,
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const executor = new SubagentExecutor({
|
|
689
|
+
configs: new Map([[configWithDefs.type, configWithDefs]]),
|
|
690
|
+
parentRunId: 'run',
|
|
691
|
+
parentAgentId: 'parent',
|
|
692
|
+
parentHandlerRegistry: registry,
|
|
693
|
+
createChildGraph: (input): StandardGraph => {
|
|
694
|
+
observedChildInputs = input.agents[0];
|
|
695
|
+
return {
|
|
696
|
+
createWorkflow: (): { invoke: jest.Mock } => ({
|
|
697
|
+
invoke: jest.fn().mockResolvedValue({
|
|
698
|
+
messages: [new AIMessage('ok')],
|
|
699
|
+
}),
|
|
700
|
+
}),
|
|
701
|
+
clearHeavyState: jest.fn(),
|
|
702
|
+
} as unknown as StandardGraph;
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
await executor.execute({
|
|
707
|
+
description: 'find weather',
|
|
708
|
+
subagentType: 'researcher',
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
expect(observedChildInputs?.toolDefinitions).toHaveLength(1);
|
|
712
|
+
expect(observedChildInputs?.toolDefinitions?.[0]?.name).toBe('web');
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('strips toolDefinitions when registry is present but ON_TOOL_EXECUTE handler is absent', async () => {
|
|
716
|
+
const registry = new HandlerRegistry();
|
|
717
|
+
let observedChildInputs: AgentInputs | undefined;
|
|
718
|
+
const configWithDefs: ResolvedSubagentConfig = {
|
|
719
|
+
type: 'researcher',
|
|
720
|
+
name: 'Research Specialist',
|
|
721
|
+
description: 'Researches topics',
|
|
722
|
+
agentInputs: {
|
|
723
|
+
agentId: 'researcher',
|
|
724
|
+
provider: Providers.OPENAI,
|
|
725
|
+
toolDefinitions: [
|
|
726
|
+
{ name: 'web', description: 'search', parameters: {} },
|
|
727
|
+
],
|
|
728
|
+
} as AgentInputs,
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const executor = new SubagentExecutor({
|
|
732
|
+
configs: new Map([[configWithDefs.type, configWithDefs]]),
|
|
733
|
+
parentRunId: 'run',
|
|
734
|
+
parentAgentId: 'parent',
|
|
735
|
+
parentHandlerRegistry: registry,
|
|
736
|
+
createChildGraph: (input): StandardGraph => {
|
|
737
|
+
observedChildInputs = input.agents[0];
|
|
738
|
+
return {
|
|
739
|
+
createWorkflow: (): { invoke: jest.Mock } => ({
|
|
740
|
+
invoke: jest.fn().mockResolvedValue({
|
|
741
|
+
messages: [new AIMessage('ok')],
|
|
742
|
+
}),
|
|
743
|
+
}),
|
|
744
|
+
clearHeavyState: jest.fn(),
|
|
745
|
+
} as unknown as StandardGraph;
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
await executor.execute({
|
|
750
|
+
description: 'find weather',
|
|
751
|
+
subagentType: 'researcher',
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
expect(observedChildInputs?.toolDefinitions).toBeUndefined();
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('forwards parentToolCallId from execute params to SubagentUpdateEvent envelopes', async () => {
|
|
758
|
+
const events: unknown[] = [];
|
|
759
|
+
const registry = new HandlerRegistry();
|
|
760
|
+
registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
761
|
+
handle: (_event, data): void => {
|
|
762
|
+
events.push(data);
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const { factory } = makeStubGraphFactory({
|
|
767
|
+
messages: [new AIMessage('done')],
|
|
768
|
+
});
|
|
769
|
+
const executor = createExecutor({
|
|
770
|
+
createChildGraph: factory,
|
|
771
|
+
parentHandlerRegistry: registry,
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
await executor.execute({
|
|
775
|
+
description: 'Task',
|
|
776
|
+
subagentType: 'researcher',
|
|
777
|
+
parentToolCallId: 'call_abc123',
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
expect(events.length).toBeGreaterThan(0);
|
|
781
|
+
for (const e of events) {
|
|
782
|
+
expect((e as { parentToolCallId?: string }).parentToolCallId).toBe(
|
|
783
|
+
'call_abc123'
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('still strips toolDefinitions when no parentHandlerRegistry is provided (legacy isolation)', async () => {
|
|
789
|
+
let observedChildInputs: AgentInputs | undefined;
|
|
790
|
+
const configWithDefs: ResolvedSubagentConfig = {
|
|
791
|
+
type: 'researcher',
|
|
792
|
+
name: 'Research Specialist',
|
|
793
|
+
description: 'Researches topics',
|
|
794
|
+
agentInputs: {
|
|
795
|
+
agentId: 'researcher',
|
|
796
|
+
provider: Providers.OPENAI,
|
|
797
|
+
toolDefinitions: [
|
|
798
|
+
{ name: 'web', description: 'search', parameters: {} },
|
|
799
|
+
],
|
|
800
|
+
} as AgentInputs,
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const executor = new SubagentExecutor({
|
|
804
|
+
configs: new Map([[configWithDefs.type, configWithDefs]]),
|
|
805
|
+
parentRunId: 'run',
|
|
806
|
+
parentAgentId: 'parent',
|
|
807
|
+
createChildGraph: (input): StandardGraph => {
|
|
808
|
+
observedChildInputs = input.agents[0];
|
|
809
|
+
return {
|
|
810
|
+
createWorkflow: (): { invoke: jest.Mock } => ({
|
|
811
|
+
invoke: jest.fn().mockResolvedValue({
|
|
812
|
+
messages: [new AIMessage('ok')],
|
|
813
|
+
}),
|
|
814
|
+
}),
|
|
815
|
+
clearHeavyState: jest.fn(),
|
|
816
|
+
} as unknown as StandardGraph;
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
await executor.execute({
|
|
821
|
+
description: 'find weather',
|
|
822
|
+
subagentType: 'researcher',
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
expect(observedChildInputs?.toolDefinitions).toBeUndefined();
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('accepts parentHandlerRegistry as a lazy getter', async () => {
|
|
829
|
+
const lazyHolder: { registry?: InstanceType<typeof HandlerRegistry> } =
|
|
830
|
+
{};
|
|
831
|
+
const events: unknown[] = [];
|
|
832
|
+
const { factory } = makeStubGraphFactory({
|
|
833
|
+
messages: [new AIMessage('done')],
|
|
834
|
+
});
|
|
835
|
+
const executor = createExecutor({
|
|
836
|
+
createChildGraph: factory,
|
|
837
|
+
parentHandlerRegistry: () => lazyHolder.registry,
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
lazyHolder.registry = new HandlerRegistry();
|
|
841
|
+
lazyHolder.registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
842
|
+
handle: (_event, data): void => {
|
|
843
|
+
events.push(data);
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
await executor.execute({
|
|
848
|
+
description: 'Task',
|
|
849
|
+
subagentType: 'researcher',
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
expect(events.length).toBeGreaterThan(0);
|
|
853
|
+
expect((events[0] as { phase: string }).phase).toBe('start');
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('routes child ON_TOOL_EXECUTE dispatches through the parent registry', async () => {
|
|
857
|
+
/**
|
|
858
|
+
* Drives the forwarder callback the executor installs on the child's
|
|
859
|
+
* `workflow.invoke({ callbacks: [forwarder] })`. We capture that
|
|
860
|
+
* callback when the child workflow runs, then synthesize the same
|
|
861
|
+
* `handleCustomEvent` call that a real `ToolNode` would make when
|
|
862
|
+
* the child LLM emits a tool_call. If the forwarder routes correctly,
|
|
863
|
+
* the parent's `ON_TOOL_EXECUTE` handler receives the batch and
|
|
864
|
+
* resolves the promise with our canned results.
|
|
865
|
+
*/
|
|
866
|
+
|
|
867
|
+
const parentToolHandler = jest.fn(
|
|
868
|
+
async (_event: string, rawData: unknown): Promise<void> => {
|
|
869
|
+
const req = rawData as {
|
|
870
|
+
toolCalls: Array<{ id: string; name: string }>;
|
|
871
|
+
resolve: (results: unknown[]) => void;
|
|
872
|
+
};
|
|
873
|
+
req.resolve(
|
|
874
|
+
req.toolCalls.map((tc) => ({
|
|
875
|
+
toolCallId: tc.id,
|
|
876
|
+
status: 'success',
|
|
877
|
+
content: `ran ${tc.name}`,
|
|
878
|
+
}))
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
const registry = new HandlerRegistry();
|
|
884
|
+
registry.register(GraphEvents.ON_TOOL_EXECUTE, {
|
|
885
|
+
handle: parentToolHandler,
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
let capturedInvokeOptions: unknown;
|
|
889
|
+
const factory: () => StandardGraph = (): StandardGraph =>
|
|
890
|
+
({
|
|
891
|
+
createWorkflow: (): { invoke: jest.Mock } => ({
|
|
892
|
+
invoke: jest.fn().mockImplementation(async (_state, options) => {
|
|
893
|
+
capturedInvokeOptions = options;
|
|
894
|
+
return { messages: [new AIMessage('ok')] };
|
|
895
|
+
}),
|
|
896
|
+
}),
|
|
897
|
+
clearHeavyState: jest.fn(),
|
|
898
|
+
}) as unknown as StandardGraph;
|
|
899
|
+
|
|
900
|
+
const executor = createExecutor({
|
|
901
|
+
createChildGraph: factory,
|
|
902
|
+
parentHandlerRegistry: registry,
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
await executor.execute({
|
|
906
|
+
description: 'Task',
|
|
907
|
+
subagentType: 'researcher',
|
|
908
|
+
parentToolCallId: 'call_parent_123',
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const opts = capturedInvokeOptions as
|
|
912
|
+
| { callbacks?: unknown[] }
|
|
913
|
+
| undefined;
|
|
914
|
+
expect(opts?.callbacks).toBeDefined();
|
|
915
|
+
const forwarder = (opts?.callbacks ?? [])[0] as {
|
|
916
|
+
handleCustomEvent?: (
|
|
917
|
+
eventName: string,
|
|
918
|
+
data: unknown,
|
|
919
|
+
runId: string,
|
|
920
|
+
tags?: string[],
|
|
921
|
+
metadata?: Record<string, unknown>
|
|
922
|
+
) => Promise<void> | void;
|
|
923
|
+
};
|
|
924
|
+
expect(typeof forwarder.handleCustomEvent).toBe('function');
|
|
925
|
+
|
|
926
|
+
/** Simulate the child's ToolNode emitting a real batch request. */
|
|
927
|
+
const resolvePromise = new Promise<
|
|
928
|
+
Array<{ toolCallId: string; status: string; content: string }>
|
|
929
|
+
>((resolve, reject) => {
|
|
930
|
+
const batchRequest = {
|
|
931
|
+
toolCalls: [{ id: 'call_child_xyz', name: 'calculator', args: {} }],
|
|
932
|
+
agentId: 'researcher',
|
|
933
|
+
resolve,
|
|
934
|
+
reject,
|
|
935
|
+
};
|
|
936
|
+
forwarder.handleCustomEvent?.(
|
|
937
|
+
GraphEvents.ON_TOOL_EXECUTE,
|
|
938
|
+
batchRequest,
|
|
939
|
+
'child-run-id'
|
|
940
|
+
);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
const results = await resolvePromise;
|
|
944
|
+
expect(parentToolHandler).toHaveBeenCalledTimes(1);
|
|
945
|
+
expect(results).toEqual([
|
|
946
|
+
{
|
|
947
|
+
toolCallId: 'call_child_xyz',
|
|
948
|
+
status: 'success',
|
|
949
|
+
content: 'ran calculator',
|
|
950
|
+
},
|
|
951
|
+
]);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it('does NOT forward ON_TOOL_EXECUTE when the parent registry has no handler (safe fallback)', async () => {
|
|
955
|
+
/**
|
|
956
|
+
* The executor strips `toolDefinitions` when the parent registry has
|
|
957
|
+
* no `ON_TOOL_EXECUTE` handler (see the companion strip-on-no-handler
|
|
958
|
+
* test). Defence-in-depth: if the LLM somehow still dispatches a tool
|
|
959
|
+
* call, the forwarder must not silently consume it without resolving;
|
|
960
|
+
* reject would be better than hang. This test confirms no handler
|
|
961
|
+
* is invoked on the parent side so it's clear a forwarded request
|
|
962
|
+
* would need separate treatment.
|
|
963
|
+
*/
|
|
964
|
+
|
|
965
|
+
const registry = new HandlerRegistry();
|
|
966
|
+
/** Only ON_SUBAGENT_UPDATE registered — no ON_TOOL_EXECUTE. */
|
|
967
|
+
registry.register(GraphEvents.ON_SUBAGENT_UPDATE, { handle: jest.fn() });
|
|
968
|
+
|
|
969
|
+
let capturedInvokeOptions: unknown;
|
|
970
|
+
const factory: () => StandardGraph = (): StandardGraph =>
|
|
971
|
+
({
|
|
972
|
+
createWorkflow: (): { invoke: jest.Mock } => ({
|
|
973
|
+
invoke: jest.fn().mockImplementation(async (_state, options) => {
|
|
974
|
+
capturedInvokeOptions = options;
|
|
975
|
+
return { messages: [new AIMessage('ok')] };
|
|
976
|
+
}),
|
|
977
|
+
}),
|
|
978
|
+
clearHeavyState: jest.fn(),
|
|
979
|
+
}) as unknown as StandardGraph;
|
|
980
|
+
|
|
981
|
+
const executor = createExecutor({
|
|
982
|
+
createChildGraph: factory,
|
|
983
|
+
parentHandlerRegistry: registry,
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
await executor.execute({
|
|
987
|
+
description: 'Task',
|
|
988
|
+
subagentType: 'researcher',
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
const opts = capturedInvokeOptions as { callbacks?: unknown[] };
|
|
992
|
+
const forwarder = (opts.callbacks ?? [])[0] as {
|
|
993
|
+
handleCustomEvent?: (
|
|
994
|
+
eventName: string,
|
|
995
|
+
data: unknown
|
|
996
|
+
) => Promise<void> | void;
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
let resolved = false;
|
|
1000
|
+
const batchRequest = {
|
|
1001
|
+
toolCalls: [{ id: 'call_x', name: 'calculator', args: {} }],
|
|
1002
|
+
agentId: 'researcher',
|
|
1003
|
+
resolve: (): void => {
|
|
1004
|
+
resolved = true;
|
|
1005
|
+
},
|
|
1006
|
+
reject: (): void => {},
|
|
1007
|
+
};
|
|
1008
|
+
await forwarder.handleCustomEvent?.(
|
|
1009
|
+
GraphEvents.ON_TOOL_EXECUTE,
|
|
1010
|
+
batchRequest
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
/** No handler exists → nothing resolves the promise. This is the
|
|
1014
|
+
* state that justifies the `keepToolDefinitions` gate: without the
|
|
1015
|
+
* gate we'd deadlock here. The gate ensures the LLM never sees
|
|
1016
|
+
* tools in the first place, making this scenario unreachable in
|
|
1017
|
+
* practice — the test just documents the fallback. */
|
|
1018
|
+
expect(resolved).toBe(false);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it('emits an `error` phase envelope when the child graph throws', async () => {
|
|
1022
|
+
const events: unknown[] = [];
|
|
1023
|
+
const registry = new HandlerRegistry();
|
|
1024
|
+
registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
1025
|
+
handle: (_event, data): void => {
|
|
1026
|
+
events.push(data);
|
|
1027
|
+
},
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const executor = createExecutor({
|
|
1031
|
+
createChildGraph: makeThrowingGraphFactory(
|
|
1032
|
+
new Error('recursion limit')
|
|
1033
|
+
),
|
|
1034
|
+
parentHandlerRegistry: registry,
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
const result = await executor.execute({
|
|
1038
|
+
description: 'Task',
|
|
1039
|
+
subagentType: 'researcher',
|
|
1040
|
+
parentToolCallId: 'call_err',
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
expect(result.content).toContain('Subagent error: recursion limit');
|
|
1044
|
+
const phases = events.map((e) => (e as { phase: string }).phase);
|
|
1045
|
+
expect(phases).toContain('start');
|
|
1046
|
+
expect(phases).toContain('error');
|
|
1047
|
+
const errEvent = events.find(
|
|
1048
|
+
(e) => (e as { phase: string }).phase === 'error'
|
|
1049
|
+
) as { data?: { message?: string }; parentToolCallId?: string };
|
|
1050
|
+
expect(errEvent.data?.message).toContain('recursion limit');
|
|
1051
|
+
expect(errEvent.parentToolCallId).toBe('call_err');
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
describe('summarizeEvent', () => {
|
|
1057
|
+
it('labels a run step tool_calls stepDetails by tool name', () => {
|
|
1058
|
+
const label = summarizeEvent(GraphEvents.ON_RUN_STEP, {
|
|
1059
|
+
stepDetails: {
|
|
1060
|
+
type: 'tool_calls',
|
|
1061
|
+
tool_calls: [{ name: 'calculator', id: 'c1' }],
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
expect(label).toBe('Using tool: calculator');
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('joins multiple tool names on a single run step', () => {
|
|
1068
|
+
const label = summarizeEvent(GraphEvents.ON_RUN_STEP, {
|
|
1069
|
+
stepDetails: {
|
|
1070
|
+
type: 'tool_calls',
|
|
1071
|
+
tool_calls: [{ name: 'web' }, { name: 'calculator' }],
|
|
1072
|
+
},
|
|
1073
|
+
});
|
|
1074
|
+
expect(label).toBe('Using tool: web, calculator');
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it('falls back to "Planning tool call" when tool_calls is empty', () => {
|
|
1078
|
+
const label = summarizeEvent(GraphEvents.ON_RUN_STEP, {
|
|
1079
|
+
stepDetails: { type: 'tool_calls', tool_calls: [] },
|
|
1080
|
+
});
|
|
1081
|
+
expect(label).toBe('Planning tool call');
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('labels message_creation steps as "Thinking…"', () => {
|
|
1085
|
+
const label = summarizeEvent(GraphEvents.ON_RUN_STEP, {
|
|
1086
|
+
stepDetails: { type: 'message_creation' },
|
|
1087
|
+
});
|
|
1088
|
+
expect(label).toBe('Thinking…');
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('labels ON_TOOL_EXECUTE with the batch of tool names', () => {
|
|
1092
|
+
const label = summarizeEvent(GraphEvents.ON_TOOL_EXECUTE, {
|
|
1093
|
+
toolCalls: [{ name: 'web' }, { name: 'calculator' }],
|
|
1094
|
+
});
|
|
1095
|
+
expect(label).toBe('Calling web, calculator');
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it('falls back to a generic "Calling tool" when toolCalls is empty', () => {
|
|
1099
|
+
const label = summarizeEvent(GraphEvents.ON_TOOL_EXECUTE, {
|
|
1100
|
+
toolCalls: [],
|
|
1101
|
+
});
|
|
1102
|
+
expect(label).toBe('Calling tool');
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it('labels completed run steps by completed tool name', () => {
|
|
1106
|
+
const label = summarizeEvent(GraphEvents.ON_RUN_STEP_COMPLETED, {
|
|
1107
|
+
result: { type: 'tool_call', tool_call: { name: 'calculator' } },
|
|
1108
|
+
});
|
|
1109
|
+
expect(label).toBe('Tool calculator complete');
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('labels completed steps without a tool name as "Step complete"', () => {
|
|
1113
|
+
const label = summarizeEvent(GraphEvents.ON_RUN_STEP_COMPLETED, {
|
|
1114
|
+
result: { type: 'message_creation' },
|
|
1115
|
+
});
|
|
1116
|
+
expect(label).toBe('Step complete');
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it('labels ON_MESSAGE_DELTA as "Streaming…"', () => {
|
|
1120
|
+
expect(summarizeEvent(GraphEvents.ON_MESSAGE_DELTA, {})).toBe('Streaming…');
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it('falls back to top-level `step.type` when `stepDetails` is absent', () => {
|
|
1124
|
+
/**
|
|
1125
|
+
* Covers the `step.stepDetails?.type ?? step.type ?? 'step'` chain
|
|
1126
|
+
* when the payload uses the top-level form (no `stepDetails` wrapper).
|
|
1127
|
+
* Exercises the second clause of the fallback so future changes to
|
|
1128
|
+
* the resolution order fail fast.
|
|
1129
|
+
*/
|
|
1130
|
+
expect(
|
|
1131
|
+
summarizeEvent(GraphEvents.ON_RUN_STEP, { type: 'tool_calls' })
|
|
1132
|
+
).toBe('Planning tool call');
|
|
1133
|
+
expect(
|
|
1134
|
+
summarizeEvent(GraphEvents.ON_RUN_STEP, { type: 'message_creation' })
|
|
1135
|
+
).toBe('Thinking…');
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it('falls back to "Step: step" when neither `stepDetails.type` nor `step.type` is present', () => {
|
|
1139
|
+
/** Exercises the final `?? 'step'` default plus the generic
|
|
1140
|
+
* `Step: <detailType>` branch when a run step arrives with an
|
|
1141
|
+
* unrecognized shape. */
|
|
1142
|
+
expect(summarizeEvent(GraphEvents.ON_RUN_STEP, {})).toBe('Step: step');
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
it('returns the event name for unknown events', () => {
|
|
1146
|
+
expect(summarizeEvent('on_unknown_event', {})).toBe('on_unknown_event');
|
|
1147
|
+
});
|
|
615
1148
|
});
|