@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.
- package/dist/governance.d.ts +21 -0
- package/dist/governance.js +32 -0
- package/dist/harness/harness.d.ts +30 -0
- package/dist/harness/harness.js +40 -20
- package/dist/index.d.ts +2 -2
- package/dist/providers/anthropic.js +102 -15
- package/dist/runtime/runner.d.ts +21 -0
- package/dist/runtime/runner.js +423 -332
- package/dist/types.d.ts +37 -0
- package/package.json +2 -2
package/dist/governance.d.ts
CHANGED
|
@@ -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;
|
package/dist/governance.js
CHANGED
|
@@ -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>;
|
package/dist/harness/harness.js
CHANGED
|
@@ -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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
let
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 (
|
|
149
|
-
const { name, description, whenToUse, content } =
|
|
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
|
-
...(
|
|
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
|
|
92
|
-
const
|
|
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
|
|
133
|
-
const
|
|
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
|
-
...(
|
|
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,
|
|
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,
|
|
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);
|
package/dist/runtime/runner.d.ts
CHANGED
|
@@ -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;
|