@deepstrike/sdk 0.2.21 → 0.2.23

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.
@@ -29,7 +29,28 @@ export interface GovernancePolicy {
29
29
  windowMs: number;
30
30
  }[];
31
31
  constraints?: GovernanceConstraint[];
32
+ /** I5: when true (default), the runner pre-filters denied tools out of the schema passed to the
33
+ * provider — the model never sees them and never tries to call them, eliminating the rollback
34
+ * turn the kernel would otherwise produce. The denied tool names are also surfaced as a single
35
+ * line on the system slot so the model knows not to plan around them. Set to false to fall
36
+ * back to the v0.2.22 rollback-based behavior (useful for measuring the delta or when the
37
+ * agent should learn the denial via a real attempt). */
38
+ surfaceDeniedInSystem?: boolean;
32
39
  }
40
+ /** I5: walk the tool list and bucket each tool into `allowed` / `denied` based on a declarative
41
+ * policy. A tool is denied when:
42
+ * - the tool name appears in `vetoes`
43
+ * - a `rules[i].pattern` matches the tool name and the rule's `action === "deny"`
44
+ * - or `defaultAction === "deny"` and no `allow` rule matches
45
+ * `ask_user` is treated as allowed at the schema layer — the runtime decides at call time.
46
+ * Pattern matching is exact match or a glob with a single trailing `*` (so `"write_*"` denies
47
+ * `write_file` and `write_db`). Pure — no side effects. */
48
+ export declare function governanceFilterSchema<T extends {
49
+ name: string;
50
+ }>(tools: T[], policy: GovernancePolicy | undefined): {
51
+ allowed: T[];
52
+ denied: string[];
53
+ };
33
54
  export type GovernanceConstraint = {
34
55
  kind: "required";
35
56
  tool: string;
@@ -32,6 +32,38 @@ export class Governance {
32
32
  return this.inner.evaluate(toolName, argsJson);
33
33
  }
34
34
  }
35
+ /** I5: walk the tool list and bucket each tool into `allowed` / `denied` based on a declarative
36
+ * policy. A tool is denied when:
37
+ * - the tool name appears in `vetoes`
38
+ * - a `rules[i].pattern` matches the tool name and the rule's `action === "deny"`
39
+ * - or `defaultAction === "deny"` and no `allow` rule matches
40
+ * `ask_user` is treated as allowed at the schema layer — the runtime decides at call time.
41
+ * Pattern matching is exact match or a glob with a single trailing `*` (so `"write_*"` denies
42
+ * `write_file` and `write_db`). Pure — no side effects. */
43
+ export function governanceFilterSchema(tools, policy) {
44
+ if (!policy)
45
+ return { allowed: tools, denied: [] };
46
+ const vetoes = new Set(policy.vetoes ?? []);
47
+ const allowed = [];
48
+ const denied = [];
49
+ const matches = (pat, name) => pat === name || (pat.endsWith("*") && name.startsWith(pat.slice(0, -1)));
50
+ for (const tool of tools) {
51
+ if (vetoes.has(tool.name)) {
52
+ denied.push(tool.name);
53
+ continue;
54
+ }
55
+ let action = policy.defaultAction ?? "allow";
56
+ for (const r of policy.rules ?? []) {
57
+ if (matches(r.pattern, tool.name))
58
+ action = r.action;
59
+ }
60
+ if (action === "deny")
61
+ denied.push(tool.name);
62
+ else
63
+ allowed.push(tool);
64
+ }
65
+ return { allowed, denied };
66
+ }
35
67
  /**
36
68
  * Convert a declarative {@link GovernancePolicy} into the `load_governance_policy`
37
69
  * kernel event payload (snake_case wire fields). Pure — no side effects.
@@ -5,6 +5,14 @@ export interface Criterion {
5
5
  text: string;
6
6
  required: boolean;
7
7
  weight?: number;
8
+ /** I3.3 (A4): optional stable identifier from the host's contract layer (e.g. an
9
+ * `acceptance[].id` field). The harness does not interpret it; it just threads it through to
10
+ * `verdictFn` so the host can dispatch per-criterion deterministic checks by id. */
11
+ id?: string;
12
+ /** I3.3 (A4): host hint — when true, the host has a deterministic check for this criterion
13
+ * and would short-circuit the LLM eval. The harness still defers to the host's `verdictFn`
14
+ * for the actual decision; this is purely a transparency field on the request. */
15
+ machineCheckable?: boolean;
8
16
  }
