@inflexa-ai/harness 0.35.0 → 0.35.1
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 +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -2
- package/dist/loop/run-agent.js +9 -9
- package/dist/providers/prompt-cache.d.ts +109 -14
- package/dist/providers/prompt-cache.js +196 -14
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Requirements:
|
|
|
9
9
|
- Node.js `>=24` (the runtime; Bun is used only to run tests)
|
|
10
10
|
- Postgres with pgvector (app tables + durable workflow state)
|
|
11
11
|
- a sandbox backend, selected by `SANDBOX_BACKEND` (`docker` | `k8s`)
|
|
12
|
+
- a package store, plus a `farmSource` that names where the package farm of an analysis comes from
|
|
12
13
|
- a chat/embedding provider — Anthropic, or any OpenAI-compatible endpoint
|
|
13
14
|
|
|
14
15
|
```bash
|
|
@@ -18,6 +19,8 @@ tsc -p tsconfig.json # build: emit dist/ from src/ (also `npm run b
|
|
|
18
19
|
bun test # run the test suite
|
|
19
20
|
```
|
|
20
21
|
|
|
22
|
+
The runtime image bakes no R or Python package, so the packages arrive from a host **package store** that the backend mounts read-only at `/mnt/libs`. `farmSource` is a **required** backend config field, and it has two kinds: `fixed` names one farm for every analysis (the managed shape), and `per-analysis` supplies a resolver of the embedder. `ExtendAnalysisFarm` is the optional link seam behind the `link_packages` tool. The harness composes no farm and installs nothing — the embedder owns the store — and the harness names the missing packages but no host command, because a managed deployment holds no `inflexa` binary.
|
|
23
|
+
|
|
21
24
|
The package emits `dist/` from `src/` and publishes only `dist`. Most configuration arrives through dependency objects passed to the composition point — the LLM backend, for instance, is a fully injected provider config (endpoint/key/model or a `LanguageModel` instance), not read from env; helper modules read conventional env vars (`DB_PG_*`, sandbox limits) — see `CONTEXT.md` and the specs for the catalog.
|
|
22
25
|
|
|
23
26
|
## Public surface
|
package/dist/index.d.ts
CHANGED
|
@@ -71,7 +71,7 @@ export type { AiSdkProviderConfig, ConfiguredAiSdkProviderDeps } from "./provide
|
|
|
71
71
|
export { createEmbeddingProvider } from "./providers/embedding.js";
|
|
72
72
|
export type { EmbeddingProviderDeps } from "./providers/embedding.js";
|
|
73
73
|
export type { AgentChat, ChatProvider, EmbeddingProvider, ChatRequest, ChatResponse, ChatStreamEvent, ChatUsage, ModelMessage, PromptCachePolicy, ReasoningPolicy, } from "./providers/types.js";
|
|
74
|
-
export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "./providers/prompt-cache.js";
|
|
74
|
+
export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions, withPromptCacheBreakpoint } from "./providers/prompt-cache.js";
|
|
75
75
|
export { DEFAULT_REASONING } from "./providers/reasoning.js";
|
|
76
76
|
export { toProviderError, isProviderError } from "./providers/errors.js";
|
|
77
77
|
export type { ProviderError } from "./providers/errors.js";
|
package/dist/index.js
CHANGED
|
@@ -110,8 +110,14 @@ export { createEmbeddingProvider } from "./providers/embedding.js";
|
|
|
110
110
|
// re-sends its prefix every iteration and breaks even immediately). Hosts whose
|
|
111
111
|
// endpoint ignores cache directives — notably the Claude Max OAuth path — pass
|
|
112
112
|
// `"off"`; the `cortex.harness.agent.cache_*_tokens` metrics show which case a
|
|
113
|
-
// deployment is actually in.
|
|
114
|
-
|
|
113
|
+
// deployment is actually in. `withPromptCacheBreakpoint` is the placement: it
|
|
114
|
+
// puts the ONE marker of a request on its last message, where every hop upstream
|
|
115
|
+
// can count it, in the namespace of each vendor that needs an explicit
|
|
116
|
+
// breakpoint (Anthropic and Bedrock; the OpenAI family and Gemini cache without
|
|
117
|
+
// one). Never attach `promptCacheProviderOptions` to a request itself — that
|
|
118
|
+
// emits a top-level field an intermediary cannot see, and a proxy that trims to
|
|
119
|
+
// Anthropic's cap of four breakpoints then sends five.
|
|
120
|
+
export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions, withPromptCacheBreakpoint } from "./providers/prompt-cache.js";
|
|
115
121
|
// Reasoning depth. `ReasoningPolicy` is the vendor-neutral name for how deep a
|
|
116
122
|
// model reasons; a composition root sets it per run through
|
|
117
123
|
// `RunAgentOptions.reasoning`. It defaults to `DEFAULT_REASONING` — `xhigh` —
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -7,7 +7,7 @@ import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
|
|
|
7
7
|
import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
|
|
8
8
|
import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
|
|
9
9
|
import { classifyProviderError } from "../providers/errors.js";
|
|
10
|
-
import { DEFAULT_PROMPT_CACHE,
|
|
10
|
+
import { DEFAULT_PROMPT_CACHE, withPromptCacheBreakpoint } from "../providers/prompt-cache.js";
|
|
11
11
|
import { DEFAULT_REASONING } from "../providers/reasoning.js";
|
|
12
12
|
import { resultStep } from "./run-step.js";
|
|
13
13
|
import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
|
|
@@ -82,10 +82,11 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
82
82
|
// `debug` records name the tools when that distinction is not enough.
|
|
83
83
|
let toolCallCount = 0;
|
|
84
84
|
let toolErrorCount = 0;
|
|
85
|
-
// Resolved once, not per iteration: an identical
|
|
86
|
-
//
|
|
87
|
-
// byte-identical to be read back.
|
|
88
|
-
|
|
85
|
+
// Resolved once, not per iteration: an identical policy across every call is
|
|
86
|
+
// itself part of the cache contract — the request prefix has to be
|
|
87
|
+
// byte-identical to be read back. The breakpoint it places is re-derived per
|
|
88
|
+
// call, because it rides the last message and the transcript grows.
|
|
89
|
+
const promptCache = opts.promptCache ?? DEFAULT_PROMPT_CACHE;
|
|
89
90
|
// The depth is a neutral name, and the provider package resolves it for the
|
|
90
91
|
// model. A vendor key here would turn that resolution off.
|
|
91
92
|
const reasoning = opts.reasoning ?? DEFAULT_REASONING;
|
|
@@ -232,10 +233,9 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
232
233
|
iterations = i + 1;
|
|
233
234
|
const request = {
|
|
234
235
|
system: agent.systemPrompt,
|
|
235
|
-
messages,
|
|
236
|
+
messages: withPromptCacheBreakpoint(messages, promptCache),
|
|
236
237
|
tools: toolDefs,
|
|
237
238
|
...(opts.toolChoice !== undefined ? { toolChoice: opts.toolChoice } : {}),
|
|
238
|
-
providerOptions,
|
|
239
239
|
reasoning,
|
|
240
240
|
};
|
|
241
241
|
const llmStepName = formatStepName.llm(i);
|
|
@@ -343,11 +343,11 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
343
343
|
// Cache defeater (known; not fixed here). Emptying the tool set changes the
|
|
344
344
|
// very front of the request prefix — tool definitions are cached ahead of
|
|
345
345
|
// system and history — so this call reads *nothing* back from the cache and
|
|
346
|
-
// rewrites the whole prefix from scratch. It still
|
|
346
|
+
// rewrites the whole prefix from scratch. It still places the breakpoint
|
|
347
347
|
// because it is the one call whose write is pure waste, and the
|
|
348
348
|
// cache_write_tokens counter is what makes that waste visible.
|
|
349
349
|
const wrapUpStepName = formatStepName.llm(agent.maxIterations);
|
|
350
|
-
const wrapUp = await resultStep(runStep)(wrapUpStepName, () => provider.chat({ system: agent.systemPrompt, messages, tools: {}, toolChoice: "none",
|
|
350
|
+
const wrapUp = await resultStep(runStep)(wrapUpStepName, () => provider.chat({ system: agent.systemPrompt, messages: withPromptCacheBreakpoint(messages, promptCache), tools: {}, toolChoice: "none", reasoning }, session, signal));
|
|
351
351
|
accountForCall(wrapUp, wrapUpStepName);
|
|
352
352
|
if (wrapUp.finishReason === "aborted") {
|
|
353
353
|
// An abort during the tool-less wrap-up is still the user cutting the turn — the
|
|
@@ -15,12 +15,53 @@
|
|
|
15
15
|
* error — on an OpenAI-compatible model, which is exactly what we want: that
|
|
16
16
|
* family does automatic server-side prefix caching and needs no directive.
|
|
17
17
|
*
|
|
18
|
-
* ## What the Anthropic namespace does
|
|
18
|
+
* ## What the Anthropic namespace does, and where the marker must go
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* The options go on the LAST MESSAGE of the request, never on the request
|
|
21
|
+
* itself. Both placements cache the same bytes — one breakpoint at the end of
|
|
22
|
+
* the messages covers the whole prefix (tools → system → history), because the
|
|
23
|
+
* cache keys on a prefix — but only the message placement survives an
|
|
24
|
+
* intermediary.
|
|
25
|
+
*
|
|
26
|
+
* A REQUEST-level `cacheControl` makes the provider emit a top-level
|
|
27
|
+
* `cache_control` field instead of a per-block marker, and the server then
|
|
28
|
+
* places the breakpoint itself. That is one fewer thing for the harness to get
|
|
29
|
+
* right, and it is why this module used to do it. It is also invisible: an
|
|
30
|
+
* intermediary that counts breakpoints to stay under Anthropic's cap of four
|
|
31
|
+
* counts BLOCKS, so a top-level field is a breakpoint nothing on the way
|
|
32
|
+
* upstream can see. CLIProxyAPI — what the OSS CLI routes through on the Claude
|
|
33
|
+
* OAuth path — injects up to four block markers of its own and trims to four by
|
|
34
|
+
* that blind count, so the harness's invisible fifth breakpoint made every
|
|
35
|
+
* request past a thread's first turn fail with `A maximum of 4 blocks with
|
|
36
|
+
* cache_control may be provided. Found 5.` (HTTP 400, non-retryable — the thread
|
|
37
|
+
* is wedged, because the next turn rebuilds the same shape).
|
|
38
|
+
*
|
|
39
|
+
* A marker on a message block is counted, trimmed, and reasoned about by every
|
|
40
|
+
* hop. Keep it there. {@link withPromptCacheBreakpoint} is the only writer.
|
|
41
|
+
*
|
|
42
|
+
* ## Which vendors need a marker at all
|
|
43
|
+
*
|
|
44
|
+
* Only two of them, and both take it on a message:
|
|
45
|
+
*
|
|
46
|
+
* - **Anthropic** — `anthropic.cacheControl`. Fully opt-in: no marker, no
|
|
47
|
+
* caching, at full price.
|
|
48
|
+
* - **Amazon Bedrock** — `bedrock.cachePoint`. Also fully opt-in.
|
|
49
|
+
*
|
|
50
|
+
* The OpenAI family caches prefixes server-side, unprompted, and exposes no
|
|
51
|
+
* breakpoint to place (`promptCacheKey` and `promptCacheRetention` are request
|
|
52
|
+
* settings for affinity and retention, not placement — a separate concern from
|
|
53
|
+
* this policy). Gemini caches implicitly by default, and its explicit mode is a
|
|
54
|
+
* cache RESOURCE created out of band and referenced by name, which is not a
|
|
55
|
+
* marker and does not fit a ttl policy.
|
|
56
|
+
*
|
|
57
|
+
* So both markers ride together on the one chosen message. A provider reads only
|
|
58
|
+
* its own namespace, thus the pair is free to whichever vendor is not serving
|
|
59
|
+
* the call, and the placement never learns which one that is.
|
|
60
|
+
*
|
|
61
|
+
* The asymmetry worth remembering: under-marking silently costs money, while
|
|
62
|
+
* over-marking hard-fails. Anthropic answers 400 past four breakpoints, and the
|
|
63
|
+
* legacy Bedrock integration answers 400 on a top-level `cache_control`. Every
|
|
64
|
+
* failure in this area comes from asking for too much.
|
|
24
65
|
*
|
|
25
66
|
* ## Cache defeaters — what silently kills the hit rate
|
|
26
67
|
*
|
|
@@ -43,15 +84,29 @@
|
|
|
43
84
|
* `prompts/briefing.ts`). Keep it that way — one interpolated id or path there
|
|
44
85
|
* makes every step's ~20k-char prefix unique.
|
|
45
86
|
*
|
|
46
|
-
* ##
|
|
87
|
+
* ## What the cache-token metrics do and do not prove
|
|
88
|
+
*
|
|
89
|
+
* Caching DOES engage on the Claude OAuth path the OSS CLI routes through a
|
|
90
|
+
* local CLIProxyAPI: measured, 2050-2151 cache-read tokens on the turns after
|
|
91
|
+
* the first. An earlier note here claimed that path ignores cache directives
|
|
92
|
+
* outright. It does not.
|
|
47
93
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* `
|
|
94
|
+
* But a read there is not evidence that OUR breakpoint earned it. While cloaking
|
|
95
|
+
* — which is what that path does — the proxy places breakpoints of its own on
|
|
96
|
+
* the system block, the first user message, the system message it relocates, and
|
|
97
|
+
* the last message. Those cache the prefix whatever the harness sends: with
|
|
98
|
+
* `promptCache: "off"` and no marker of ours on the wire, a repeat request still
|
|
99
|
+
* read 1902 tokens back.
|
|
100
|
+
*
|
|
101
|
+
* So on that path `cache_read_tokens` (`loop/metrics.ts`) reports whether the
|
|
102
|
+
* deployment gets caching at all, which is what the counter is for. It does not
|
|
103
|
+
* attribute the read, and it cannot tell a working harness breakpoint from a
|
|
104
|
+
* proxy that is quietly doing the work. Against a direct API key or a gateway,
|
|
105
|
+
* where nothing injects markers, the counter does mean exactly that — and a
|
|
106
|
+
* flat-zero read counter beside a non-zero write counter is still the signature
|
|
107
|
+
* of an endpoint billing for writes and serving no reads.
|
|
53
108
|
*/
|
|
54
|
-
import type { PromptCachePolicy, ProviderOptions } from "./types.js";
|
|
109
|
+
import type { ModelMessage, PromptCachePolicy, ProviderOptions } from "./types.js";
|
|
55
110
|
/**
|
|
56
111
|
* The default policy: cache with the 5-minute TTL.
|
|
57
112
|
*
|
|
@@ -68,7 +123,47 @@ export declare const DEFAULT_PROMPT_CACHE: PromptCachePolicy;
|
|
|
68
123
|
/**
|
|
69
124
|
* Translate a neutral cache policy into provider wire options.
|
|
70
125
|
*
|
|
71
|
-
* Returns `undefined` for `"off"` so the caller can leave
|
|
72
|
-
*
|
|
126
|
+
* Returns `undefined` for `"off"` so the caller can leave the bag entirely unset
|
|
127
|
+
* rather than sending an empty one.
|
|
128
|
+
*
|
|
129
|
+
* Both vendors that take an explicit marker get one, in their own namespace. A
|
|
130
|
+
* provider reads only its own key, so the pair costs nothing to whichever one is
|
|
131
|
+
* not serving the call, and the placement needs no idea which vendor it is
|
|
132
|
+
* talking to. The two shapes differ in more than spelling: Anthropic marks the
|
|
133
|
+
* last content block of the message, while Bedrock appends a `cachePoint` block
|
|
134
|
+
* after it. Both end up at the same position in the prefix.
|
|
135
|
+
*
|
|
136
|
+
* The `satisfies` clauses are the point of naming the vendor types at all. This
|
|
137
|
+
* bag is a `Record<string, JSONObject>`, thus nothing here is checked by
|
|
138
|
+
* default, and a renamed key would compile and silently stop caching —
|
|
139
|
+
* `cacheControl` has already been renamed once, and the provider still accepts
|
|
140
|
+
* `cache_control` as its alias.
|
|
141
|
+
*
|
|
142
|
+
* CAUTION: this is the marker's VALUE, not its placement. Attaching it to a
|
|
143
|
+
* `ChatRequest` emits the invisible top-level field the module header warns
|
|
144
|
+
* about. Place it with {@link withPromptCacheBreakpoint}.
|
|
73
145
|
*/
|
|
74
146
|
export declare function promptCacheProviderOptions(policy: PromptCachePolicy): ProviderOptions | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* Place the request's ONE cache breakpoint, on the last message that can host it.
|
|
149
|
+
*
|
|
150
|
+
* Returns a copy — the caller's array is the transcript the loop keeps pushing
|
|
151
|
+
* onto and the host later persists, and a marker written into it would ride into
|
|
152
|
+
* the stored thread and come back on every later turn, one more breakpoint per
|
|
153
|
+
* turn until the request is refused. Only the request-shaped copy carries one.
|
|
154
|
+
*
|
|
155
|
+
* The placement is deliberately ROLLING, not pinned to the end of the stable
|
|
156
|
+
* prefix: within one run the loop appends an assistant reply and its tool results
|
|
157
|
+
* on every iteration, and a breakpoint that advances with them lets iteration N+1
|
|
158
|
+
* read back everything iteration N wrote. A pinned breakpoint would re-process
|
|
159
|
+
* the whole tool transcript uncached on every iteration, which is the opposite of
|
|
160
|
+
* what an agent loop needs.
|
|
161
|
+
*
|
|
162
|
+
* A marker already on any other message is REMOVED — the invariant is exactly one
|
|
163
|
+
* breakpoint per request, and a stored message that carries one from an older
|
|
164
|
+
* build (`memory/ai-sdk-message-storage.ts` reads `cache_control` back off a
|
|
165
|
+
* stored block) would otherwise spend a slot that the harness never budgeted.
|
|
166
|
+
* `"off"` strips and places nothing, so turning caching off really does send no
|
|
167
|
+
* directive at all.
|
|
168
|
+
*/
|
|
169
|
+
export declare function withPromptCacheBreakpoint<M extends ModelMessage>(messages: readonly M[], policy: PromptCachePolicy): readonly M[];
|
|
@@ -15,12 +15,53 @@
|
|
|
15
15
|
* error — on an OpenAI-compatible model, which is exactly what we want: that
|
|
16
16
|
* family does automatic server-side prefix caching and needs no directive.
|
|
17
17
|
*
|
|
18
|
-
* ## What the Anthropic namespace does
|
|
18
|
+
* ## What the Anthropic namespace does, and where the marker must go
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* The options go on the LAST MESSAGE of the request, never on the request
|
|
21
|
+
* itself. Both placements cache the same bytes — one breakpoint at the end of
|
|
22
|
+
* the messages covers the whole prefix (tools → system → history), because the
|
|
23
|
+
* cache keys on a prefix — but only the message placement survives an
|
|
24
|
+
* intermediary.
|
|
25
|
+
*
|
|
26
|
+
* A REQUEST-level `cacheControl` makes the provider emit a top-level
|
|
27
|
+
* `cache_control` field instead of a per-block marker, and the server then
|
|
28
|
+
* places the breakpoint itself. That is one fewer thing for the harness to get
|
|
29
|
+
* right, and it is why this module used to do it. It is also invisible: an
|
|
30
|
+
* intermediary that counts breakpoints to stay under Anthropic's cap of four
|
|
31
|
+
* counts BLOCKS, so a top-level field is a breakpoint nothing on the way
|
|
32
|
+
* upstream can see. CLIProxyAPI — what the OSS CLI routes through on the Claude
|
|
33
|
+
* OAuth path — injects up to four block markers of its own and trims to four by
|
|
34
|
+
* that blind count, so the harness's invisible fifth breakpoint made every
|
|
35
|
+
* request past a thread's first turn fail with `A maximum of 4 blocks with
|
|
36
|
+
* cache_control may be provided. Found 5.` (HTTP 400, non-retryable — the thread
|
|
37
|
+
* is wedged, because the next turn rebuilds the same shape).
|
|
38
|
+
*
|
|
39
|
+
* A marker on a message block is counted, trimmed, and reasoned about by every
|
|
40
|
+
* hop. Keep it there. {@link withPromptCacheBreakpoint} is the only writer.
|
|
41
|
+
*
|
|
42
|
+
* ## Which vendors need a marker at all
|
|
43
|
+
*
|
|
44
|
+
* Only two of them, and both take it on a message:
|
|
45
|
+
*
|
|
46
|
+
* - **Anthropic** — `anthropic.cacheControl`. Fully opt-in: no marker, no
|
|
47
|
+
* caching, at full price.
|
|
48
|
+
* - **Amazon Bedrock** — `bedrock.cachePoint`. Also fully opt-in.
|
|
49
|
+
*
|
|
50
|
+
* The OpenAI family caches prefixes server-side, unprompted, and exposes no
|
|
51
|
+
* breakpoint to place (`promptCacheKey` and `promptCacheRetention` are request
|
|
52
|
+
* settings for affinity and retention, not placement — a separate concern from
|
|
53
|
+
* this policy). Gemini caches implicitly by default, and its explicit mode is a
|
|
54
|
+
* cache RESOURCE created out of band and referenced by name, which is not a
|
|
55
|
+
* marker and does not fit a ttl policy.
|
|
56
|
+
*
|
|
57
|
+
* So both markers ride together on the one chosen message. A provider reads only
|
|
58
|
+
* its own namespace, thus the pair is free to whichever vendor is not serving
|
|
59
|
+
* the call, and the placement never learns which one that is.
|
|
60
|
+
*
|
|
61
|
+
* The asymmetry worth remembering: under-marking silently costs money, while
|
|
62
|
+
* over-marking hard-fails. Anthropic answers 400 past four breakpoints, and the
|
|
63
|
+
* legacy Bedrock integration answers 400 on a top-level `cache_control`. Every
|
|
64
|
+
* failure in this area comes from asking for too much.
|
|
24
65
|
*
|
|
25
66
|
* ## Cache defeaters — what silently kills the hit rate
|
|
26
67
|
*
|
|
@@ -43,13 +84,27 @@
|
|
|
43
84
|
* `prompts/briefing.ts`). Keep it that way — one interpolated id or path there
|
|
44
85
|
* makes every step's ~20k-char prefix unique.
|
|
45
86
|
*
|
|
46
|
-
* ##
|
|
87
|
+
* ## What the cache-token metrics do and do not prove
|
|
47
88
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
89
|
+
* Caching DOES engage on the Claude OAuth path the OSS CLI routes through a
|
|
90
|
+
* local CLIProxyAPI: measured, 2050-2151 cache-read tokens on the turns after
|
|
91
|
+
* the first. An earlier note here claimed that path ignores cache directives
|
|
92
|
+
* outright. It does not.
|
|
93
|
+
*
|
|
94
|
+
* But a read there is not evidence that OUR breakpoint earned it. While cloaking
|
|
95
|
+
* — which is what that path does — the proxy places breakpoints of its own on
|
|
96
|
+
* the system block, the first user message, the system message it relocates, and
|
|
97
|
+
* the last message. Those cache the prefix whatever the harness sends: with
|
|
98
|
+
* `promptCache: "off"` and no marker of ours on the wire, a repeat request still
|
|
99
|
+
* read 1902 tokens back.
|
|
100
|
+
*
|
|
101
|
+
* So on that path `cache_read_tokens` (`loop/metrics.ts`) reports whether the
|
|
102
|
+
* deployment gets caching at all, which is what the counter is for. It does not
|
|
103
|
+
* attribute the read, and it cannot tell a working harness breakpoint from a
|
|
104
|
+
* proxy that is quietly doing the work. Against a direct API key or a gateway,
|
|
105
|
+
* where nothing injects markers, the counter does mean exactly that — and a
|
|
106
|
+
* flat-zero read counter beside a non-zero write counter is still the signature
|
|
107
|
+
* of an endpoint billing for writes and serving no reads.
|
|
53
108
|
*/
|
|
54
109
|
/**
|
|
55
110
|
* The default policy: cache with the 5-minute TTL.
|
|
@@ -67,11 +122,138 @@ export const DEFAULT_PROMPT_CACHE = { ttl: "5m" };
|
|
|
67
122
|
/**
|
|
68
123
|
* Translate a neutral cache policy into provider wire options.
|
|
69
124
|
*
|
|
70
|
-
* Returns `undefined` for `"off"` so the caller can leave
|
|
71
|
-
*
|
|
125
|
+
* Returns `undefined` for `"off"` so the caller can leave the bag entirely unset
|
|
126
|
+
* rather than sending an empty one.
|
|
127
|
+
*
|
|
128
|
+
* Both vendors that take an explicit marker get one, in their own namespace. A
|
|
129
|
+
* provider reads only its own key, so the pair costs nothing to whichever one is
|
|
130
|
+
* not serving the call, and the placement needs no idea which vendor it is
|
|
131
|
+
* talking to. The two shapes differ in more than spelling: Anthropic marks the
|
|
132
|
+
* last content block of the message, while Bedrock appends a `cachePoint` block
|
|
133
|
+
* after it. Both end up at the same position in the prefix.
|
|
134
|
+
*
|
|
135
|
+
* The `satisfies` clauses are the point of naming the vendor types at all. This
|
|
136
|
+
* bag is a `Record<string, JSONObject>`, thus nothing here is checked by
|
|
137
|
+
* default, and a renamed key would compile and silently stop caching —
|
|
138
|
+
* `cacheControl` has already been renamed once, and the provider still accepts
|
|
139
|
+
* `cache_control` as its alias.
|
|
140
|
+
*
|
|
141
|
+
* CAUTION: this is the marker's VALUE, not its placement. Attaching it to a
|
|
142
|
+
* `ChatRequest` emits the invisible top-level field the module header warns
|
|
143
|
+
* about. Place it with {@link withPromptCacheBreakpoint}.
|
|
72
144
|
*/
|
|
73
145
|
export function promptCacheProviderOptions(policy) {
|
|
74
146
|
if (policy === "off")
|
|
75
147
|
return undefined;
|
|
76
|
-
|
|
148
|
+
const anthropic = { cacheControl: { type: "ephemeral", ttl: policy.ttl } };
|
|
149
|
+
const bedrock = { cachePoint: { type: "default", ttl: policy.ttl } };
|
|
150
|
+
return { anthropic, bedrock };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Every namespace that can carry a breakpoint, and the key it carries it under.
|
|
154
|
+
*
|
|
155
|
+
* The writer and the stripper both read this list, thus they cannot drift apart,
|
|
156
|
+
* and the one-breakpoint invariant covers each vendor rather than only the one
|
|
157
|
+
* that happens to be serving the call. `@ai-sdk/amazon-bedrock` reads either
|
|
158
|
+
* `bedrock` or `amazonBedrock`; its own documentation uses `bedrock`.
|
|
159
|
+
*/
|
|
160
|
+
const BREAKPOINT_SITES = [
|
|
161
|
+
["anthropic", "cacheControl"],
|
|
162
|
+
["bedrock", "cachePoint"],
|
|
163
|
+
];
|
|
164
|
+
/**
|
|
165
|
+
* Whether a message can HOST the breakpoint — that is, whether the marker put on
|
|
166
|
+
* it actually reaches the wire.
|
|
167
|
+
*
|
|
168
|
+
* The Anthropic provider resolves a message-level marker onto the message's last
|
|
169
|
+
* content part. A thinking block cannot carry `cache_control`, so a marker on an
|
|
170
|
+
* assistant message that ends in one is dropped by the provider: no error, no
|
|
171
|
+
* breakpoint, and a silent cache miss on every call after it. Walking back past
|
|
172
|
+
* such a message is what the server-side placement did for us before, so this
|
|
173
|
+
* keeps the behaviour rather than introducing a new failure mode.
|
|
174
|
+
*
|
|
175
|
+
* An empty content array hosts nothing either — there is no last part to mark.
|
|
176
|
+
* The content is read as `unknown` because the four message roles carry four
|
|
177
|
+
* different part unions and this predicate cares about exactly one field.
|
|
178
|
+
*/
|
|
179
|
+
function canHostBreakpoint(message) {
|
|
180
|
+
const content = message.content;
|
|
181
|
+
if (typeof content === "string")
|
|
182
|
+
return content.length > 0;
|
|
183
|
+
if (!Array.isArray(content))
|
|
184
|
+
return false;
|
|
185
|
+
const last = content.at(-1);
|
|
186
|
+
if (typeof last !== "object" || last === null)
|
|
187
|
+
return false;
|
|
188
|
+
return last.type !== "reasoning";
|
|
189
|
+
}
|
|
190
|
+
/** The index of the last message that can host the breakpoint, or `-1` when none can. */
|
|
191
|
+
function lastHostIndex(messages) {
|
|
192
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
193
|
+
const message = messages[i];
|
|
194
|
+
if (message !== undefined && canHostBreakpoint(message))
|
|
195
|
+
return i;
|
|
196
|
+
}
|
|
197
|
+
return -1;
|
|
198
|
+
}
|
|
199
|
+
/** Whether this message already carries a cache marker in ANY breakpoint namespace. */
|
|
200
|
+
function carriesBreakpoint(message) {
|
|
201
|
+
return BREAKPOINT_SITES.some(([namespace, key]) => message.providerOptions?.[namespace]?.[key] !== undefined);
|
|
202
|
+
}
|
|
203
|
+
/** The same message with each namespace of `options` merged into its own bag. */
|
|
204
|
+
function withBreakpoint(message, options) {
|
|
205
|
+
const merged = { ...message.providerOptions };
|
|
206
|
+
for (const [namespace] of BREAKPOINT_SITES) {
|
|
207
|
+
const marker = options[namespace];
|
|
208
|
+
if (marker === undefined)
|
|
209
|
+
continue;
|
|
210
|
+
merged[namespace] = { ...message.providerOptions?.[namespace], ...marker };
|
|
211
|
+
}
|
|
212
|
+
return { ...message, providerOptions: merged };
|
|
213
|
+
}
|
|
214
|
+
/** The same message with every marker removed, and each namespace dropped when it held nothing else. */
|
|
215
|
+
function withoutBreakpoint(message) {
|
|
216
|
+
if (!carriesBreakpoint(message))
|
|
217
|
+
return message;
|
|
218
|
+
const remaining = { ...message.providerOptions };
|
|
219
|
+
for (const [namespace, key] of BREAKPOINT_SITES) {
|
|
220
|
+
const bag = remaining[namespace];
|
|
221
|
+
if (bag?.[key] === undefined)
|
|
222
|
+
continue;
|
|
223
|
+
const { [key]: _dropped, ...rest } = bag;
|
|
224
|
+
if (Object.keys(rest).length === 0)
|
|
225
|
+
delete remaining[namespace];
|
|
226
|
+
else
|
|
227
|
+
remaining[namespace] = rest;
|
|
228
|
+
}
|
|
229
|
+
return { ...message, providerOptions: remaining };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Place the request's ONE cache breakpoint, on the last message that can host it.
|
|
233
|
+
*
|
|
234
|
+
* Returns a copy — the caller's array is the transcript the loop keeps pushing
|
|
235
|
+
* onto and the host later persists, and a marker written into it would ride into
|
|
236
|
+
* the stored thread and come back on every later turn, one more breakpoint per
|
|
237
|
+
* turn until the request is refused. Only the request-shaped copy carries one.
|
|
238
|
+
*
|
|
239
|
+
* The placement is deliberately ROLLING, not pinned to the end of the stable
|
|
240
|
+
* prefix: within one run the loop appends an assistant reply and its tool results
|
|
241
|
+
* on every iteration, and a breakpoint that advances with them lets iteration N+1
|
|
242
|
+
* read back everything iteration N wrote. A pinned breakpoint would re-process
|
|
243
|
+
* the whole tool transcript uncached on every iteration, which is the opposite of
|
|
244
|
+
* what an agent loop needs.
|
|
245
|
+
*
|
|
246
|
+
* A marker already on any other message is REMOVED — the invariant is exactly one
|
|
247
|
+
* breakpoint per request, and a stored message that carries one from an older
|
|
248
|
+
* build (`memory/ai-sdk-message-storage.ts` reads `cache_control` back off a
|
|
249
|
+
* stored block) would otherwise spend a slot that the harness never budgeted.
|
|
250
|
+
* `"off"` strips and places nothing, so turning caching off really does send no
|
|
251
|
+
* directive at all.
|
|
252
|
+
*/
|
|
253
|
+
export function withPromptCacheBreakpoint(messages, policy) {
|
|
254
|
+
const options = promptCacheProviderOptions(policy);
|
|
255
|
+
const target = options === undefined ? -1 : lastHostIndex(messages);
|
|
256
|
+
if (target < 0 && !messages.some(carriesBreakpoint))
|
|
257
|
+
return messages;
|
|
258
|
+
return messages.map((message, index) => (index === target && options !== undefined ? withBreakpoint(message, options) : withoutBreakpoint(message)));
|
|
77
259
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inflexa-ai/harness",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"uuid": "^11.1.1"
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|
|
97
|
+
"@ai-sdk/amazon-bedrock": "^5.0.74",
|
|
97
98
|
"@eslint/js": "^10.0.1",
|
|
98
99
|
"@types/bun": "latest",
|
|
99
100
|
"@types/dockerode": "^3.3.47",
|