@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.
Files changed (38) hide show
  1. package/dist/core/agents/agent.d.ts +5 -1
  2. package/dist/core/agents/agent.d.ts.map +1 -1
  3. package/dist/core/agents/agent.js.map +1 -1
  4. package/dist/core/agents/config.d.ts +5 -1
  5. package/dist/core/agents/config.d.ts.map +1 -1
  6. package/dist/core/llm/anthropic.d.ts.map +1 -1
  7. package/dist/core/llm/anthropic.js +7 -1
  8. package/dist/core/llm/anthropic.js.map +1 -1
  9. package/dist/core/llm/cache-breakpoints.d.ts +39 -5
  10. package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
  11. package/dist/core/llm/cache-breakpoints.js +22 -8
  12. package/dist/core/llm/cache-breakpoints.js.map +1 -1
  13. package/dist/core/llm/cache-breakpoints.test.js +32 -8
  14. package/dist/core/llm/cache-breakpoints.test.js.map +1 -1
  15. package/dist/core/llm/openrouter-mapping.test.js +29 -0
  16. package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
  17. package/dist/core/llm/openrouter.d.ts.map +1 -1
  18. package/dist/core/llm/openrouter.js +28 -8
  19. package/dist/core/llm/openrouter.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/plugins/result-eviction/plugin.d.ts.map +1 -1
  24. package/dist/plugins/result-eviction/plugin.js +24 -9
  25. package/dist/plugins/result-eviction/plugin.js.map +1 -1
  26. package/dist/plugins/result-eviction/result-eviction.integration.test.js +77 -0
  27. package/dist/plugins/result-eviction/result-eviction.integration.test.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/core/agents/agent.ts +5 -2
  30. package/src/core/agents/config.ts +5 -1
  31. package/src/core/llm/anthropic.ts +7 -1
  32. package/src/core/llm/cache-breakpoints.test.ts +38 -8
  33. package/src/core/llm/cache-breakpoints.ts +54 -9
  34. package/src/core/llm/openrouter-mapping.test.ts +37 -1
  35. package/src/core/llm/openrouter.ts +35 -9
  36. package/src/index.ts +1 -0
  37. package/src/plugins/result-eviction/plugin.ts +26 -11
  38. 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` opts into Anthropic's 1-hour cache tier (write cost 2× input, read
32
- * still 0.1×). Useful for long-lived agents where the default 5-minute TTL
33
- * would expire between user turns. Omit for the default 5-minute tier.
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?: '5m' | '1h',
79
+ ttl?: CacheTtlConfig,
39
80
  cachedPrefixCount = 0,
40
81
  ): LLMMessage[] {
41
- const cacheControl: LLMMessageCacheControl = ttl ? { type: 'ephemeral', ttl } : { type: 'ephemeral' }
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
- mark(tailIdx)
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?: { type: 'ephemeral' }
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: { type: 'ephemeral' }` to the LAST content block of an
108
- * OpenRouterMessage, converting string content to an array text block first
109
- * so the mark has a place to live.
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: { type: 'ephemeral' } }]
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: { type: 'ephemeral' } }
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: { type: 'ephemeral' } }] },
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
- applyCacheControlToLastBlock(mapped)
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
- const truncation = truncateByTokens(content, maxTokens)
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 content to file via FileStore
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, content)
67
+ await ctx.files.session.write(filePath, text)
54
68
 
55
- const truncatedContent = `${truncation.content}\n\n[Full output saved to: ${filePath}]`
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: truncatedContent,
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()],