@roj-ai/sdk 0.1.31 → 0.1.32
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/core/agents/agent.d.ts +5 -1
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/agents/config.d.ts +5 -1
- package/dist/core/agents/config.d.ts.map +1 -1
- package/dist/core/llm/anthropic.d.ts.map +1 -1
- package/dist/core/llm/anthropic.js +7 -1
- package/dist/core/llm/anthropic.js.map +1 -1
- package/dist/core/llm/cache-breakpoints.d.ts +39 -5
- package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
- package/dist/core/llm/cache-breakpoints.js +22 -8
- package/dist/core/llm/cache-breakpoints.js.map +1 -1
- package/dist/core/llm/cache-breakpoints.test.js +32 -8
- package/dist/core/llm/cache-breakpoints.test.js.map +1 -1
- package/dist/core/llm/openrouter-mapping.test.js +29 -0
- package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
- package/dist/core/llm/openrouter.d.ts.map +1 -1
- package/dist/core/llm/openrouter.js +28 -8
- package/dist/core/llm/openrouter.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/result-eviction/plugin.d.ts.map +1 -1
- package/dist/plugins/result-eviction/plugin.js +24 -9
- package/dist/plugins/result-eviction/plugin.js.map +1 -1
- package/dist/plugins/result-eviction/result-eviction.integration.test.js +77 -0
- package/dist/plugins/result-eviction/result-eviction.integration.test.js.map +1 -1
- package/package.json +2 -2
- package/src/core/agents/agent.ts +5 -2
- package/src/core/agents/config.ts +5 -1
- package/src/core/llm/anthropic.ts +7 -1
- package/src/core/llm/cache-breakpoints.test.ts +38 -8
- package/src/core/llm/cache-breakpoints.ts +54 -9
- package/src/core/llm/openrouter-mapping.test.ts +37 -1
- package/src/core/llm/openrouter.ts +35 -9
- package/src/index.ts +1 -0
- package/src/plugins/result-eviction/plugin.ts +26 -11
- package/src/plugins/result-eviction/result-eviction.integration.test.ts +88 -0
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
import type { LLMMessage, LLMMessageCacheControl } from '~/core/agents/state.js'
|
|
2
2
|
|
|
3
|
+
/** Anthropic's two ephemeral cache tiers: 5-minute (write 1.25×) and 1-hour (write 2×). Reads are 0.1× on both. */
|
|
4
|
+
export type CacheTtl = '5m' | '1h'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-breakpoint prompt cache TTL.
|
|
8
|
+
*
|
|
9
|
+
* A bare tier applies to both breakpoints. The object form sets them
|
|
10
|
+
* independently, which is usually what you want: the stable prefix and the
|
|
11
|
+
* churning conversation tail have opposite cost profiles.
|
|
12
|
+
*
|
|
13
|
+
* Choosing between them is an economic question about a specific agent's call
|
|
14
|
+
* pattern, so the SDK does not pick for you:
|
|
15
|
+
*
|
|
16
|
+
* - A long TTL on the **prefix** pays off when calls are spread further apart
|
|
17
|
+
* than the short tier survives — an orchestrator waiting on a human.
|
|
18
|
+
* - A long TTL on the **tail** costs 2× on every write while the tail is
|
|
19
|
+
* rewritten turn-to-turn anyway, so it only pays when consecutive calls are
|
|
20
|
+
* themselves minutes apart.
|
|
21
|
+
*
|
|
22
|
+
* Worked example, from a measured 5-week production session: 95% of inferences
|
|
23
|
+
* followed a sub-5-minute gap, so `'1h'` uniformly came out ~2.5% *worse* than
|
|
24
|
+
* the default, while `{ prefix: '1h' }` targeted the 29 rewrites that actually
|
|
25
|
+
* expired. A batch agent called in a tight loop would want the opposite.
|
|
26
|
+
*
|
|
27
|
+
* Constraint: Anthropic requires a longer TTL to precede a shorter one, and the
|
|
28
|
+
* prefix breakpoint always sits before the tail. So `{ prefix: '5m', tail: '1h' }`
|
|
29
|
+
* is invalid at the API and will be rejected — the tail TTL must not exceed the
|
|
30
|
+
* prefix TTL.
|
|
31
|
+
*/
|
|
32
|
+
export type CacheTtlConfig = CacheTtl | { prefix?: CacheTtl; tail?: CacheTtl }
|
|
33
|
+
|
|
34
|
+
const resolveTtls = (config: CacheTtlConfig | undefined): { prefix?: CacheTtl; tail?: CacheTtl } => {
|
|
35
|
+
if (config === undefined) return {}
|
|
36
|
+
if (typeof config === 'string') return { prefix: config, tail: config }
|
|
37
|
+
return config
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cacheControl = (ttl: CacheTtl | undefined): LLMMessageCacheControl => ttl ? { type: 'ephemeral', ttl } : { type: 'ephemeral' }
|
|
41
|
+
|
|
3
42
|
/**
|
|
4
43
|
* Mark the prompt cache breakpoints on a message list.
|
|
5
44
|
*
|
|
@@ -26,22 +65,26 @@ import type { LLMMessage, LLMMessageCacheControl } from '~/core/agents/state.js'
|
|
|
26
65
|
* every inference AND compaction call. Anthropic allows up to 4 breakpoints
|
|
27
66
|
* and matches the longest cached prefix, so the two coexist.
|
|
28
67
|
*
|
|
29
|
-
* If the prefix and tail indices coincide, only one breakpoint is set
|
|
68
|
+
* If the prefix and tail indices coincide, only one breakpoint is set — it
|
|
69
|
+
* covers the preamble, so it takes the prefix TTL.
|
|
30
70
|
*
|
|
31
|
-
* `ttl`
|
|
32
|
-
*
|
|
33
|
-
*
|
|
71
|
+
* `ttl` sets the cache tier per breakpoint; see {@link CacheTtlConfig} for how
|
|
72
|
+
* to choose. Providers must additionally give the system block the longest TTL
|
|
73
|
+
* in use, since it precedes every message and Anthropic rejects a longer entry
|
|
74
|
+
* that follows a shorter one.
|
|
34
75
|
*/
|
|
35
76
|
export function applyCacheBreakpoint(
|
|
36
77
|
messages: LLMMessage[],
|
|
37
78
|
uncachedSuffixCount: number,
|
|
38
|
-
ttl?:
|
|
79
|
+
ttl?: CacheTtlConfig,
|
|
39
80
|
cachedPrefixCount = 0,
|
|
40
81
|
): LLMMessage[] {
|
|
41
|
-
const
|
|
82
|
+
const ttls = resolveTtls(ttl)
|
|
83
|
+
const prefixCacheControl = cacheControl(ttls.prefix)
|
|
84
|
+
const tailCacheControl = cacheControl(ttls.tail)
|
|
42
85
|
const result = [...messages]
|
|
43
86
|
|
|
44
|
-
const mark = (idx: number) => {
|
|
87
|
+
const mark = (idx: number, cacheControl: LLMMessageCacheControl) => {
|
|
45
88
|
if (idx < 0 || idx >= result.length) return
|
|
46
89
|
const target = result[idx]
|
|
47
90
|
switch (target.role) {
|
|
@@ -59,7 +102,9 @@ export function applyCacheBreakpoint(
|
|
|
59
102
|
// summary, sliding recent window), so the immutable preamble is read at 0.1×
|
|
60
103
|
// on every inference AND compaction call instead of being re-billed each turn.
|
|
61
104
|
const prefixIdx = cachedPrefixCount - 1
|
|
62
|
-
if (prefixIdx !== tailIdx) mark(prefixIdx)
|
|
63
|
-
|
|
105
|
+
if (prefixIdx !== tailIdx) mark(prefixIdx, prefixCacheControl)
|
|
106
|
+
// When the two coincide there is only one entry to pin, and it covers the
|
|
107
|
+
// preamble — so it takes the prefix TTL.
|
|
108
|
+
mark(tailIdx, prefixIdx === tailIdx ? prefixCacheControl : tailCacheControl)
|
|
64
109
|
return result
|
|
65
110
|
}
|
|
@@ -21,7 +21,7 @@ const createProvider = () =>
|
|
|
21
21
|
defaultModel: 'anthropic/claude-haiku-4.5',
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
-
type AnyBlock = { type: string; cache_control?: { type: 'ephemeral' }; [key: string]: unknown }
|
|
24
|
+
type AnyBlock = { type: string; cache_control?: { type: 'ephemeral'; ttl?: '5m' | '1h' }; [key: string]: unknown }
|
|
25
25
|
type OpenRouterMessage = {
|
|
26
26
|
role: string
|
|
27
27
|
content: string | AnyBlock[]
|
|
@@ -152,6 +152,42 @@ describe('OpenRouterProvider buildHttpRequest', () => {
|
|
|
152
152
|
expect(last.content).toBe('<session-context>plugin status</session-context>')
|
|
153
153
|
})
|
|
154
154
|
|
|
155
|
+
test('ttl reaches the wire and the system block matches it', async () => {
|
|
156
|
+
// The whole point of the fix: OpenRouter dropped `ttl` on the floor, so a
|
|
157
|
+
// preset asking for the 1-hour tier silently ran on the 5-minute default.
|
|
158
|
+
const messages = applyCacheBreakpoint([
|
|
159
|
+
{ role: 'user', content: 'preamble' },
|
|
160
|
+
{ role: 'assistant', content: 'ok' },
|
|
161
|
+
{ role: 'user', content: 'work' },
|
|
162
|
+
], 0, { prefix: '1h' }, 1)
|
|
163
|
+
|
|
164
|
+
const http = await createProvider().buildHttpRequest(buildRequest(messages))
|
|
165
|
+
const msgs = getBodyMessages(http.body)
|
|
166
|
+
|
|
167
|
+
// System precedes every message, so it must carry the longest TTL in use —
|
|
168
|
+
// Anthropic rejects a longer entry that follows a shorter one.
|
|
169
|
+
const systemBlocks = asBlocks(msgs[0].content)
|
|
170
|
+
expect(systemBlocks[0].cache_control).toEqual({ type: 'ephemeral', ttl: '1h' })
|
|
171
|
+
|
|
172
|
+
// prefix breakpoint (message index 0 → body index 1) carries 1h
|
|
173
|
+
expect(asBlocks(msgs[1].content)[0].cache_control).toEqual({ type: 'ephemeral', ttl: '1h' })
|
|
174
|
+
// tail breakpoint stays on the cheap short tier
|
|
175
|
+
expect(asBlocks(msgs[3].content)[0].cache_control).toEqual({ type: 'ephemeral' })
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('no ttl requested: system block stays on the default tier', async () => {
|
|
179
|
+
const messages = applyCacheBreakpoint([
|
|
180
|
+
{ role: 'user', content: 'preamble' },
|
|
181
|
+
{ role: 'user', content: 'work' },
|
|
182
|
+
], 0, undefined, 1)
|
|
183
|
+
|
|
184
|
+
const http = await createProvider().buildHttpRequest(buildRequest(messages))
|
|
185
|
+
const msgs = getBodyMessages(http.body)
|
|
186
|
+
|
|
187
|
+
expect(asBlocks(msgs[0].content)[0].cache_control).toEqual({ type: 'ephemeral' })
|
|
188
|
+
expect(asBlocks(msgs[1].content)[0].cache_control).toEqual({ type: 'ephemeral' })
|
|
189
|
+
})
|
|
190
|
+
|
|
155
191
|
test('no flag on any message: only system has cache_control', async () => {
|
|
156
192
|
const http = await createProvider().buildHttpRequest(buildRequest([
|
|
157
193
|
{ role: 'user', content: 'What is 2+2?' },
|
|
@@ -89,11 +89,17 @@ interface OpenRouterErrorResponse {
|
|
|
89
89
|
// Request body types
|
|
90
90
|
// ============================================================================
|
|
91
91
|
|
|
92
|
+
interface OpenRouterCacheControl {
|
|
93
|
+
type: 'ephemeral'
|
|
94
|
+
/** Omitted = the provider default 5-minute tier. */
|
|
95
|
+
ttl?: '5m' | '1h'
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
interface OpenRouterContentItem {
|
|
93
99
|
type: string
|
|
94
100
|
text?: string
|
|
95
101
|
image_url?: { url: string; detail?: string }
|
|
96
|
-
cache_control?:
|
|
102
|
+
cache_control?: OpenRouterCacheControl
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
interface OpenRouterMessage {
|
|
@@ -104,18 +110,34 @@ interface OpenRouterMessage {
|
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
/**
|
|
107
|
-
* Add `cache_control
|
|
108
|
-
*
|
|
109
|
-
*
|
|
113
|
+
* Add `cache_control` to the LAST content block of an OpenRouterMessage,
|
|
114
|
+
* converting string content to an array text block first so the mark has a
|
|
115
|
+
* place to live.
|
|
116
|
+
*
|
|
117
|
+
* `ttl` reaches Anthropic through the Chat Completions surface. It is NOT
|
|
118
|
+
* expressible on the Responses API, where a breakpoint is downgraded to the
|
|
119
|
+
* default 5-minute tier — so this must stay on `/chat/completions`.
|
|
110
120
|
*/
|
|
111
|
-
function applyCacheControlToLastBlock(msg: OpenRouterMessage): void {
|
|
121
|
+
function applyCacheControlToLastBlock(msg: OpenRouterMessage, cacheControl: OpenRouterCacheControl): void {
|
|
112
122
|
if (typeof msg.content === 'string') {
|
|
113
|
-
msg.content = [{ type: 'text', text: msg.content, cache_control:
|
|
123
|
+
msg.content = [{ type: 'text', text: msg.content, cache_control: cacheControl }]
|
|
114
124
|
return
|
|
115
125
|
}
|
|
116
126
|
if (msg.content.length === 0) return
|
|
117
127
|
const lastIdx = msg.content.length - 1
|
|
118
|
-
msg.content[lastIdx] = { ...msg.content[lastIdx], cache_control:
|
|
128
|
+
msg.content[lastIdx] = { ...msg.content[lastIdx], cache_control: cacheControl }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Longest TTL requested anywhere in the message list, or undefined when none.
|
|
133
|
+
*
|
|
134
|
+
* The system prompt precedes every message, and Anthropic requires a longer TTL
|
|
135
|
+
* to come BEFORE a shorter one. So when any breakpoint asks for 1h, the system
|
|
136
|
+
* block has to carry 1h too — otherwise the order is 5m → 1h → 5m and the
|
|
137
|
+
* longer entry is invalid.
|
|
138
|
+
*/
|
|
139
|
+
function longestCacheTtl(messages: LLMMessage[]): '1h' | undefined {
|
|
140
|
+
return messages.some((m) => m.cacheControl?.ttl === '1h') ? '1h' : undefined
|
|
119
141
|
}
|
|
120
142
|
|
|
121
143
|
interface OpenRouterToolDefinition {
|
|
@@ -261,8 +283,10 @@ export class OpenRouterProvider implements LLMProvider {
|
|
|
261
283
|
const sanitizedMessages = sanitizeProviderMessages(request.messages, this.name, this.logger)
|
|
262
284
|
const mappedMessages = await Promise.all(sanitizedMessages.map((m) => this.mapMessage(m, context)))
|
|
263
285
|
|
|
286
|
+
const systemTtl = longestCacheTtl(sanitizedMessages)
|
|
287
|
+
const systemCacheControl: OpenRouterCacheControl = systemTtl ? { type: 'ephemeral', ttl: systemTtl } : { type: 'ephemeral' }
|
|
264
288
|
const messages: OpenRouterMessage[] = [
|
|
265
|
-
{ role: 'system', content: [{ type: 'text', text: request.systemPrompt, cache_control:
|
|
289
|
+
{ role: 'system', content: [{ type: 'text', text: request.systemPrompt, cache_control: systemCacheControl }] },
|
|
266
290
|
...mappedMessages,
|
|
267
291
|
]
|
|
268
292
|
|
|
@@ -317,7 +341,9 @@ export class OpenRouterProvider implements LLMProvider {
|
|
|
317
341
|
private async mapMessage(msg: LLMMessage, context?: InferenceContext): Promise<OpenRouterMessage> {
|
|
318
342
|
const mapped = await this.mapMessageContent(msg, context)
|
|
319
343
|
if (msg.cacheControl) {
|
|
320
|
-
|
|
344
|
+
const cacheControl: OpenRouterCacheControl = { type: 'ephemeral' }
|
|
345
|
+
if (msg.cacheControl.ttl) cacheControl.ttl = msg.cacheControl.ttl
|
|
346
|
+
applyCacheControlToLastBlock(mapped, cacheControl)
|
|
321
347
|
}
|
|
322
348
|
return mapped
|
|
323
349
|
}
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type { AgentPauseReason, AgentState, LLMMessage } from '~/core/agents/sta
|
|
|
53
53
|
export type { DomainEvent } from '~/core/events/types.js'
|
|
54
54
|
export type { FactoryEventType } from '~/core/events/types.js'
|
|
55
55
|
export { isDomainEvent } from '~/core/events/types.js'
|
|
56
|
+
export type { CacheTtl, CacheTtlConfig } from '~/core/llm/cache-breakpoints.js'
|
|
56
57
|
export type { ChatMessageContentItem, LLMCallLogEntry, LLMCallMessage, ToolResultContent } from '~/core/llm/llm-log-types.js'
|
|
57
58
|
export { contentToString } from '~/core/llm/llm-log-types.js'
|
|
58
59
|
export { applyMiddleware, useProvider, withAnthropic, withMaxTokens, withOpenRouter, withTemperature } from '~/core/llm/middleware.js'
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* head + tail + file path is returned instead.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { type ChatMessageContentItem, contentToString, type ToolResultContent } from '~/core/llm/llm-log-types.js'
|
|
9
10
|
import { truncateByTokens } from '~/core/llm/tokens.js'
|
|
10
11
|
import { definePlugin } from '~/core/plugins/plugin-builder.js'
|
|
11
12
|
|
|
@@ -23,6 +24,21 @@ export interface EvictionAgentConfig {
|
|
|
23
24
|
|
|
24
25
|
const DEFAULT_MAX_TOKENS = 20_000
|
|
25
26
|
|
|
27
|
+
const isImage = (item: ChatMessageContentItem): boolean => item.type === 'image_url'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rebuild a multi-part result with its text collapsed to `preview` and every
|
|
31
|
+
* image part preserved in order.
|
|
32
|
+
*
|
|
33
|
+
* Images are kept rather than truncated because there is no partial image — the
|
|
34
|
+
* alternative to keeping it is dropping the observation entirely. Text is the
|
|
35
|
+
* part that both dominates the payload and survives being summarised.
|
|
36
|
+
*/
|
|
37
|
+
const withTextReplaced = (content: ChatMessageContentItem[], preview: string): ChatMessageContentItem[] => [
|
|
38
|
+
{ type: 'text', text: preview },
|
|
39
|
+
...content.filter(isImage),
|
|
40
|
+
]
|
|
41
|
+
|
|
26
42
|
export const resultEvictionPlugin = definePlugin('result-eviction')
|
|
27
43
|
.agentConfig<EvictionAgentConfig>()
|
|
28
44
|
.hook('afterToolCall', async (ctx) => {
|
|
@@ -34,31 +50,30 @@ export const resultEvictionPlugin = definePlugin('result-eviction')
|
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
const { content } = ctx.result
|
|
37
|
-
|
|
38
|
-
// Only evict string content
|
|
39
|
-
if (typeof content !== 'string') {
|
|
40
|
-
return null
|
|
41
|
-
}
|
|
42
|
-
|
|
43
53
|
const maxTokens = agentConfig?.config?.maxTokens ?? DEFAULT_MAX_TOKENS
|
|
44
54
|
|
|
45
|
-
|
|
55
|
+
// Multi-part results (e.g. a page snapshot returning JSON + a screenshot)
|
|
56
|
+
// used to bail out here, so no threshold could ever evict them however
|
|
57
|
+
// large the text grew. Measure the text, evict the text, keep the images.
|
|
58
|
+
const text = contentToString(content)
|
|
59
|
+
const truncation = truncateByTokens(text, maxTokens)
|
|
46
60
|
if (!truncation) {
|
|
47
61
|
return null
|
|
48
62
|
}
|
|
49
63
|
|
|
50
|
-
// Write full
|
|
64
|
+
// Write full text to file via FileStore
|
|
51
65
|
const fileName = `${ctx.toolCall.id}.txt`
|
|
52
66
|
const filePath = `.results/${fileName}`
|
|
53
|
-
await ctx.files.session.write(filePath,
|
|
67
|
+
await ctx.files.session.write(filePath, text)
|
|
54
68
|
|
|
55
|
-
const
|
|
69
|
+
const preview = `${truncation.content}\n\n[Full output saved to: ${filePath}]`
|
|
70
|
+
const evicted: ToolResultContent = typeof content === 'string' ? preview : withTextReplaced(content, preview)
|
|
56
71
|
|
|
57
72
|
return {
|
|
58
73
|
action: 'modify',
|
|
59
74
|
result: {
|
|
60
75
|
isError: false,
|
|
61
|
-
content:
|
|
76
|
+
content: evicted,
|
|
62
77
|
},
|
|
63
78
|
}
|
|
64
79
|
})
|
|
@@ -19,6 +19,18 @@ const bigOutputPlugin = definePlugin('big-output')
|
|
|
19
19
|
input: z.object({ size: z.number() }),
|
|
20
20
|
execute: async (input) => Ok('x'.repeat(input.size)),
|
|
21
21
|
}),
|
|
22
|
+
// Mirrors a page-snapshot style result: a large JSON/text block plus an
|
|
23
|
+
// image part. These used to bypass eviction entirely.
|
|
24
|
+
createTool({
|
|
25
|
+
name: 'generate_snapshot',
|
|
26
|
+
description: 'Generate a text + image result of a given text size',
|
|
27
|
+
input: z.object({ size: z.number() }),
|
|
28
|
+
execute: async (input) =>
|
|
29
|
+
Ok([
|
|
30
|
+
{ type: 'text' as const, text: 'x'.repeat(input.size) },
|
|
31
|
+
{ type: 'image_url' as const, imageUrl: { url: 'data:image/png;base64,AAAA' } },
|
|
32
|
+
]),
|
|
33
|
+
}),
|
|
22
34
|
])
|
|
23
35
|
.build()
|
|
24
36
|
|
|
@@ -100,6 +112,82 @@ describe('result-eviction plugin', () => {
|
|
|
100
112
|
await harness.shutdown()
|
|
101
113
|
})
|
|
102
114
|
|
|
115
|
+
it('large multi-part output → text evicted, image part kept', async () => {
|
|
116
|
+
let toolContent: unknown = null
|
|
117
|
+
const harness = createEvictionHarness({
|
|
118
|
+
presets: [createEvictionPreset()],
|
|
119
|
+
mockHandler: (request) => {
|
|
120
|
+
const callCount = harness.llmProvider.getCallCount()
|
|
121
|
+
if (callCount === 1) {
|
|
122
|
+
return {
|
|
123
|
+
content: null,
|
|
124
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'generate_snapshot', input: { size: 250_000 } }],
|
|
125
|
+
finishReason: 'stop',
|
|
126
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const msg of request.messages) {
|
|
130
|
+
if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
131
|
+
toolContent = msg.content
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const session = await harness.createSession('test')
|
|
139
|
+
await session.sendAndWaitForIdle('Generate large snapshot')
|
|
140
|
+
|
|
141
|
+
expect(Array.isArray(toolContent)).toBe(true)
|
|
142
|
+
const parts = toolContent as Array<{ type: string; text?: string }>
|
|
143
|
+
|
|
144
|
+
// Text collapsed to a preview that names the spill file...
|
|
145
|
+
const textParts = parts.filter((p) => p.type === 'text')
|
|
146
|
+
expect(textParts).toHaveLength(1)
|
|
147
|
+
expect(textParts[0].text).toContain('truncated')
|
|
148
|
+
expect(textParts[0].text).toContain('.results/')
|
|
149
|
+
expect(textParts[0].text!.length).toBeLessThan(250_000)
|
|
150
|
+
|
|
151
|
+
// ...and the image survives, because there is no partial image: the only
|
|
152
|
+
// alternative to keeping it is losing the observation.
|
|
153
|
+
expect(parts.filter((p) => p.type === 'image_url')).toHaveLength(1)
|
|
154
|
+
|
|
155
|
+
await harness.shutdown()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('small multi-part output → returned unchanged', async () => {
|
|
159
|
+
let toolContent: unknown = null
|
|
160
|
+
const harness = createEvictionHarness({
|
|
161
|
+
presets: [createEvictionPreset()],
|
|
162
|
+
mockHandler: (request) => {
|
|
163
|
+
const callCount = harness.llmProvider.getCallCount()
|
|
164
|
+
if (callCount === 1) {
|
|
165
|
+
return {
|
|
166
|
+
content: null,
|
|
167
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'generate_snapshot', input: { size: 100 } }],
|
|
168
|
+
finishReason: 'stop',
|
|
169
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const msg of request.messages) {
|
|
173
|
+
if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
174
|
+
toolContent = msg.content
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const session = await harness.createSession('test')
|
|
182
|
+
await session.sendAndWaitForIdle('Generate small snapshot')
|
|
183
|
+
|
|
184
|
+
const parts = toolContent as Array<{ type: string; text?: string }>
|
|
185
|
+
expect(parts.filter((p) => p.type === 'text')[0].text).toBe('x'.repeat(100))
|
|
186
|
+
expect(parts.filter((p) => p.type === 'image_url')).toHaveLength(1)
|
|
187
|
+
|
|
188
|
+
await harness.shutdown()
|
|
189
|
+
})
|
|
190
|
+
|
|
103
191
|
it('full output saved to .results/<toolCallId>.txt', async () => {
|
|
104
192
|
const harness = createEvictionHarness({
|
|
105
193
|
presets: [createEvictionPreset()],
|