@poncho-ai/harness 0.59.19 → 0.60.0
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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +33 -0
- package/dist/index.d.ts +23 -3
- package/dist/index.js +37 -9
- package/package.json +2 -2
- package/src/harness.ts +44 -9
- package/src/orchestrator/run-conversation-turn.ts +19 -3
- package/src/prompt-cache.ts +18 -9
- package/test/prompt-cache.test.ts +70 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @poncho-ai/harness@0.
|
|
2
|
+
> @poncho-ai/harness@0.60.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
|
|
3
3
|
> node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
|
|
4
4
|
|
|
5
5
|
[embed-docs] Generated poncho-docs.ts with 4 topics
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
[34mCLI[39m Target: es2022
|
|
10
10
|
[34mESM[39m Build start
|
|
11
11
|
[32mESM[39m [1mdist/isolate-F2PPSUL6.js [22m[32m53.82 KB[39m
|
|
12
|
-
[32mESM[39m [1mdist/index.js [22m[
|
|
13
|
-
[32mESM[39m ⚡️ Build success in
|
|
12
|
+
[32mESM[39m [1mdist/index.js [22m[32m574.06 KB[39m
|
|
13
|
+
[32mESM[39m ⚡️ Build success in 251ms
|
|
14
14
|
[34mDTS[39m Build start
|
|
15
|
-
[32mDTS[39m ⚡️ Build success in
|
|
16
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[
|
|
15
|
+
[32mDTS[39m ⚡️ Build success in 7890ms
|
|
16
|
+
[32mDTS[39m [1mdist/index.d.ts [22m[32m105.18 KB[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @poncho-ai/harness
|
|
2
2
|
|
|
3
|
+
## 0.60.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`8a5a367`](https://github.com/cesr/poncho-ai/commit/8a5a367dd4b2ea477b3e0146a7253cedcdc34a1c) Thanks [@cesr](https://github.com/cesr)! - Prompt-cache efficiency: tail breakpoint pin + volatile context slot + span attribution.
|
|
8
|
+
- **Second message-history cache breakpoint.** When prior-run tool results
|
|
9
|
+
are still untruncated, the history breakpoint used to sit only at the
|
|
10
|
+
last _stable_ index (before those results) — so a tool-heavy turn
|
|
11
|
+
re-sent everything after it raw on every step. Now a second breakpoint
|
|
12
|
+
is pinned at the true tail, so the current run reads its own growing
|
|
13
|
+
history at 0.1× while the stable entry keeps serving across runs.
|
|
14
|
+
`addPromptCacheBreakpoints` accepts `number | number[]` (out-of-range
|
|
15
|
+
indices dropped, duplicates collapsed). The Anthropic breakpoint budget
|
|
16
|
+
of 4 is now fully spent: static (1h), memory (1h), stable-history, tail.
|
|
17
|
+
- **`RunInput.volatileContext`** — per-run context rendered into the
|
|
18
|
+
_uncached_ dynamic tail of the system prompt. Embedders that previously
|
|
19
|
+
appended volatile blocks (live VFS tree, connected integrations) to the
|
|
20
|
+
agent body — busting the 1h static cache block on every change — can
|
|
21
|
+
pass them here instead, keeping the static block byte-stable (and
|
|
22
|
+
shareable across users when the agent definition is identical). The
|
|
23
|
+
value is captured per conversation so orchestrator-initiated turns
|
|
24
|
+
(continuations, subagent-callback resumes) reuse it. Forwarded through
|
|
25
|
+
`runConversationTurn` opts.
|
|
26
|
+
- **`RunInput.telemetryAttributes`** — extra attributes stamped on the
|
|
27
|
+
`invoke_agent` root span (e.g. `{"poncho.run.kind": "job"}`), letting
|
|
28
|
+
observability backends segment traffic classes without timing
|
|
29
|
+
forensics. Forwarded through `runConversationTurn` opts.
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- Updated dependencies [[`8a5a367`](https://github.com/cesr/poncho-ai/commit/8a5a367dd4b2ea477b3e0146a7253cedcdc34a1c)]:
|
|
34
|
+
- @poncho-ai/sdk@1.16.0
|
|
35
|
+
|
|
3
36
|
## 0.59.19
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -1482,6 +1482,12 @@ declare class AgentHarness {
|
|
|
1482
1482
|
private mcpBridge?;
|
|
1483
1483
|
private subagentManager?;
|
|
1484
1484
|
private readonly archivedToolResultsByConversation;
|
|
1485
|
+
/** Last explicit `RunInput.volatileContext` per conversation, reused by
|
|
1486
|
+
* orchestrator-initiated turns (continuations, subagent-callback
|
|
1487
|
+
* resumes) that build their own RunInput and can't know the embedder's
|
|
1488
|
+
* volatile blocks. Values are small (the field's contract) and entries
|
|
1489
|
+
* live for the harness instance's lifetime. */
|
|
1490
|
+
private readonly volatileContextByConversation;
|
|
1485
1491
|
/** Unified storage engine (replaces individual KV-backed stores). */
|
|
1486
1492
|
storageEngine?: StorageEngine;
|
|
1487
1493
|
/** Bash environment manager (creates per-tenant bash instances). */
|
|
@@ -2371,17 +2377,31 @@ interface RunConversationTurnOpts {
|
|
|
2371
2377
|
abortSignal?: AbortSignal;
|
|
2372
2378
|
tenantId?: string | null;
|
|
2373
2379
|
/**
|
|
2374
|
-
* Forwarded to `RunInput.disablePromptCache`.
|
|
2375
|
-
*
|
|
2376
|
-
*
|
|
2380
|
+
* Forwarded to `RunInput.disablePromptCache`. Only worth it for runs
|
|
2381
|
+
* that are both single-step and one-shot — see the RunInput doc; a
|
|
2382
|
+
* multi-step run reads its own growing history through the tail
|
|
2383
|
+
* breakpoint, so disabling it costs more than the write it saves.
|
|
2377
2384
|
*/
|
|
2378
2385
|
disablePromptCache?: boolean;
|
|
2386
|
+
/**
|
|
2387
|
+
* Forwarded to `RunInput.volatileContext`: per-run context appended to
|
|
2388
|
+
* the uncached dynamic tail of the system prompt. Keep it small — it is
|
|
2389
|
+
* re-sent raw every step. Also captured per conversation so
|
|
2390
|
+
* orchestrator-initiated turns (subagent-callback resumes) reuse the
|
|
2391
|
+
* value from the turn that spawned the subagent.
|
|
2392
|
+
*/
|
|
2393
|
+
volatileContext?: string;
|
|
2379
2394
|
/**
|
|
2380
2395
|
* Forwarded to `RunInput.suppressTelemetry`. Set true to emit no telemetry
|
|
2381
2396
|
* for this run (e.g. an incognito / telemetry-off turn) even on a harness
|
|
2382
2397
|
* built with an OTLP exporter attached.
|
|
2383
2398
|
*/
|
|
2384
2399
|
suppressTelemetry?: boolean;
|
|
2400
|
+
/**
|
|
2401
|
+
* Forwarded to `RunInput.telemetryAttributes` — extra attributes for the
|
|
2402
|
+
* `invoke_agent` root span (e.g. run kind / job name for segmentation).
|
|
2403
|
+
*/
|
|
2404
|
+
telemetryAttributes?: Record<string, string>;
|
|
2385
2405
|
/**
|
|
2386
2406
|
* Forwarded to `RunInput.model`. Per-run model override, captured once at
|
|
2387
2407
|
* run start — safe under concurrent runs on a shared harness, unlike
|
package/dist/index.js
CHANGED
|
@@ -7978,19 +7978,22 @@ function isAnthropicModel(model) {
|
|
|
7978
7978
|
}
|
|
7979
7979
|
return model.provider === "anthropic" || model.provider.includes("anthropic") || model.modelId.includes("anthropic") || model.modelId.includes("claude");
|
|
7980
7980
|
}
|
|
7981
|
-
function addPromptCacheBreakpoints(messages, model,
|
|
7981
|
+
function addPromptCacheBreakpoints(messages, model, target) {
|
|
7982
7982
|
if (messages.length === 0 || !isAnthropicModel(model)) {
|
|
7983
7983
|
return messages;
|
|
7984
7984
|
}
|
|
7985
|
-
const
|
|
7986
|
-
|
|
7985
|
+
const requested = target === void 0 ? [messages.length - 1] : Array.isArray(target) ? target : [target];
|
|
7986
|
+
const indices = new Set(
|
|
7987
|
+
requested.filter((i) => i >= 0 && i < messages.length)
|
|
7988
|
+
);
|
|
7989
|
+
if (indices.size === 0) {
|
|
7987
7990
|
return messages;
|
|
7988
7991
|
}
|
|
7989
7992
|
const cacheDirective = {
|
|
7990
7993
|
anthropic: { cacheControl: { type: "ephemeral" } }
|
|
7991
7994
|
};
|
|
7992
7995
|
return messages.map((message, i) => {
|
|
7993
|
-
if (i
|
|
7996
|
+
if (indices.has(i)) {
|
|
7994
7997
|
return {
|
|
7995
7998
|
...message,
|
|
7996
7999
|
providerOptions: {
|
|
@@ -9674,6 +9677,12 @@ var AgentHarness = class _AgentHarness {
|
|
|
9674
9677
|
mcpBridge;
|
|
9675
9678
|
subagentManager;
|
|
9676
9679
|
archivedToolResultsByConversation = /* @__PURE__ */ new Map();
|
|
9680
|
+
/** Last explicit `RunInput.volatileContext` per conversation, reused by
|
|
9681
|
+
* orchestrator-initiated turns (continuations, subagent-callback
|
|
9682
|
+
* resumes) that build their own RunInput and can't know the embedder's
|
|
9683
|
+
* volatile blocks. Values are small (the field's contract) and entries
|
|
9684
|
+
* live for the harness instance's lifetime. */
|
|
9685
|
+
volatileContextByConversation = /* @__PURE__ */ new Map();
|
|
9677
9686
|
/** Unified storage engine (replaces individual KV-backed stores). */
|
|
9678
9687
|
storageEngine;
|
|
9679
9688
|
/** Bash environment manager (creates per-tenant bash instances). */
|
|
@@ -10732,7 +10741,10 @@ var AgentHarness = class _AgentHarness {
|
|
|
10732
10741
|
"session.id": input.conversationId
|
|
10733
10742
|
} : {},
|
|
10734
10743
|
...this.telemetryUserId ? { "user.id": this.telemetryUserId } : {},
|
|
10735
|
-
...input.tenantId ? { "tenant.id": input.tenantId } : {}
|
|
10744
|
+
...input.tenantId ? { "tenant.id": input.tenantId } : {},
|
|
10745
|
+
// Embedder-supplied attribution (e.g. run kind, job name) so
|
|
10746
|
+
// observability backends can segment traffic classes directly.
|
|
10747
|
+
...input.telemetryAttributes ?? {}
|
|
10736
10748
|
}
|
|
10737
10749
|
});
|
|
10738
10750
|
let spanContext = trace.setSpan(otelContext.active(), rootSpan);
|
|
@@ -10816,6 +10828,14 @@ var AgentHarness = class _AgentHarness {
|
|
|
10816
10828
|
}
|
|
10817
10829
|
const hasFullToolResults = hasUntruncatedToolResults(messages);
|
|
10818
10830
|
const skipTailCache = input.disablePromptCache === true;
|
|
10831
|
+
let volatileRunContext = input.volatileContext;
|
|
10832
|
+
if (input.conversationId) {
|
|
10833
|
+
if (volatileRunContext !== void 0) {
|
|
10834
|
+
this.volatileContextByConversation.set(input.conversationId, volatileRunContext);
|
|
10835
|
+
} else {
|
|
10836
|
+
volatileRunContext = this.volatileContextByConversation.get(input.conversationId);
|
|
10837
|
+
}
|
|
10838
|
+
}
|
|
10819
10839
|
if (skipTailCache) {
|
|
10820
10840
|
costLog.debug(`tail cache breakpoint skipped \u2014 disablePromptCache (run=${runId.slice(0, 12)})`);
|
|
10821
10841
|
} else if (hasFullToolResults) {
|
|
@@ -10929,7 +10949,10 @@ ${skillContextWindow}${browserContext}${fsContext}${isolateContext}` : `${agentP
|
|
|
10929
10949
|
const timeContext = `
|
|
10930
10950
|
|
|
10931
10951
|
Current UTC time (hour precision): ${hourlyTime}`;
|
|
10932
|
-
const
|
|
10952
|
+
const volatileContext = volatileRunContext ? `
|
|
10953
|
+
|
|
10954
|
+
${volatileRunContext.trim()}` : "";
|
|
10955
|
+
const dynamicPart = `${todoContext}${volatileContext}${timeContext}`;
|
|
10933
10956
|
return { staticPart, memoryPart: memoryContext, dynamicPart };
|
|
10934
10957
|
};
|
|
10935
10958
|
let { staticPart: staticSystemPart, memoryPart: memorySystemPart, dynamicPart: dynamicSystemPart } = await buildSystemPromptParts();
|
|
@@ -11402,7 +11425,7 @@ ${textContent}` };
|
|
|
11402
11425
|
const cachedMessages = skipTailCache ? coreMessages : addPromptCacheBreakpoints(
|
|
11403
11426
|
coreMessages,
|
|
11404
11427
|
modelInstance,
|
|
11405
|
-
hasFullToolResults ? findLastStableCacheIndex(coreMessages) : coreMessages.length - 1
|
|
11428
|
+
hasFullToolResults ? [findLastStableCacheIndex(coreMessages), coreMessages.length - 1] : coreMessages.length - 1
|
|
11406
11429
|
);
|
|
11407
11430
|
const useStaticCache = isAnthropicModel(modelInstance);
|
|
11408
11431
|
const finalMessages = useStaticCache ? [
|
|
@@ -11417,7 +11440,10 @@ ${textContent}` };
|
|
|
11417
11440
|
// an explicit memory write — its own 1h breakpoint means a
|
|
11418
11441
|
// memory edit busts THIS block forward but a normal turn reads
|
|
11419
11442
|
// it (plus everything before it) from cache. Breakpoint budget:
|
|
11420
|
-
// Anthropic allows 4
|
|
11443
|
+
// Anthropic allows 4 and ALL FOUR ARE NOW SPENT (static,
|
|
11444
|
+
// memory, stable-history, tail — the last two collapse to one
|
|
11445
|
+
// when there are no untruncated prior tool results). Adding a
|
|
11446
|
+
// fifth cache_control block anywhere is an API 400.
|
|
11421
11447
|
...memorySystemPart.length > 0 ? [{
|
|
11422
11448
|
role: "system",
|
|
11423
11449
|
content: memorySystemPart,
|
|
@@ -14608,7 +14634,9 @@ var runConversationTurn = async (opts) => {
|
|
|
14608
14634
|
abortSignal: opts.abortSignal,
|
|
14609
14635
|
disablePromptCache: opts.disablePromptCache,
|
|
14610
14636
|
suppressTelemetry: opts.suppressTelemetry,
|
|
14611
|
-
model: opts.model
|
|
14637
|
+
model: opts.model,
|
|
14638
|
+
volatileContext: opts.volatileContext,
|
|
14639
|
+
telemetryAttributes: opts.telemetryAttributes
|
|
14612
14640
|
},
|
|
14613
14641
|
initialContextTokens: conversation.contextTokens ?? 0,
|
|
14614
14642
|
initialContextWindow: conversation.contextWindow ?? 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poncho-ai/harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"mustache": "^4.2.0",
|
|
35
35
|
"yaml": "^2.4.0",
|
|
36
36
|
"zod": "^3.22.0",
|
|
37
|
-
"@poncho-ai/sdk": "1.
|
|
37
|
+
"@poncho-ai/sdk": "1.16.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"esbuild": ">=0.17.0",
|
package/src/harness.ts
CHANGED
|
@@ -937,6 +937,12 @@ export class AgentHarness {
|
|
|
937
937
|
private mcpBridge?: LocalMcpBridge;
|
|
938
938
|
private subagentManager?: SubagentManager;
|
|
939
939
|
private readonly archivedToolResultsByConversation = new Map<string, Record<string, ArchivedToolResult>>();
|
|
940
|
+
/** Last explicit `RunInput.volatileContext` per conversation, reused by
|
|
941
|
+
* orchestrator-initiated turns (continuations, subagent-callback
|
|
942
|
+
* resumes) that build their own RunInput and can't know the embedder's
|
|
943
|
+
* volatile blocks. Values are small (the field's contract) and entries
|
|
944
|
+
* live for the harness instance's lifetime. */
|
|
945
|
+
private readonly volatileContextByConversation = new Map<string, string>();
|
|
940
946
|
|
|
941
947
|
/** Unified storage engine (replaces individual KV-backed stores). */
|
|
942
948
|
storageEngine?: StorageEngine;
|
|
@@ -2224,6 +2230,9 @@ export class AgentHarness {
|
|
|
2224
2230
|
: {}),
|
|
2225
2231
|
...(this.telemetryUserId ? { "user.id": this.telemetryUserId } : {}),
|
|
2226
2232
|
...(input.tenantId ? { "tenant.id": input.tenantId } : {}),
|
|
2233
|
+
// Embedder-supplied attribution (e.g. run kind, job name) so
|
|
2234
|
+
// observability backends can segment traffic classes directly.
|
|
2235
|
+
...(input.telemetryAttributes ?? {}),
|
|
2227
2236
|
},
|
|
2228
2237
|
});
|
|
2229
2238
|
|
|
@@ -2337,6 +2346,20 @@ export class AgentHarness {
|
|
|
2337
2346
|
// 1-hour static breakpoint on the system prompt is always on — it
|
|
2338
2347
|
// amortizes across every later turn or job within the hour.
|
|
2339
2348
|
const skipTailCache = input.disablePromptCache === true;
|
|
2349
|
+
// Effective volatile context for this run. An explicit value (even "")
|
|
2350
|
+
// wins and is remembered per conversation so orchestrator-initiated
|
|
2351
|
+
// turns on the same conversation — continuations, subagent-callback
|
|
2352
|
+
// resumes — reuse the value from the turn that set it instead of
|
|
2353
|
+
// silently dropping it (they build their own RunInput and can't know
|
|
2354
|
+
// the embedder's blocks).
|
|
2355
|
+
let volatileRunContext = input.volatileContext;
|
|
2356
|
+
if (input.conversationId) {
|
|
2357
|
+
if (volatileRunContext !== undefined) {
|
|
2358
|
+
this.volatileContextByConversation.set(input.conversationId, volatileRunContext);
|
|
2359
|
+
} else {
|
|
2360
|
+
volatileRunContext = this.volatileContextByConversation.get(input.conversationId);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2340
2363
|
if (skipTailCache) {
|
|
2341
2364
|
costLog.debug(`tail cache breakpoint skipped — disablePromptCache (run=${runId.slice(0, 12)})`);
|
|
2342
2365
|
} else if (hasFullToolResults) {
|
|
@@ -2485,7 +2508,11 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
|
|
|
2485
2508
|
return `${weekday} ${d.toISOString().slice(0, 13)}Z`;
|
|
2486
2509
|
})();
|
|
2487
2510
|
const timeContext = `\n\nCurrent UTC time (hour precision): ${hourlyTime}`;
|
|
2488
|
-
|
|
2511
|
+
// Embedder-supplied volatile blocks (live VFS tree, connected
|
|
2512
|
+
// integrations, …) land here — uncached by design, so their churn
|
|
2513
|
+
// never busts the 1h static/memory cache entries above.
|
|
2514
|
+
const volatileContext = volatileRunContext ? `\n\n${volatileRunContext.trim()}` : "";
|
|
2515
|
+
const dynamicPart = `${todoContext}${volatileContext}${timeContext}`;
|
|
2489
2516
|
return { staticPart, memoryPart: memoryContext, dynamicPart };
|
|
2490
2517
|
};
|
|
2491
2518
|
let { staticPart: staticSystemPart, memoryPart: memorySystemPart, dynamicPart: dynamicSystemPart } =
|
|
@@ -3107,19 +3134,24 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
|
|
|
3107
3134
|
// below: omitted from the request when undefined.
|
|
3108
3135
|
const temperature = agent.frontmatter.model?.temperature;
|
|
3109
3136
|
const maxTokens = agent.frontmatter.model?.maxTokens;
|
|
3110
|
-
//
|
|
3111
|
-
//
|
|
3112
|
-
//
|
|
3113
|
-
//
|
|
3114
|
-
//
|
|
3115
|
-
//
|
|
3137
|
+
// Two history breakpoints: one just before any untruncated
|
|
3138
|
+
// prior-run tool-result (the stable prefix keeps its cross-run
|
|
3139
|
+
// cache entry — truncateHistoricalToolResults rewrites everything
|
|
3140
|
+
// after it next run) AND one pinned at the true tail so the
|
|
3141
|
+
// current run's own steps read the growing history at 0.1x instead
|
|
3142
|
+
// of re-paying it raw every step. Without the tail pin, a
|
|
3143
|
+
// tool-heavy turn with full prior results re-sent everything past
|
|
3144
|
+
// the stable index uncached on all of its (up to maxSteps) steps.
|
|
3145
|
+
// When there are no untruncated results the two collapse to the
|
|
3146
|
+
// tail (addPromptCacheBreakpoints dedupes). When `skipTailCache`
|
|
3147
|
+
// is set (per-run override), write no history breakpoints at all.
|
|
3116
3148
|
const cachedMessages = skipTailCache
|
|
3117
3149
|
? coreMessages
|
|
3118
3150
|
: addPromptCacheBreakpoints(
|
|
3119
3151
|
coreMessages,
|
|
3120
3152
|
modelInstance,
|
|
3121
3153
|
hasFullToolResults
|
|
3122
|
-
? findLastStableCacheIndex(coreMessages)
|
|
3154
|
+
? [findLastStableCacheIndex(coreMessages), coreMessages.length - 1]
|
|
3123
3155
|
: coreMessages.length - 1,
|
|
3124
3156
|
);
|
|
3125
3157
|
|
|
@@ -3146,7 +3178,10 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
|
|
|
3146
3178
|
// an explicit memory write — its own 1h breakpoint means a
|
|
3147
3179
|
// memory edit busts THIS block forward but a normal turn reads
|
|
3148
3180
|
// it (plus everything before it) from cache. Breakpoint budget:
|
|
3149
|
-
// Anthropic allows 4
|
|
3181
|
+
// Anthropic allows 4 and ALL FOUR ARE NOW SPENT (static,
|
|
3182
|
+
// memory, stable-history, tail — the last two collapse to one
|
|
3183
|
+
// when there are no untruncated prior tool results). Adding a
|
|
3184
|
+
// fifth cache_control block anywhere is an API 400.
|
|
3150
3185
|
...(memorySystemPart.length > 0
|
|
3151
3186
|
? [{
|
|
3152
3187
|
role: "system" as const,
|
|
@@ -64,17 +64,31 @@ export interface RunConversationTurnOpts {
|
|
|
64
64
|
abortSignal?: AbortSignal;
|
|
65
65
|
tenantId?: string | null;
|
|
66
66
|
/**
|
|
67
|
-
* Forwarded to `RunInput.disablePromptCache`.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
67
|
+
* Forwarded to `RunInput.disablePromptCache`. Only worth it for runs
|
|
68
|
+
* that are both single-step and one-shot — see the RunInput doc; a
|
|
69
|
+
* multi-step run reads its own growing history through the tail
|
|
70
|
+
* breakpoint, so disabling it costs more than the write it saves.
|
|
70
71
|
*/
|
|
71
72
|
disablePromptCache?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Forwarded to `RunInput.volatileContext`: per-run context appended to
|
|
75
|
+
* the uncached dynamic tail of the system prompt. Keep it small — it is
|
|
76
|
+
* re-sent raw every step. Also captured per conversation so
|
|
77
|
+
* orchestrator-initiated turns (subagent-callback resumes) reuse the
|
|
78
|
+
* value from the turn that spawned the subagent.
|
|
79
|
+
*/
|
|
80
|
+
volatileContext?: string;
|
|
72
81
|
/**
|
|
73
82
|
* Forwarded to `RunInput.suppressTelemetry`. Set true to emit no telemetry
|
|
74
83
|
* for this run (e.g. an incognito / telemetry-off turn) even on a harness
|
|
75
84
|
* built with an OTLP exporter attached.
|
|
76
85
|
*/
|
|
77
86
|
suppressTelemetry?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Forwarded to `RunInput.telemetryAttributes` — extra attributes for the
|
|
89
|
+
* `invoke_agent` root span (e.g. run kind / job name for segmentation).
|
|
90
|
+
*/
|
|
91
|
+
telemetryAttributes?: Record<string, string>;
|
|
78
92
|
/**
|
|
79
93
|
* Forwarded to `RunInput.model`. Per-run model override, captured once at
|
|
80
94
|
* run start — safe under concurrent runs on a shared harness, unlike
|
|
@@ -238,6 +252,8 @@ export const runConversationTurn = async (
|
|
|
238
252
|
disablePromptCache: opts.disablePromptCache,
|
|
239
253
|
suppressTelemetry: opts.suppressTelemetry,
|
|
240
254
|
model: opts.model,
|
|
255
|
+
volatileContext: opts.volatileContext,
|
|
256
|
+
telemetryAttributes: opts.telemetryAttributes,
|
|
241
257
|
},
|
|
242
258
|
initialContextTokens: conversation.contextTokens ?? 0,
|
|
243
259
|
initialContextWindow: conversation.contextWindow ?? 0,
|
package/src/prompt-cache.ts
CHANGED
|
@@ -17,23 +17,32 @@ export function isAnthropicModel(model: LanguageModel): boolean {
|
|
|
17
17
|
* explicit opt-in (Anthropic). For providers with automatic caching
|
|
18
18
|
* (OpenAI), messages are returned unchanged.
|
|
19
19
|
*
|
|
20
|
-
* For Anthropic, marks the target message with ephemeral cache control
|
|
21
|
-
* the conversation prefix is incrementally cached across steps. When
|
|
22
|
-
* `
|
|
23
|
-
* Callers
|
|
24
|
-
* results that will be truncated next turn)
|
|
20
|
+
* For Anthropic, marks the target message(s) with ephemeral cache control
|
|
21
|
+
* so the conversation prefix is incrementally cached across steps. When
|
|
22
|
+
* `target` is omitted, the last message is used (default behavior).
|
|
23
|
+
* Callers can pass an earlier index to cache only a stable prefix (e.g.
|
|
24
|
+
* skipping tool results that will be truncated next turn), or an array to
|
|
25
|
+
* mark several — typically `[stableIndex, tail]` so the stable prefix
|
|
26
|
+
* keeps its cross-run entry while the moving tail serves within-run reads.
|
|
27
|
+
* Out-of-range indices are dropped and duplicates collapse to one mark
|
|
28
|
+
* (a message must never carry two cache_control blocks).
|
|
25
29
|
*/
|
|
26
30
|
export function addPromptCacheBreakpoints(
|
|
27
31
|
messages: ModelMessage[],
|
|
28
32
|
model: LanguageModel,
|
|
29
|
-
|
|
33
|
+
target?: number | number[],
|
|
30
34
|
): ModelMessage[] {
|
|
31
35
|
if (messages.length === 0 || !isAnthropicModel(model)) {
|
|
32
36
|
return messages;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
const
|
|
36
|
-
|
|
39
|
+
const requested = target === undefined
|
|
40
|
+
? [messages.length - 1]
|
|
41
|
+
: Array.isArray(target) ? target : [target];
|
|
42
|
+
const indices = new Set(
|
|
43
|
+
requested.filter((i) => i >= 0 && i < messages.length),
|
|
44
|
+
);
|
|
45
|
+
if (indices.size === 0) {
|
|
37
46
|
return messages;
|
|
38
47
|
}
|
|
39
48
|
|
|
@@ -42,7 +51,7 @@ export function addPromptCacheBreakpoints(
|
|
|
42
51
|
};
|
|
43
52
|
|
|
44
53
|
return messages.map((message, i) => {
|
|
45
|
-
if (i
|
|
54
|
+
if (indices.has(i)) {
|
|
46
55
|
return {
|
|
47
56
|
...message,
|
|
48
57
|
providerOptions: {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { ModelMessage } from "ai";
|
|
3
|
+
import { addPromptCacheBreakpoints } from "../src/prompt-cache.js";
|
|
4
|
+
|
|
5
|
+
const ANTHROPIC_MODEL = "anthropic/claude-opus-4-8";
|
|
6
|
+
const OPENAI_MODEL = "openai/gpt-4o";
|
|
7
|
+
|
|
8
|
+
function messages(n: number): ModelMessage[] {
|
|
9
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
10
|
+
role: i % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
|
11
|
+
content: `message ${i}`,
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function markedIndices(result: ModelMessage[]): number[] {
|
|
16
|
+
return result
|
|
17
|
+
.map((m, i) => ((m.providerOptions as Record<string, unknown> | undefined)?.anthropic ? i : -1))
|
|
18
|
+
.filter((i) => i >= 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("addPromptCacheBreakpoints", () => {
|
|
22
|
+
it("marks the last message by default", () => {
|
|
23
|
+
const result = addPromptCacheBreakpoints(messages(4), ANTHROPIC_MODEL);
|
|
24
|
+
expect(markedIndices(result)).toEqual([3]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("marks a single explicit index", () => {
|
|
28
|
+
const result = addPromptCacheBreakpoints(messages(4), ANTHROPIC_MODEL, 1);
|
|
29
|
+
expect(markedIndices(result)).toEqual([1]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("marks stable + tail when given an array", () => {
|
|
33
|
+
const result = addPromptCacheBreakpoints(messages(6), ANTHROPIC_MODEL, [2, 5]);
|
|
34
|
+
expect(markedIndices(result)).toEqual([2, 5]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("collapses duplicate indices to one mark (stable == tail)", () => {
|
|
38
|
+
const result = addPromptCacheBreakpoints(messages(4), ANTHROPIC_MODEL, [3, 3]);
|
|
39
|
+
expect(markedIndices(result)).toEqual([3]);
|
|
40
|
+
const marked = result[3].providerOptions as Record<string, unknown>;
|
|
41
|
+
// One cacheControl object, not a doubled/merged mess.
|
|
42
|
+
expect(marked.anthropic).toEqual({ cacheControl: { type: "ephemeral" } });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("drops out-of-range indices (findLastStableCacheIndex can return -1)", () => {
|
|
46
|
+
const result = addPromptCacheBreakpoints(messages(4), ANTHROPIC_MODEL, [-1, 3]);
|
|
47
|
+
expect(markedIndices(result)).toEqual([3]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns messages unchanged when every index is out of range", () => {
|
|
51
|
+
const input = messages(2);
|
|
52
|
+
const result = addPromptCacheBreakpoints(input, ANTHROPIC_MODEL, [-1, 9]);
|
|
53
|
+
expect(result).toBe(input);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("leaves non-Anthropic models untouched", () => {
|
|
57
|
+
const input = messages(3);
|
|
58
|
+
const result = addPromptCacheBreakpoints(input, OPENAI_MODEL, [0, 2]);
|
|
59
|
+
expect(result).toBe(input);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("preserves existing providerOptions on the marked message", () => {
|
|
63
|
+
const input = messages(2);
|
|
64
|
+
input[1] = { ...input[1], providerOptions: { other: { keep: true } } as never };
|
|
65
|
+
const result = addPromptCacheBreakpoints(input, ANTHROPIC_MODEL, [1]);
|
|
66
|
+
const opts = result[1].providerOptions as Record<string, unknown>;
|
|
67
|
+
expect(opts.other).toEqual({ keep: true });
|
|
68
|
+
expect(opts.anthropic).toEqual({ cacheControl: { type: "ephemeral" } });
|
|
69
|
+
});
|
|
70
|
+
});
|