@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.
Files changed (188) hide show
  1. package/dist/cjs/common/enum.cjs +54 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +148 -4
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
  6. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
  7. package/dist/cjs/llm/openai/index.cjs +317 -1
  8. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  9. package/dist/cjs/main.cjs +90 -0
  10. package/dist/cjs/main.cjs.map +1 -1
  11. package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
  12. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
  13. package/dist/cjs/messages/prune.cjs +27 -0
  14. package/dist/cjs/messages/prune.cjs.map +1 -1
  15. package/dist/cjs/messages/recency.cjs +99 -0
  16. package/dist/cjs/messages/recency.cjs.map +1 -0
  17. package/dist/cjs/run.cjs +30 -0
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/summarization/node.cjs +100 -6
  20. package/dist/cjs/summarization/node.cjs.map +1 -1
  21. package/dist/cjs/tools/ToolNode.cjs +635 -23
  22. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  23. package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
  24. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
  25. package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
  26. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
  27. package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
  28. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
  29. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
  30. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
  31. package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
  32. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
  33. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
  34. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
  35. package/dist/cjs/tools/local/attachments.cjs +183 -0
  36. package/dist/cjs/tools/local/attachments.cjs.map +1 -0
  37. package/dist/cjs/tools/local/bashAst.cjs +129 -0
  38. package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
  39. package/dist/cjs/tools/local/editStrategies.cjs +188 -0
  40. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
  41. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
  42. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
  43. package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
  44. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
  45. package/dist/cjs/tools/local/textEncoding.cjs +30 -0
  46. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
  47. package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
  48. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  50. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  51. package/dist/esm/common/enum.mjs +53 -1
  52. package/dist/esm/common/enum.mjs.map +1 -1
  53. package/dist/esm/graphs/Graph.mjs +149 -5
  54. package/dist/esm/graphs/Graph.mjs.map +1 -1
  55. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
  56. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
  57. package/dist/esm/llm/openai/index.mjs +318 -2
  58. package/dist/esm/llm/openai/index.mjs.map +1 -1
  59. package/dist/esm/main.mjs +17 -2
  60. package/dist/esm/main.mjs.map +1 -1
  61. package/dist/esm/messages/anthropicToolCache.mjs +99 -0
  62. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
  63. package/dist/esm/messages/prune.mjs +26 -1
  64. package/dist/esm/messages/prune.mjs.map +1 -1
  65. package/dist/esm/messages/recency.mjs +97 -0
  66. package/dist/esm/messages/recency.mjs.map +1 -0
  67. package/dist/esm/run.mjs +30 -0
  68. package/dist/esm/run.mjs.map +1 -1
  69. package/dist/esm/summarization/node.mjs +100 -6
  70. package/dist/esm/summarization/node.mjs.map +1 -1
  71. package/dist/esm/tools/ToolNode.mjs +635 -23
  72. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  73. package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
  74. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
  75. package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
  76. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
  77. package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
  78. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
  79. package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
  80. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
  81. package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
  82. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
  83. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
  84. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
  85. package/dist/esm/tools/local/attachments.mjs +180 -0
  86. package/dist/esm/tools/local/attachments.mjs.map +1 -0
  87. package/dist/esm/tools/local/bashAst.mjs +126 -0
  88. package/dist/esm/tools/local/bashAst.mjs.map +1 -0
  89. package/dist/esm/tools/local/editStrategies.mjs +185 -0
  90. package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
  91. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
  92. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
  93. package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
  94. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
  95. package/dist/esm/tools/local/textEncoding.mjs +27 -0
  96. package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
  97. package/dist/esm/tools/local/workspaceFS.mjs +49 -0
  98. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
  99. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  100. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  101. package/dist/types/common/enum.d.ts +39 -1
  102. package/dist/types/graphs/Graph.d.ts +34 -0
  103. package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
  104. package/dist/types/hooks/index.d.ts +2 -0
  105. package/dist/types/index.d.ts +1 -0
  106. package/dist/types/llm/openai/index.d.ts +17 -0
  107. package/dist/types/messages/anthropicToolCache.d.ts +51 -0
  108. package/dist/types/messages/index.d.ts +2 -0
  109. package/dist/types/messages/prune.d.ts +11 -0
  110. package/dist/types/messages/recency.d.ts +64 -0
  111. package/dist/types/run.d.ts +21 -0
  112. package/dist/types/tools/ToolNode.d.ts +145 -2
  113. package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
  114. package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
  115. package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
  116. package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
  117. package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
  118. package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
  119. package/dist/types/tools/local/attachments.d.ts +84 -0
  120. package/dist/types/tools/local/bashAst.d.ts +11 -0
  121. package/dist/types/tools/local/editStrategies.d.ts +28 -0
  122. package/dist/types/tools/local/index.d.ts +12 -0
  123. package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
  124. package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
  125. package/dist/types/tools/local/textEncoding.d.ts +21 -0
  126. package/dist/types/tools/local/workspaceFS.d.ts +49 -0
  127. package/dist/types/types/hitl.d.ts +56 -27
  128. package/dist/types/types/run.d.ts +8 -1
  129. package/dist/types/types/summarize.d.ts +30 -0
  130. package/dist/types/types/tools.d.ts +341 -6
  131. package/package.json +21 -2
  132. package/src/common/enum.ts +54 -0
  133. package/src/graphs/Graph.ts +164 -6
  134. package/src/hooks/__tests__/compactHooks.test.ts +38 -2
  135. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
  136. package/src/hooks/createWorkspacePolicyHook.ts +355 -0
  137. package/src/hooks/index.ts +6 -0
  138. package/src/index.ts +1 -0
  139. package/src/llm/openai/deepseek.test.ts +479 -0
  140. package/src/llm/openai/index.ts +484 -1
  141. package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
  142. package/src/messages/__tests__/recency.test.ts +267 -0
  143. package/src/messages/anthropicToolCache.ts +116 -0
  144. package/src/messages/index.ts +2 -0
  145. package/src/messages/prune.ts +27 -1
  146. package/src/messages/recency.ts +155 -0
  147. package/src/run.ts +31 -0
  148. package/src/scripts/compare_pi_vs_ours.ts +840 -0
  149. package/src/scripts/local_engine.ts +166 -0
  150. package/src/scripts/local_engine_checkpointer.ts +205 -0
  151. package/src/scripts/local_engine_compile.ts +263 -0
  152. package/src/scripts/local_engine_hooks.ts +226 -0
  153. package/src/scripts/local_engine_image.ts +201 -0
  154. package/src/scripts/local_engine_ptc.ts +151 -0
  155. package/src/scripts/local_engine_workspace.ts +258 -0
  156. package/src/scripts/summarization-recency.ts +462 -0
  157. package/src/specs/prune.test.ts +39 -0
  158. package/src/summarization/__tests__/node.test.ts +499 -3
  159. package/src/summarization/node.ts +124 -7
  160. package/src/tools/ToolNode.ts +769 -20
  161. package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
  162. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
  163. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
  164. package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
  165. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
  166. package/src/tools/__tests__/directToolHooks.test.ts +411 -0
  167. package/src/tools/__tests__/localToolNames.test.ts +73 -0
  168. package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
  169. package/src/tools/local/CompileCheckTool.ts +278 -0
  170. package/src/tools/local/FileCheckpointer.ts +93 -0
  171. package/src/tools/local/LocalCodingTools.ts +1342 -0
  172. package/src/tools/local/LocalExecutionEngine.ts +1329 -0
  173. package/src/tools/local/LocalExecutionTools.ts +167 -0
  174. package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
  175. package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
  176. package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
  177. package/src/tools/local/attachments.ts +251 -0
  178. package/src/tools/local/bashAst.ts +151 -0
  179. package/src/tools/local/editStrategies.ts +188 -0
  180. package/src/tools/local/index.ts +12 -0
  181. package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
  182. package/src/tools/local/syntaxCheck.ts +243 -0
  183. package/src/tools/local/textEncoding.ts +37 -0
  184. package/src/tools/local/workspaceFS.ts +89 -0
  185. package/src/types/hitl.ts +56 -27
  186. package/src/types/run.ts +12 -1
  187. package/src/types/summarize.ts +31 -0
  188. 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
- ...(summarizationConfig != null ? { summarizationConfig } : {}),
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();