@librechat/agents 3.1.77-dev.1 → 3.1.78-dev.0
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 +54 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +148 -4
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
- package/dist/cjs/llm/openai/index.cjs +317 -1
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +90 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
- package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +27 -0
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/messages/recency.cjs +99 -0
- package/dist/cjs/messages/recency.cjs.map +1 -0
- package/dist/cjs/run.cjs +30 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +100 -6
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +635 -23
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
- package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
- package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
- package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
- package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
- package/dist/cjs/tools/local/attachments.cjs +183 -0
- package/dist/cjs/tools/local/attachments.cjs.map +1 -0
- package/dist/cjs/tools/local/bashAst.cjs +129 -0
- package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
- package/dist/cjs/tools/local/editStrategies.cjs +188 -0
- package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
- package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
- package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
- package/dist/cjs/tools/local/textEncoding.cjs +30 -0
- package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
- package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
- package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +53 -1
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +149 -5
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
- package/dist/esm/llm/openai/index.mjs +318 -2
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/main.mjs +17 -2
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/anthropicToolCache.mjs +99 -0
- package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +26 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/messages/recency.mjs +97 -0
- package/dist/esm/messages/recency.mjs.map +1 -0
- package/dist/esm/run.mjs +30 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +100 -6
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +635 -23
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
- package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
- package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
- package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
- package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
- package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
- package/dist/esm/tools/local/attachments.mjs +180 -0
- package/dist/esm/tools/local/attachments.mjs.map +1 -0
- package/dist/esm/tools/local/bashAst.mjs +126 -0
- package/dist/esm/tools/local/bashAst.mjs.map +1 -0
- package/dist/esm/tools/local/editStrategies.mjs +185 -0
- package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
- package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
- package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
- package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
- package/dist/esm/tools/local/textEncoding.mjs +27 -0
- package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
- package/dist/esm/tools/local/workspaceFS.mjs +49 -0
- package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +39 -1
- package/dist/types/graphs/Graph.d.ts +34 -0
- package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
- package/dist/types/hooks/index.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/llm/openai/index.d.ts +17 -0
- package/dist/types/messages/anthropicToolCache.d.ts +51 -0
- package/dist/types/messages/index.d.ts +2 -0
- package/dist/types/messages/prune.d.ts +11 -0
- package/dist/types/messages/recency.d.ts +64 -0
- package/dist/types/run.d.ts +21 -0
- package/dist/types/tools/ToolNode.d.ts +145 -2
- package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
- package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
- package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
- package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
- package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
- package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
- package/dist/types/tools/local/attachments.d.ts +84 -0
- package/dist/types/tools/local/bashAst.d.ts +11 -0
- package/dist/types/tools/local/editStrategies.d.ts +28 -0
- package/dist/types/tools/local/index.d.ts +12 -0
- package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
- package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
- package/dist/types/tools/local/textEncoding.d.ts +21 -0
- package/dist/types/tools/local/workspaceFS.d.ts +49 -0
- package/dist/types/types/hitl.d.ts +56 -27
- package/dist/types/types/run.d.ts +8 -1
- package/dist/types/types/summarize.d.ts +30 -0
- package/dist/types/types/tools.d.ts +341 -6
- package/package.json +21 -2
- package/src/common/enum.ts +54 -0
- package/src/graphs/Graph.ts +164 -6
- package/src/hooks/__tests__/compactHooks.test.ts +38 -2
- package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
- package/src/hooks/createWorkspacePolicyHook.ts +355 -0
- package/src/hooks/index.ts +6 -0
- package/src/index.ts +1 -0
- package/src/llm/openai/deepseek.test.ts +479 -0
- package/src/llm/openai/index.ts +484 -1
- package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
- package/src/messages/__tests__/recency.test.ts +267 -0
- package/src/messages/anthropicToolCache.ts +116 -0
- package/src/messages/index.ts +2 -0
- package/src/messages/prune.ts +27 -1
- package/src/messages/recency.ts +155 -0
- package/src/run.ts +31 -0
- package/src/scripts/compare_pi_vs_ours.ts +840 -0
- package/src/scripts/local_engine.ts +166 -0
- package/src/scripts/local_engine_checkpointer.ts +205 -0
- package/src/scripts/local_engine_compile.ts +263 -0
- package/src/scripts/local_engine_hooks.ts +226 -0
- package/src/scripts/local_engine_image.ts +201 -0
- package/src/scripts/local_engine_ptc.ts +151 -0
- package/src/scripts/local_engine_workspace.ts +258 -0
- package/src/scripts/summarization-recency.ts +462 -0
- package/src/specs/prune.test.ts +39 -0
- package/src/summarization/__tests__/node.test.ts +499 -3
- package/src/summarization/node.ts +124 -7
- package/src/tools/ToolNode.ts +769 -20
- package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
- package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
- package/src/tools/__tests__/directToolHooks.test.ts +411 -0
- package/src/tools/__tests__/localToolNames.test.ts +73 -0
- package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
- package/src/tools/local/CompileCheckTool.ts +278 -0
- package/src/tools/local/FileCheckpointer.ts +93 -0
- package/src/tools/local/LocalCodingTools.ts +1342 -0
- package/src/tools/local/LocalExecutionEngine.ts +1329 -0
- package/src/tools/local/LocalExecutionTools.ts +167 -0
- package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
- package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
- package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
- package/src/tools/local/attachments.ts +251 -0
- package/src/tools/local/bashAst.ts +151 -0
- package/src/tools/local/editStrategies.ts +188 -0
- package/src/tools/local/index.ts +12 -0
- package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
- package/src/tools/local/syntaxCheck.ts +243 -0
- package/src/tools/local/textEncoding.ts +37 -0
- package/src/tools/local/workspaceFS.ts +89 -0
- package/src/types/hitl.ts +56 -27
- package/src/types/run.ts +12 -1
- package/src/types/summarize.ts +31 -0
- package/src/types/tools.ts +359 -7
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HumanMessage } from '@langchain/core/messages';
|
|
1
|
+
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
2
|
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
3
3
|
import type * as t from '@/types';
|
|
4
4
|
import { GraphEvents, Providers } from '@/common';
|
|
@@ -16,7 +16,13 @@ import { AgentContext } from '@/agents/AgentContext';
|
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
18
|
/** Creates a real AgentContext via fromConfig with sensible defaults.
|
|
19
|
-
* Extra properties are assigned directly for test-specific overrides.
|
|
19
|
+
* Extra properties are assigned directly for test-specific overrides.
|
|
20
|
+
*
|
|
21
|
+
* Defaults `retainRecent.turns` to `0` so that tests which use 1–2 message
|
|
22
|
+
* states still exercise the LLM-call summarization path. The recency-window
|
|
23
|
+
* default of `2` turns would otherwise short-circuit summarization for those
|
|
24
|
+
* inputs. Tests that target recency-window behavior should pass an explicit
|
|
25
|
+
* `summarizationConfig.retainRecent` value. */
|
|
20
26
|
function createAgentContext(
|
|
21
27
|
overrides: Record<string, unknown> = {}
|
|
22
28
|
): AgentContext {
|
|
@@ -32,12 +38,17 @@ function createAgentContext(
|
|
|
32
38
|
...extra
|
|
33
39
|
} = overrides;
|
|
34
40
|
|
|
41
|
+
const effectiveSummarizationConfig =
|
|
42
|
+
summarizationConfig != null
|
|
43
|
+
? summarizationConfig
|
|
44
|
+
: { retainRecent: { turns: 0 } };
|
|
45
|
+
|
|
35
46
|
const ctx = AgentContext.fromConfig({
|
|
36
47
|
agentId: agentId as string,
|
|
37
48
|
provider: provider as Providers,
|
|
38
49
|
instructions: instructions as string,
|
|
39
50
|
summarizationEnabled: summarizationEnabled as boolean,
|
|
40
|
-
|
|
51
|
+
summarizationConfig: effectiveSummarizationConfig,
|
|
41
52
|
...(maxContextTokens != null ? { maxContextTokens } : {}),
|
|
42
53
|
...(tools != null ? { tools } : {}),
|
|
43
54
|
} as import('@/types').AgentInputs);
|
|
@@ -706,6 +717,491 @@ describe('budget check — instructions exceed context', () => {
|
|
|
706
717
|
});
|
|
707
718
|
});
|
|
708
719
|
|
|
720
|
+
describe('recency window — first-turn protection', () => {
|
|
721
|
+
it('skips the LLM call when only one turn exists (default turns: 2)', async () => {
|
|
722
|
+
const events = captureEvents();
|
|
723
|
+
|
|
724
|
+
const invokeMock = jest
|
|
725
|
+
.fn()
|
|
726
|
+
.mockResolvedValue({ content: 'should not be called' });
|
|
727
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
728
|
+
class {
|
|
729
|
+
constructor() {
|
|
730
|
+
return { invoke: invokeMock };
|
|
731
|
+
}
|
|
732
|
+
} as never
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
const setSummary = jest.fn();
|
|
736
|
+
const agentContext = createAgentContext({
|
|
737
|
+
summarizationConfig: {} /* defaults to retainRecent.turns = 2 */,
|
|
738
|
+
setSummary,
|
|
739
|
+
} as never);
|
|
740
|
+
const graph = mockGraph();
|
|
741
|
+
const summarizeNode = createSummarizeNode({
|
|
742
|
+
agentContext,
|
|
743
|
+
graph: graph as never,
|
|
744
|
+
generateStepId,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const largePayload = 'paste'.repeat(10_000);
|
|
748
|
+
const result = await summarizeNode(
|
|
749
|
+
{
|
|
750
|
+
messages: [new HumanMessage(largePayload)],
|
|
751
|
+
summarizationRequest: {
|
|
752
|
+
remainingContextTokens: 0,
|
|
753
|
+
agentId: 'agent_0',
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
{} as RunnableConfig
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
// No LLM call — first user message is preserved verbatim.
|
|
760
|
+
expect(invokeMock).not.toHaveBeenCalled();
|
|
761
|
+
expect(setSummary).not.toHaveBeenCalled();
|
|
762
|
+
// No state mutation — original messages stay.
|
|
763
|
+
expect(result.messages).toBeUndefined();
|
|
764
|
+
expect(result.summarizationRequest).toBeUndefined();
|
|
765
|
+
// No ON_SUMMARIZE_START emitted on the skip path.
|
|
766
|
+
const eventNames = events.map((e) => e.event);
|
|
767
|
+
expect(eventNames).not.toContain(GraphEvents.ON_SUMMARIZE_START);
|
|
768
|
+
expect(eventNames).not.toContain(GraphEvents.ON_SUMMARIZE_COMPLETE);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('skips when a single-turn includes assistant + tool messages', async () => {
|
|
772
|
+
captureEvents();
|
|
773
|
+
|
|
774
|
+
const invokeMock = jest.fn().mockResolvedValue({ content: 'unused' });
|
|
775
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
776
|
+
class {
|
|
777
|
+
constructor() {
|
|
778
|
+
return { invoke: invokeMock };
|
|
779
|
+
}
|
|
780
|
+
} as never
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
const setSummary = jest.fn();
|
|
784
|
+
const agentContext = createAgentContext({
|
|
785
|
+
summarizationConfig: {},
|
|
786
|
+
setSummary,
|
|
787
|
+
} as never);
|
|
788
|
+
const graph = mockGraph();
|
|
789
|
+
const summarizeNode = createSummarizeNode({
|
|
790
|
+
agentContext,
|
|
791
|
+
graph: graph as never,
|
|
792
|
+
generateStepId,
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const result = await summarizeNode(
|
|
796
|
+
{
|
|
797
|
+
messages: [
|
|
798
|
+
new HumanMessage('the first user prompt'),
|
|
799
|
+
new AIMessage({
|
|
800
|
+
content: '',
|
|
801
|
+
tool_calls: [{ id: 'c', name: 'search', args: {} }],
|
|
802
|
+
}),
|
|
803
|
+
new ToolMessage({
|
|
804
|
+
content: 'result',
|
|
805
|
+
tool_call_id: 'c',
|
|
806
|
+
name: 'search',
|
|
807
|
+
}),
|
|
808
|
+
new AIMessage('here is what i found'),
|
|
809
|
+
],
|
|
810
|
+
summarizationRequest: {
|
|
811
|
+
remainingContextTokens: 0,
|
|
812
|
+
agentId: 'agent_0',
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
{} as RunnableConfig
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
expect(invokeMock).not.toHaveBeenCalled();
|
|
819
|
+
expect(setSummary).not.toHaveBeenCalled();
|
|
820
|
+
expect(result.messages).toBeUndefined();
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('still summarizes the head when older turns exist beyond the recency window', async () => {
|
|
824
|
+
captureEvents();
|
|
825
|
+
|
|
826
|
+
let capturedMessages: { type: string; content: unknown }[] = [];
|
|
827
|
+
const invokeMock = jest.fn().mockImplementation((messages: unknown) => {
|
|
828
|
+
capturedMessages = (
|
|
829
|
+
messages as Array<{ getType: () => string; content: unknown }>
|
|
830
|
+
).map((m) => ({ type: m.getType(), content: m.content }));
|
|
831
|
+
return Promise.resolve({ content: 'Summary of older turns' });
|
|
832
|
+
});
|
|
833
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
834
|
+
class {
|
|
835
|
+
constructor() {
|
|
836
|
+
return { invoke: invokeMock };
|
|
837
|
+
}
|
|
838
|
+
} as never
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
const setSummary = jest.fn();
|
|
842
|
+
const agentContext = createAgentContext({
|
|
843
|
+
summarizationConfig: { retainRecent: { turns: 1 } },
|
|
844
|
+
setSummary,
|
|
845
|
+
} as never);
|
|
846
|
+
const graph = mockGraph();
|
|
847
|
+
const summarizeNode = createSummarizeNode({
|
|
848
|
+
agentContext,
|
|
849
|
+
graph: graph as never,
|
|
850
|
+
generateStepId,
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
const messages = [
|
|
854
|
+
new HumanMessage('turn 1 query'),
|
|
855
|
+
new AIMessage('turn 1 reply'),
|
|
856
|
+
new HumanMessage('turn 2 query'),
|
|
857
|
+
new AIMessage('turn 2 reply'),
|
|
858
|
+
];
|
|
859
|
+
const result = await summarizeNode(
|
|
860
|
+
{
|
|
861
|
+
messages,
|
|
862
|
+
summarizationRequest: {
|
|
863
|
+
remainingContextTokens: 0,
|
|
864
|
+
agentId: 'agent_0',
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
{} as RunnableConfig
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
// Head (turn 1) summarized; tail (turn 2) preserved verbatim.
|
|
871
|
+
expect(setSummary).toHaveBeenCalledWith(
|
|
872
|
+
expect.stringContaining('Summary of older turns'),
|
|
873
|
+
expect.any(Number)
|
|
874
|
+
);
|
|
875
|
+
// Captured messages are the head + the appended summarization instruction.
|
|
876
|
+
// Head has 2 messages (turn 1) + 1 instruction = 3 total.
|
|
877
|
+
expect(capturedMessages).toHaveLength(3);
|
|
878
|
+
expect(capturedMessages[0]?.content).toBe('turn 1 query');
|
|
879
|
+
expect(capturedMessages[1]?.content).toBe('turn 1 reply');
|
|
880
|
+
|
|
881
|
+
// Returned messages: removeAll marker + tail.
|
|
882
|
+
expect(result.messages).toBeDefined();
|
|
883
|
+
expect(result.messages![0]?._getType()).toBe('remove');
|
|
884
|
+
expect(result.messages!.slice(1)).toHaveLength(2);
|
|
885
|
+
expect((result.messages![1] as HumanMessage).content).toBe('turn 2 query');
|
|
886
|
+
expect((result.messages![2] as AIMessage).content).toBe('turn 2 reply');
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it('keeps the masked tail content (does not re-inject restored tool payloads into state)', async () => {
|
|
890
|
+
captureEvents();
|
|
891
|
+
|
|
892
|
+
let summarizerSawRestored = false;
|
|
893
|
+
const invokeMock = jest.fn().mockImplementation((messages: unknown) => {
|
|
894
|
+
const arr = messages as Array<{
|
|
895
|
+
getType: () => string;
|
|
896
|
+
content: unknown;
|
|
897
|
+
tool_call_id?: string;
|
|
898
|
+
}>;
|
|
899
|
+
// Confirm the summarizer's input for the restored tool result has
|
|
900
|
+
// the FULL content, not the masked stub.
|
|
901
|
+
summarizerSawRestored = arr.some(
|
|
902
|
+
(m) =>
|
|
903
|
+
m.getType() === 'tool' &&
|
|
904
|
+
typeof m.content === 'string' &&
|
|
905
|
+
(m.content as string).includes('FULL_ORIGINAL_OUTPUT')
|
|
906
|
+
);
|
|
907
|
+
return Promise.resolve({ content: 'summary' });
|
|
908
|
+
});
|
|
909
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
910
|
+
class {
|
|
911
|
+
constructor() {
|
|
912
|
+
return { invoke: invokeMock };
|
|
913
|
+
}
|
|
914
|
+
} as never
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
const setSummary = jest.fn();
|
|
918
|
+
const agentContext = createAgentContext({
|
|
919
|
+
summarizationConfig: { retainRecent: { turns: 1 } },
|
|
920
|
+
setSummary,
|
|
921
|
+
} as never);
|
|
922
|
+
// Restoration map keyed by message index — applies to head (idx 2)
|
|
923
|
+
// AND to a tool message inside the retained tail (idx 5). Only the
|
|
924
|
+
// head's restoration should leak into the summarizer; the tail must
|
|
925
|
+
// keep the masked content.
|
|
926
|
+
agentContext.pendingOriginalToolContent = new Map<number, string>([
|
|
927
|
+
[2, 'FULL_ORIGINAL_OUTPUT for head tool result'],
|
|
928
|
+
[5, 'FULL_ORIGINAL_OUTPUT for tail tool result — must NOT survive'],
|
|
929
|
+
]);
|
|
930
|
+
|
|
931
|
+
const graph = mockGraph();
|
|
932
|
+
const summarizeNode = createSummarizeNode({
|
|
933
|
+
agentContext,
|
|
934
|
+
graph: graph as never,
|
|
935
|
+
generateStepId,
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
const headToolCall = new AIMessage({
|
|
939
|
+
content: '',
|
|
940
|
+
tool_calls: [{ id: 'h', name: 'search', args: {} }],
|
|
941
|
+
});
|
|
942
|
+
const headToolResult = new ToolMessage({
|
|
943
|
+
content: 'masked-head-stub',
|
|
944
|
+
tool_call_id: 'h',
|
|
945
|
+
name: 'search',
|
|
946
|
+
});
|
|
947
|
+
const tailToolCall = new AIMessage({
|
|
948
|
+
content: '',
|
|
949
|
+
tool_calls: [{ id: 't', name: 'search', args: {} }],
|
|
950
|
+
});
|
|
951
|
+
const tailToolResult = new ToolMessage({
|
|
952
|
+
content: 'masked-tail-stub',
|
|
953
|
+
tool_call_id: 't',
|
|
954
|
+
name: 'search',
|
|
955
|
+
});
|
|
956
|
+
const messages = [
|
|
957
|
+
new HumanMessage('turn 1 query'),
|
|
958
|
+
headToolCall,
|
|
959
|
+
headToolResult, // index 2 — restored for summarizer
|
|
960
|
+
new HumanMessage('turn 2 query'),
|
|
961
|
+
tailToolCall,
|
|
962
|
+
tailToolResult, // index 5 — must stay masked in returned tail
|
|
963
|
+
];
|
|
964
|
+
|
|
965
|
+
const result = await summarizeNode(
|
|
966
|
+
{
|
|
967
|
+
messages,
|
|
968
|
+
summarizationRequest: {
|
|
969
|
+
remainingContextTokens: 0,
|
|
970
|
+
agentId: 'agent_0',
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
{} as RunnableConfig
|
|
974
|
+
);
|
|
975
|
+
|
|
976
|
+
// Summarizer saw the full restored head tool result.
|
|
977
|
+
expect(summarizerSawRestored).toBe(true);
|
|
978
|
+
|
|
979
|
+
// Returned tail must contain the MASKED tool result, not the restored one.
|
|
980
|
+
expect(result.messages).toBeDefined();
|
|
981
|
+
const tailToolMsg = result.messages!.find(
|
|
982
|
+
(m) => m._getType() === 'tool'
|
|
983
|
+
) as ToolMessage | undefined;
|
|
984
|
+
expect(tailToolMsg).toBeDefined();
|
|
985
|
+
expect(tailToolMsg!.content).toBe('masked-tail-stub');
|
|
986
|
+
expect(tailToolMsg!.content).not.toContain('FULL_ORIGINAL_OUTPUT');
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('preserves tail-relevant pendingOriginalToolContent entries (reindexed) for future summaries', async () => {
|
|
990
|
+
captureEvents();
|
|
991
|
+
|
|
992
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
993
|
+
class {
|
|
994
|
+
constructor() {
|
|
995
|
+
return mockInvokeModel('summary');
|
|
996
|
+
}
|
|
997
|
+
} as never
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
const setSummary = jest.fn();
|
|
1001
|
+
const agentContext = createAgentContext({
|
|
1002
|
+
summarizationConfig: { retainRecent: { turns: 1 } },
|
|
1003
|
+
setSummary,
|
|
1004
|
+
} as never);
|
|
1005
|
+
// Original-content map covers BOTH a head index (1) and tail indices
|
|
1006
|
+
// (3, 5). Only the tail entries should survive, reindexed to the
|
|
1007
|
+
// post-removeAll state where tail messages start at 0.
|
|
1008
|
+
agentContext.pendingOriginalToolContent = new Map<number, string>([
|
|
1009
|
+
[1, 'fullHead'], // belongs to summarized head — should be dropped
|
|
1010
|
+
[3, 'fullTailA'], // tail position 3 → reindexed to 0
|
|
1011
|
+
[5, 'fullTailB'], // tail position 5 → reindexed to 2
|
|
1012
|
+
]);
|
|
1013
|
+
|
|
1014
|
+
const graph = mockGraph();
|
|
1015
|
+
const summarizeNode = createSummarizeNode({
|
|
1016
|
+
agentContext,
|
|
1017
|
+
graph: graph as never,
|
|
1018
|
+
generateStepId,
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
const messages = [
|
|
1022
|
+
new HumanMessage('turn 1 query'),
|
|
1023
|
+
new AIMessage('turn 1 reply'), // idx 1
|
|
1024
|
+
new HumanMessage('turn 2 query'), // idx 2 — tail starts here
|
|
1025
|
+
new ToolMessage({
|
|
1026
|
+
// idx 3
|
|
1027
|
+
content: 'masked-stub-A',
|
|
1028
|
+
tool_call_id: 'a',
|
|
1029
|
+
name: 'search',
|
|
1030
|
+
}),
|
|
1031
|
+
new AIMessage('turn 2 reply'), // idx 4
|
|
1032
|
+
new ToolMessage({
|
|
1033
|
+
// idx 5
|
|
1034
|
+
content: 'masked-stub-B',
|
|
1035
|
+
tool_call_id: 'b',
|
|
1036
|
+
name: 'search',
|
|
1037
|
+
}),
|
|
1038
|
+
];
|
|
1039
|
+
|
|
1040
|
+
await summarizeNode(
|
|
1041
|
+
{
|
|
1042
|
+
messages,
|
|
1043
|
+
summarizationRequest: {
|
|
1044
|
+
remainingContextTokens: 0,
|
|
1045
|
+
agentId: 'agent_0',
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
{} as RunnableConfig
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
// Summarize fired (we used a real summary text), so head index 1
|
|
1052
|
+
// is gone. Tail entries should remain, reindexed by subtracting
|
|
1053
|
+
// tailStartIndex (=2): 3→1, 5→3.
|
|
1054
|
+
const carryOver = agentContext.pendingOriginalToolContent;
|
|
1055
|
+
expect(carryOver).toBeDefined();
|
|
1056
|
+
expect(carryOver!.size).toBe(2);
|
|
1057
|
+
expect(carryOver!.get(1)).toBe('fullTailA');
|
|
1058
|
+
expect(carryOver!.get(3)).toBe('fullTailB');
|
|
1059
|
+
expect(carryOver!.has(0)).toBe(false);
|
|
1060
|
+
expect(carryOver!.has(5)).toBe(false);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('aligns the dedupe baseline with the surviving tail length after compaction', async () => {
|
|
1064
|
+
captureEvents();
|
|
1065
|
+
|
|
1066
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
1067
|
+
class {
|
|
1068
|
+
constructor() {
|
|
1069
|
+
return mockInvokeModel('summary');
|
|
1070
|
+
}
|
|
1071
|
+
} as never
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
const agentContext = createAgentContext({
|
|
1075
|
+
summarizationConfig: { retainRecent: { turns: 1 } },
|
|
1076
|
+
} as never);
|
|
1077
|
+
const graph = mockGraph();
|
|
1078
|
+
const summarizeNode = createSummarizeNode({
|
|
1079
|
+
agentContext,
|
|
1080
|
+
graph: graph as never,
|
|
1081
|
+
generateStepId,
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
const messages = [
|
|
1085
|
+
new HumanMessage('turn 1 query'),
|
|
1086
|
+
new AIMessage('turn 1 reply'),
|
|
1087
|
+
new HumanMessage('turn 2 query'),
|
|
1088
|
+
new AIMessage('turn 2 reply'),
|
|
1089
|
+
];
|
|
1090
|
+
|
|
1091
|
+
await summarizeNode(
|
|
1092
|
+
{
|
|
1093
|
+
messages,
|
|
1094
|
+
summarizationRequest: {
|
|
1095
|
+
remainingContextTokens: 0,
|
|
1096
|
+
agentId: 'agent_0',
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
{} as RunnableConfig
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
// Tail = last turn = 2 messages. Baseline must equal that count
|
|
1103
|
+
// so a follow-up prune call on the unchanged tail short-circuits
|
|
1104
|
+
// via shouldSkipSummarization rather than re-triggering compaction.
|
|
1105
|
+
expect(agentContext.shouldSkipSummarization(2)).toBe(true);
|
|
1106
|
+
expect(agentContext.shouldSkipSummarization(3)).toBe(false);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('does not clear pendingOriginalToolContent on the skip path (state unchanged)', async () => {
|
|
1110
|
+
captureEvents();
|
|
1111
|
+
|
|
1112
|
+
const invokeMock = jest.fn();
|
|
1113
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
1114
|
+
class {
|
|
1115
|
+
constructor() {
|
|
1116
|
+
return { invoke: invokeMock };
|
|
1117
|
+
}
|
|
1118
|
+
} as never
|
|
1119
|
+
);
|
|
1120
|
+
|
|
1121
|
+
const agentContext = createAgentContext({
|
|
1122
|
+
summarizationConfig: { retainRecent: { turns: 2 } },
|
|
1123
|
+
} as never);
|
|
1124
|
+
const seededMap = new Map<number, string>([[1, 'preserved-original']]);
|
|
1125
|
+
agentContext.pendingOriginalToolContent = seededMap;
|
|
1126
|
+
|
|
1127
|
+
const graph = mockGraph();
|
|
1128
|
+
const summarizeNode = createSummarizeNode({
|
|
1129
|
+
agentContext,
|
|
1130
|
+
graph: graph as never,
|
|
1131
|
+
generateStepId,
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
await summarizeNode(
|
|
1135
|
+
{
|
|
1136
|
+
messages: [
|
|
1137
|
+
new HumanMessage('only turn'),
|
|
1138
|
+
new ToolMessage({
|
|
1139
|
+
content: 'masked',
|
|
1140
|
+
tool_call_id: 'x',
|
|
1141
|
+
name: 'search',
|
|
1142
|
+
}),
|
|
1143
|
+
],
|
|
1144
|
+
summarizationRequest: {
|
|
1145
|
+
remainingContextTokens: 0,
|
|
1146
|
+
agentId: 'agent_0',
|
|
1147
|
+
},
|
|
1148
|
+
},
|
|
1149
|
+
{} as RunnableConfig
|
|
1150
|
+
);
|
|
1151
|
+
|
|
1152
|
+
// No LLM call (skip path); pendingOriginalToolContent must still be
|
|
1153
|
+
// available so a future summarization can restore the original.
|
|
1154
|
+
expect(invokeMock).not.toHaveBeenCalled();
|
|
1155
|
+
expect(agentContext.pendingOriginalToolContent).toBe(seededMap);
|
|
1156
|
+
expect(agentContext.pendingOriginalToolContent!.get(1)).toBe(
|
|
1157
|
+
'preserved-original'
|
|
1158
|
+
);
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it('preserves the legacy "remove all, summary only" shape when retainRecent.turns is 0', async () => {
|
|
1162
|
+
captureEvents();
|
|
1163
|
+
|
|
1164
|
+
jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
|
|
1165
|
+
class {
|
|
1166
|
+
constructor() {
|
|
1167
|
+
return mockInvokeModel('Legacy summary');
|
|
1168
|
+
}
|
|
1169
|
+
} as never
|
|
1170
|
+
);
|
|
1171
|
+
|
|
1172
|
+
const setSummary = jest.fn();
|
|
1173
|
+
const agentContext = createAgentContext({
|
|
1174
|
+
summarizationConfig: { retainRecent: { turns: 0 } },
|
|
1175
|
+
setSummary,
|
|
1176
|
+
} as never);
|
|
1177
|
+
const graph = mockGraph();
|
|
1178
|
+
const summarizeNode = createSummarizeNode({
|
|
1179
|
+
agentContext,
|
|
1180
|
+
graph: graph as never,
|
|
1181
|
+
generateStepId,
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
const result = await summarizeNode(
|
|
1185
|
+
{
|
|
1186
|
+
messages: [
|
|
1187
|
+
new HumanMessage('only message'),
|
|
1188
|
+
new AIMessage('only reply'),
|
|
1189
|
+
],
|
|
1190
|
+
summarizationRequest: {
|
|
1191
|
+
remainingContextTokens: 0,
|
|
1192
|
+
agentId: 'agent_0',
|
|
1193
|
+
},
|
|
1194
|
+
},
|
|
1195
|
+
{} as RunnableConfig
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
expect(setSummary).toHaveBeenCalled();
|
|
1199
|
+
// Legacy: remove-all only, no tail re-injection.
|
|
1200
|
+
expect(result.messages).toHaveLength(1);
|
|
1201
|
+
expect(result.messages![0]?._getType()).toBe('remove');
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
|
|
709
1205
|
describe('emoji-heavy content does not break summarization', () => {
|
|
710
1206
|
it('summarization completes without JSON errors on emoji-heavy messages', async () => {
|
|
711
1207
|
captureEvents();
|