@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.
- package/dist/cjs/graphs/Graph.cjs +248 -27
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +228 -16
- package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +13 -2
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +1 -1
- package/dist/cjs/llm/invoke.cjs +160 -43
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +190 -13
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/streamLimits.cjs +723 -0
- package/dist/cjs/llm/streamLimits.cjs.map +1 -0
- package/dist/cjs/main.cjs +12 -3
- package/dist/cjs/messages/core.cjs +592 -27
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/run.cjs +7 -3
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +4 -1
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/stream.cjs +105 -14
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +157 -37
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +3 -2
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +4 -3
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +77 -4
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +3 -2
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
- package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/crw-search.cjs +3 -1
- package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
- package/dist/cjs/tools/search/firecrawl.cjs +7 -1
- package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-search.cjs +3 -1
- package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
- package/dist/cjs/tools/search/rerankers.cjs +26 -8
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +30 -10
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
- package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
- package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
- package/dist/cjs/tools/search/tavily-search.cjs +3 -1
- package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +17 -3
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +2 -1
- package/dist/cjs/utils/misc.cjs +12 -0
- package/dist/cjs/utils/misc.cjs.map +1 -1
- package/dist/cjs/utils/proxy.cjs +63 -0
- package/dist/cjs/utils/proxy.cjs.map +1 -0
- package/dist/esm/graphs/Graph.mjs +247 -26
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +228 -16
- package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +13 -2
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +1 -1
- package/dist/esm/llm/invoke.mjs +160 -43
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +192 -15
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/streamLimits.mjs +704 -0
- package/dist/esm/llm/streamLimits.mjs.map +1 -0
- package/dist/esm/main.mjs +8 -6
- package/dist/esm/messages/core.mjs +592 -28
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/run.mjs +7 -3
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +4 -1
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/stream.mjs +105 -14
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +157 -37
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +3 -2
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +4 -3
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
- package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +77 -4
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +3 -2
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/search/crw-scraper.mjs +7 -1
- package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/crw-search.mjs +3 -1
- package/dist/esm/tools/search/crw-search.mjs.map +1 -1
- package/dist/esm/tools/search/firecrawl.mjs +7 -1
- package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
- package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-search.mjs +3 -1
- package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
- package/dist/esm/tools/search/rerankers.mjs +26 -8
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +30 -10
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/serper-scraper.mjs +7 -1
- package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
- package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
- package/dist/esm/tools/search/tavily-search.mjs +3 -1
- package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +17 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +2 -1
- package/dist/esm/utils/misc.mjs +12 -1
- package/dist/esm/utils/misc.mjs.map +1 -1
- package/dist/esm/utils/proxy.mjs +62 -0
- package/dist/esm/utils/proxy.mjs.map +1 -0
- package/dist/types/graphs/Graph.d.ts +53 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/langfuseToolOutputTracing.d.ts +1 -0
- package/dist/types/llm/invoke.d.ts +26 -5
- package/dist/types/llm/openai/index.d.ts +3 -0
- package/dist/types/llm/streamLimits.d.ts +314 -0
- package/dist/types/messages/core.d.ts +11 -6
- package/dist/types/run.d.ts +1 -0
- package/dist/types/summarization/node.d.ts +27 -2
- package/dist/types/tools/BashExecutor.d.ts +2 -2
- package/dist/types/tools/CodeExecutor.d.ts +3 -3
- package/dist/types/tools/ToolNode.d.ts +11 -1
- package/dist/types/tools/search/crw-scraper.d.ts +2 -0
- package/dist/types/tools/search/firecrawl.d.ts +2 -0
- package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
- package/dist/types/tools/search/rerankers.d.ts +9 -5
- package/dist/types/tools/search/serper-scraper.d.ts +2 -0
- package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
- package/dist/types/tools/search/types.d.ts +28 -33
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
- package/dist/types/types/graph.d.ts +7 -1
- package/dist/types/types/run.d.ts +46 -1
- package/dist/types/types/tools.d.ts +21 -0
- package/dist/types/utils/index.d.ts +1 -0
- package/dist/types/utils/misc.d.ts +7 -0
- package/dist/types/utils/proxy.d.ts +31 -0
- package/package.json +2 -1
- package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
- package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
- package/src/__tests__/stream.streamLimits.test.ts +1982 -0
- package/src/graphs/Graph.ts +302 -27
- package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
- package/src/index.ts +11 -0
- package/src/langfuseToolOutputTracing.ts +410 -14
- package/src/llm/bedrock/index.ts +22 -2
- package/src/llm/custom-chat-models.smoke.test.ts +747 -0
- package/src/llm/invoke.streamLimits.test.ts +142 -0
- package/src/llm/invoke.test.ts +187 -1
- package/src/llm/invoke.ts +231 -43
- package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
- package/src/llm/openai/index.ts +348 -31
- package/src/llm/openai/llm.spec.ts +107 -6
- package/src/llm/streamLimits.test.ts +450 -0
- package/src/llm/streamLimits.ts +1158 -0
- package/src/messages/core.ts +1290 -42
- package/src/messages/formatAgentMessages.test.ts +2623 -0
- package/src/run.ts +4 -0
- package/src/session/AgentSession.ts +5 -0
- package/src/specs/langfuse-tool-output-tracing.test.ts +887 -0
- package/src/specs/preemptSeal.test.ts +374 -5
- package/src/stream.ts +141 -6
- package/src/summarization/__tests__/node.test.ts +269 -0
- package/src/summarization/chunkHandler.test.ts +196 -0
- package/src/summarization/node.ts +203 -6
- package/src/tools/BashExecutor.ts +4 -3
- package/src/tools/CodeExecutor.ts +5 -4
- package/src/tools/ProgrammaticToolCalling.ts +7 -5
- package/src/tools/ToolNode.ts +109 -6
- package/src/tools/ToolSearch.ts +4 -3
- package/src/tools/__tests__/BashExecutor.test.ts +2 -2
- package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
- package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
- package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
- package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
- package/src/tools/search/crw-scraper.ts +6 -0
- package/src/tools/search/crw-search.ts +6 -1
- package/src/tools/search/firecrawl.ts +6 -0
- package/src/tools/search/http-agent.test.ts +133 -0
- package/src/tools/search/keenable-scraper.ts +6 -0
- package/src/tools/search/keenable-search.ts +6 -1
- package/src/tools/search/rerankers.ts +36 -10
- package/src/tools/search/search.ts +29 -11
- package/src/tools/search/serper-scraper.ts +6 -0
- package/src/tools/search/tavily-scraper.ts +6 -0
- package/src/tools/search/tavily-search.ts +2 -0
- package/src/tools/search/tool.ts +16 -0
- package/src/tools/search/types.ts +31 -33
- package/src/tools/subagent/SubagentExecutor.ts +96 -3
- package/src/types/graph.ts +7 -0
- package/src/types/run.ts +49 -1
- package/src/types/tools.ts +21 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/misc.ts +19 -0
- package/src/utils/proxy.test.ts +176 -0
- 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
|
+
});
|