@poncho-ai/harness 0.59.19 → 0.60.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.59.19 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.60.1 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
@@ -8,9 +8,9 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
+ ESM dist/index.js 574.16 KB
11
12
  ESM dist/isolate-F2PPSUL6.js 53.82 KB
12
- ESM dist/index.js 572.59 KB
13
- ESM ⚡️ Build success in 249ms
13
+ ESM ⚡️ Build success in 245ms
14
14
  DTS Build start
15
- DTS ⚡️ Build success in 7835ms
16
- DTS dist/index.d.ts 104.09 KB
15
+ DTS ⚡️ Build success in 7563ms
16
+ DTS dist/index.d.ts 105.62 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.60.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`47d92ba`](https://github.com/cesr/poncho-ai/commit/47d92ba6e0001147423ffda39f9dbeb29b5ca359) Thanks [@cesr](https://github.com/cesr)! - Forward `volatileContext` and `telemetryAttributes` through `continueFromToolResult`. The per-conversation capture already covers same-process continuations, but a checkpoint resumed after a process restart would silently lose the embedder's volatile blocks; resume callers can now pass them explicitly.
8
+
9
+ ## 0.60.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`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.
14
+ - **Second message-history cache breakpoint.** When prior-run tool results
15
+ are still untruncated, the history breakpoint used to sit only at the
16
+ last _stable_ index (before those results) — so a tool-heavy turn
17
+ re-sent everything after it raw on every step. Now a second breakpoint
18
+ is pinned at the true tail, so the current run reads its own growing
19
+ history at 0.1× while the stable entry keeps serving across runs.
20
+ `addPromptCacheBreakpoints` accepts `number | number[]` (out-of-range
21
+ indices dropped, duplicates collapsed). The Anthropic breakpoint budget
22
+ of 4 is now fully spent: static (1h), memory (1h), stable-history, tail.
23
+ - **`RunInput.volatileContext`** — per-run context rendered into the
24
+ _uncached_ dynamic tail of the system prompt. Embedders that previously
25
+ appended volatile blocks (live VFS tree, connected integrations) to the
26
+ agent body — busting the 1h static cache block on every change — can
27
+ pass them here instead, keeping the static block byte-stable (and
28
+ shareable across users when the agent definition is identical). The
29
+ value is captured per conversation so orchestrator-initiated turns
30
+ (continuations, subagent-callback resumes) reuse it. Forwarded through
31
+ `runConversationTurn` opts.
32
+ - **`RunInput.telemetryAttributes`** — extra attributes stamped on the
33
+ `invoke_agent` root span (e.g. `{"poncho.run.kind": "job"}`), letting
34
+ observability backends segment traffic classes without timing
35
+ forensics. Forwarded through `runConversationTurn` opts.
36
+
37
+ ### Patch Changes
38
+
39
+ - Updated dependencies [[`8a5a367`](https://github.com/cesr/poncho-ai/commit/8a5a367dd4b2ea477b3e0146a7253cedcdc34a1c)]:
40
+ - @poncho-ai/sdk@1.16.0
41
+
3
42
  ## 0.59.19
4
43
 
5
44
  ### 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). */
@@ -1629,6 +1635,13 @@ declare class AgentHarness {
1629
1635
  * otherwise the continuation falls back to the agent definition's
1630
1636
  * (possibly concurrently-mutated) frontmatter model. */
1631
1637
  model?: string;
1638
+ /** Same as `RunInput.volatileContext`. Pass it explicitly on resume —
1639
+ * the per-conversation capture covers same-process continuations, but
1640
+ * a checkpoint resumed after a restart would otherwise lose the
1641
+ * embedder's volatile blocks. */
1642
+ volatileContext?: string;
1643
+ /** Same as `RunInput.telemetryAttributes`, for the continuation run. */
1644
+ telemetryAttributes?: Record<string, string>;
1632
1645
  }): AsyncGenerator<AgentEvent>;
1633
1646
  runToCompletion(input: RunInput): Promise<HarnessRunOutput>;
1634
1647
  }
@@ -2371,17 +2384,31 @@ interface RunConversationTurnOpts {
2371
2384
  abortSignal?: AbortSignal;
2372
2385
  tenantId?: string | null;
2373
2386
  /**
2374
- * Forwarded to `RunInput.disablePromptCache`. Set true for one-shot
2375
- * turns with no follow-up coming (cron-fired jobs, etc.) so the
2376
- * harness skips the Anthropic cache write.
2387
+ * Forwarded to `RunInput.disablePromptCache`. Only worth it for runs
2388
+ * that are both single-step and one-shot see the RunInput doc; a
2389
+ * multi-step run reads its own growing history through the tail
2390
+ * breakpoint, so disabling it costs more than the write it saves.
2377
2391
  */
2378
2392
  disablePromptCache?: boolean;
2393
+ /**
2394
+ * Forwarded to `RunInput.volatileContext`: per-run context appended to
2395
+ * the uncached dynamic tail of the system prompt. Keep it small — it is
2396
+ * re-sent raw every step. Also captured per conversation so
2397
+ * orchestrator-initiated turns (subagent-callback resumes) reuse the
2398
+ * value from the turn that spawned the subagent.
2399
+ */
2400
+ volatileContext?: string;
2379
2401
  /**
2380
2402
  * Forwarded to `RunInput.suppressTelemetry`. Set true to emit no telemetry
2381
2403
  * for this run (e.g. an incognito / telemetry-off turn) even on a harness
2382
2404
  * built with an OTLP exporter attached.
2383
2405
  */
2384
2406
  suppressTelemetry?: boolean;
2407
+ /**
2408
+ * Forwarded to `RunInput.telemetryAttributes` — extra attributes for the
2409
+ * `invoke_agent` root span (e.g. run kind / job name for segmentation).
2410
+ */
2411
+ telemetryAttributes?: Record<string, string>;
2385
2412
  /**
2386
2413
  * Forwarded to `RunInput.model`. Per-run model override, captured once at
2387
2414
  * 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, targetIndex) {
7981
+ function addPromptCacheBreakpoints(messages, model, target) {
7982
7982
  if (messages.length === 0 || !isAnthropicModel(model)) {
7983
7983
  return messages;
7984
7984
  }
7985
- const index = targetIndex ?? messages.length - 1;
7986
- if (index < 0 || index >= messages.length) {
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 === index) {
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 dynamicPart = `${todoContext}${timeContext}`;
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; this is #2 of 3 (static, memory, tail).
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,
@@ -12203,7 +12229,9 @@ ${this.skillFingerprint}`;
12203
12229
  parameters: input.parameters,
12204
12230
  abortSignal: input.abortSignal,
12205
12231
  suppressTelemetry: input.suppressTelemetry,
12206
- model: input.model
12232
+ model: input.model,
12233
+ volatileContext: input.volatileContext,
12234
+ telemetryAttributes: input.telemetryAttributes
12207
12235
  });
12208
12236
  }
12209
12237
  async runToCompletion(input) {
@@ -14608,7 +14636,9 @@ var runConversationTurn = async (opts) => {
14608
14636
  abortSignal: opts.abortSignal,
14609
14637
  disablePromptCache: opts.disablePromptCache,
14610
14638
  suppressTelemetry: opts.suppressTelemetry,
14611
- model: opts.model
14639
+ model: opts.model,
14640
+ volatileContext: opts.volatileContext,
14641
+ telemetryAttributes: opts.telemetryAttributes
14612
14642
  },
14613
14643
  initialContextTokens: conversation.contextTokens ?? 0,
14614
14644
  initialContextWindow: conversation.contextWindow ?? 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.59.19",
3
+ "version": "0.60.1",
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.15.2"
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
- const dynamicPart = `${todoContext}${timeContext}`;
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
- // Place the tail breakpoint before any untruncated tool-result so
3111
- // we cache only the stable prefix when prior-run tool results are
3112
- // still full-fidelity. Otherwise cache at the history tail. When
3113
- // `skipTailCache` is set (per-run override), don't write the tail
3114
- // breakpoint at all. The 1-hour static-prefix breakpoint is added
3115
- // separately when assembling the final messages array.
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; this is #2 of 3 (static, memory, tail).
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,
@@ -4074,6 +4109,13 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
4074
4109
  * otherwise the continuation falls back to the agent definition's
4075
4110
  * (possibly concurrently-mutated) frontmatter model. */
4076
4111
  model?: string;
4112
+ /** Same as `RunInput.volatileContext`. Pass it explicitly on resume —
4113
+ * the per-conversation capture covers same-process continuations, but
4114
+ * a checkpoint resumed after a restart would otherwise lose the
4115
+ * embedder's volatile blocks. */
4116
+ volatileContext?: string;
4117
+ /** Same as `RunInput.telemetryAttributes`, for the continuation run. */
4118
+ telemetryAttributes?: Record<string, string>;
4077
4119
  }): AsyncGenerator<AgentEvent> {
4078
4120
  const messages = [...input.messages];
4079
4121
  const lastMsg = messages[messages.length - 1];
@@ -4134,6 +4176,8 @@ Code is wrapped in an async IIFE — use \`return\` to return a value to the too
4134
4176
  abortSignal: input.abortSignal,
4135
4177
  suppressTelemetry: input.suppressTelemetry,
4136
4178
  model: input.model,
4179
+ volatileContext: input.volatileContext,
4180
+ telemetryAttributes: input.telemetryAttributes,
4137
4181
  });
4138
4182
  }
4139
4183
 
@@ -64,17 +64,31 @@ export interface RunConversationTurnOpts {
64
64
  abortSignal?: AbortSignal;
65
65
  tenantId?: string | null;
66
66
  /**
67
- * Forwarded to `RunInput.disablePromptCache`. Set true for one-shot
68
- * turns with no follow-up coming (cron-fired jobs, etc.) so the
69
- * harness skips the Anthropic cache write.
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,
@@ -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 so
21
- * the conversation prefix is incrementally cached across steps. When
22
- * `targetIndex` is omitted, the last message is used (default behavior).
23
- * Callers that want to cache only a stable prefix (e.g. skipping tool
24
- * results that will be truncated next turn) can pass an earlier index.
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
- targetIndex?: number,
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 index = targetIndex ?? messages.length - 1;
36
- if (index < 0 || index >= messages.length) {
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 === index) {
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
+ });