@librechat/agents 3.3.10 → 3.3.12

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 (211) hide show
  1. package/dist/cjs/graphs/Graph.cjs +248 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/langfuseToolOutputTracing.cjs +228 -16
  4. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/index.cjs +13 -2
  6. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  7. package/dist/cjs/llm/init.cjs +1 -1
  8. package/dist/cjs/llm/invoke.cjs +160 -43
  9. package/dist/cjs/llm/invoke.cjs.map +1 -1
  10. package/dist/cjs/llm/openai/index.cjs +190 -13
  11. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  12. package/dist/cjs/llm/streamLimits.cjs +723 -0
  13. package/dist/cjs/llm/streamLimits.cjs.map +1 -0
  14. package/dist/cjs/main.cjs +12 -3
  15. package/dist/cjs/messages/core.cjs +592 -27
  16. package/dist/cjs/messages/core.cjs.map +1 -1
  17. package/dist/cjs/run.cjs +7 -3
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/session/AgentSession.cjs +4 -1
  20. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  21. package/dist/cjs/stream.cjs +105 -14
  22. package/dist/cjs/stream.cjs.map +1 -1
  23. package/dist/cjs/summarization/node.cjs +157 -37
  24. package/dist/cjs/summarization/node.cjs.map +1 -1
  25. package/dist/cjs/tools/BashExecutor.cjs +3 -2
  26. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  27. package/dist/cjs/tools/CodeExecutor.cjs +4 -3
  28. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  29. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
  30. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  31. package/dist/cjs/tools/ToolNode.cjs +77 -4
  32. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  33. package/dist/cjs/tools/ToolSearch.cjs +3 -2
  34. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  35. package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
  36. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  37. package/dist/cjs/tools/search/crw-search.cjs +3 -1
  38. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  39. package/dist/cjs/tools/search/firecrawl.cjs +7 -1
  40. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  41. package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
  42. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  43. package/dist/cjs/tools/search/keenable-search.cjs +3 -1
  44. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  45. package/dist/cjs/tools/search/rerankers.cjs +26 -8
  46. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  47. package/dist/cjs/tools/search/search.cjs +30 -10
  48. package/dist/cjs/tools/search/search.cjs.map +1 -1
  49. package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
  50. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  51. package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
  52. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  53. package/dist/cjs/tools/search/tavily-search.cjs +3 -1
  54. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  55. package/dist/cjs/tools/search/tool.cjs +17 -3
  56. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  57. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
  58. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  59. package/dist/cjs/utils/index.cjs +2 -1
  60. package/dist/cjs/utils/misc.cjs +12 -0
  61. package/dist/cjs/utils/misc.cjs.map +1 -1
  62. package/dist/cjs/utils/proxy.cjs +63 -0
  63. package/dist/cjs/utils/proxy.cjs.map +1 -0
  64. package/dist/esm/graphs/Graph.mjs +247 -26
  65. package/dist/esm/graphs/Graph.mjs.map +1 -1
  66. package/dist/esm/langfuseToolOutputTracing.mjs +228 -16
  67. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  68. package/dist/esm/llm/bedrock/index.mjs +13 -2
  69. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  70. package/dist/esm/llm/init.mjs +1 -1
  71. package/dist/esm/llm/invoke.mjs +160 -43
  72. package/dist/esm/llm/invoke.mjs.map +1 -1
  73. package/dist/esm/llm/openai/index.mjs +192 -15
  74. package/dist/esm/llm/openai/index.mjs.map +1 -1
  75. package/dist/esm/llm/streamLimits.mjs +704 -0
  76. package/dist/esm/llm/streamLimits.mjs.map +1 -0
  77. package/dist/esm/main.mjs +8 -6
  78. package/dist/esm/messages/core.mjs +592 -28
  79. package/dist/esm/messages/core.mjs.map +1 -1
  80. package/dist/esm/run.mjs +7 -3
  81. package/dist/esm/run.mjs.map +1 -1
  82. package/dist/esm/session/AgentSession.mjs +4 -1
  83. package/dist/esm/session/AgentSession.mjs.map +1 -1
  84. package/dist/esm/stream.mjs +105 -14
  85. package/dist/esm/stream.mjs.map +1 -1
  86. package/dist/esm/summarization/node.mjs +157 -37
  87. package/dist/esm/summarization/node.mjs.map +1 -1
  88. package/dist/esm/tools/BashExecutor.mjs +3 -2
  89. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  90. package/dist/esm/tools/CodeExecutor.mjs +4 -3
  91. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  92. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
  93. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  94. package/dist/esm/tools/ToolNode.mjs +77 -4
  95. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  96. package/dist/esm/tools/ToolSearch.mjs +3 -2
  97. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  98. package/dist/esm/tools/search/crw-scraper.mjs +7 -1
  99. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  100. package/dist/esm/tools/search/crw-search.mjs +3 -1
  101. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  102. package/dist/esm/tools/search/firecrawl.mjs +7 -1
  103. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  104. package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
  105. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  106. package/dist/esm/tools/search/keenable-search.mjs +3 -1
  107. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  108. package/dist/esm/tools/search/rerankers.mjs +26 -8
  109. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  110. package/dist/esm/tools/search/search.mjs +30 -10
  111. package/dist/esm/tools/search/search.mjs.map +1 -1
  112. package/dist/esm/tools/search/serper-scraper.mjs +7 -1
  113. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  114. package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
  115. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  116. package/dist/esm/tools/search/tavily-search.mjs +3 -1
  117. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  118. package/dist/esm/tools/search/tool.mjs +17 -3
  119. package/dist/esm/tools/search/tool.mjs.map +1 -1
  120. package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
  121. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  122. package/dist/esm/utils/index.mjs +2 -1
  123. package/dist/esm/utils/misc.mjs +12 -1
  124. package/dist/esm/utils/misc.mjs.map +1 -1
  125. package/dist/esm/utils/proxy.mjs +62 -0
  126. package/dist/esm/utils/proxy.mjs.map +1 -0
  127. package/dist/types/graphs/Graph.d.ts +53 -1
  128. package/dist/types/index.d.ts +2 -0
  129. package/dist/types/langfuseToolOutputTracing.d.ts +1 -0
  130. package/dist/types/llm/invoke.d.ts +26 -5
  131. package/dist/types/llm/openai/index.d.ts +3 -0
  132. package/dist/types/llm/streamLimits.d.ts +314 -0
  133. package/dist/types/messages/core.d.ts +11 -6
  134. package/dist/types/run.d.ts +1 -0
  135. package/dist/types/summarization/node.d.ts +27 -2
  136. package/dist/types/tools/BashExecutor.d.ts +2 -2
  137. package/dist/types/tools/CodeExecutor.d.ts +3 -3
  138. package/dist/types/tools/ToolNode.d.ts +11 -1
  139. package/dist/types/tools/search/crw-scraper.d.ts +2 -0
  140. package/dist/types/tools/search/firecrawl.d.ts +2 -0
  141. package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
  142. package/dist/types/tools/search/rerankers.d.ts +9 -5
  143. package/dist/types/tools/search/serper-scraper.d.ts +2 -0
  144. package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
  145. package/dist/types/tools/search/types.d.ts +28 -33
  146. package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
  147. package/dist/types/types/graph.d.ts +7 -1
  148. package/dist/types/types/run.d.ts +46 -1
  149. package/dist/types/types/tools.d.ts +21 -0
  150. package/dist/types/utils/index.d.ts +1 -0
  151. package/dist/types/utils/misc.d.ts +7 -0
  152. package/dist/types/utils/proxy.d.ts +31 -0
  153. package/package.json +2 -1
  154. package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
  155. package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
  156. package/src/__tests__/stream.streamLimits.test.ts +1982 -0
  157. package/src/graphs/Graph.ts +302 -27
  158. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
  159. package/src/index.ts +11 -0
  160. package/src/langfuseToolOutputTracing.ts +410 -14
  161. package/src/llm/bedrock/index.ts +22 -2
  162. package/src/llm/custom-chat-models.smoke.test.ts +747 -0
  163. package/src/llm/invoke.streamLimits.test.ts +142 -0
  164. package/src/llm/invoke.test.ts +187 -1
  165. package/src/llm/invoke.ts +231 -43
  166. package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
  167. package/src/llm/openai/index.ts +348 -31
  168. package/src/llm/openai/llm.spec.ts +107 -6
  169. package/src/llm/streamLimits.test.ts +450 -0
  170. package/src/llm/streamLimits.ts +1158 -0
  171. package/src/messages/core.ts +1290 -42
  172. package/src/messages/formatAgentMessages.test.ts +2623 -0
  173. package/src/run.ts +4 -0
  174. package/src/session/AgentSession.ts +5 -0
  175. package/src/specs/langfuse-tool-output-tracing.test.ts +887 -0
  176. package/src/specs/preemptSeal.test.ts +374 -5
  177. package/src/stream.ts +141 -6
  178. package/src/summarization/__tests__/node.test.ts +269 -0
  179. package/src/summarization/chunkHandler.test.ts +196 -0
  180. package/src/summarization/node.ts +203 -6
  181. package/src/tools/BashExecutor.ts +4 -3
  182. package/src/tools/CodeExecutor.ts +5 -4
  183. package/src/tools/ProgrammaticToolCalling.ts +7 -5
  184. package/src/tools/ToolNode.ts +109 -6
  185. package/src/tools/ToolSearch.ts +4 -3
  186. package/src/tools/__tests__/BashExecutor.test.ts +2 -2
  187. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
  188. package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
  189. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
  190. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
  191. package/src/tools/search/crw-scraper.ts +6 -0
  192. package/src/tools/search/crw-search.ts +6 -1
  193. package/src/tools/search/firecrawl.ts +6 -0
  194. package/src/tools/search/http-agent.test.ts +133 -0
  195. package/src/tools/search/keenable-scraper.ts +6 -0
  196. package/src/tools/search/keenable-search.ts +6 -1
  197. package/src/tools/search/rerankers.ts +36 -10
  198. package/src/tools/search/search.ts +29 -11
  199. package/src/tools/search/serper-scraper.ts +6 -0
  200. package/src/tools/search/tavily-scraper.ts +6 -0
  201. package/src/tools/search/tavily-search.ts +2 -0
  202. package/src/tools/search/tool.ts +16 -0
  203. package/src/tools/search/types.ts +31 -33
  204. package/src/tools/subagent/SubagentExecutor.ts +96 -3
  205. package/src/types/graph.ts +7 -0
  206. package/src/types/run.ts +49 -1
  207. package/src/types/tools.ts +21 -0
  208. package/src/utils/index.ts +1 -0
  209. package/src/utils/misc.ts +19 -0
  210. package/src/utils/proxy.test.ts +176 -0
  211. package/src/utils/proxy.ts +93 -0
