@librechat/agents 3.3.11 → 3.3.13
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 +246 -25
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/instrumentation.cjs +1 -0
- package/dist/cjs/instrumentation.cjs.map +1 -1
- package/dist/cjs/langfuseSpanRegistry.cjs +6 -3
- package/dist/cjs/langfuseSpanRegistry.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/invoke.cjs +146 -36
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +2 -2
- 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 +8 -0
- package/dist/cjs/run.cjs +6 -2
- 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 +103 -12
- 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 +76 -3
- 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 +16 -2
- 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 +1 -0
- 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 +245 -24
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/instrumentation.mjs +1 -0
- package/dist/esm/instrumentation.mjs.map +1 -1
- package/dist/esm/langfuseSpanRegistry.mjs +6 -3
- package/dist/esm/langfuseSpanRegistry.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/invoke.mjs +146 -36
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +2 -2
- 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 +4 -2
- package/dist/esm/run.mjs +6 -2
- 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 +103 -12
- 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 +76 -3
- 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 +16 -2
- 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 +1 -0
- 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/llm/invoke.d.ts +25 -4
- package/dist/types/llm/openai/index.d.ts +3 -0
- package/dist/types/llm/streamLimits.d.ts +314 -0
- 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 +12 -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/instrumentation.ts +1 -0
- package/src/langfuseSpanRegistry.ts +9 -0
- package/src/llm/bedrock/index.ts +22 -2
- package/src/llm/invoke.streamLimits.test.ts +142 -0
- package/src/llm/invoke.test.ts +89 -1
- package/src/llm/invoke.ts +197 -20
- package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
- package/src/llm/openai/index.ts +14 -6
- package/src/llm/streamLimits.test.ts +450 -0
- package/src/llm/streamLimits.ts +1158 -0
- package/src/run.ts +4 -0
- package/src/session/AgentSession.ts +5 -0
- package/src/specs/langfuse-instrumentation.test.ts +26 -0
- package/src/specs/langfuse-span-registry.test.ts +17 -0
- package/src/specs/summarization.test.ts +5 -2
- 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 +12 -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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import type { RunnableConfig } from '@langchain/core/runnables';
|
|
3
|
+
import type * as t from '@/types';
|
|
4
|
+
import { StreamLimitExceededError } from '@/llm/streamLimits';
|
|
5
|
+
import { Providers } from '@/common';
|
|
6
|
+
import { StandardGraph } from '../Graph';
|
|
7
|
+
|
|
8
|
+
const makeAgent = (agentId: string): t.AgentInputs => ({
|
|
9
|
+
agentId,
|
|
10
|
+
provider: Providers.OPENAI,
|
|
11
|
+
instructions: `You are ${agentId}.`,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const makeGraph = (): StandardGraph =>
|
|
15
|
+
new StandardGraph({
|
|
16
|
+
runId: 'run_1',
|
|
17
|
+
agents: [makeAgent('agent')],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const makeTrip = (): StreamLimitExceededError =>
|
|
21
|
+
new StreamLimitExceededError({
|
|
22
|
+
kind: 'tool_call_args',
|
|
23
|
+
limit: 10,
|
|
24
|
+
observed: 11,
|
|
25
|
+
toolName: 'db_query',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('run breaker lifecycle', () => {
|
|
29
|
+
it('replaces the breaker at every run start, even when un-aborted', () => {
|
|
30
|
+
const graph = makeGraph();
|
|
31
|
+
const previous = graph.breakerAbort;
|
|
32
|
+
expect(previous.signal.aborted).toBe(false);
|
|
33
|
+
|
|
34
|
+
graph.resetValues();
|
|
35
|
+
|
|
36
|
+
expect(graph.breakerAbort).not.toBe(previous);
|
|
37
|
+
/** A straggler from the failed run trips the controller it captured;
|
|
38
|
+
* the run starting now must not observe it. */
|
|
39
|
+
previous.abort(makeTrip());
|
|
40
|
+
expect(graph.breakerAbort.signal.aborted).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('keeps straggler accounting through cleanup and one reset, then sweeps it', () => {
|
|
44
|
+
const graph = makeGraph();
|
|
45
|
+
const epoch = graph.breakerEpoch;
|
|
46
|
+
graph.streamedToolCallArgTallies.set('gen:i:0', { bytes: 42, epoch });
|
|
47
|
+
graph.streamDeltaEventCounts.set('gen', { count: 7, epoch });
|
|
48
|
+
graph.streamLimitChargeCredits = new WeakMap();
|
|
49
|
+
|
|
50
|
+
/** Cleanup runs while sibling attempts can still be unwinding on the
|
|
51
|
+
* retained breaker; clearing here would hand a cancellation-ignoring
|
|
52
|
+
* provider's late chunks a fresh budget. */
|
|
53
|
+
graph.clearHeavyState();
|
|
54
|
+
expect(graph.streamedToolCallArgTallies.get('gen:i:0')?.bytes).toBe(42);
|
|
55
|
+
expect(graph.streamDeltaEventCounts.get('gen')?.count).toBe(7);
|
|
56
|
+
expect(graph.streamLimitChargeCredits).toBeDefined();
|
|
57
|
+
|
|
58
|
+
/** Producer loops of straggling attempts sit OUTSIDE the consumer-only
|
|
59
|
+
* epoch gate, so their entries must survive the next run start too —
|
|
60
|
+
* one grace reset keeps them on their original budgets. */
|
|
61
|
+
graph.resetValues();
|
|
62
|
+
expect(graph.streamedToolCallArgTallies.get('gen:i:0')?.bytes).toBe(42);
|
|
63
|
+
expect(graph.streamDeltaEventCounts.get('gen')?.count).toBe(7);
|
|
64
|
+
expect(graph.streamLimitChargeCredits).toBeUndefined();
|
|
65
|
+
|
|
66
|
+
/** A second reset sweeps entries from older epochs. */
|
|
67
|
+
graph.resetValues();
|
|
68
|
+
expect(graph.streamedToolCallArgTallies.size).toBe(0);
|
|
69
|
+
expect(graph.streamDeltaEventCounts.size).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('holds a leased producer straggler to its budget across two run resets', async () => {
|
|
73
|
+
const { attemptInvoke } = await import('@/llm/invoke');
|
|
74
|
+
const { resolveStreamLimits } = await import('@/llm/streamLimits');
|
|
75
|
+
const { AIMessageChunk, HumanMessage } = await import(
|
|
76
|
+
'@langchain/core/messages'
|
|
77
|
+
);
|
|
78
|
+
const graph = makeGraph();
|
|
79
|
+
graph.streamLimits = resolveStreamLimits({ maxToolCallArgBytes: 100 });
|
|
80
|
+
const chunkOf = (args: string): InstanceType<typeof AIMessageChunk> =>
|
|
81
|
+
new AIMessageChunk({
|
|
82
|
+
content: '',
|
|
83
|
+
tool_call_chunks: [
|
|
84
|
+
{
|
|
85
|
+
type: 'tool_call_chunk',
|
|
86
|
+
id: 'call_1',
|
|
87
|
+
name: 'writer',
|
|
88
|
+
args,
|
|
89
|
+
index: 0,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
let yields = 0;
|
|
94
|
+
const model = {
|
|
95
|
+
stream: async function* (): AsyncGenerator<
|
|
96
|
+
InstanceType<typeof AIMessageChunk>
|
|
97
|
+
> {
|
|
98
|
+
yields += 1;
|
|
99
|
+
yield chunkOf('x'.repeat(60));
|
|
100
|
+
/** Two prompt runs start and reset while this attempt is still
|
|
101
|
+
* draining. Its lease must keep the tally alive — reset counting
|
|
102
|
+
* cannot prove the attempt finished. */
|
|
103
|
+
graph.resetValues();
|
|
104
|
+
graph.resetValues();
|
|
105
|
+
yields += 1;
|
|
106
|
+
yield chunkOf('x'.repeat(60));
|
|
107
|
+
yields += 1;
|
|
108
|
+
yield chunkOf('x');
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
await expect(
|
|
113
|
+
attemptInvoke(
|
|
114
|
+
{
|
|
115
|
+
model: model as unknown as t.ChatModel,
|
|
116
|
+
messages: [new HumanMessage('hi')],
|
|
117
|
+
provider: Providers.OPENAI,
|
|
118
|
+
context: graph as Parameters<
|
|
119
|
+
typeof attemptInvoke
|
|
120
|
+
>[0]['context'],
|
|
121
|
+
onChunk: () => {
|
|
122
|
+
/* producer loop enforcement is under test */
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{ metadata: {} }
|
|
126
|
+
)
|
|
127
|
+
).rejects.toMatchObject({ kind: 'tool_call_args', limit: 100 });
|
|
128
|
+
expect(yields).toBe(2);
|
|
129
|
+
/** The attempt's finally released its lease and entries. */
|
|
130
|
+
expect(graph.activeStreamLimitGenerations?.size ?? 0).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('takes no attempt lease when every guard is disabled', async () => {
|
|
134
|
+
const { attemptInvoke } = await import('@/llm/invoke');
|
|
135
|
+
const { resolveStreamLimits } = await import('@/llm/streamLimits');
|
|
136
|
+
const { AIMessage, HumanMessage } = await import(
|
|
137
|
+
'@langchain/core/messages'
|
|
138
|
+
);
|
|
139
|
+
const graph = makeGraph();
|
|
140
|
+
graph.streamLimits = resolveStreamLimits({
|
|
141
|
+
maxToolCallArgBytes: 0,
|
|
142
|
+
maxToolCallArgBytesByTool: { writer: 0 },
|
|
143
|
+
});
|
|
144
|
+
const model = {
|
|
145
|
+
invoke: async (): Promise<InstanceType<typeof AIMessage>> =>
|
|
146
|
+
new AIMessage({ content: 'ok' }),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
await attemptInvoke(
|
|
150
|
+
{
|
|
151
|
+
model: model as unknown as t.ChatModel,
|
|
152
|
+
messages: [new HumanMessage('hi')],
|
|
153
|
+
provider: Providers.OPENAI,
|
|
154
|
+
context: graph as Parameters<typeof attemptInvoke>[0]['context'],
|
|
155
|
+
},
|
|
156
|
+
{ metadata: {} }
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
/** The lease only protects accounting entries; with no guard able to
|
|
160
|
+
* fire there are none, and per-attempt bookkeeping must not allocate. */
|
|
161
|
+
expect(graph.activeStreamLimitGenerations).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('holds a post-reset producer straggler to its original byte budget', async () => {
|
|
165
|
+
const { enforceStreamLimitsForWireChunk } = await import(
|
|
166
|
+
'@/llm/streamLimits'
|
|
167
|
+
);
|
|
168
|
+
const graph = makeGraph();
|
|
169
|
+
graph.streamLimits = {
|
|
170
|
+
maxToolCallArgBytes: 100,
|
|
171
|
+
maxDeltaEventsPerTurn: 0,
|
|
172
|
+
hasEnforceableToolCallArgLimit: true,
|
|
173
|
+
};
|
|
174
|
+
const metadata = {
|
|
175
|
+
langgraph_checkpoint_ns: '',
|
|
176
|
+
langgraph_node: 'agent',
|
|
177
|
+
langgraph_step: 1,
|
|
178
|
+
};
|
|
179
|
+
const chunkOf = (
|
|
180
|
+
args: string
|
|
181
|
+
): { tool_call_chunks: Array<Record<string, unknown>> } => ({
|
|
182
|
+
tool_call_chunks: [{ id: 'call_1', name: 'writer', args, index: 0 }],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
enforceStreamLimitsForWireChunk({
|
|
186
|
+
graph,
|
|
187
|
+
metadata,
|
|
188
|
+
chunk: chunkOf('x'.repeat(60)) as Parameters<
|
|
189
|
+
typeof enforceStreamLimitsForWireChunk
|
|
190
|
+
>[0]['chunk'],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/** The next run starts while this producer is still draining. Its next
|
|
194
|
+
* chunk must land on the ORIGINAL 60-byte tally and trip, not open a
|
|
195
|
+
* fresh allowance. */
|
|
196
|
+
graph.resetValues();
|
|
197
|
+
expect(() =>
|
|
198
|
+
enforceStreamLimitsForWireChunk({
|
|
199
|
+
graph,
|
|
200
|
+
metadata,
|
|
201
|
+
chunk: chunkOf('x'.repeat(60)) as Parameters<
|
|
202
|
+
typeof enforceStreamLimitsForWireChunk
|
|
203
|
+
>[0]['chunk'],
|
|
204
|
+
})
|
|
205
|
+
).toThrow(StreamLimitExceededError);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('rejects a model node at entry when the breaker has already tripped', async () => {
|
|
209
|
+
const graph = makeGraph();
|
|
210
|
+
const trip = makeTrip();
|
|
211
|
+
graph.breakerAbort.abort(trip);
|
|
212
|
+
|
|
213
|
+
const node = graph.createCallModel('agent');
|
|
214
|
+
await expect(
|
|
215
|
+
node(
|
|
216
|
+
{ messages: [] } as unknown as t.AgentSubgraphState,
|
|
217
|
+
{} as RunnableConfig
|
|
218
|
+
)
|
|
219
|
+
).rejects.toBe(trip);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('lets a model node proceed past entry when the breaker is live', async () => {
|
|
223
|
+
const graph = makeGraph();
|
|
224
|
+
const node = graph.createCallModel('agent');
|
|
225
|
+
/** With a live breaker the entry guard must not fire; the node then
|
|
226
|
+
* fails later, on the missing config, proving it got past the guard. */
|
|
227
|
+
await expect(
|
|
228
|
+
node(
|
|
229
|
+
{ messages: [] } as unknown as t.AgentSubgraphState,
|
|
230
|
+
undefined
|
|
231
|
+
)
|
|
232
|
+
).rejects.toThrow('No config provided');
|
|
233
|
+
});
|
|
234
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -82,3 +82,14 @@ export { initializeModel } from './llm/init';
|
|
|
82
82
|
export { attemptInvoke, tryFallbackProviders } from './llm/invoke';
|
|
83
83
|
export { canSealPreempt } from './llm/preempt';
|
|
84
84
|
export { isThinkingEnabled, getMaxOutputTokensKey } from './llm/request';
|
|
85
|
+
export {
|
|
86
|
+
DEFAULT_MAX_TOOL_CALL_ARG_BYTES,
|
|
87
|
+
StreamLimitExceededError,
|
|
88
|
+
resolveStreamLimits,
|
|
89
|
+
} from './llm/streamLimits';
|
|
90
|
+
export type {
|
|
91
|
+
StreamedToolCallArgTally,
|
|
92
|
+
ResolvedStreamLimits,
|
|
93
|
+
StreamLimitState,
|
|
94
|
+
StreamLimitKind,
|
|
95
|
+
} from './llm/streamLimits';
|
package/src/instrumentation.ts
CHANGED
|
@@ -72,6 +72,9 @@ export function getLangfuseSpanProcessorParams(
|
|
|
72
72
|
secretKey: langfuse.secretKey,
|
|
73
73
|
...(isPresent(langfuse.baseUrl) ? { baseUrl: langfuse.baseUrl } : {}),
|
|
74
74
|
...(isPresent(environment) ? { environment } : {}),
|
|
75
|
+
...(langfuse.mediaUploadEnabled != null
|
|
76
|
+
? { mediaUploadEnabled: langfuse.mediaUploadEnabled }
|
|
77
|
+
: {}),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
if (hasLangfuseEnvConfig()) {
|
|
@@ -84,6 +87,9 @@ export function getLangfuseSpanProcessorParams(
|
|
|
84
87
|
secretKey: process.env.LANGFUSE_SECRET_KEY as string,
|
|
85
88
|
...(isPresent(baseUrl) ? { baseUrl } : {}),
|
|
86
89
|
...(isPresent(environment) ? { environment } : {}),
|
|
90
|
+
...(langfuse?.mediaUploadEnabled != null
|
|
91
|
+
? { mediaUploadEnabled: langfuse.mediaUploadEnabled }
|
|
92
|
+
: {}),
|
|
87
93
|
};
|
|
88
94
|
}
|
|
89
95
|
if (isPresent(langfuse?.baseUrl) && hasLangfuseEnvCredentials()) {
|
|
@@ -92,6 +98,9 @@ export function getLangfuseSpanProcessorParams(
|
|
|
92
98
|
secretKey: process.env.LANGFUSE_SECRET_KEY as string,
|
|
93
99
|
baseUrl: langfuse.baseUrl,
|
|
94
100
|
...(isPresent(environment) ? { environment } : {}),
|
|
101
|
+
...(langfuse.mediaUploadEnabled != null
|
|
102
|
+
? { mediaUploadEnabled: langfuse.mediaUploadEnabled }
|
|
103
|
+
: {}),
|
|
95
104
|
};
|
|
96
105
|
}
|
|
97
106
|
return undefined;
|
package/src/llm/bedrock/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
supportsBedrockToolCache,
|
|
47
47
|
type PromptCacheTtl,
|
|
48
48
|
} from '@/messages/cache';
|
|
49
|
+
import { linkStreamLimitCanonical } from '@/llm/streamLimits';
|
|
49
50
|
import { applyCachePointsToConversePayload } from './cachePoints';
|
|
50
51
|
import { insertBedrockToolCachePoint } from './toolCache';
|
|
51
52
|
|
|
@@ -572,8 +573,17 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
|
|
|
572
573
|
}
|
|
573
574
|
|
|
574
575
|
const deltaChunk = handleConverseStreamContentBlockDelta(splitDelta);
|
|
576
|
+
const enrichedChunk = this.enrichChunk(deltaChunk, seenBlockIndices);
|
|
577
|
+
if (enrichedChunk !== deltaChunk) {
|
|
578
|
+
/** The callback copy is the same emission as the enriched yield;
|
|
579
|
+
* without the link, stream-limit accounting charges both message
|
|
580
|
+
* objects and Bedrock tool arguments falsely trip near half the
|
|
581
|
+
* cap. Linked on the messages, which are what the accounting
|
|
582
|
+
* observes. */
|
|
583
|
+
linkStreamLimitCanonical(deltaChunk.message, enrichedChunk.message);
|
|
584
|
+
}
|
|
575
585
|
await enqueueChunk({
|
|
576
|
-
chunk:
|
|
586
|
+
chunk: enrichedChunk,
|
|
577
587
|
callbackChunk: deltaChunk,
|
|
578
588
|
callbackToken: deltaChunk.text,
|
|
579
589
|
smooth,
|
|
@@ -602,8 +612,18 @@ export class CustomChatBedrockConverse extends ChatBedrockConverse {
|
|
|
602
612
|
toolUseBlockIndices.add(idx);
|
|
603
613
|
}
|
|
604
614
|
}
|
|
615
|
+
const enrichedStart = this.enrichChunk(
|
|
616
|
+
startChunk,
|
|
617
|
+
seenBlockIndices
|
|
618
|
+
);
|
|
619
|
+
if (enrichedStart !== startChunk) {
|
|
620
|
+
linkStreamLimitCanonical(
|
|
621
|
+
startChunk.message,
|
|
622
|
+
enrichedStart.message
|
|
623
|
+
);
|
|
624
|
+
}
|
|
605
625
|
await enqueueChunk({
|
|
606
|
-
chunk:
|
|
626
|
+
chunk: enrichedStart,
|
|
607
627
|
callbackChunk: startChunk,
|
|
608
628
|
callbackToken: startChunk.text,
|
|
609
629
|
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `attemptInvoke`'s local stream branch (no registered SDK dispatcher) must
|
|
3
|
+
* charge the event budget even for chunks that produce no handling chunk:
|
|
4
|
+
* a pure OpenRouter reasoning-replay chunk is skipped for content handling,
|
|
5
|
+
* and in this branch no `streamEvents` consumer counts the wire event
|
|
6
|
+
* either, so a looping replay stream would otherwise bypass an enabled
|
|
7
|
+
* `maxDeltaEventsPerTurn` indefinitely.
|
|
8
|
+
*/
|
|
9
|
+
import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
|
|
10
|
+
import { describe, it, expect, jest } from '@jest/globals';
|
|
11
|
+
import type { AgentContext } from '@/agents/AgentContext';
|
|
12
|
+
import type { StandardGraph } from '@/graphs';
|
|
13
|
+
import type * as t from '@/types';
|
|
14
|
+
import {
|
|
15
|
+
StreamLimitExceededError,
|
|
16
|
+
resolveStreamLimits,
|
|
17
|
+
} from '@/llm/streamLimits';
|
|
18
|
+
import { attemptInvoke } from '@/llm/invoke';
|
|
19
|
+
import { ContentTypes, Providers } from '@/common';
|
|
20
|
+
|
|
21
|
+
function createContext(
|
|
22
|
+
overrides: Partial<StandardGraph> = {}
|
|
23
|
+
): StandardGraph {
|
|
24
|
+
const runSteps = new Map<string, t.RunStep>();
|
|
25
|
+
const stepIdsByKey = new Map<string, string>();
|
|
26
|
+
let stepCounter = 0;
|
|
27
|
+
|
|
28
|
+
const graph = {
|
|
29
|
+
config: { configurable: { user_id: 'user_1' }, metadata: { run_id: 'r1' } },
|
|
30
|
+
eagerEventToolExecution: undefined,
|
|
31
|
+
eagerEventToolExecutions: new Map(),
|
|
32
|
+
eagerEventToolCallChunks: new Map(),
|
|
33
|
+
eagerEventToolSuppressions: new Set<string>(),
|
|
34
|
+
handlerRegistry: undefined,
|
|
35
|
+
hookRegistry: undefined,
|
|
36
|
+
humanInTheLoop: undefined,
|
|
37
|
+
toolOutputReferences: undefined,
|
|
38
|
+
sessions: new Map(),
|
|
39
|
+
toolCallStepIds: new Map(),
|
|
40
|
+
messageIdsByStepKey: new Map(),
|
|
41
|
+
messageStepHasToolCalls: new Map(),
|
|
42
|
+
prelimMessageIdsByStepKey: new Map([['step-key', 'msg_1']]),
|
|
43
|
+
getAgentContext: jest.fn(
|
|
44
|
+
(): Partial<AgentContext> => ({
|
|
45
|
+
provider: Providers.OPENAI,
|
|
46
|
+
reasoningKey: 'reasoning_content',
|
|
47
|
+
currentTokenType: ContentTypes.TEXT,
|
|
48
|
+
toolDefinitions: [],
|
|
49
|
+
graphTools: [],
|
|
50
|
+
agentId: 'agent_1',
|
|
51
|
+
})
|
|
52
|
+
),
|
|
53
|
+
getOrCreateToolOutputRegistry: jest.fn(() => undefined),
|
|
54
|
+
shouldPreemptStream: jest.fn(() => false),
|
|
55
|
+
claimPreemptSeal: jest.fn(() => false),
|
|
56
|
+
getStepKey: jest.fn(() => 'step-key'),
|
|
57
|
+
getStepIdByKey: jest.fn((stepKey: string) => {
|
|
58
|
+
const stepId = stepIdsByKey.get(stepKey);
|
|
59
|
+
if (stepId == null) {
|
|
60
|
+
throw new Error('no current step');
|
|
61
|
+
}
|
|
62
|
+
return stepId;
|
|
63
|
+
}),
|
|
64
|
+
getRunStep: jest.fn((stepId: string) => runSteps.get(stepId)),
|
|
65
|
+
dispatchRunStep: jest.fn(async (stepKey: string, details: unknown) => {
|
|
66
|
+
const id = `step_${++stepCounter}`;
|
|
67
|
+
stepIdsByKey.set(stepKey, id);
|
|
68
|
+
runSteps.set(id, {
|
|
69
|
+
id,
|
|
70
|
+
type: (details as { type: t.RunStep['type'] }).type,
|
|
71
|
+
stepDetails: details as t.RunStep['stepDetails'],
|
|
72
|
+
} as t.RunStep);
|
|
73
|
+
return id;
|
|
74
|
+
}),
|
|
75
|
+
dispatchRunStepDelta: jest.fn(async () => undefined),
|
|
76
|
+
dispatchMessageDelta: jest.fn(async () => undefined),
|
|
77
|
+
dispatchReasoningDelta: jest.fn(async () => undefined),
|
|
78
|
+
...overrides,
|
|
79
|
+
};
|
|
80
|
+
return graph as unknown as StandardGraph;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const replayChunk = (): AIMessageChunk =>
|
|
84
|
+
new AIMessageChunk({
|
|
85
|
+
content: 'abc',
|
|
86
|
+
additional_kwargs: { reasoning_details: [{ type: 'reasoning.text' }] },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function createReplayModel(replays: number): t.ChatModel {
|
|
90
|
+
return {
|
|
91
|
+
stream: async function* stream(): AsyncGenerator<AIMessageChunk> {
|
|
92
|
+
yield new AIMessageChunk({ content: 'abc' });
|
|
93
|
+
for (let i = 0; i < replays; i++) {
|
|
94
|
+
yield replayChunk();
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
} as unknown as t.ChatModel;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('attemptInvoke local branch stream limits', () => {
|
|
101
|
+
it('charges skipped OpenRouter replay chunks against the event budget', async () => {
|
|
102
|
+
const context = createContext({
|
|
103
|
+
streamLimits: resolveStreamLimits({ maxDeltaEventsPerTurn: 3 }),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
let caught: unknown;
|
|
107
|
+
try {
|
|
108
|
+
await attemptInvoke(
|
|
109
|
+
{
|
|
110
|
+
model: createReplayModel(10),
|
|
111
|
+
messages: [new HumanMessage('hi')],
|
|
112
|
+
provider: Providers.OPENROUTER,
|
|
113
|
+
context: context as unknown as Parameters<
|
|
114
|
+
typeof attemptInvoke
|
|
115
|
+
>[0]['context'],
|
|
116
|
+
},
|
|
117
|
+
{ metadata: { langgraph_node: 'agent', langgraph_step: 1 } }
|
|
118
|
+
);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
caught = error;
|
|
121
|
+
}
|
|
122
|
+
expect(caught).toBeInstanceOf(StreamLimitExceededError);
|
|
123
|
+
expect((caught as StreamLimitExceededError).kind).toBe('delta_events');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('leaves replay streams unbounded when the cap is disabled', async () => {
|
|
127
|
+
const context = createContext({ streamLimits: resolveStreamLimits() });
|
|
128
|
+
|
|
129
|
+
const result = await attemptInvoke(
|
|
130
|
+
{
|
|
131
|
+
model: createReplayModel(10),
|
|
132
|
+
messages: [new HumanMessage('hi')],
|
|
133
|
+
provider: Providers.OPENROUTER,
|
|
134
|
+
context: context as unknown as Parameters<
|
|
135
|
+
typeof attemptInvoke
|
|
136
|
+
>[0]['context'],
|
|
137
|
+
},
|
|
138
|
+
{ metadata: { langgraph_node: 'agent', langgraph_step: 1 } }
|
|
139
|
+
);
|
|
140
|
+
expect(result.messages).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
package/src/llm/invoke.test.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/messag
|
|
|
19
19
|
import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
|
|
20
20
|
import { convertMessageContentToParts } from '@/llm/google/utils/common';
|
|
21
21
|
import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
|
|
22
|
+
import { StreamLimitExceededError } from '@/llm/streamLimits';
|
|
22
23
|
import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
|
|
23
24
|
import { toLangChainContent } from '@/messages/langchain';
|
|
24
25
|
import { Constants, Providers } from '@/common';
|
|
@@ -556,7 +557,7 @@ describe('attemptInvoke applies lazy ref annotation', () => {
|
|
|
556
557
|
responses.model.defaultOptions = { tools: [] };
|
|
557
558
|
responses.model._useResponsesApi = (options) =>
|
|
558
559
|
Array.isArray((options as { tools?: unknown[] } | undefined)?.tools) &&
|
|
559
|
-
(
|
|
560
|
+
(options as { tools: unknown[] }).tools.length > 0;
|
|
560
561
|
|
|
561
562
|
await attemptInvoke(
|
|
562
563
|
{
|
|
@@ -829,6 +830,93 @@ describe('tryFallbackProviders applies the same lazy annotation transform', () =
|
|
|
829
830
|
jest.resetModules();
|
|
830
831
|
});
|
|
831
832
|
|
|
833
|
+
it('stops draining a cancellation-ignoring stream once the breaker trips', async () => {
|
|
834
|
+
const abort = new AbortController();
|
|
835
|
+
/** Statically imported class: neighboring tests reset the module
|
|
836
|
+
* registry, and a dynamically imported instance would fail the
|
|
837
|
+
* statically-linked drain loop's instanceof check. */
|
|
838
|
+
const trip = new StreamLimitExceededError({
|
|
839
|
+
kind: 'tool_call_args',
|
|
840
|
+
limit: 10,
|
|
841
|
+
observed: 11,
|
|
842
|
+
toolName: 'db_query',
|
|
843
|
+
});
|
|
844
|
+
let yields = 0;
|
|
845
|
+
const model: StubModel = {
|
|
846
|
+
stream: jest.fn(async function* (): AsyncGenerator<AIMessageChunk> {
|
|
847
|
+
for (let i = 0; i < 5; i++) {
|
|
848
|
+
yields += 1;
|
|
849
|
+
yield new AIMessageChunk({ content: `c${i}` });
|
|
850
|
+
}
|
|
851
|
+
}),
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
/** The stub ignores the abort signal entirely; only the per-chunk
|
|
855
|
+
* breaker check in the drain loop can stop it. */
|
|
856
|
+
await expect(
|
|
857
|
+
attemptInvoke(
|
|
858
|
+
{
|
|
859
|
+
model: model as t.ChatModel,
|
|
860
|
+
messages: [new HumanMessage('hi')],
|
|
861
|
+
provider: Providers.ANTHROPIC,
|
|
862
|
+
onChunk: () => {
|
|
863
|
+
abort.abort(trip);
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
{ signal: abort.signal }
|
|
867
|
+
)
|
|
868
|
+
).rejects.toBe(trip);
|
|
869
|
+
expect(yields).toBeLessThanOrEqual(2);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('rejects before invoking a fallback when the breaker tripped during preparation', async () => {
|
|
873
|
+
const abort = new AbortController();
|
|
874
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
875
|
+
await jest.isolateModulesAsync(async () => {
|
|
876
|
+
jest.doMock('@/llm/init', () => ({
|
|
877
|
+
initializeModel: (): unknown => model,
|
|
878
|
+
}));
|
|
879
|
+
const { tryFallbackProviders: freshTry } = (await import(
|
|
880
|
+
'@/llm/invoke'
|
|
881
|
+
)) as { tryFallbackProviders: typeof tryFallbackProviders };
|
|
882
|
+
const { StreamLimitExceededError: FreshStreamLimitError } = (await import(
|
|
883
|
+
'@/llm/streamLimits'
|
|
884
|
+
)) as typeof import('@/llm/streamLimits');
|
|
885
|
+
const trip = new FreshStreamLimitError({
|
|
886
|
+
kind: 'tool_call_args',
|
|
887
|
+
limit: 10,
|
|
888
|
+
observed: 11,
|
|
889
|
+
toolName: 'db_query',
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
/** The sibling's trip lands while fallback preparation is awaited; the
|
|
893
|
+
* fallback must not be invoked at all — a provider that ignores the
|
|
894
|
+
* aborted signal and SUCCEEDS would resolve a run that must reject. */
|
|
895
|
+
await expect(
|
|
896
|
+
freshTry({
|
|
897
|
+
fallbacks: [{ provider: Providers.ANTHROPIC }],
|
|
898
|
+
messages: [
|
|
899
|
+
new ToolMessage({
|
|
900
|
+
name: 'echo',
|
|
901
|
+
tool_call_id: 'tc1',
|
|
902
|
+
status: 'success',
|
|
903
|
+
content: 'output',
|
|
904
|
+
}),
|
|
905
|
+
],
|
|
906
|
+
primaryError: new Error('primary failed'),
|
|
907
|
+
config: { signal: abort.signal },
|
|
908
|
+
prepareProviderMessages: async ({ messages: preparedMessages }) => {
|
|
909
|
+
abort.abort(trip);
|
|
910
|
+
return preparedMessages;
|
|
911
|
+
},
|
|
912
|
+
})
|
|
913
|
+
).rejects.toBe(trip);
|
|
914
|
+
});
|
|
915
|
+
expect(invokeMessages).toHaveLength(0);
|
|
916
|
+
|
|
917
|
+
jest.dontMock('@/llm/init');
|
|
918
|
+
});
|
|
919
|
+
|
|
832
920
|
it('neutralizes preempted Responses history before an OpenAI Chat fallback', async () => {
|
|
833
921
|
const reasoning = {
|
|
834
922
|
id: 'rs_fallback',
|