@librechat/agents 3.3.3 → 3.3.5
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/agents/AgentContext.cjs +2 -1
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/constants.cjs +21 -0
- package/dist/cjs/common/constants.cjs.map +1 -1
- package/dist/cjs/events.cjs +10 -1
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +793 -111
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +25 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +8 -0
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +38 -13
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +19 -7
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +331 -5
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +41 -5
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/utils/index.cjs +25 -4
- package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs +4 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/llm/preempt.cjs +132 -0
- package/dist/cjs/llm/preempt.cjs.map +1 -0
- package/dist/cjs/main.cjs +36 -5
- package/dist/cjs/messages/alternation.cjs +92 -0
- package/dist/cjs/messages/alternation.cjs.map +1 -0
- package/dist/cjs/messages/cache.cjs +54 -33
- package/dist/cjs/messages/cache.cjs.map +1 -1
- package/dist/cjs/messages/contextPruning.cjs +17 -43
- package/dist/cjs/messages/contextPruning.cjs.map +1 -1
- package/dist/cjs/messages/core.cjs +315 -23
- package/dist/cjs/messages/core.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +290 -60
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/handoffCue.cjs +63 -0
- package/dist/cjs/messages/handoffCue.cjs.map +1 -0
- package/dist/cjs/messages/index.cjs +4 -1
- package/dist/cjs/messages/injected.cjs +60 -0
- package/dist/cjs/messages/injected.cjs.map +1 -0
- package/dist/cjs/messages/prune.cjs +503 -127
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +80 -7
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/handlers.cjs +18 -9
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +35 -10
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +28 -14
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +56 -63
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
- package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
- package/dist/cjs/tools/search/tool.cjs +9 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +1 -1
- package/dist/cjs/utils/llm.cjs +1 -1
- package/dist/cjs/utils/llm.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +307 -20
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/cjs/utils/toolContent.cjs +1514 -0
- package/dist/cjs/utils/toolContent.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +2 -1
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/constants.mjs +19 -1
- package/dist/esm/common/constants.mjs.map +1 -1
- package/dist/esm/events.mjs +10 -1
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +798 -116
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +25 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +9 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +38 -13
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +19 -7
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +332 -8
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +41 -5
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/utils/index.mjs +25 -4
- package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs +4 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/llm/preempt.mjs +131 -0
- package/dist/esm/llm/preempt.mjs.map +1 -0
- package/dist/esm/main.mjs +14 -11
- package/dist/esm/messages/alternation.mjs +91 -0
- package/dist/esm/messages/alternation.mjs.map +1 -0
- package/dist/esm/messages/cache.mjs +55 -34
- package/dist/esm/messages/cache.mjs.map +1 -1
- package/dist/esm/messages/contextPruning.mjs +17 -43
- package/dist/esm/messages/contextPruning.mjs.map +1 -1
- package/dist/esm/messages/core.mjs +305 -24
- package/dist/esm/messages/core.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +290 -61
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/handoffCue.mjs +61 -0
- package/dist/esm/messages/handoffCue.mjs.map +1 -0
- package/dist/esm/messages/index.mjs +4 -1
- package/dist/esm/messages/injected.mjs +60 -0
- package/dist/esm/messages/injected.mjs.map +1 -0
- package/dist/esm/messages/prune.mjs +500 -128
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +80 -7
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/handlers.mjs +19 -10
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +34 -11
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +28 -14
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +56 -63
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
- package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
- package/dist/esm/tools/search/tool.mjs +9 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +6 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +1 -1
- package/dist/esm/utils/llm.mjs +1 -1
- package/dist/esm/utils/llm.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +307 -21
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/esm/utils/toolContent.mjs +1503 -0
- package/dist/esm/utils/toolContent.mjs.map +1 -0
- package/dist/types/common/constants.d.ts +18 -0
- package/dist/types/graphs/Graph.d.ts +139 -2
- package/dist/types/hooks/HookRegistry.d.ts +15 -0
- package/dist/types/hooks/index.d.ts +12 -1
- package/dist/types/hooks/types.d.ts +45 -6
- package/dist/types/index.d.ts +1 -1
- package/dist/types/llm/invoke.d.ts +34 -1
- package/dist/types/llm/openai/index.d.ts +4 -0
- package/dist/types/llm/openai/utils/index.d.ts +2 -0
- package/dist/types/llm/openrouter/index.d.ts +5 -0
- package/dist/types/llm/preempt.d.ts +40 -0
- package/dist/types/messages/alternation.d.ts +23 -0
- package/dist/types/messages/contextPruning.d.ts +2 -1
- package/dist/types/messages/core.d.ts +36 -0
- package/dist/types/messages/format.d.ts +6 -0
- package/dist/types/messages/handoffCue.d.ts +40 -0
- package/dist/types/messages/index.d.ts +3 -0
- package/dist/types/messages/injected.d.ts +3 -0
- package/dist/types/messages/prune.d.ts +16 -12
- package/dist/types/run.d.ts +7 -0
- package/dist/types/stream.d.ts +14 -0
- package/dist/types/tools/ToolNode.d.ts +0 -7
- package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
- package/dist/types/tools/search/types.d.ts +31 -2
- package/dist/types/types/graph.d.ts +8 -1
- package/dist/types/types/run.d.ts +65 -0
- package/dist/types/types/stream.d.ts +1 -26
- package/dist/types/utils/tokens.d.ts +7 -0
- package/dist/types/utils/toolContent.d.ts +107 -0
- package/package.json +1 -1
- package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
- package/src/agents/AgentContext.ts +1 -0
- package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
- package/src/common/constants.ts +21 -0
- package/src/events.ts +15 -1
- package/src/graphs/Graph.ts +1261 -198
- package/src/graphs/MultiAgentGraph.ts +6 -1
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1139 -4
- package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
- package/src/hooks/HookRegistry.ts +40 -0
- package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
- package/src/hooks/index.ts +16 -2
- package/src/hooks/types.ts +47 -3
- package/src/index.ts +1 -1
- package/src/langfuse.ts +26 -1
- package/src/llm/anthropic/utils/message_inputs.ts +158 -12
- package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
- package/src/llm/bedrock/utils/message_inputs.test.ts +131 -1
- package/src/llm/bedrock/utils/message_inputs.ts +100 -24
- package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
- package/src/llm/google/utils/common.test.ts +75 -9
- package/src/llm/google/utils/common.ts +34 -3
- package/src/llm/invoke.alternation.test.ts +87 -0
- package/src/llm/invoke.handoffCue.test.ts +112 -0
- package/src/llm/invoke.test.ts +488 -0
- package/src/llm/invoke.ts +611 -8
- package/src/llm/openai/index.ts +106 -6
- package/src/llm/openai/utils/index.ts +109 -53
- package/src/llm/openai/utils/messages.test.ts +330 -1
- package/src/llm/openrouter/index.ts +12 -2
- package/src/llm/openrouter/reasoning.test.ts +314 -0
- package/src/llm/preempt.test.ts +323 -0
- package/src/llm/preempt.ts +178 -0
- package/src/messages/__tests__/observationMasking.test.ts +93 -2
- package/src/messages/alternation.test.ts +212 -0
- package/src/messages/alternation.ts +112 -0
- package/src/messages/cache.tail.test.ts +193 -0
- package/src/messages/cache.test.ts +113 -0
- package/src/messages/cache.ts +92 -60
- package/src/messages/contextPruning.test.ts +184 -0
- package/src/messages/contextPruning.ts +49 -42
- package/src/messages/core.ts +653 -40
- package/src/messages/ensureThinkingBlock.test.ts +49 -1
- package/src/messages/foldToollessToolBlocks.test.ts +163 -5
- package/src/messages/format.ts +454 -91
- package/src/messages/formatAgentMessages.steer.test.ts +267 -0
- package/src/messages/formatAgentMessages.test.ts +604 -14
- package/src/messages/formatAgentMessages.tools.test.ts +103 -0
- package/src/messages/handoffCue.test.ts +96 -0
- package/src/messages/handoffCue.ts +78 -0
- package/src/messages/index.ts +3 -0
- package/src/messages/injected.test.ts +90 -0
- package/src/messages/injected.ts +74 -0
- package/src/messages/prune.ts +996 -183
- package/src/run.ts +91 -6
- package/src/scripts/preempt-probe.ts +330 -0
- package/src/scripts/preempt-scenarios.ts +388 -0
- package/src/session/handlers.ts +32 -12
- package/src/specs/handoffCue.test.ts +165 -0
- package/src/specs/langfuse-callbacks.test.ts +352 -2
- package/src/specs/preemptSeal.test.ts +309 -0
- package/src/specs/prune.test.ts +1083 -6
- package/src/specs/summarization-unit.test.ts +105 -0
- package/src/specs/tokens.test.ts +609 -32
- package/src/stream.dispatch.test.ts +63 -0
- package/src/stream.ts +59 -23
- package/src/summarization/__tests__/node.test.ts +77 -0
- package/src/summarization/node.ts +46 -18
- package/src/tools/ToolNode.ts +150 -99
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
- package/src/tools/__tests__/directToolHooks.test.ts +255 -7
- package/src/tools/search/keenable-scraper.test.ts +153 -0
- package/src/tools/search/keenable-scraper.ts +137 -0
- package/src/tools/search/tool.ts +13 -2
- package/src/tools/search/types.ts +50 -3
- package/src/tools/toolOutputReferences.ts +6 -0
- package/src/types/graph.ts +12 -1
- package/src/types/run.ts +67 -0
- package/src/types/stream.ts +1 -42
- package/src/utils/llm.test.ts +18 -0
- package/src/utils/llm.ts +4 -1
- package/src/utils/tokens.ts +586 -31
- package/src/utils/toolContent.test.ts +1432 -0
- package/src/utils/toolContent.ts +2707 -0
- package/dist/cjs/splitStream.cjs +0 -151
- package/dist/cjs/splitStream.cjs.map +0 -1
- package/dist/esm/splitStream.mjs +0 -150
- package/dist/esm/splitStream.mjs.map +0 -1
- package/dist/types/mockStream.d.ts +0 -32
- package/dist/types/splitStream.d.ts +0 -37
- package/src/mockStream.ts +0 -99
- package/src/splitStream.ts +0 -234
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { AIMessageChunk } from '@langchain/core/messages';
|
|
2
|
+
import { canSealPreempt, resolveMaxSeals } from './preempt';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The budget is read by two consumers that interpret it differently — a
|
|
6
|
+
* numeric comparison in the seal gate, and an addition into the graph's
|
|
7
|
+
* recursion limit. Normalizing once keeps them in agreement.
|
|
8
|
+
*/
|
|
9
|
+
describe('resolveMaxSeals', () => {
|
|
10
|
+
it('defaults when unset', () => {
|
|
11
|
+
expect(resolveMaxSeals(undefined)).toBe(8);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('passes a sane whole number through', () => {
|
|
15
|
+
expect(resolveMaxSeals(3)).toBe(3);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('floors a fractional budget so both readers agree', () => {
|
|
19
|
+
expect(resolveMaxSeals(1.5)).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('honors zero as a deliberate never-seal', () => {
|
|
23
|
+
expect(resolveMaxSeals(0)).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('clamps a negative budget to never-seal', () => {
|
|
27
|
+
expect(resolveMaxSeals(-4)).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('falls back rather than poisoning the recursion limit', () => {
|
|
31
|
+
expect(resolveMaxSeals(NaN)).toBe(8);
|
|
32
|
+
expect(resolveMaxSeals(Infinity)).toBe(8);
|
|
33
|
+
expect(resolveMaxSeals(-Infinity)).toBe(8);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function chunk(fields: Partial<AIMessageChunk>): AIMessageChunk {
|
|
38
|
+
return new AIMessageChunk({
|
|
39
|
+
content: '',
|
|
40
|
+
...fields,
|
|
41
|
+
} as ConstructorParameters<typeof AIMessageChunk>[0]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('canSealPreempt', () => {
|
|
45
|
+
describe('refuses to seal', () => {
|
|
46
|
+
it('on a missing chunk', () => {
|
|
47
|
+
expect(canSealPreempt(undefined)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('on empty string content', () => {
|
|
51
|
+
expect(canSealPreempt(chunk({ content: '' }))).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('on whitespace-only string content', () => {
|
|
55
|
+
expect(canSealPreempt(chunk({ content: ' \n\t ' }))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('on an empty content array', () => {
|
|
59
|
+
expect(canSealPreempt(chunk({ content: [] }))).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('on whitespace-only text blocks', () => {
|
|
63
|
+
expect(
|
|
64
|
+
canSealPreempt(chunk({ content: [{ type: 'text', text: ' ' }] }))
|
|
65
|
+
).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('when the only content is thinking', () => {
|
|
69
|
+
expect(
|
|
70
|
+
canSealPreempt(
|
|
71
|
+
chunk({
|
|
72
|
+
content: [
|
|
73
|
+
{ type: 'thinking', thinking: 'Let me work through this.' },
|
|
74
|
+
],
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('when the only content is reasoning', () => {
|
|
81
|
+
expect(
|
|
82
|
+
canSealPreempt(
|
|
83
|
+
chunk({
|
|
84
|
+
content: [{ type: 'reasoning', reasoning: 'Considering.' }],
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('when the only content is redacted_thinking', () => {
|
|
91
|
+
expect(
|
|
92
|
+
canSealPreempt(
|
|
93
|
+
chunk({ content: [{ type: 'redacted_thinking', data: 'xxx' }] })
|
|
94
|
+
)
|
|
95
|
+
).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('with a resolved tool call present', () => {
|
|
99
|
+
expect(
|
|
100
|
+
canSealPreempt(
|
|
101
|
+
chunk({
|
|
102
|
+
content: 'Looking that up.',
|
|
103
|
+
tool_calls: [{ id: 'c1', name: 'search', args: {} }],
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('with a partial tool call still streaming', () => {
|
|
110
|
+
expect(
|
|
111
|
+
canSealPreempt(
|
|
112
|
+
chunk({
|
|
113
|
+
content: 'Looking that up.',
|
|
114
|
+
tool_call_chunks: [
|
|
115
|
+
{ id: 'c1', name: 'search', args: '{"q"', index: 0 },
|
|
116
|
+
],
|
|
117
|
+
})
|
|
118
|
+
)
|
|
119
|
+
).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Assigned after construction on purpose: `AIMessageChunk` derives
|
|
124
|
+
* `invalid_tool_calls` from `tool_call_chunks` and drops a directly
|
|
125
|
+
* supplied array, so this is the only way to exercise the guard on its
|
|
126
|
+
* own rather than through the `tool_call_chunks` check above it.
|
|
127
|
+
*/
|
|
128
|
+
it('with an invalid tool call present', () => {
|
|
129
|
+
const c = chunk({ content: 'Looking that up.' });
|
|
130
|
+
c.invalid_tool_calls = [
|
|
131
|
+
{ id: 'c1', name: 'search', args: '{oops', error: 'bad json' },
|
|
132
|
+
];
|
|
133
|
+
expect(canSealPreempt(c)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Anthropic emits a `tool_call_chunk` alongside every `server_tool_use`
|
|
138
|
+
* content block, and `concat` keeps it on the accumulated message for the
|
|
139
|
+
* rest of the turn. So a web-search turn is refused by the tool-call gate,
|
|
140
|
+
* both while the search is in flight and after its result has landed —
|
|
141
|
+
* this pins that invariant rather than a content-block check.
|
|
142
|
+
*/
|
|
143
|
+
it('while an Anthropic server tool is in flight', () => {
|
|
144
|
+
expect(
|
|
145
|
+
canSealPreempt(
|
|
146
|
+
chunk({
|
|
147
|
+
content: [
|
|
148
|
+
{ type: 'text', text: 'Searching the web for that.' },
|
|
149
|
+
{
|
|
150
|
+
type: 'server_tool_use',
|
|
151
|
+
id: 'srvtoolu_1',
|
|
152
|
+
name: 'web_search',
|
|
153
|
+
input: { query: 'x' },
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
tool_call_chunks: [
|
|
157
|
+
{ id: 'srvtoolu_1', name: 'web_search', args: '', index: 0 },
|
|
158
|
+
],
|
|
159
|
+
})
|
|
160
|
+
)
|
|
161
|
+
).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Gemini server-side tools land as `toolCall` / `toolResponse` content
|
|
166
|
+
* blocks and never populate `tool_calls` or `tool_call_chunks`, so the
|
|
167
|
+
* tool-call gates above cannot see them.
|
|
168
|
+
*/
|
|
169
|
+
it('while a Google server tool call is unanswered', () => {
|
|
170
|
+
expect(
|
|
171
|
+
canSealPreempt(
|
|
172
|
+
chunk({
|
|
173
|
+
content: [
|
|
174
|
+
{ type: 'text', text: 'Let me look that up.' },
|
|
175
|
+
{ type: 'toolCall', id: 'gcall_1', name: 'google_search' },
|
|
176
|
+
],
|
|
177
|
+
})
|
|
178
|
+
)
|
|
179
|
+
).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('with more Google server tool calls than responses', () => {
|
|
183
|
+
expect(
|
|
184
|
+
canSealPreempt(
|
|
185
|
+
chunk({
|
|
186
|
+
content: [
|
|
187
|
+
{ type: 'text', text: 'Checking two things.' },
|
|
188
|
+
{ type: 'toolCall', id: 'gcall_1', name: 'google_search' },
|
|
189
|
+
{ type: 'toolResponse', id: 'gcall_1', response: {} },
|
|
190
|
+
{ type: 'toolCall', id: 'gcall_2', name: 'google_search' },
|
|
191
|
+
],
|
|
192
|
+
})
|
|
193
|
+
)
|
|
194
|
+
).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('while one of several server tool calls is still unanswered', () => {
|
|
198
|
+
expect(
|
|
199
|
+
canSealPreempt(
|
|
200
|
+
chunk({
|
|
201
|
+
content: [
|
|
202
|
+
{ type: 'text', text: 'Checking two sources.' },
|
|
203
|
+
{
|
|
204
|
+
type: 'web_search_tool_result',
|
|
205
|
+
tool_use_id: 'srvtoolu_1',
|
|
206
|
+
content: [],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
tool_call_chunks: [
|
|
210
|
+
{ id: 'srvtoolu_1', name: 'web_search', args: '', index: 0 },
|
|
211
|
+
{ id: 'srvtoolu_2', name: 'web_search', args: '', index: 1 },
|
|
212
|
+
],
|
|
213
|
+
})
|
|
214
|
+
)
|
|
215
|
+
).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('seals', () => {
|
|
220
|
+
it('on non-empty string content', () => {
|
|
221
|
+
expect(canSealPreempt(chunk({ content: 'The migration has' }))).toBe(
|
|
222
|
+
true
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('on a non-empty text block', () => {
|
|
227
|
+
expect(
|
|
228
|
+
canSealPreempt(
|
|
229
|
+
chunk({ content: [{ type: 'text', text: 'The migration has' }] })
|
|
230
|
+
)
|
|
231
|
+
).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('on text that follows signed thinking', () => {
|
|
235
|
+
expect(
|
|
236
|
+
canSealPreempt(
|
|
237
|
+
chunk({
|
|
238
|
+
content: [
|
|
239
|
+
{
|
|
240
|
+
type: 'thinking',
|
|
241
|
+
thinking: 'Working it out.',
|
|
242
|
+
signature: 'sig',
|
|
243
|
+
},
|
|
244
|
+
{ type: 'text', text: 'The migration has' },
|
|
245
|
+
],
|
|
246
|
+
})
|
|
247
|
+
)
|
|
248
|
+
).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('with empty tool-call arrays present', () => {
|
|
252
|
+
expect(
|
|
253
|
+
canSealPreempt(
|
|
254
|
+
chunk({
|
|
255
|
+
content: 'The migration has',
|
|
256
|
+
tool_calls: [],
|
|
257
|
+
tool_call_chunks: [],
|
|
258
|
+
invalid_tool_calls: [],
|
|
259
|
+
})
|
|
260
|
+
)
|
|
261
|
+
).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('once every Google server tool call has been answered', () => {
|
|
265
|
+
expect(
|
|
266
|
+
canSealPreempt(
|
|
267
|
+
chunk({
|
|
268
|
+
content: [
|
|
269
|
+
{ type: 'text', text: 'Here is what I found.' },
|
|
270
|
+
{ type: 'toolCall', id: 'gcall_1', name: 'google_search' },
|
|
271
|
+
{ type: 'toolResponse', id: 'gcall_1', response: {} },
|
|
272
|
+
],
|
|
273
|
+
})
|
|
274
|
+
)
|
|
275
|
+
).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Provider-side tools never reach `ToolNode`, so no `PostToolBatch`
|
|
280
|
+
* boundary exists to drain into. Refusing forever after a search would
|
|
281
|
+
* defer a queued message to the end of the turn rather than to the next
|
|
282
|
+
* tool step, so a call whose result has landed counts as settled.
|
|
283
|
+
*/
|
|
284
|
+
it('once an Anthropic server tool result has landed', () => {
|
|
285
|
+
expect(
|
|
286
|
+
canSealPreempt(
|
|
287
|
+
chunk({
|
|
288
|
+
content: [
|
|
289
|
+
{ type: 'text', text: 'Here is what I found.' },
|
|
290
|
+
{
|
|
291
|
+
type: 'server_tool_use',
|
|
292
|
+
id: 'srvtoolu_1',
|
|
293
|
+
name: 'web_search',
|
|
294
|
+
input: { query: 'x' },
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
type: 'web_search_tool_result',
|
|
298
|
+
tool_use_id: 'srvtoolu_1',
|
|
299
|
+
content: [],
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
tool_call_chunks: [
|
|
303
|
+
{ id: 'srvtoolu_1', name: 'web_search', args: '', index: 0 },
|
|
304
|
+
],
|
|
305
|
+
})
|
|
306
|
+
)
|
|
307
|
+
).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('on a non-text block that carries no tool call', () => {
|
|
311
|
+
expect(
|
|
312
|
+
canSealPreempt(
|
|
313
|
+
chunk({
|
|
314
|
+
content: [
|
|
315
|
+
{ type: 'text', text: 'Here is the diagram.' },
|
|
316
|
+
{ type: 'image_url', image_url: { url: 'https://x/y.png' } },
|
|
317
|
+
],
|
|
318
|
+
})
|
|
319
|
+
)
|
|
320
|
+
).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// src/llm/preempt.ts
|
|
2
|
+
import type { AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
+
import { ContentTypes, DEFAULT_MAX_SEALS } from '@/common';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes a host-supplied seal budget.
|
|
7
|
+
*
|
|
8
|
+
* Read in two places that interpret it differently — a numeric comparison in
|
|
9
|
+
* the seal gate and an addition into the recursion limit — so a value like
|
|
10
|
+
* `1.5` would permit two seals while reserving fractional headroom, `NaN`
|
|
11
|
+
* would poison the recursion limit outright, and `Infinity` would remove both
|
|
12
|
+
* bounds at once. Normalizing once keeps the two readings in agreement.
|
|
13
|
+
*
|
|
14
|
+
* `0` is honored as a deliberate "never seal"; anything not finite falls back
|
|
15
|
+
* to the default rather than silently disabling the feature.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveMaxSeals(maxSeals: number | undefined): number {
|
|
18
|
+
if (maxSeals == null || !Number.isFinite(maxSeals)) {
|
|
19
|
+
return DEFAULT_MAX_SEALS;
|
|
20
|
+
}
|
|
21
|
+
const whole = Math.floor(maxSeals);
|
|
22
|
+
return whole > 0 ? whole : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* True when the accumulated content carries at least one non-whitespace text
|
|
27
|
+
* block. `thinking` / `reasoning` blocks deliberately do not count: they are
|
|
28
|
+
* stripped or signed on several providers and cannot stand in for the visible
|
|
29
|
+
* assistant turn a sealed sequence needs.
|
|
30
|
+
*/
|
|
31
|
+
function hasNonEmptyTextContent(content: AIMessageChunk['content']): boolean {
|
|
32
|
+
if (typeof content === 'string') {
|
|
33
|
+
return content.trim() !== '';
|
|
34
|
+
}
|
|
35
|
+
for (const block of content) {
|
|
36
|
+
if (block.type !== ContentTypes.TEXT) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const text = block[ContentTypes.TEXT];
|
|
40
|
+
if (typeof text === 'string' && text.trim() !== '') {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True while a Gemini server-side tool call is still awaiting its response.
|
|
49
|
+
*
|
|
50
|
+
* Google's server-side tools (Search, URL context) never populate
|
|
51
|
+
* `tool_calls` or `tool_call_chunks` — those are derived from `functionCall`
|
|
52
|
+
* parts only, while a server-side invocation arrives as a `toolCall` CONTENT
|
|
53
|
+
* block and its result as a later `toolResponse` block. The tool-call gates
|
|
54
|
+
* cannot see it, so sealing between the two would replay a model turn holding
|
|
55
|
+
* an unanswered server-side call.
|
|
56
|
+
*
|
|
57
|
+
* Counted rather than id-matched: Google answers calls in order, and counting
|
|
58
|
+
* stays correct when a part omits its id.
|
|
59
|
+
*/
|
|
60
|
+
function hasOpenGoogleServerToolCall(
|
|
61
|
+
content: AIMessageChunk['content']
|
|
62
|
+
): boolean {
|
|
63
|
+
if (typeof content === 'string') {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
let calls = 0;
|
|
67
|
+
let responses = 0;
|
|
68
|
+
for (const block of content) {
|
|
69
|
+
if (block.type === 'toolCall') {
|
|
70
|
+
calls += 1;
|
|
71
|
+
} else if (block.type === 'toolResponse') {
|
|
72
|
+
responses += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return calls > responses;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Ids of Anthropic server-side tool calls whose paired result block is already
|
|
80
|
+
* on the accumulated content. Those calls are answered and cannot be orphaned
|
|
81
|
+
* by a seal.
|
|
82
|
+
*/
|
|
83
|
+
function settledServerToolCallIds(
|
|
84
|
+
content: AIMessageChunk['content']
|
|
85
|
+
): Set<string> {
|
|
86
|
+
const settled = new Set<string>();
|
|
87
|
+
if (typeof content === 'string') {
|
|
88
|
+
return settled;
|
|
89
|
+
}
|
|
90
|
+
for (const block of content) {
|
|
91
|
+
if (block.type !== 'web_search_tool_result') {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const id = block.tool_use_id;
|
|
95
|
+
if (typeof id === 'string') {
|
|
96
|
+
settled.add(id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return settled;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Tool calls still awaiting an answer, ignoring ones already settled. */
|
|
103
|
+
function countOpenToolCalls(
|
|
104
|
+
calls: ReadonlyArray<{ id?: string }> | undefined,
|
|
105
|
+
settled: Set<string>
|
|
106
|
+
): number {
|
|
107
|
+
if (calls == null || calls.length === 0) {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (settled.size === 0) {
|
|
111
|
+
return calls.length;
|
|
112
|
+
}
|
|
113
|
+
let open = 0;
|
|
114
|
+
for (const call of calls) {
|
|
115
|
+
if (call.id == null || !settled.has(call.id)) {
|
|
116
|
+
open += 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return open;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Cooperative mid-generation seal gate. Returns true ONLY when sealing here
|
|
124
|
+
* yields a message sequence valid on EVERY supported provider:
|
|
125
|
+
* - non-whitespace TEXT content, so the FIRST injected user turn is preceded
|
|
126
|
+
* by a non-empty assistant turn — no empty-content 400s. Note this says
|
|
127
|
+
* nothing about adjacency AMONG several injected turns: a boundary that
|
|
128
|
+
* drains two steers emits two consecutive user messages, which strict
|
|
129
|
+
* providers reject. That is normalized at the provider-facing hop by
|
|
130
|
+
* `coalesceAdjacentUserTurns`, not here;
|
|
131
|
+
* - no tool call in flight, so no `tool_use` can be orphaned AND no eagerly
|
|
132
|
+
* prestarted execution can be stripped out from under the model.
|
|
133
|
+
*
|
|
134
|
+
* Anthropic's server-side tools need no check of their own. Every
|
|
135
|
+
* `server_tool_use` content block also emits a `tool_call_chunk`
|
|
136
|
+
* (`_makeMessageChunkFromAnthropicEvent`), and `concat` keeps that chunk on
|
|
137
|
+
* the accumulated message for the remainder of the turn, so the tool-call
|
|
138
|
+
* gates below already cover it. The practical consequence is worth stating
|
|
139
|
+
* plainly: once a turn starts a web search it is no longer preemptible, and
|
|
140
|
+
* a queued message waits for the ordinary tool boundary instead.
|
|
141
|
+
*
|
|
142
|
+
* Nothing is stripped and nothing is repaired: when the accumulated shape is
|
|
143
|
+
* not already safe the stream simply runs on, and whatever the host queued
|
|
144
|
+
* lands at the next tool boundary instead. Chunks accumulate monotonically
|
|
145
|
+
* through `concat`, so an unsafe shape can never be observed at a seal point.
|
|
146
|
+
*/
|
|
147
|
+
export function canSealPreempt(chunk: AIMessageChunk | undefined): boolean {
|
|
148
|
+
if (chunk == null) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Anthropic's server-side tools run inside the provider, so unlike a client
|
|
153
|
+
* tool they never reach `ToolNode` and never produce a `PostToolBatch`
|
|
154
|
+
* boundary. `concat` also keeps their `server_tool_use` chunk on the
|
|
155
|
+
* accumulated message for the rest of the turn. Together that means a naive
|
|
156
|
+
* tool-call gate makes a turn permanently unsealable the moment a web
|
|
157
|
+
* search starts, with no later boundary to drain into — the queued message
|
|
158
|
+
* waits for the whole turn to finish rather than for the next tool step.
|
|
159
|
+
*
|
|
160
|
+
* So calls whose paired result block has already landed are treated as
|
|
161
|
+
* settled: they cannot be orphaned, because the provider has already
|
|
162
|
+
* answered them.
|
|
163
|
+
*/
|
|
164
|
+
const settled = settledServerToolCallIds(chunk.content);
|
|
165
|
+
if (countOpenToolCalls(chunk.tool_calls, settled) > 0) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (countOpenToolCalls(chunk.tool_call_chunks, settled) > 0) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if ((chunk.invalid_tool_calls?.length ?? 0) > 0) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (hasOpenGoogleServerToolCall(chunk.content)) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return hasNonEmptyTextContent(chunk.content);
|
|
178
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
2
2
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
3
|
import type { TokenCounter } from '@/types/run';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
maskConsumedToolResults,
|
|
6
|
+
ORIGINAL_CONTENT_MAX_CHARS,
|
|
7
|
+
} from '@/messages/prune';
|
|
5
8
|
|
|
6
9
|
const charCounter: TokenCounter = (msg) => {
|
|
7
10
|
const raw = msg.content;
|
|
8
11
|
if (typeof raw === 'string') return raw.length;
|
|
9
|
-
return
|
|
12
|
+
return JSON.stringify(raw).length;
|
|
10
13
|
};
|
|
11
14
|
|
|
12
15
|
function toolMsg(
|
|
@@ -209,6 +212,94 @@ describe('maskConsumedToolResults', () => {
|
|
|
209
212
|
expect(map[2]).toBe((messages[2].content as string).length);
|
|
210
213
|
});
|
|
211
214
|
|
|
215
|
+
it('masks consumed structured tool results with a bounded text preview', () => {
|
|
216
|
+
const tcId = 'tc-structured';
|
|
217
|
+
const toolMessage = new ToolMessage({
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: 'json',
|
|
221
|
+
rows: Array.from({ length: 20 }, (_, index) => ({
|
|
222
|
+
id: index,
|
|
223
|
+
value: `${'x'.repeat(100)}-${index}`,
|
|
224
|
+
})),
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
tool_call_id: tcId,
|
|
228
|
+
name: 'run_select_query',
|
|
229
|
+
status: 'success',
|
|
230
|
+
artifact: { source: 'clickhouse' },
|
|
231
|
+
});
|
|
232
|
+
const messages: BaseMessage[] = [
|
|
233
|
+
new HumanMessage('query the table'),
|
|
234
|
+
aiToolCall(tcId, 'run_select_query'),
|
|
235
|
+
toolMessage,
|
|
236
|
+
aiWithText('The query returned 20 rows.'),
|
|
237
|
+
];
|
|
238
|
+
const map: Record<string, number | undefined> = {
|
|
239
|
+
0: 5,
|
|
240
|
+
1: 20,
|
|
241
|
+
2: 0,
|
|
242
|
+
3: 30,
|
|
243
|
+
};
|
|
244
|
+
const originalContentStore = new Map<number, string>();
|
|
245
|
+
const originalContent = JSON.stringify(toolMessage.content);
|
|
246
|
+
|
|
247
|
+
const count = maskConsumedToolResults({
|
|
248
|
+
messages,
|
|
249
|
+
indexTokenCountMap: map,
|
|
250
|
+
tokenCounter: charCounter,
|
|
251
|
+
originalContentStore,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(count).toBe(1);
|
|
255
|
+
expect(typeof messages[2].content).toBe('string');
|
|
256
|
+
expect(messages[2].content).toContain('truncated');
|
|
257
|
+
expect((messages[2].content as string).length).toBeLessThanOrEqual(300);
|
|
258
|
+
const masked = messages[2] as ToolMessage;
|
|
259
|
+
expect(masked.tool_call_id).toBe(tcId);
|
|
260
|
+
expect(masked.name).toBe('run_select_query');
|
|
261
|
+
expect(masked.status).toBe('success');
|
|
262
|
+
expect(masked.artifact).toEqual({ source: 'clickhouse' });
|
|
263
|
+
expect(map[2]).toBe(charCounter(masked));
|
|
264
|
+
expect(originalContentStore.get(2)).toBe(originalContent);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('bounds stored structured originals without invoking custom toJSON', () => {
|
|
268
|
+
const tcId = 'tc-large-structured';
|
|
269
|
+
const toJSON = jest.fn(() => ({ expanded: 'should not run' }));
|
|
270
|
+
const messages: BaseMessage[] = [
|
|
271
|
+
new HumanMessage('query the table'),
|
|
272
|
+
aiToolCall(tcId, 'run_select_query'),
|
|
273
|
+
new ToolMessage({
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
type: 'json',
|
|
277
|
+
payload: 'x'.repeat(ORIGINAL_CONTENT_MAX_CHARS + 1_000),
|
|
278
|
+
toJSON,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
tool_call_id: tcId,
|
|
282
|
+
name: 'run_select_query',
|
|
283
|
+
}),
|
|
284
|
+
aiWithText('The query completed.'),
|
|
285
|
+
];
|
|
286
|
+
const originalContentStore = new Map<number, string>();
|
|
287
|
+
|
|
288
|
+
const count = maskConsumedToolResults({
|
|
289
|
+
messages,
|
|
290
|
+
indexTokenCountMap: { 0: 1, 1: 1, 2: 1, 3: 1 },
|
|
291
|
+
tokenCounter: () => 1,
|
|
292
|
+
originalContentStore,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(count).toBe(1);
|
|
296
|
+
expect(toJSON).not.toHaveBeenCalled();
|
|
297
|
+
expect(originalContentStore.get(2)?.length).toBeLessThanOrEqual(
|
|
298
|
+
ORIGINAL_CONTENT_MAX_CHARS
|
|
299
|
+
);
|
|
300
|
+
expect(originalContentStore.get(2)).toContain('truncated');
|
|
301
|
+
});
|
|
302
|
+
|
|
212
303
|
it('handles empty messages array', () => {
|
|
213
304
|
const map: Record<string, number | undefined> = {};
|
|
214
305
|
const count = maskConsumedToolResults({
|