@opencode-ai/ai 0.0.0-next-16167 → 0.0.0-next-16169

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/README.md CHANGED
@@ -207,7 +207,9 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
207
207
 
208
208
  ### Auto placement
209
209
 
210
- `"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
210
+ `"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary is the load-bearing detail in tool loops: it advances on every request so the previous cache entry stays within Anthropic's 20-block lookback.
211
+
212
+ Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
211
213
 
212
214
  The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
213
215
 
@@ -235,7 +237,7 @@ cache: {
235
237
 
236
238
  ### Manual hints
237
239
 
238
- Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
240
+ Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints, counts them against Anthropic and Bedrock's four-breakpoint limit, and only fills the remaining slots.
239
241
 
240
242
  ```ts
241
243
  LLM.request({
@@ -251,8 +253,8 @@ LLM.request({
251
253
 
252
254
  | Protocol | `cache: "auto"` |
253
255
  | ----------------------- | ------------------------------------------------------------------------- |
254
- | Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
255
- | Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
256
+ | Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) |
257
+ | Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) |
256
258
  | OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
257
259
  | Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
258
260
 
@@ -2,29 +2,28 @@
2
2
  // the policy designates. Runs once at compile time, before the per-protocol
3
3
  // body builder, so the existing inline-hint lowering path handles the rest.
4
4
  //
5
- // The default `"auto"` shape places one breakpoint at the last tool definition,
6
- // one at the last system part, and one at the latest user message. This
7
- // matches what production agent harnesses (LangChain's caching middleware,
8
- // kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
9
- // latest user message stays put while a single turn explodes into many
10
- // assistant/tool round-trips, so caching at that boundary lets every
11
- // intra-turn API call hit the prefix.
5
+ // The default `"auto"` shape places breakpoints at the last tool definition,
6
+ // the first and last distinct system parts, and the conversation tail. This
7
+ // exposes reusable tool, base-agent, project, and session prefixes while
8
+ // advancing the tail after each tool result keeps the previous cache entry
9
+ // within Anthropic's 20-block lookback during long agent turns.
12
10
  //
13
- // Manual `cache: CacheHint` placements on individual parts are preserved
14
- // this function only fills gaps the caller left empty.
11
+ // Manual `cache: CacheHint` placements on individual parts are preserved and
12
+ // count against the four-breakpoint budget; auto only fills remaining slots.
15
13
  import { CacheHint } from "./schema/options";
16
14
  import { LLMRequest, Message, ToolDefinition } from "./schema/messages";
17
15
  const AUTO = {
18
16
  tools: true,
19
17
  system: true,
20
- messages: "latest-user-message",
18
+ messages: { tail: 1 },
21
19
  };
22
20
  const NONE = {};
21
+ const BREAKPOINT_CAP = 4;
23
22
  // Resolution rules:
24
23
  // - undefined → "auto" — caching is on by default. The math favors it:
25
24
  // Anthropic 5m-cache write is 1.25x base, read is 0.1x,
26
25
  // so a single reuse within 5 minutes already wins.
27
- // - "auto" → tools + system + latest user msg.
26
+ // - "auto" → tools + first/last system + final message boundary.
28
27
  // - "none" → no auto placement; manual `CacheHint`s still flow.
29
28
  // - object form → exactly what the caller asked for.
30
29
  const resolve = (policy) => {
@@ -39,27 +38,33 @@ const resolve = (policy) => {
39
38
  // whole policy pass for these — emitting hints would be harmless but pointless.
40
39
  const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]);
41
40
  const makeHint = (ttlSeconds) => ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" });
42
- const markLastTool = (tools, hint) => {
41
+ const markLastTool = (tools, hint, budget) => {
43
42
  if (tools.length === 0)
44
43
  return tools;
45
44
  const last = tools.length - 1;
46
- if (tools[last].cache)
45
+ if (tools[last].cache || budget.remaining === 0)
47
46
  return tools;
47
+ budget.remaining -= 1;
48
48
  return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool));
49
49
  };
50
- const markLastSystem = (system, hint) => {
50
+ const markSystemBoundaries = (system, hint, budget) => {
51
51
  if (system.length === 0)
52
52
  return system;
53
- const last = system.length - 1;
54
- if (system[last].cache)
55
- return system;
56
- return system.map((part, i) => (i === last ? { ...part, cache: hint } : part));
53
+ let changed = false;
54
+ const next = system.map((part, index) => {
55
+ if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0)
56
+ return part;
57
+ budget.remaining -= 1;
58
+ changed = true;
59
+ return { ...part, cache: hint };
60
+ });
61
+ return changed ? next : system;
57
62
  };
58
63
  const lastIndexOfRole = (messages, role) => messages.findLastIndex((m) => m.role === role);
59
64
  // Mark the last text part of `messages[index]`. If no text part exists, mark
60
65
  // the last content part regardless of type — that's the breakpoint position
61
66
  // in tool-result-only messages too.
62
- const markMessageAt = (messages, index, hint) => {
67
+ const markMessageAt = (messages, index, hint, budget) => {
63
68
  if (index < 0 || index >= messages.length)
64
69
  return messages;
65
70
  const target = messages[index];
@@ -68,8 +73,9 @@ const markMessageAt = (messages, index, hint) => {
68
73
  const lastTextIndex = target.content.findLastIndex((part) => part.type === "text");
69
74
  const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1;
70
75
  const existing = target.content[markAt];
71
- if ("cache" in existing && existing.cache)
76
+ if (("cache" in existing && existing.cache) || budget.remaining === 0)
72
77
  return messages;
78
+ budget.remaining -= 1;
73
79
  const nextContent = target.content.map((part, i) => (i === markAt ? { ...part, cache: hint } : part));
74
80
  const next = new Message({ ...target, content: nextContent });
75
81
  // Single pass over `messages`, substituting the one updated entry. Long
@@ -79,19 +85,23 @@ const markMessageAt = (messages, index, hint) => {
79
85
  result[index] = next;
80
86
  return result;
81
87
  };
82
- const markMessages = (messages, strategy, hint) => {
88
+ const markMessages = (messages, strategy, hint, budget) => {
83
89
  if (messages.length === 0)
84
90
  return messages;
85
91
  if (strategy === "latest-user-message")
86
- return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint);
92
+ return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget);
87
93
  if (strategy === "latest-assistant")
88
- return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint);
94
+ return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget);
89
95
  const start = Math.max(0, messages.length - strategy.tail);
90
96
  let next = messages;
91
97
  for (let i = start; i < messages.length; i++)
92
- next = markMessageAt(next, i, hint);
98
+ next = markMessageAt(next, i, hint, budget);
93
99
  return next;
94
100
  };
101
+ const countHints = (request) => request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
102
+ request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
103
+ request.messages.reduce((count, message) => count +
104
+ message.content.reduce((contentCount, part) => contentCount + ("cache" in part && part.cache !== undefined ? 1 : 0), 0), 0);
95
105
  export const applyCachePolicy = (request) => {
96
106
  if (!RESPECTS_INLINE_HINTS.has(request.model.route.id))
97
107
  return request;
@@ -99,9 +109,10 @@ export const applyCachePolicy = (request) => {
99
109
  if (!policy.tools && !policy.system && !policy.messages)
100
110
  return request;
101
111
  const hint = makeHint(policy.ttlSeconds);
102
- const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools;
103
- const system = policy.system ? markLastSystem(request.system, hint) : request.system;
104
- const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages;
112
+ const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) };
113
+ const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools;
114
+ const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system;
115
+ const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages;
105
116
  if (tools === request.tools && system === request.system && messages === request.messages)
106
117
  return request;
107
118
  return LLMRequest.update(request, { tools, system, messages });
@@ -175,11 +175,11 @@ export class CacheHint extends Schema.Class("LLM.CacheHint")({
175
175
  // Auto-placement policy for prompt caching. The protocol-neutral lowering step
176
176
  // reads this and injects `CacheHint`s at the configured boundaries; the
177
177
  // per-protocol body builders then translate those hints into wire markers as
178
- // usual. `"auto"` is the recommended default for agent loops — it places one
179
- // breakpoint at the last tool definition, one at the last system part, and one
180
- // at the latest user message. The combination of provider invalidation
181
- // hierarchy (tools system → messages) and Anthropic/Bedrock's 20-block
182
- // lookback means three trailing breakpoints reliably cover the static prefix.
178
+ // usual. `"auto"` is the recommended default for agent loops — it places
179
+ // breakpoints at the last tool definition, the first and last distinct system
180
+ // parts, and the conversation tail. The rolling message breakpoint keeps a
181
+ // prior cache entry within Anthropic/Bedrock's 20-block lookback during long
182
+ // tool loops.
183
183
  //
184
184
  // Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
185
185
  // object form to override individual choices.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "0.0.0-next-16167",
3
+ "version": "0.0.0-next-16169",
4
4
  "name": "@opencode-ai/ai",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@
26
26
  "devDependencies": {
27
27
  "@clack/prompts": "1.0.0-alpha.1",
28
28
  "@effect/platform-node": "4.0.0-beta.98",
29
- "@opencode-ai/http-recorder": "0.0.0-next-16167",
29
+ "@opencode-ai/http-recorder": "0.0.0-next-16169",
30
30
  "@tsconfig/bun": "1.0.9",
31
31
  "@types/bun": "1.3.13",
32
32
  "@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -35,7 +35,7 @@
35
35
  "dependencies": {
36
36
  "@smithy/eventstream-codec": "4.2.14",
37
37
  "@smithy/util-utf8": "4.2.2",
38
- "@opencode-ai/schema": "0.0.0-next-16167",
38
+ "@opencode-ai/schema": "0.0.0-next-16169",
39
39
  "aws4fetch": "1.0.20",
40
40
  "effect": "4.0.0-beta.98",
41
41
  "google-auth-library": "10.5.0"