@@ -7,6 +7,7 @@ import {
7
7
  DEFAULT_SUMMARIZATION_PROMPT,
8
8
  DEFAULT_UPDATE_SUMMARIZATION_PROMPT,
9
9
  } from '@/summarization/node';
10
+ import { StreamLimitExceededError } from '@/llm/streamLimits';
10
11
  import { convertInjectedMessages } from '@/messages/injected';
11
12
  import { Constants, GraphEvents, Providers } from '@/common';
12
13
  import { AgentContext } from '@/agents/AgentContext';
@@ -1707,3 +1708,271 @@ describe('createSummarizeNode — overflow recovery', () => {
1707
1708
  expect(agentContext.hasSummary()).toBe(true);
1708
1709
  });
1709
1710
  });
1711
+
1712
+ describe('summarize node breaker capture', () => {
1713
+ it('stamps the entry-captured breaker epoch into summary attempt metadata', async () => {
1714
+ captureEvents();
1715
+ const capturedConfigs: Array<{ metadata?: Record<string, unknown> }> = [];
1716
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
1717
+ class {
1718
+ constructor() {
1719
+ return {
1720
+ invoke: jest
1721
+ .fn()
1722
+ .mockImplementation(
1723
+ async (_messages: unknown, config?: unknown) => {
1724
+ capturedConfigs.push(
1725
+ config as { metadata?: Record<string, unknown> }
1726
+ );
1727
+ return { content: 'Summary text' };
1728
+ }
1729
+ ),
1730
+ };
1731
+ }
1732
+ } as never
1733
+ );
1734
+
1735
+ const agentContext = createAgentContext();
1736
+ const graph = {
1737
+ ...mockGraph(),
1738
+ getBreakerEpoch: (): number => 7,
1739
+ };
1740
+ const node = createSummarizeNode({
1741
+ agentContext,
1742
+ graph,
1743
+ generateStepId,
1744
+ });
1745
+
1746
+ await node(
1747
+ {
1748
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1749
+ summarizationRequest: {
1750
+ remainingContextTokens: 1000,
1751
+ agentId: 'agent_0',
1752
+ },
1753
+ },
1754
+ {} as RunnableConfig
1755
+ );
1756
+
1757
+ expect(capturedConfigs.length).toBeGreaterThan(0);
1758
+ for (const config of capturedConfigs) {
1759
+ expect(config.metadata?.lc_stream_limit_epoch).toBe(7);
1760
+ }
1761
+ });
1762
+
1763
+ it('rejects before the model call when the breaker trips during pre-call awaits', async () => {
1764
+ const trip = new StreamLimitExceededError({
1765
+ kind: 'tool_call_args',
1766
+ limit: 10,
1767
+ observed: 11,
1768
+ toolName: 'db_query',
1769
+ });
1770
+ const entryBreaker = new AbortController();
1771
+ /** The trip lands while ON_SUMMARIZE_START is awaited — after the
1772
+ * entry check already passed. */
1773
+ jest.spyOn(eventUtils, 'safeDispatchCustomEvent').mockImplementation((async (
1774
+ ...args: unknown[]
1775
+ ) => {
1776
+ if (args[0] === GraphEvents.ON_SUMMARIZE_START) {
1777
+ entryBreaker.abort(trip);
1778
+ }
1779
+ }) as never);
1780
+ const modelClassSpy = jest
1781
+ .spyOn(providers, 'getChatModelClass')
1782
+ .mockReturnValue(
1783
+ class {
1784
+ constructor() {
1785
+ return mockInvokeModel('should never run');
1786
+ }
1787
+ } as never
1788
+ );
1789
+
1790
+ const agentContext = createAgentContext();
1791
+ const graph = {
1792
+ ...mockGraph(),
1793
+ getBreakerSignal: (): AbortSignal => entryBreaker.signal,
1794
+ };
1795
+ const node = createSummarizeNode({
1796
+ agentContext,
1797
+ graph,
1798
+ generateStepId,
1799
+ });
1800
+
1801
+ await expect(
1802
+ node(
1803
+ {
1804
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1805
+ summarizationRequest: {
1806
+ remainingContextTokens: 1000,
1807
+ agentId: 'agent_0',
1808
+ },
1809
+ },
1810
+ {} as RunnableConfig
1811
+ )
1812
+ ).rejects.toBe(trip);
1813
+ expect(modelClassSpy).not.toHaveBeenCalled();
1814
+ });
1815
+
1816
+ it('rethrows a parent trip on the config signal instead of degrading to the stub', async () => {
1817
+ const trip = new StreamLimitExceededError({
1818
+ kind: 'tool_call_args',
1819
+ limit: 10,
1820
+ observed: 11,
1821
+ toolName: 'db_query',
1822
+ });
1823
+ /** Child graph's own breaker stays live: in a subagent, a ROOT
1824
+ * sibling's trip arrives only through the composed invocation signal. */
1825
+ const childBreaker = new AbortController();
1826
+ const configAbort = new AbortController();
1827
+ captureEvents();
1828
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
1829
+ class {
1830
+ constructor() {
1831
+ return {
1832
+ invoke: jest.fn().mockImplementation(async () => {
1833
+ configAbort.abort(trip);
1834
+ throw new Error('The operation was aborted');
1835
+ }),
1836
+ };
1837
+ }
1838
+ } as never
1839
+ );
1840
+
1841
+ const agentContext = createAgentContext();
1842
+ const graph = {
1843
+ ...mockGraph(),
1844
+ getBreakerSignal: (): AbortSignal => childBreaker.signal,
1845
+ };
1846
+ const node = createSummarizeNode({
1847
+ agentContext,
1848
+ graph,
1849
+ generateStepId,
1850
+ });
1851
+
1852
+ await expect(
1853
+ node(
1854
+ {
1855
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1856
+ summarizationRequest: {
1857
+ remainingContextTokens: 1000,
1858
+ agentId: 'agent_0',
1859
+ },
1860
+ },
1861
+ { signal: configAbort.signal } as RunnableConfig
1862
+ )
1863
+ ).rejects.toBe(trip);
1864
+ });
1865
+
1866
+ it('rejects at entry when the breaker has already tripped', async () => {
1867
+ const trip = new StreamLimitExceededError({
1868
+ kind: 'tool_call_args',
1869
+ limit: 10,
1870
+ observed: 11,
1871
+ toolName: 'db_query',
1872
+ });
1873
+ const entryBreaker = new AbortController();
1874
+ entryBreaker.abort(trip);
1875
+
1876
+ const modelClassSpy = jest
1877
+ .spyOn(providers, 'getChatModelClass')
1878
+ .mockReturnValue(
1879
+ class {
1880
+ constructor() {
1881
+ return mockInvokeModel('should never run');
1882
+ }
1883
+ } as never
1884
+ );
1885
+
1886
+ const agentContext = createAgentContext();
1887
+ const graph = {
1888
+ ...mockGraph(),
1889
+ getBreakerSignal: (): AbortSignal => entryBreaker.signal,
1890
+ };
1891
+ const node = createSummarizeNode({
1892
+ agentContext,
1893
+ graph,
1894
+ generateStepId,
1895
+ });
1896
+
1897
+ await expect(
1898
+ node(
1899
+ {
1900
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1901
+ summarizationRequest: {
1902
+ remainingContextTokens: 1000,
1903
+ agentId: 'agent_0',
1904
+ },
1905
+ },
1906
+ {} as RunnableConfig
1907
+ )
1908
+ ).rejects.toBe(trip);
1909
+
1910
+ expect(graph.contentData).toHaveLength(0);
1911
+ expect(modelClassSpy).not.toHaveBeenCalled();
1912
+ });
1913
+
1914
+ it('binds the model call to the breaker signal read at node entry', async () => {
1915
+ const entryBreaker = new AbortController();
1916
+ const lateBreaker = new AbortController();
1917
+ let started = false;
1918
+ jest.spyOn(eventUtils, 'safeDispatchCustomEvent').mockImplementation((async (
1919
+ ...args: unknown[]
1920
+ ) => {
1921
+ if (args[0] === GraphEvents.ON_SUMMARIZE_START) {
1922
+ started = true;
1923
+ }
1924
+ }) as never);
1925
+
1926
+ const capturedSignals: Array<AbortSignal | undefined> = [];
1927
+ jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
1928
+ class {
1929
+ constructor() {
1930
+ return {
1931
+ invoke: jest
1932
+ .fn()
1933
+ .mockImplementation(
1934
+ async (_messages: unknown, config?: unknown) => {
1935
+ capturedSignals.push(
1936
+ (config as RunnableConfig | undefined)?.signal
1937
+ );
1938
+ return { content: 'Summary text' };
1939
+ }
1940
+ ),
1941
+ };
1942
+ }
1943
+ } as never
1944
+ );
1945
+
1946
+ const agentContext = createAgentContext();
1947
+ /** Simulates a graph reset between node entry and the model call: once
1948
+ * ON_SUMMARIZE_START has been awaited, the live accessor hands out a
1949
+ * fresh controller's signal. The model call must still see the signal
1950
+ * captured at entry. */
1951
+ const graph = {
1952
+ ...mockGraph(),
1953
+ getBreakerSignal: (): AbortSignal =>
1954
+ started ? lateBreaker.signal : entryBreaker.signal,
1955
+ };
1956
+ const node = createSummarizeNode({
1957
+ agentContext,
1958
+ graph,
1959
+ generateStepId,
1960
+ });
1961
+
1962
+ await node(
1963
+ {
1964
+ messages: [new HumanMessage('Hello'), new HumanMessage('World')],
1965
+ summarizationRequest: {
1966
+ remainingContextTokens: 1000,
1967
+ agentId: 'agent_0',
1968
+ },
1969
+ },
1970
+ {} as RunnableConfig
1971
+ );
1972
+
1973
+ expect(capturedSignals.length).toBeGreaterThan(0);
1974
+ for (const signal of capturedSignals) {
1975
+ expect(signal).toBe(entryBreaker.signal);
1976
+ }
1977
+ });
1978
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Summarization streams replace `ChatModelStreamHandler` with an `onChunk`
3
+ * callback, so the stream circuit breakers are enforced inside
4
+ * `createSummarizationChunkHandler`: the per-generation event cap, and the
5
+ * argument byte cap for tool calls a tool-bound summarization model may
6
+ * emit. Keys derive from the attempt metadata `attemptInvoke` hands each
7
+ * chunk, so a fallback summary attempt never inherits the failed primary's
8
+ * counts.
9
+ */
10
+ import { AIMessageChunk } from '@langchain/core/messages';
11
+ import { describe, it, expect } from '@jest/globals';
12
+ import type { StreamLimitState } from '@/llm/streamLimits';
13
+ import {
14
+ STREAM_LIMIT_ATTEMPT_KEY,
15
+ StreamLimitExceededError,
16
+ enforceStreamLimitsForWireChunk,
17
+ resolveStreamLimits,
18
+ } from '@/llm/streamLimits';
19
+ import { createSummarizationChunkHandler } from '@/summarization/node';
20
+ import { Providers } from '@/common';
21
+
22
+ const makeHandler = (graph: StreamLimitState) =>
23
+ createSummarizationChunkHandler({
24
+ stepId: 'step_1',
25
+ config: { metadata: { langgraph_node: 'summarize', langgraph_step: 4 } },
26
+ provider: Providers.OPENAI,
27
+ reasoningKey: 'reasoning_content',
28
+ graph,
29
+ });
30
+
31
+ const textChunk = (): AIMessageChunk => new AIMessageChunk({ content: 'part' });
32
+
33
+ describe('createSummarizationChunkHandler stream limits', () => {
34
+ it('enforces the event cap on summary streams', async () => {
35
+ const graph: StreamLimitState = {
36
+ streamLimits: resolveStreamLimits({ maxDeltaEventsPerTurn: 2 }),
37
+ };
38
+ const onChunk = makeHandler(graph);
39
+ expect(onChunk).toBeDefined();
40
+ await onChunk!(textChunk());
41
+ await onChunk!(textChunk());
42
+ await expect(async () => onChunk!(textChunk())).rejects.toThrow(
43
+ StreamLimitExceededError
44
+ );
45
+ });
46
+
47
+ it('keys each summary attempt by the metadata attemptInvoke hands the chunk', async () => {
48
+ const graph: StreamLimitState = {
49
+ streamLimits: resolveStreamLimits({ maxDeltaEventsPerTurn: 2 }),
50
+ };
51
+ const onChunk = makeHandler(graph);
52
+ const primary = {
53
+ langgraph_node: 'summarize',
54
+ langgraph_step: 4,
55
+ [STREAM_LIMIT_ATTEMPT_KEY]: 1,
56
+ };
57
+ const fallback = { ...primary, [STREAM_LIMIT_ATTEMPT_KEY]: 2 };
58
+ await onChunk!(textChunk(), primary);
59
+ await onChunk!(textChunk(), primary);
60
+ await onChunk!(textChunk(), fallback);
61
+ await onChunk!(textChunk(), fallback);
62
+ await expect(async () => onChunk!(textChunk(), fallback)).rejects.toThrow(
63
+ StreamLimitExceededError
64
+ );
65
+ });
66
+
67
+ it('enforces the argument byte cap on tool calls from tool-bound summary models', async () => {
68
+ const graph: StreamLimitState = {
69
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 50 }),
70
+ };
71
+ const onChunk = makeHandler(graph);
72
+ const toolChunk = (args: string): AIMessageChunk =>
73
+ new AIMessageChunk({
74
+ content: '',
75
+ tool_call_chunks: [
76
+ { type: 'tool_call_chunk', name: 'db_query', args, index: 0 },
77
+ ],
78
+ });
79
+ await onChunk!(toolChunk('x'.repeat(40)));
80
+ await expect(async () => onChunk!(toolChunk('x'.repeat(20)))).rejects.toThrow(
81
+ '(tool call: db_query)'
82
+ );
83
+ });
84
+
85
+ it('trips the run breaker when its own enforcement breaches', async () => {
86
+ const breaker = new AbortController();
87
+ const graph: StreamLimitState & {
88
+ getBreakerController?: () => AbortController;
89
+ } = {
90
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 50 }),
91
+ getBreakerController: () => breaker,
92
+ };
93
+ const onChunk = createSummarizationChunkHandler({
94
+ stepId: 'step_1',
95
+ config: { metadata: { langgraph_node: 'summarize', langgraph_step: 4 } },
96
+ provider: Providers.OPENAI,
97
+ reasoningKey: 'reasoning_content',
98
+ graph,
99
+ });
100
+ const toolChunk = (args: string): AIMessageChunk =>
101
+ new AIMessageChunk({
102
+ content: '',
103
+ tool_call_chunks: [
104
+ { type: 'tool_call_chunk', name: 'db_query', args, index: 0 },
105
+ ],
106
+ });
107
+
108
+ /** This producer claim wins the race, so the wire consumer's
109
+ * breaker-aborting catch never fires for the same chunk — the trip has
110
+ * to happen here or sibling branches keep consuming quota. */
111
+ await expect(async () => onChunk!(toolChunk('x'.repeat(60)))).rejects.toThrow(
112
+ StreamLimitExceededError
113
+ );
114
+ expect(breaker.signal.aborted).toBe(true);
115
+ expect(breaker.signal.reason).toMatchObject({ kind: 'tool_call_args' });
116
+ });
117
+
118
+ it('charges a chunk once when the run wire consumer also claims it', async () => {
119
+ const graph: StreamLimitState = {
120
+ streamLimits: resolveStreamLimits({ maxToolCallArgBytes: 100 }),
121
+ };
122
+ const onChunk = makeHandler(graph);
123
+ const metadata = { langgraph_node: 'summarize', langgraph_step: 4 };
124
+ const toolChunk = (args: string): AIMessageChunk =>
125
+ new AIMessageChunk({
126
+ content: '',
127
+ tool_call_chunks: [
128
+ { type: 'tool_call_chunk', name: 'db_query', args, index: 0 },
129
+ ],
130
+ });
131
+
132
+ /** Inside a live run the provider callbacks route the same chunk object
133
+ * through the graph's registered stream consumer as well. Wire consumer
134
+ * first, then the summarization handler: 60 bytes must be tallied once
135
+ * (a second same-side claim would put the tally at 120 and trip). */
136
+ const first = toolChunk('x'.repeat(60));
137
+ enforceStreamLimitsForWireChunk({
138
+ graph,
139
+ metadata,
140
+ chunk: first,
141
+ side: 'consumer',
142
+ });
143
+ await onChunk!(first, metadata);
144
+
145
+ /** Opposite order for the next chunk: handler (producer) before wire
146
+ * consumer. 60 + 39 = 99 stays under the cap only when each chunk was
147
+ * charged exactly once. */
148
+ const second = toolChunk('x'.repeat(39));
149
+ await onChunk!(second, metadata);
150
+ enforceStreamLimitsForWireChunk({
151
+ graph,
152
+ metadata,
153
+ chunk: second,
154
+ side: 'consumer',
155
+ });
156
+
157
+ /** Enforcement stays armed: two more bytes cross the 100-byte cap. */
158
+ await expect(async () => onChunk!(toolChunk('xx'), metadata)).rejects.toThrow(
159
+ StreamLimitExceededError
160
+ );
161
+ });
162
+
163
+ it('counts dual-consumed events once against the event cap', async () => {
164
+ const graph: StreamLimitState = {
165
+ streamLimits: resolveStreamLimits({ maxDeltaEventsPerTurn: 2 }),
166
+ };
167
+ const onChunk = makeHandler(graph);
168
+ const metadata = { langgraph_node: 'summarize', langgraph_step: 4 };
169
+ const passBoth = async (chunk: AIMessageChunk): Promise<void> => {
170
+ enforceStreamLimitsForWireChunk({
171
+ graph,
172
+ metadata,
173
+ chunk,
174
+ side: 'consumer',
175
+ });
176
+ await onChunk!(chunk, metadata);
177
+ };
178
+
179
+ await passBoth(textChunk());
180
+ await passBoth(textChunk());
181
+ await expect(async () => passBoth(textChunk())).rejects.toThrow(
182
+ StreamLimitExceededError
183
+ );
184
+ });
185
+
186
+ it('stays inert without graph state', async () => {
187
+ const onChunk = createSummarizationChunkHandler({
188
+ stepId: 'step_1',
189
+ config: { metadata: {} },
190
+ provider: Providers.OPENAI,
191
+ });
192
+ for (let i = 0; i < 10; i++) {
193
+ await onChunk!(textChunk());
194
+ }
195
+ });
196
+ });