9
17
  export interface CriterionResult {
10
18
  criterion: string;
@@ -83,6 +91,14 @@ export declare class SinglePassHarness {
83
91
  run(request: HarnessRequest): Promise<HarnessOutcome>;
84
92
  stream(request: HarnessRequest): AsyncIterable<StreamEvent>;
85
93
  }
94
+ /**
95
+ * @deprecated I3.4 (A1): prefer {@link HarnessLoop} with `verdictFn` for host-defined judgment.
96
+ * `EvalLoopHarness.stream()` does NOT honor the `gate` passed via `request.gate` (only `.run()`
97
+ * does), which is a long-standing footgun for streaming hosts. `HarnessLoop` runs the eval loop
98
+ * uniformly across both stream and run, accepts an optional `verdictFn` for short-circuiting the
99
+ * built-in LLM eval, and otherwise mirrors `EvalLoopHarness`'s behavior. New code should use
100
+ * `HarnessLoop`; existing call sites can migrate by switching the class + (if applicable)
101
+ * passing a `verdictFn` for the same gate logic. Slated for removal in a future major. */
86
102
  export declare class EvalLoopHarness {
87
103
  private runner;
88
104
  private gate;
@@ -91,15 +107,29 @@ export declare class EvalLoopHarness {
91
107
  run(request: HarnessRequest): Promise<HarnessOutcome>;
92
108
  stream(request: HarnessRequest): AsyncIterable<StreamEvent>;
93
109
  }
110
+ /** I3.2 (A2/A3): host-supplied judgment for each attempt's result. Returning a `Verdict` short-
111
+ * circuits the built-in LLM eval (no `evalProvider.stream` call); returning `undefined` defers to
112
+ * the built-in eval (enables hybrid judgment: machine-checkable items deterministic, subjective
113
+ * items LLM). Pure addition — when not set, HarnessLoop.stream() is byte-equivalent to its prior
114
+ * behavior. The closure owns its own context (doc reader, deterministic checks, etc.); the SDK
115
+ * is intentionally agnostic about what it inspects. */
116
+ export type VerdictFn = (ctx: {
117
+ goal: string;
118
+ criteria: Criterion[];
119
+ attempt: number;
120
+ result: string;
121
+ }) => Verdict | undefined | Promise<Verdict | undefined>;
94
122
  export interface HarnessLoopOptions {
95
123
  maxAttempts?: number;
96
124
  skillDir?: string;
125
+ verdictFn?: VerdictFn;
97
126
  }
98
127
  export declare class HarnessLoop {
99
128
  private runner;
100
129
  private evalProvider;
101
130
  private maxAttempts;
102
131
  private skillDir?;
132
+ private verdictFn?;
103
133
  constructor(runner: RuntimeRunner, evalProvider: import("../types.js").LLMProvider, options?: HarnessLoopOptions);
104
134
  run(request: HarnessRequest): Promise<HarnessOutcome>;
105
135
  stream(request: HarnessRequest): AsyncIterable<HarnessEvent>;
@@ -26,6 +26,14 @@ export class SinglePassHarness {
26
26
  yield* this.runner.run({ sessionId: crypto.randomUUID(), goal: request.goal, criteria: request.criteria?.map(c => c.text), extensions: request.extensions });
27
27
  }
28
28
  }
29
+ /**
30
+ * @deprecated I3.4 (A1): prefer {@link HarnessLoop} with `verdictFn` for host-defined judgment.
31
+ * `EvalLoopHarness.stream()` does NOT honor the `gate` passed via `request.gate` (only `.run()`
32
+ * does), which is a long-standing footgun for streaming hosts. `HarnessLoop` runs the eval loop
33
+ * uniformly across both stream and run, accepts an optional `verdictFn` for short-circuiting the
34
+ * built-in LLM eval, and otherwise mirrors `EvalLoopHarness`'s behavior. New code should use
35
+ * `HarnessLoop`; existing call sites can migrate by switching the class + (if applicable)
36
+ * passing a `verdictFn` for the same gate logic. Slated for removal in a future major. */
29
37
  export class EvalLoopHarness {
30
38
  runner;
31
39
  gate;
@@ -53,11 +61,13 @@ export class HarnessLoop {
53
61
  evalProvider;
54
62
  maxAttempts;
55
63
  skillDir;
64
+ verdictFn;
56
65
  constructor(runner, evalProvider, options = {}) {
57
66
  this.runner = runner;
58
67
  this.evalProvider = evalProvider;
59
68
  this.maxAttempts = options.maxAttempts ?? 3;
60
69
  this.skillDir = options.skillDir;
70
+ this.verdictFn = options.verdictFn;
61
71
  }
62
72
  async run(request) {
63
73
  let last;
@@ -125,28 +135,38 @@ export class HarnessLoop {
125
135
  }
126
136
  }
127
137
  yield { type: "supervising" };
128
- // #6 (0.5.0): the eval/verdict compute is the kernel's stateless free functions (was the
129
- // EvalPipeline state machine). Build the eval prompt, call the eval LLM, parse the verdict.
130
- const evalMsgs = kernel.buildEvalMessages(request.goal, criteria, lastResult, attempt, true);
131
- let evalText = "";
132
- const evalContext = {
133
- systemText: evalMsgs.filter((m) => m.role === "system").map((m) => m.content).join("\n\n"),
134
- turns: evalMsgs.filter((m) => m.role !== "system"),
135
- };
136
- for await (const evt of this.evalProvider.stream(evalContext, [], undefined)) {
137
- if (evt.type === "text_delta")
138
- evalText += evt.delta;
138
+ // I3.2 (A2/A3): host-supplied `verdictFn` short-circuits the LLM eval. When it returns a
139
+ // Verdict, use it; when it returns undefined, defer to the built-in eval (hybrid path).
140
+ let verdict;
141
+ let skillCandidate;
142
+ if (this.verdictFn) {
143
+ verdict = await this.verdictFn({ goal: request.goal, criteria, attempt, result: lastResult });
144
+ }
145
+ if (!verdict) {
146
+ // #6 (0.5.0): the eval/verdict compute is the kernel's stateless free functions (was the
147
+ // EvalPipeline state machine). Build the eval prompt, call the eval LLM, parse the verdict.
148
+ const evalMsgs = kernel.buildEvalMessages(request.goal, criteria, lastResult, attempt, true);
149
+ let evalText = "";
150
+ const evalContext = {
151
+ systemText: evalMsgs.filter((m) => m.role === "system").map((m) => m.content).join("\n\n"),
152
+ turns: evalMsgs.filter((m) => m.role !== "system"),
153
+ };
154
+ for await (const evt of this.evalProvider.stream(evalContext, [], undefined)) {
155
+ if (evt.type === "text_delta")
156
+ evalText += evt.delta;
157
+ }
158
+ const parsed = kernel.parseVerdict(evalText);
159
+ verdict = {
160
+ passed: parsed.passed,
161
+ overallScore: parsed.overallScore,
162
+ feedback: parsed.feedback,
163
+ details: parsed.details ?? [],
164
+ };
165
+ skillCandidate = parsed.skillCandidate;
139
166
  }
140
- const parsed = kernel.parseVerdict(evalText);
141
- const verdict = {
142
- passed: parsed.passed,
143
- overallScore: parsed.overallScore,
144
- feedback: parsed.feedback,
145
- details: parsed.details ?? [],
146
- };
147
167
  if (verdict.passed) {
148
- if (parsed.skillCandidate && this.skillDir) {
149
- const { name, description, whenToUse, content } = parsed.skillCandidate;
168
+ if (skillCandidate && this.skillDir) {
169
+ const { name, description, whenToUse, content } = skillCandidate;
150
170
  const fm = ["---", `name: ${name}`, `description: ${description}`,
151
171
  whenToUse ? `when_to_use: ${whenToUse}` : null, "---", ""]
152
172
  .filter(Boolean).join("\n");
package/dist/index.d.ts CHANGED
@@ -71,8 +71,8 @@ export type { PermissionDecision, Permission } from "./safety/permissions.js";
71
71
  export { Governance, governancePolicyToKernelEvent } from "./governance.js";
72
72
  export type { GovernanceVerdict, GovernancePolicy, GovernanceConstraint } from "./governance.js";
73
73
  export { SinglePassHarness, EvalLoopHarness, HarnessLoop } from "./harness/harness.js";
74
- export type { HarnessRequest, HarnessOutcome, HarnessLoopOptions, QualityGate } from "./harness/harness.js";
75
- export type { Message, ToolCall, ToolResult, ToolSchema, ContentPart, TextPart, ImagePart, AudioPart, StreamEvent, TextDelta, ThinkingDelta, ToolCallEvent, ToolChunk, ToolDeltaEvent, ToolSuspendEvent, ToolResultEvent, DoneEvent, ErrorEvent, PermissionRequestEvent, PermissionResolvedEvent, PermissionResponse, LLMProvider, RetryConfig, TokenUsage, ProviderToolSpec, ProviderRunState, ProviderReplay, RenderedContext, ReplayabilityAssessment, } from "./types.js";
74
+ export type { HarnessRequest, HarnessOutcome, HarnessLoopOptions, QualityGate, CriterionResult, HarnessEvent, VerdictFn } from "./harness/harness.js";
75
+ export type { Message, ToolCall, ToolResult, ToolSchema, ContentPart, TextPart, ImagePart, AudioPart, StreamEvent, TextDelta, ThinkingDelta, ToolCallEvent, ToolChunk, ToolDeltaEvent, ToolSuspendEvent, ToolResultEvent, DoneEvent, ErrorEvent, PermissionRequestEvent, PermissionResolvedEvent, PermissionResponse, LLMProvider, RetryConfig, TokenUsage, ProviderToolSpec, ProviderRunState, ProviderReplay, RenderedContext, ReplayabilityAssessment, CacheBreakpointStrategy, } from "./types.js";
76
76
  export type { AgentCapabilityFilter, AgentIdentity, AgentIsolation, AgentRunSpec, AgentProcessChangedObservation, ContextInheritance, KernelAgentRole, LoopResult, MilestoneCheckResult, MilestoneContract, MilestonePhase, MilestonePolicy, SubAgentResult, TerminationReason, WorkflowSpec, WorkflowNodeSpec, WorkflowTaskSpec, WorkflowSpawnInfo, } from "./types/agent.js";
77
77
  export { agentIdentitySub, agentRunSpecToKernel, milestoneCheckFail, milestoneCheckPass, milestoneCheckResultToKernel, subAgentResultToKernel, workflowSpecToKernel, workflowNodeSpecToKernel, submitWorkflowNodesToKernel, submitWorkflowToKernel, submitWorkflowNodesTool, startWorkflowTool, fanoutSynthesize, generateAndFilter, verifyRules, genEval, } from "./types/agent.js";
78
78
  export type { AcceptanceCriterion, VerificationContract, ContractCheckResult, } from "./collaboration/contract.js";
@@ -77,19 +77,24 @@ export class AnthropicProvider {
77
77
  * (tools render before system), so a redundant tool breakpoint would only burn
78
78
  * one of Anthropic's 4 cache_control slots — slots the message history needs.
79
79
  */
80
- buildTools(tools, anchorCache) {
80
+ buildTools(tools, anchorCache, strategy) {
81
+ // Tool cache_control is emitted under "default" and "tools-only". "system-only",
82
+ // "frozen-prefix", and "none" all skip it.
83
+ const emitOnLastTool = anchorCache &&
84
+ (strategy === "default" || strategy === "tools-only");
81
85
  return tools.map((t, i) => ({
82
86
  name: t.name,
83
87
  description: t.description,
84
88
  input_schema: JSON.parse(t.parameters),
85
- ...(anchorCache && i === tools.length - 1 ? { cache_control: { type: "ephemeral" } } : {}),
89
+ ...(emitOnLastTool && i === tools.length - 1 ? { cache_control: { type: "ephemeral" } } : {}),
86
90
  }));
87
91
  }
88
92
  async complete(context, tools, extensions) {
89
93
  if (this.circuit.isOpen())
90
94
  throw new Error("Circuit breaker open");
91
- const system = this.buildSystem(context);
92
- const msgs = this.buildMessages(context);
95
+ const strategy = resolveCacheBreakpointStrategy(extensions);
96
+ const system = this.buildSystem(context, strategy);
97
+ const msgs = this.buildMessages(context, strategy);
93
98
  assertCacheBudget(system, tools.length);
94
99
  const requestExtensions = this.requestExtensions(extensions);
95
100
  let lastErr;
@@ -101,7 +106,7 @@ export class AnthropicProvider {
101
106
  max_tokens: typeof extensions?.max_tokens === "number" ? extensions.max_tokens : 8096,
102
107
  ...(system ? { system } : {}),
103
108
  messages: msgs,
104
- ...(tools.length ? { tools: this.buildTools(tools, !Array.isArray(system)) } : {}),
109
+ ...(tools.length ? { tools: this.buildTools(tools, !Array.isArray(system), strategy) } : {}),
105
110
  }, extensions);
106
111
  this.circuit.recordSuccess();
107
112
  let content = "";
@@ -129,10 +134,16 @@ export class AnthropicProvider {
129
134
  throw lastErr;
130
135
  }
131
136
  async *stream(context, tools, extensions, _state, signal) {
132
- const system = this.buildSystem(context);
133
- const msgs = this.buildMessages(context);
137
+ const strategy = resolveCacheBreakpointStrategy(extensions);
138
+ const system = this.buildSystem(context, strategy);
139
+ const msgs = this.buildMessages(context, strategy);
134
140
  assertCacheBudget(system, tools.length);
135
141
  const requestExtensions = this.requestExtensions(extensions);
142
+ const builtTools = tools.length ? this.buildTools(tools, !Array.isArray(system), strategy) : undefined;
143
+ // I1: capture which slots will carry cache_control so cache_read_input_tokens can be
144
+ // attributed pro-rata when the response arrives. Honest annotation: Anthropic returns one
145
+ // scalar (no per-slot breakdown), so this is an estimate, not authoritative.
146
+ const slotBp = countCacheControlSlots(system, builtTools, msgs);
136
147
  const toolBlocks = {};
137
148
  const nativeBlocks = {};
138
149
  let finalText = "";
@@ -143,7 +154,7 @@ export class AnthropicProvider {
143
154
  max_tokens: typeof extensions?.max_tokens === "number" ? extensions.max_tokens : 8096,
144
155
  ...(system ? { system } : {}),
145
156
  messages: msgs,
146
- ...(tools.length ? { tools: this.buildTools(tools, !Array.isArray(system)) } : {}),
157
+ ...(builtTools ? { tools: builtTools } : {}),
147
158
  }, extensions, signal);
148
159
  let uncachedInput = 0;
149
160
  let cacheReadTokens = 0;
@@ -165,6 +176,7 @@ export class AnthropicProvider {
165
176
  // context-pressure/compaction — excluding cached tokens would make a
166
177
  // cache-heavy turn look tiny and suppress compaction until a 413.
167
178
  const inputTokens = uncachedInput + cacheReadTokens + cacheCreationTokens;
179
+ const bySlot = estimateCacheReadBySlot(cacheReadTokens, slotBp);
168
180
  yield {
169
181
  type: "usage",
170
182
  totalTokens: inputTokens + outputTokens,
@@ -172,6 +184,7 @@ export class AnthropicProvider {
172
184
  outputTokens,
173
185
  cacheReadInputTokens: cacheReadTokens,
174
186
  cacheCreationInputTokens: cacheCreationTokens,
187
+ ...(bySlot ? { cacheReadInputTokensBySlot: bySlot } : {}),
175
188
  };
176
189
  }
177
190
  }
@@ -235,7 +248,7 @@ export class AnthropicProvider {
235
248
  ? this.client.beta.messages.stream(params, opts)
236
249
  : this.client.messages.stream(params, opts));
237
250
  }
238
- buildSystem(context) {
251
+ buildSystem(context, strategy) {
239
252
  // B3 note: the system shape is content-driven — 0 blocks (string), 1 block
240
253
  // (stable only), or 2 blocks (stable + knowledge). The first turn `systemKnowledge`
241
254
  // appears, the block count rises 1→2, which is a one-time prompt-cache invalidation
@@ -245,23 +258,27 @@ export class AnthropicProvider {
245
258
  if (!context.systemStable && !context.systemKnowledge) {
246
259
  return context.systemText || undefined;
247
260
  }
261
+ // System cache_control is emitted under "default" and "system-only". Other strategies
262
+ // keep the text-block structure for protocol parity but omit cache_control.
263
+ const emitOnSystemBlocks = strategy === "default" || strategy === "system-only";
264
+ const cc = { type: "ephemeral" };
248
265
  const blocks = [];
249
266
  if (context.systemStable) {
250
- blocks.push({ type: "text", text: context.systemStable, cache_control: { type: "ephemeral" } });
267
+ blocks.push({ type: "text", text: context.systemStable, ...(emitOnSystemBlocks ? { cache_control: cc } : {}) });
251
268
  }
252
269
  if (context.systemKnowledge) {
253
- blocks.push({ type: "text", text: context.systemKnowledge, cache_control: { type: "ephemeral" } });
270
+ blocks.push({ type: "text", text: context.systemKnowledge, ...(emitOnSystemBlocks ? { cache_control: cc } : {}) });
254
271
  }
255
272
  return blocks.length ? blocks : undefined;
256
273
  }
257
- buildMessages(context) {
274
+ buildMessages(context, strategy) {
258
275
  const msgs = toAnthropicMessages(context.turns, message => this.nativeAssistantBlocks.get(assistantReplayKey(message)));
259
276
  // Cache breakpoints anchor on the stable history; the volatile State turn is
260
277
  // appended AFTER them as the uncached tail (so the history prefix re-reads
261
278
  // across turns). On un-rebuilt bindings stateTurn is absent and the state is
262
279
  // already inside `turns` — rendered as-is above. `frozenPrefixLen` (P1-E) pins
263
280
  // the deep breakpoint at the compaction boundary; absent ⇒ rolling-pair fallback.
264
- applyMessageCacheControl(msgs, context.frozenPrefixLen);
281
+ applyMessageCacheControl(msgs, context.frozenPrefixLen, strategy);
265
282
  if (context.stateTurn) {
266
283
  // Render through toAnthropicMessages so assistant tool_use blocks and
267
284
  // tool-role tool_result parts are serialized correctly — toAnthropicContent
@@ -282,6 +299,70 @@ export class AnthropicProvider {
282
299
  this.nativeAssistantBlocks.set(assistantReplayKey(message), blocks);
283
300
  }
284
301
  }
302
+ /** Recognised cache-breakpoint strategy values; any other input (incl. undefined) falls to `"default"`. */
303
+ const CACHE_BREAKPOINT_STRATEGIES = new Set([
304
+ "default", "tools-only", "system-only", "frozen-prefix", "none",
305
+ ]);
306
+ /** Pull `cacheBreakpointStrategy` from per-call extensions; unrecognised values → `"default"`. */
307
+ function resolveCacheBreakpointStrategy(extensions) {
308
+ const raw = extensions?.cacheBreakpointStrategy;
309
+ if (typeof raw === "string" && CACHE_BREAKPOINT_STRATEGIES.has(raw)) {
310
+ return raw;
311
+ }
312
+ return "default";
313
+ }
314
+ /**
315
+ * I1: count which slots of the outgoing request carry a `cache_control` breakpoint. Used to
316
+ * pro-rata-attribute the response's `cache_read_input_tokens` (a single scalar with no per-slot
317
+ * breakdown) across the slots that contributed to the cache hit. Returns whether each slot has
318
+ * any breakpoint — not the actual count, since pro-rata only needs the contributing slot set.
319
+ */
320
+ function countCacheControlSlots(system, builtTools, msgs) {
321
+ const sysBp = Array.isArray(system) && system.some(b => b?.cache_control != null);
322
+ const toolBp = !!builtTools && builtTools.some(t => t?.cache_control != null);
323
+ let msgBp = false;
324
+ for (const m of msgs) {
325
+ if (Array.isArray(m.content)) {
326
+ if (m.content.some(b => b?.cache_control != null)) {
327
+ msgBp = true;
328
+ break;
329
+ }
330
+ }
331
+ }
332
+ return { system: sysBp, tools: toolBp, messages: msgBp };
333
+ }
334
+ /**
335
+ * I1: split the response's `cache_read_input_tokens` evenly across the slots that carried a
336
+ * cache_control breakpoint on the request. Returns undefined when there's no cache read or no
337
+ * contributing slot — in those cases the consumer is better off seeing the field absent than
338
+ * seeing all zeros. The remainder (if the total doesn't divide evenly) lands on the first
339
+ * contributing slot to keep the sum exact.
340
+ */
341
+ function estimateCacheReadBySlot(cacheRead, slotBp) {
342
+ if (cacheRead <= 0)
343
+ return undefined;
344
+ const count = (slotBp.system ? 1 : 0) + (slotBp.tools ? 1 : 0) + (slotBp.messages ? 1 : 0);
345
+ if (count === 0)
346
+ return undefined;
347
+ const share = Math.floor(cacheRead / count);
348
+ const remainder = cacheRead - share * count;
349
+ const out = {};
350
+ let firstDone = false;
351
+ const give = () => {
352
+ if (!firstDone) {
353
+ firstDone = true;
354
+ return share + remainder;
355
+ }
356
+ return share;
357
+ };
358
+ if (slotBp.system)
359
+ out.system = give();
360
+ if (slotBp.tools)
361
+ out.tools = give();
362
+ if (slotBp.messages)
363
+ out.messages = give();
364
+ return out;
365
+ }
285
366
  /** Anthropic accepts at most this many cache_control breakpoints per request. */
286
367
  const MAX_CACHE_BREAKPOINTS = 4;
287
368
  /**
@@ -324,15 +405,21 @@ function assertCacheBudget(system, toolCount) {
324
405
  * cache_control attaches to the last content block of each target, promoting a bare
325
406
  * string body to a text block.
326
407
  */
327
- function applyMessageCacheControl(msgs, frozenPrefixLen) {
408
+ function applyMessageCacheControl(msgs, frozenPrefixLen, strategy) {
328
409
  if (!msgs.length)
329
410
  return;
411
+ // Message-level cache_control is emitted under "default" and "frozen-prefix" only.
412
+ // "tools-only", "system-only", and "none" skip the history entirely.
413
+ if (strategy === "tools-only" || strategy === "system-only" || strategy === "none")
414
+ return;
330
415
  const targets = new Set([msgs.length - 1]);
331
416
  if (typeof frozenPrefixLen === "number" && frozenPrefixLen >= 1 && frozenPrefixLen < msgs.length) {
332
417
  // Deep anchor at the frozen-prefix boundary (last frozen turn). Fixed between compactions.
333
418
  targets.add(frozenPrefixLen - 1);
334
419
  }
335
- else {
420
+ else if (strategy === "default") {
421
+ // Rolling fallback is part of the default strategy only — `"frozen-prefix"` deliberately
422
+ // skips it so a verify can isolate the deep-anchor contribution from the rolling pair.
336
423
  for (let i = msgs.length - 2; i >= 0 && targets.size < MESSAGE_CACHE_BREAKPOINTS; i--) {
337
424
  if (msgs[i].role === "user")
338
425
  targets.add(i);
@@ -36,6 +36,16 @@ export interface TurnMetrics {
36
36
  inputTokens: number;
37
37
  /** Tokens served from the prompt cache this turn (Anthropic `cache_read_input_tokens`). */
38
38
  cacheReadTokens: number;
39
+ /** I1: per-slot attribution of `cacheReadTokens`. Anthropic reports a single cache-read total,
40
+ * not a per-block breakdown — this field is a pro-rata estimate over the slots that actually
41
+ * carried a `cache_control` breakpoint on the request. Missing / empty when the provider does
42
+ * not honor `cache_control` (OpenAI-family auto-cache) or when no breakpoints were placed.
43
+ * Useful for diagnosing which slot is buying the cache hit when comparing strategies. */
44
+ cacheReadTokensBySlot?: {
45
+ system?: number;
46
+ tools?: number;
47
+ messages?: number;
48
+ };
39
49
  /** Tokens written to the prompt cache this turn (Anthropic `cache_creation_input_tokens`). */
40
50
  cacheCreationTokens: number;
41
51
  }
@@ -60,6 +70,17 @@ export interface RuntimeOptions {
60
70
  maxTurns?: number;
61
71
  timeoutMs?: number;
62
72
  agentId?: string;
73
+ /** I4: optional run-start memory pre-fetch hook. The runner calls this ONCE per run, before the
74
+ * first LLM turn, with the request's goal and (optional) run-spec. Each returned query string
75
+ * becomes a `dreamStore.search(agentId, q, 5)` and the resulting hits are paged into the
76
+ * context's knowledge partition before turn 1, so the model sees them on first call. Returning
77
+ * `undefined` / empty array is a no-op. Requires `dreamStore` + `agentId`; missing either ⇒
78
+ * silently skipped (errs-open). Bench memory-recall shows -57% turns / -55% dollars when
79
+ * relevant memories land on turn 1 instead of being discovered via the meta-tool on turn 3+. */
80
+ preQueryMemory?: (ctx: {
81
+ goal: string;
82
+ runSpec?: AgentRunSpec;
83
+ }) => Promise<string[] | undefined> | string[] | undefined;
63
84
  systemPrompt?: string;
64
85
  initialMemory?: string[];
65
86
  skillDir?: string;