@noetic-tools/core 5.1.0 → 5.3.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.
@@ -23,7 +23,14 @@ export declare function itemsToInput(items: ReadonlyArray<Item>): OpenRouterInpu
23
23
  * when the output array contains no message items.
24
24
  */
25
25
  export declare function extractOutputItems(response: OpenRouterAgent.OpenResponsesResult): Item[];
26
- /** @internal */
26
+ /**
27
+ * @internal
28
+ *
29
+ * A missing `cachedTokens` stays `undefined` rather than collapsing to `0`:
30
+ * context anchoring reads it to decide whether the prompt prefix survived, and
31
+ * "this provider reports no cache figures" must not look like "nothing was
32
+ * cached".
33
+ */
27
34
  export declare function extractUsage(usage: OpenRouterAgent.Usage | null | undefined): LLMResponse['usage'];
28
35
  /**
29
36
  * Provider APIs (Anthropic via OpenRouter) enforce tool names match
@@ -153,7 +153,14 @@ export function extractOutputItems(response) {
153
153
  ...items,
154
154
  ];
155
155
  }
156
- /** @internal */
156
+ /**
157
+ * @internal
158
+ *
159
+ * A missing `cachedTokens` stays `undefined` rather than collapsing to `0`:
160
+ * context anchoring reads it to decide whether the prompt prefix survived, and
161
+ * "this provider reports no cache figures" must not look like "nothing was
162
+ * cached".
163
+ */
157
164
  export function extractUsage(usage) {
158
165
  if (!usage) {
159
166
  return {
@@ -165,8 +172,23 @@ export function extractUsage(usage) {
165
172
  inputTokens: usage.inputTokens,
166
173
  outputTokens: usage.outputTokens,
167
174
  cachedTokens: usage.inputTokensDetails?.cachedTokens,
175
+ cacheWriteTokens: cacheWriteTokensOf(usage.inputTokensDetails),
168
176
  };
169
177
  }
178
+ /**
179
+ * OpenRouter reports `cache_write_tokens` alongside `cached_tokens`, but the
180
+ * SDK's `InputTokensDetails` type declares only the latter. Read it off the
181
+ * value rather than the type, and stay `undefined` when it is genuinely absent
182
+ * — a write of zero and no report at all mean different things to the epoch
183
+ * logic (see `noteCacheOutcome`).
184
+ */
185
+ function cacheWriteTokensOf(details) {
186
+ if (typeof details !== 'object' || details === null || !('cacheWriteTokens' in details)) {
187
+ return undefined;
188
+ }
189
+ const value = details.cacheWriteTokens;
190
+ return typeof value === 'number' ? value : undefined;
191
+ }
170
192
  //#endregion
171
193
  //#region Tool Conversion
172
194
  // We construct the SDK tool shape manually and use frameworkCast to bridge
@@ -2,7 +2,7 @@ import { ItemSchemaRegistry } from '@noetic-tools/types';
2
2
  import type { LayerStateStore, RecallCache } from './deps/context.js';
3
3
  import type { DetachedSpawnOverrides } from './deps/interpreter.js';
4
4
  import type { CheckpointStore, EventBroadcaster, RestoreCheckpointOptions, StepLedgerRetention, StepLedgerStore } from './deps/runtime.js';
5
- import type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, ContextData, ContextLayer, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, ProjectionPolicy, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StorageAdapter, StreamEvent, StreamingItem, SubHarnessSession, SubprocessAdapter, Tool, TraceExporter, ZodType } from './deps/types.js';
5
+ import type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, ContextCacheConfig, ContextCacheStore, ContextData, ContextLayer, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, ProjectionPolicy, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StorageAdapter, StreamEvent, StreamingItem, SubHarnessSession, SubprocessAdapter, Tool, TraceExporter, ZodType } from './deps/types.js';
6
6
  export { createStreamIdleWatchdog } from './model-call.js';
7
7
  interface AgentHarnessOpts<TParams extends Record<string, unknown> = Record<string, unknown>> {
8
8
  name: string;
@@ -52,6 +52,8 @@ interface AgentHarnessOpts<TParams extends Record<string, unknown> = Record<stri
52
52
  projection?: ProjectionPolicy;
53
53
  /** When true, every layer is recalled atomically regardless of its `recallMode`. */
54
54
  forceAtomicRecall?: boolean;
55
+ /** Tuning for prompt-cache anchoring. See `ContextCacheConfig` for the defaults. */
56
+ contextCache?: ContextCacheConfig;
55
57
  traceExporter?: TraceExporter;
56
58
  layerStateStore?: LayerStateStore;
57
59
  /** Default delivery mode for messages that don't specify one. Defaults to `next-turn`. */
@@ -152,6 +154,8 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
152
154
  readonly layerStateStore: LayerStateStore;
153
155
  /** Per-harness memoization cache for `recallMode: 'eventual'` layers. */
154
156
  readonly recallCache: RecallCache;
157
+ /** Per-harness pinned anchor output and epoch bookkeeping, keyed by cache lineage. */
158
+ readonly contextCache: ContextCacheStore;
155
159
  /**
156
160
  * Execution ids whose layer `init` hooks have already run, so repeated/nested
157
161
  * `run()` calls and the session turn path never re-init (which would clobber
@@ -2,8 +2,8 @@ import { frameworkCast, ItemSchemaRegistry, NoeticConfigError } from '@noetic-to
2
2
  import { SpanImpl } from '../observability/span-impl.js';
3
3
  import { NoopExporter } from '../observability/trace-exporter.js';
4
4
  import { createInMemoryFsAdapter, createInMemoryShellAdapter, createInMemorySubprocessAdapter, OpenRouter, } from './deps/adapters.js';
5
- import { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from './deps/context.js';
6
- import { collectAllTools, deduplicateTools, dispatchStepThroughAdapter, execute, } from './deps/interpreter.js';
5
+ import { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createContextCacheStore, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from './deps/context.js';
6
+ import { collectAllTools, deduplicateTools, dispatchStepThroughAdapter, execute, prepareBandedView, } from './deps/interpreter.js';
7
7
  import { buildItemStream, ChannelStore, ContextImpl, captureCheckpoint, clearCheckpoint, collectContextTree, createInMemoryStorage, createStepLedgerStore, filterReasoningStream, filterTextStream, resolveStepLedgerRetention, restoreFromCheckpoint, SessionRunner, StepLedger, snapshotCwdState, } from './deps/runtime.js';
8
8
  import { SteeringAction } from './deps/types.js';
9
9
  import { AgentHarnessModelCaller } from './model-call.js';
@@ -77,7 +77,43 @@ export function resolveLlmClient(config, env) {
77
77
  cache: config?.cache ?? false,
78
78
  };
79
79
  }
80
- function createClient(config) {
80
+ /**
81
+ * Ask the provider to cache the prompt prefix.
82
+ *
83
+ * Anthropic caching is opt-in — without a breakpoint it caches nothing however
84
+ * stable the prefix is, measured against live models. OpenRouter accepts a
85
+ * `cache_control` directive on the request and places the breakpoints itself;
86
+ * providers that cache on their own (OpenAI, Gemini) ignore it.
87
+ *
88
+ * It has to be injected here rather than passed with the other request fields:
89
+ * the SDK validates the request against a generated schema that drops keys it
90
+ * does not know, and `cache_control` is one of them.
91
+ */
92
+ async function addCacheBreakpoint(request) {
93
+ const body = await request.clone().text();
94
+ if (!body) {
95
+ return request;
96
+ }
97
+ let payload;
98
+ try {
99
+ payload = JSON.parse(body);
100
+ }
101
+ catch {
102
+ return request;
103
+ }
104
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
105
+ return request;
106
+ }
107
+ return new Request(request, {
108
+ body: JSON.stringify({
109
+ ...payload,
110
+ cache_control: {
111
+ type: 'ephemeral',
112
+ },
113
+ }),
114
+ });
115
+ }
116
+ function createClient(config, contextCache) {
81
117
  const resolved = resolveLlmClient(config, readLlmEnv());
82
118
  if (!resolved) {
83
119
  return undefined;
@@ -88,20 +124,25 @@ function createClient(config) {
88
124
  if (resolved.serverURL) {
89
125
  options.serverURL = resolved.serverURL;
90
126
  }
91
- if (resolved.cache) {
92
- // Inject `X-OpenRouter-Cache: true` on every request so the upstream serves
93
- // identical model calls from cache without re-billing (deterministic re-runs).
94
- return new OpenRouter({
95
- ...options,
96
- hooks: {
97
- beforeRequest: (_ctx, request) => {
127
+ // A cache write costs more than a plain read, so the breakpoint only pays off
128
+ // when something is holding the prefix still — which is what anchoring does.
129
+ const wantsBreakpoint = contextCache?.enabled !== false;
130
+ if (!resolved.cache && !wantsBreakpoint) {
131
+ return new OpenRouter(options);
132
+ }
133
+ return new OpenRouter({
134
+ ...options,
135
+ hooks: {
136
+ beforeRequest: async (_ctx, request) => {
137
+ if (resolved.cache) {
138
+ // Serve identical model calls from OpenRouter's own response cache
139
+ // without re-billing (deterministic re-runs).
98
140
  request.headers.set('X-OpenRouter-Cache', 'true');
99
- return request;
100
- },
141
+ }
142
+ return wantsBreakpoint ? addCacheBreakpoint(request) : request;
101
143
  },
102
- });
103
- }
104
- return new OpenRouter(options);
144
+ },
145
+ });
105
146
  }
106
147
  //#region AgentHarness
107
148
  /**
@@ -162,6 +203,8 @@ export class AgentHarness {
162
203
  layerStateStore;
163
204
  /** Per-harness memoization cache for `recallMode: 'eventual'` layers. */
164
205
  recallCache;
206
+ /** Per-harness pinned anchor output and epoch bookkeeping, keyed by cache lineage. */
207
+ contextCache;
165
208
  /**
166
209
  * Execution ids whose layer `init` hooks have already run, so repeated/nested
167
210
  * `run()` calls and the session turn path never re-init (which would clobber
@@ -191,6 +234,7 @@ export class AgentHarness {
191
234
  strictItemSchemas: opts.strictItemSchemas ?? true,
192
235
  projection: opts.projection,
193
236
  forceAtomicRecall: opts.forceAtomicRecall,
237
+ contextCache: opts.contextCache,
194
238
  };
195
239
  this.fs = opts.fs ?? createInMemoryFsAdapter();
196
240
  this.shell = opts.shell ?? createInMemoryShellAdapter();
@@ -206,7 +250,7 @@ export class AgentHarness {
206
250
  this.harnessTools = opts.tools ?? [];
207
251
  this._contextLayers = resolveContextOption(opts);
208
252
  this.callModelOverride = opts._testCallModel;
209
- this.client = opts._testCallModel ? undefined : createClient(opts.llm);
253
+ this.client = opts._testCallModel ? undefined : createClient(opts.llm, opts.contextCache);
210
254
  this.channelStore = new ChannelStore();
211
255
  this.traceExporter = opts.traceExporter ?? new NoopExporter();
212
256
  this.layerStateStore =
@@ -215,6 +259,7 @@ export class AgentHarness {
215
259
  console.warn(`[noetic] context layer '${layerId}' ${hook} error:`, error);
216
260
  });
217
261
  this.recallCache = createRecallCache();
262
+ this.contextCache = createContextCacheStore();
218
263
  this.defaultDeliveryMode = opts.defaultDeliveryMode ?? 'next-turn';
219
264
  this.streamIdleTimeoutMs = opts.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
220
265
  this.itemSchemas = new ItemSchemaRegistry(opts.itemSchemas, {
@@ -660,14 +705,32 @@ export class AgentHarness {
660
705
  try {
661
706
  await this.ensureLayersInit(ctx);
662
707
  const recallResults = await this.recallLayers(layers, '', ctx);
663
- const layerOutputItems = recallResults.flatMap((r) => r.items);
664
- if (layerOutputItems.length === 0) {
708
+ // Band the output the way a real turn would, but read-only: a preview
709
+ // must not pin, count churn, or age the epoch, or looking at a
710
+ // conversation would change what the next turn sends.
711
+ const banded = await prepareBandedView({
712
+ recallResults,
713
+ layers,
714
+ execCtx: contextToExecCtx(ctx),
715
+ store: this.contextCache,
716
+ config: this.config.contextCache,
717
+ instructions: undefined,
718
+ policy: this.config.projection ?? DEFAULT_PROJECTION,
719
+ systemPromptItems: [],
720
+ budgets: new Map(),
721
+ readOnly: true,
722
+ });
723
+ if (banded.anchorItems.length === 0 &&
724
+ banded.liveItems.length === 0 &&
725
+ banded.deltaItems.length === 0) {
665
726
  return historyItems;
666
727
  }
667
728
  return assembleView({
668
729
  systemPromptItems: [],
669
- layerOutputItems,
730
+ layerOutputItems: banded.anchorItems,
670
731
  historyItems,
732
+ liveLayerItems: banded.liveItems,
733
+ deltaItems: banded.deltaItems,
671
734
  });
672
735
  }
673
736
  finally {
@@ -1,2 +1,2 @@
1
1
  export type { LayerStateStore, RecallCache } from '@noetic-tools/context';
2
- export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/context';
2
+ export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createContextCacheStore, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/context';
@@ -1 +1 @@
1
- export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/context';
1
+ export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createContextCacheStore, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/context';
@@ -1,4 +1,5 @@
1
1
  export { collectAllTools, deduplicateTools } from '../../interpreter/collect-tools.js';
2
+ export { prepareBandedView } from '../../interpreter/context-assembly.js';
2
3
  export { execute } from '../../interpreter/execute.js';
3
4
  export type { DetachedSpawnOverrides } from '../../interpreter/step-dispatcher.js';
4
5
  export { dispatchStepThroughAdapter } from '../../interpreter/step-dispatcher.js';
@@ -1,4 +1,5 @@
1
1
  export { collectAllTools, deduplicateTools } from '../../interpreter/collect-tools.js';
2
+ export { prepareBandedView } from '../../interpreter/context-assembly.js';
2
3
  export { execute } from '../../interpreter/execute.js';
3
4
  export { dispatchStepThroughAdapter } from '../../interpreter/step-dispatcher.js';
4
5
  export { isFunctionCall } from '../../interpreter/typeguards.js';
@@ -1,4 +1,4 @@
1
- export type { ContextData, ContextLayer, ExecutionContext, ProjectionPolicy, StorageAdapter, } from '@noetic-tools/context';
1
+ export type { ContextCacheConfig, ContextCacheStore, ContextData, ContextLayer, ExecutionContext, ProjectionPolicy, StorageAdapter, } from '@noetic-tools/context';
2
2
  export type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StreamEvent, StreamingItem, SubHarnessSession, SubprocessAdapter, Tool, TraceExporter, } from '@noetic-tools/types';
3
3
  export { SteeringAction } from '@noetic-tools/types';
4
4
  export type { ZodType } from 'zod';
@@ -272,6 +272,12 @@ class CallSpanCollector {
272
272
  finishModelSpan(span, usage, cost) {
273
273
  span.setAttribute(GenAI.USAGE_INPUT_TOKENS, usage.inputTokens);
274
274
  span.setAttribute(GenAI.USAGE_OUTPUT_TOKENS, usage.outputTokens);
275
+ if (usage.cachedTokens !== undefined) {
276
+ span.setAttribute(GenAI.USAGE_CACHED_INPUT_TOKENS, usage.cachedTokens);
277
+ }
278
+ if (usage.cacheWriteTokens !== undefined) {
279
+ span.setAttribute(GenAI.USAGE_CACHE_WRITE_TOKENS, usage.cacheWriteTokens);
280
+ }
275
281
  if (cost !== undefined) {
276
282
  span.setAttribute(GenAI.COST, cost);
277
283
  }
@@ -372,6 +378,10 @@ export class AgentHarnessModelCaller {
372
378
  outputTokens: 0,
373
379
  cachedTokens: 0,
374
380
  };
381
+ // Rounds after the first replay the same prefix and so hit the prompt cache
382
+ // whatever the first round did. Keep them apart: only round 0 says whether
383
+ // the assembled view was cached (see `noteCacheOutcome`).
384
+ const rounds = [];
375
385
  let totalCost = 0;
376
386
  const conversationInput = itemsToInput(prepared.remaining);
377
387
  const textFormat = request.outputSchema ? buildTextFormat(request.outputSchema) : undefined;
@@ -503,10 +513,8 @@ export class AgentHarnessModelCaller {
503
513
  });
504
514
  const roundUsage = extractUsage(sdkResponse.usage);
505
515
  const roundCost = sdkResponse.usage?.cost ?? 0;
506
- spans.finishModelSpan(modelSpan, {
507
- inputTokens: roundUsage.inputTokens,
508
- outputTokens: roundUsage.outputTokens,
509
- }, roundCost > 0 ? roundCost : undefined);
516
+ spans.finishModelSpan(modelSpan, roundUsage, roundCost > 0 ? roundCost : undefined);
517
+ rounds.push(roundUsage);
510
518
  totalUsage.inputTokens += roundUsage.inputTokens;
511
519
  totalUsage.outputTokens += roundUsage.outputTokens;
512
520
  totalUsage.cachedTokens += roundUsage.cachedTokens ?? 0;
@@ -539,7 +547,16 @@ export class AgentHarnessModelCaller {
539
547
  }
540
548
  return {
541
549
  items: allItems,
542
- usage: totalUsage,
550
+ usage: {
551
+ inputTokens: totalUsage.inputTokens,
552
+ outputTokens: totalUsage.outputTokens,
553
+ // Stay `undefined` when no round reported a figure, so callers can tell
554
+ // "this provider says nothing about caching" from "nothing was cached".
555
+ cachedTokens: rounds.some((r) => r.cachedTokens !== undefined)
556
+ ? totalUsage.cachedTokens
557
+ : undefined,
558
+ },
559
+ rounds,
543
560
  cost: totalCost > 0 ? totalCost : undefined,
544
561
  };
545
562
  }
package/dist/index.d.ts CHANGED
@@ -70,7 +70,7 @@ export type { DurableTaskState, FactExtractor, FactSearcher, HistoryWindowConfig
70
70
  /** @public */
71
71
  /** @public */
72
72
  /** @public */
73
- export { durableTaskState, fileReference, findFunctionCall, historyWindow, observationalContext, PlanPhase, planContext, staticContent, steering, storageGetMany, stripUnresolvedToolCalls, temporalContext, toolContextLayer, workingMemoryContext, } from '@noetic-tools/context';
73
+ export { durableTaskState, fileReference, findFunctionCall, historyWindow, observationalContext, PlanPhase, PlanStyle, planContext, staticContent, steering, storageGetMany, stripUnresolvedToolCalls, temporalContext, toolContextLayer, workingMemoryContext, } from '@noetic-tools/context';
74
74
  /** @public */
75
75
  export { createMessage, estimateTokens } from '@noetic-tools/types';
76
76
  /** @public */
@@ -113,13 +113,13 @@ export { MergeStrategySchema, UntilPredicateSchema, validateWorkflow, WorkflowDo
113
113
  export type { Channel, ChannelHandle, ExternalChannel } from '@noetic-tools/types';
114
114
  /** @public */
115
115
  /** @public */
116
- export type { LLMResponse, LlmProviderConfig, ModelParams, RetryPolicy, ServerToolSpec, StepMeta, TokenUsage, Tool, ToolContextDeclaration, } from '@noetic-tools/types';
116
+ export type { LLMResponse, LlmProviderConfig, ModelParams, RetryPolicy, RoundUsage, ServerToolSpec, StepMeta, TokenUsage, Tool, ToolContextDeclaration, } from '@noetic-tools/types';
117
117
  /** @public */
118
118
  export { isServerToolSpec } from '@noetic-tools/types';
119
119
  /** @public */
120
120
  /** @public */
121
121
  /** @public */
122
- export type { Context, ContextHarness, CwdState, ItemLog, LastLayerUsage, LayerUsageEntry, RestoreContextOptions, } from '@noetic-tools/types';
122
+ export type { Context, ContextHarness, CwdState, EpochUsage, ItemLog, LastLayerUsage, LayerUsageEntry, RestoreContextOptions, } from '@noetic-tools/types';
123
123
  /** @public */
124
124
  export { getToolCwd, setToolCwd, snapshotCwdState } from './runtime/cwd-helpers.js';
125
125
  /** @public */
@@ -137,7 +137,7 @@ export type { NoeticError } from '@noetic-tools/types';
137
137
  /** @public */
138
138
  export type { ContentPart, DeveloperMessageExtensionItem, ExecuteInput, ExtendedItem, ExtensionItem, FileSearchItem, FunctionCallItem, FunctionCallOutputItem, ImageGenerationItem, InferExtendedItem, InputContentPart, InputFilePart, InputImagePart, InputMessageItem, InputTextPart, Item, ItemBase, ItemSchemaExtensions, MessageItem, OutputItem, OutputTextPart, ReasoningItem, ReasoningTextPart, RefusalPart, ServerToolItem, SummaryTextPart, WebSearchItem, } from '@noetic-tools/types';
139
139
  /** @public */
140
- export type { BudgetConfig, CompleteParams, ContextConfig, ContextData, ContextLayer, ContextLayerHooks, DisposeParams, InferContext, InferContextShape, InitParams, InitResult, LayerDataDecl, LayerFunctionDecl, LayerProvides, LayerTimeouts, ProjectionPolicy, RecallParams, RecallResult, ReturnParams, ReturnResult, ScopedStorage, SpawnParams, SpawnResult, StorageAdapter, StoreParams, StoreResult, } from '@noetic-tools/context';
140
+ export type { BudgetConfig, CompleteParams, ContextConfig, ContextData, ContextLayer, ContextLayerHooks, DisposeParams, InferContext, InferContextShape, InitParams, InitResult, LayerDataDecl, LayerFunctionDecl, LayerPlacement, LayerProvides, LayerTimeouts, ProjectionPolicy, RecallParams, RecallResult, RenderDeltaParams, ReturnParams, ReturnResult, ScopedStorage, SpawnParams, SpawnResult, StorageAdapter, StoreParams, StoreResult, } from '@noetic-tools/context';
141
141
  /** @public */
142
142
  export { Slot } from '@noetic-tools/context';
143
143
  /** @public */
@@ -147,7 +147,7 @@ export type { LayerTraceSpan, Span, TraceExporter } from '@noetic-tools/types';
147
147
  /** @public */
148
148
  export type { FrameworkStreamEvent, HarnessResponse, SdkStreamEvent, StreamEvent, StreamingItem, } from '@noetic-tools/types';
149
149
  /** @public */
150
- export type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, DeliveryMode, ExecuteOptions, HarnessStatus, RecallLayerOutput, SessionScope, } from '@noetic-tools/types';
150
+ export type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, ContextCacheConfig, ContextCacheStore, ContextEpoch, DeliveryMode, ExecuteOptions, HarnessStatus, ReanchorReason, RecallLayerOutput, SessionScope, } from '@noetic-tools/types';
151
151
  /** @public */
152
152
  export type { AfterModelCallParams, AfterModelCallResult, BeforeToolCallParams, BeforeToolCallResult, LedgerEntry, SteeringConfig, SteeringDecision, SteeringRule, SteeringState, } from '@noetic-tools/types';
153
153
  /** @public */
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ export { execute } from './interpreter/execute.js';
58
58
  /** @public */
59
59
  /** @public */
60
60
  /** @public */
61
- export { durableTaskState, fileReference, findFunctionCall, historyWindow, observationalContext, PlanPhase, planContext, staticContent, steering, storageGetMany, stripUnresolvedToolCalls, temporalContext, toolContextLayer, workingMemoryContext, } from '@noetic-tools/context';
61
+ export { durableTaskState, fileReference, findFunctionCall, historyWindow, observationalContext, PlanPhase, PlanStyle, planContext, staticContent, steering, storageGetMany, stripUnresolvedToolCalls, temporalContext, toolContextLayer, workingMemoryContext, } from '@noetic-tools/context';
62
62
  //#endregion
63
63
  //#region Message Utilities
64
64
  /** @public */
@@ -1,5 +1,5 @@
1
- export type { LayerStateStore } from '@noetic-tools/context';
2
- export { allocateBudgets, assembleView, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, resolveLayerTools, returnLayers, spawnLayers, } from '@noetic-tools/context';
1
+ export type { LayerServeInfo, LayerStateStore } from '@noetic-tools/context';
2
+ export { allocateBudgets, assembleView, churnFor, churnRate, commitLayerUsage, computeLayerUsage, contextToExecCtx, createContextCacheStore, DEFAULT_PROJECTION, lineageKey, noteCacheOutcome, openEpoch, pin, pinKey, reanchorReason, resolveCacheConfig, resolveLayerTools, returnLayers, spawnLayers, } from '@noetic-tools/context';
3
3
  export type { ItemSchemaRegistry } from '@noetic-tools/types';
4
4
  export { defaultItemSchemaRegistry } from '@noetic-tools/types';
5
5
  export { emitFrameworkEvent, getBroadcaster, shouldEmit } from '../runtime/broadcaster-utils.js';
@@ -1,4 +1,4 @@
1
- export { allocateBudgets, assembleView, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, resolveLayerTools, returnLayers, spawnLayers, } from '@noetic-tools/context';
1
+ export { allocateBudgets, assembleView, churnFor, churnRate, commitLayerUsage, computeLayerUsage, contextToExecCtx, createContextCacheStore, DEFAULT_PROJECTION, lineageKey, noteCacheOutcome, openEpoch, pin, pinKey, reanchorReason, resolveCacheConfig, resolveLayerTools, returnLayers, spawnLayers, } from '@noetic-tools/context';
2
2
  export { defaultItemSchemaRegistry } from '@noetic-tools/types';
3
3
  export { emitFrameworkEvent, getBroadcaster, shouldEmit } from '../runtime/broadcaster-utils.js';
4
4
  export { ContextImpl } from '../runtime/context-impl.js';
@@ -1,3 +1,3 @@
1
1
  export type { ContextConfig, ContextData, ContextLayer, ExecutionContext, } from '@noetic-tools/context';
2
- export type { AgentHarnessContract, Context, ContextRerenderRequest, ExecuteStepFn, FunctionCallItem, Item, ProjectionPolicy, RecallLayerOutput, RetryPolicy, ServerToolSpec, StepLLM, StepMeta, StepProvide, StepRun, StepSpawn, StepTool, Tool, } from '@noetic-tools/types';
2
+ export type { AgentHarnessContract, Context, ContextRerenderRequest, ExecuteStepFn, FunctionCallItem, InputMessageItem, Item, ProjectionPolicy, RecallLayerOutput, RetryPolicy, ServerToolSpec, StepLLM, StepMeta, StepProvide, StepRun, StepSpawn, StepTool, Tool, } from '@noetic-tools/types';
3
3
  export { isServerToolSpec, SteeringAction } from '@noetic-tools/types';
@@ -0,0 +1,50 @@
1
+ import type { ContextCacheConfig, ContextCacheStore, ContextLayer, EpochUsage, ExecutionContext, Item, ProjectionPolicy, RecallLayerOutput, Span } from '@noetic-tools/types';
2
+ import type { LayerServeInfo } from './action-deps.js';
3
+ /** What the layer bands contributed to one assembly, and how the epoch fared. */
4
+ export interface BandedView {
5
+ /** Layer output rendered before history — pinned bytes where a pin exists. */
6
+ anchorItems: Item[];
7
+ /** Layer output rendered after history. */
8
+ liveItems: Item[];
9
+ /** At most one developer message superseding stale anchors. */
10
+ deltaItems: Item[];
11
+ /** Per-layer output as actually served, for the usage breakdown. */
12
+ servedPerLayer: RecallLayerOutput[];
13
+ serveInfo: Map<string, LayerServeInfo>;
14
+ epoch: EpochUsage | undefined;
15
+ }
16
+ interface PrepareBandedViewParams {
17
+ /** Recall output, already slot-sorted and merged with any re-render. */
18
+ recallResults: ReadonlyArray<RecallLayerOutput>;
19
+ layers: ReadonlyArray<ContextLayer>;
20
+ execCtx: ExecutionContext;
21
+ store: ContextCacheStore;
22
+ config?: ContextCacheConfig;
23
+ /** Resolved instructions — a change to them invalidates the prefix anyway. */
24
+ instructions: string | undefined;
25
+ policy: ProjectionPolicy;
26
+ systemPromptItems: ReadonlyArray<Item>;
27
+ /** Budget per layer id, used to size a `renderDelta`. */
28
+ budgets: ReadonlyMap<string, number>;
29
+ /**
30
+ * When true, read pins without writing: no new pins, no churn, no epoch
31
+ * ageing. For preview paths that must not disturb a live conversation.
32
+ */
33
+ readOnly?: boolean;
34
+ }
35
+ /**
36
+ * Split recall output into the anchor and live bands, replay pinned anchors, and
37
+ * gather everything that changed into a single supersede message.
38
+ *
39
+ * Runs once per LLM step, before the steering retry loop — a retry replays the
40
+ * same view, so re-pinning or re-counting churn there would distort both.
41
+ */
42
+ export declare function prepareBandedView(params: PrepareBandedViewParams): Promise<BandedView>;
43
+ /**
44
+ * Record how the view was banded on the step's span, so a slow or expensive run
45
+ * can be traced back to the layer that keeps invalidating the prefix.
46
+ */
47
+ export declare function stampAnchoringAttributes(span: Span | undefined, view: BandedView): void;
48
+ /** Tokens the prompt prefix was expected to have cached — what `noteCacheOutcome` judges against. */
49
+ export declare function expectedCachedTokens(systemPromptItems: ReadonlyArray<Item>, view: BandedView): number;
50
+ export {};
@@ -0,0 +1,547 @@
1
+ import { createMessage, estimateTokens, hashItems, hashString } from '@noetic-tools/types';
2
+ import { NoeticAttr } from '../observability/genai-attributes.js';
3
+ import { churnFor, churnRate, lineageKey, openEpoch, pin, pinKey, reanchorReason, resolveCacheConfig, } from './action-deps.js';
4
+ /**
5
+ * Supersedes below this are never treated as delta pressure, whatever share of
6
+ * the anchor band they are. Keeps a small context from re-anchoring on the fixed
7
+ * cost of the `<context_updates>` wrapper alone.
8
+ */
9
+ const MIN_PRESSURE_TOKENS = 256;
10
+ //#endregion
11
+ //#region Public API
12
+ /**
13
+ * Split recall output into the anchor and live bands, replay pinned anchors, and
14
+ * gather everything that changed into a single supersede message.
15
+ *
16
+ * Runs once per LLM step, before the steering retry loop — a retry replays the
17
+ * same view, so re-pinning or re-counting churn there would distort both.
18
+ */
19
+ export async function prepareBandedView(params) {
20
+ const config = resolveCacheConfig(params.config);
21
+ if (!config.enabled) {
22
+ return legacyView(params.recallResults);
23
+ }
24
+ const key = lineageKey(params.execCtx);
25
+ const instructionsHash = hashString(params.instructions ?? '');
26
+ const epoch = resolveEpoch({
27
+ ...params,
28
+ config,
29
+ key,
30
+ instructionsHash,
31
+ });
32
+ const banded = bandAndPin({
33
+ ...params,
34
+ config,
35
+ epoch,
36
+ });
37
+ const anchorTokens = totalTokens(banded.anchorItems);
38
+ const rendered = await buildDeltaItems(banded.stale, epoch.id, params.execCtx);
39
+ // A change we cannot put into words must not be published as silence: the
40
+ // pinned block is already in the view and the model would read it as current.
41
+ // Re-anchor so the fresh render reaches it directly instead.
42
+ const undescribed = rendered.undescribed;
43
+ // Two ways the supersedes stop being worth their tokens: they outgrow the
44
+ // band they patch, or they no longer fit the window at all. Either way the
45
+ // answer is the same — re-anchor to fresh output and publish nothing. That
46
+ // costs one cache miss and never risks shipping content known to be stale.
47
+ //
48
+ // The ratio alone is not enough: the wrapper around a supersede costs a fixed
49
+ // few dozen tokens, which would always look enormous beside a small anchor
50
+ // band and re-anchor every turn. Pressure needs the supersedes to be both
51
+ // absolutely and proportionally large.
52
+ const deltaTokens = totalTokens(rendered.items);
53
+ const overPressure = deltaTokens > Math.max(MIN_PRESSURE_TOKENS, config.deltaBudgetFraction * anchorTokens);
54
+ if (!params.readOnly && (undescribed || overPressure)) {
55
+ return rebuildOnFreshEpoch({
56
+ ...params,
57
+ config,
58
+ key,
59
+ instructionsHash,
60
+ reason: overPressure ? 'delta-pressure' : 'delta-overflow',
61
+ });
62
+ }
63
+ if (!params.readOnly) {
64
+ epoch.anchorTokens = anchorTokens;
65
+ epoch.assemblies += 1;
66
+ epoch.deltaTokens = deltaTokens;
67
+ }
68
+ const deltaItems = rendered.items;
69
+ return {
70
+ anchorItems: banded.anchorItems,
71
+ liveItems: banded.liveItems,
72
+ deltaItems,
73
+ servedPerLayer: banded.servedPerLayer,
74
+ serveInfo: banded.serveInfo,
75
+ epoch: summarize(epoch, banded.liveTokens, deltaTokens),
76
+ };
77
+ }
78
+ /**
79
+ * Record how the view was banded on the step's span, so a slow or expensive run
80
+ * can be traced back to the layer that keeps invalidating the prefix.
81
+ */
82
+ export function stampAnchoringAttributes(span, view) {
83
+ if (!span || !view.epoch) {
84
+ return;
85
+ }
86
+ const { epoch } = view;
87
+ span.setAttribute(NoeticAttr.CONTEXT_EPOCH_ID, epoch.id);
88
+ span.setAttribute(NoeticAttr.CONTEXT_EPOCH_AGE, epoch.age);
89
+ span.setAttribute(NoeticAttr.CONTEXT_ANCHOR_TOKENS, epoch.anchorTokens);
90
+ span.setAttribute(NoeticAttr.CONTEXT_LIVE_TOKENS, epoch.liveTokens);
91
+ span.setAttribute(NoeticAttr.CONTEXT_DELTA_TOKENS, epoch.deltaTokens);
92
+ if (epoch.reanchorReason) {
93
+ span.setAttribute(NoeticAttr.CONTEXT_REANCHOR_REASON, epoch.reanchorReason);
94
+ }
95
+ if (view.serveInfo.size === 0) {
96
+ return;
97
+ }
98
+ const placements = [];
99
+ const churn = [];
100
+ for (const [id, info] of view.serveInfo) {
101
+ placements.push({
102
+ id,
103
+ placement: info.placement,
104
+ served: info.served,
105
+ changed: info.changed,
106
+ });
107
+ churn.push({
108
+ id,
109
+ rate: Number(info.churnRate.toFixed(3)),
110
+ rebillTokens: Math.round(info.rebillTokens),
111
+ });
112
+ }
113
+ // Span attributes hold only scalars, so structured values travel as JSON.
114
+ span.setAttribute(NoeticAttr.CONTEXT_LAYER_PLACEMENTS, JSON.stringify(placements));
115
+ span.setAttribute(NoeticAttr.CONTEXT_LAYER_CHURN, JSON.stringify(churn));
116
+ }
117
+ /** Tokens the prompt prefix was expected to have cached — what `noteCacheOutcome` judges against. */
118
+ export function expectedCachedTokens(systemPromptItems, view) {
119
+ return totalTokens(systemPromptItems) + (view.epoch?.anchorTokens ?? 0);
120
+ }
121
+ //#endregion
122
+ //#region Epoch resolution
123
+ function resolveEpoch(params) {
124
+ const { store, key, instructionsHash, config } = params;
125
+ const current = store.epochs.get(key);
126
+ const reason = reanchorReason({
127
+ epoch: current,
128
+ key,
129
+ instructionsHash,
130
+ store,
131
+ config,
132
+ });
133
+ // A read-only pass never opens or rebuilds an epoch — looking at a
134
+ // conversation must not change what its next turn sends. It reuses whatever
135
+ // exists, or works against a throwaway when the lineage is still cold.
136
+ if (params.readOnly) {
137
+ return current ?? emptyEpoch(key, instructionsHash);
138
+ }
139
+ if (!reason) {
140
+ // No reason fires while `current` is absent — that case is 'cold-start'.
141
+ return current ?? emptyEpoch(key, instructionsHash);
142
+ }
143
+ return openEpoch({
144
+ store,
145
+ key,
146
+ reason,
147
+ instructionsHash,
148
+ layers: params.layers,
149
+ config,
150
+ });
151
+ }
152
+ /** Re-anchor and band again, publishing no supersedes against the fresh pins. */
153
+ async function rebuildOnFreshEpoch(params) {
154
+ const epoch = openEpoch({
155
+ store: params.store,
156
+ key: params.key,
157
+ reason: params.reason,
158
+ instructionsHash: params.instructionsHash,
159
+ layers: params.layers,
160
+ config: params.config,
161
+ });
162
+ const banded = bandAndPin({
163
+ ...params,
164
+ epoch,
165
+ });
166
+ epoch.anchorTokens = totalTokens(banded.anchorItems);
167
+ epoch.assemblies += 1;
168
+ epoch.deltaTokens = 0;
169
+ return {
170
+ anchorItems: banded.anchorItems,
171
+ liveItems: banded.liveItems,
172
+ deltaItems: [],
173
+ servedPerLayer: banded.servedPerLayer,
174
+ serveInfo: banded.serveInfo,
175
+ epoch: summarize(epoch, banded.liveTokens, 0),
176
+ };
177
+ }
178
+ /**
179
+ * Place each layer's output in a band, then build the anchor band from the pins
180
+ * themselves rather than from this turn's recall.
181
+ *
182
+ * Serving the pin map — in pin insertion order, which is the slot order of the
183
+ * assembly that anchored it — is what makes the prefix byte-stable by
184
+ * construction. Nothing a later turn does (a layer falling silent, a new layer
185
+ * appearing, a layer turning non-idempotent) can reorder or shorten it; those
186
+ * all become supersedes at the tail instead.
187
+ */
188
+ function bandAndPin(params) {
189
+ const { epoch, store, layers, budgets, readOnly } = params;
190
+ const byId = new Map(layers.map((l) => [
191
+ l.id,
192
+ l,
193
+ ]));
194
+ const out = {
195
+ anchorItems: [],
196
+ liveItems: [],
197
+ liveTokens: 0,
198
+ servedPerLayer: [],
199
+ serveInfo: new Map(),
200
+ stale: [],
201
+ };
202
+ const anchored = new Map();
203
+ for (const result of params.recallResults) {
204
+ const layer = byId.get(result.layerId);
205
+ // Output from a layer that is not in this step's set can never be pinned
206
+ // against it; render it live rather than guessing.
207
+ if (!layer || bandFor(layer, result, epoch) === 'live') {
208
+ out.liveItems.push(...result.items);
209
+ out.liveTokens += result.tokenCount;
210
+ out.servedPerLayer.push(result);
211
+ if (layer) {
212
+ out.serveInfo.set(layer.id, {
213
+ placement: 'live',
214
+ served: 'fresh',
215
+ changed: false,
216
+ churnRate: churnRate(churnFor(store, pinKey(layer))),
217
+ rebillTokens: 0,
218
+ });
219
+ }
220
+ continue;
221
+ }
222
+ anchored.set(pinKey(layer), result);
223
+ }
224
+ // A fresh epoch anchors whatever it is given, in slot order. A read-only pass
225
+ // does the same but keeps the result to itself, so a preview still shows the
226
+ // view the next real turn would send without deciding it in advance.
227
+ const pins = epoch.pins.size > 0 || epoch.assemblies > 0 ? epoch.pins : new Map();
228
+ if (pins !== epoch.pins) {
229
+ for (const [key, result] of anchored) {
230
+ const layer = byId.get(result.layerId);
231
+ if (!layer) {
232
+ continue;
233
+ }
234
+ const entry = {
235
+ layerId: layer.id,
236
+ items: result.items,
237
+ tokenCount: result.tokenCount,
238
+ contentHash: hashItems(result.items),
239
+ state: undefined,
240
+ slot: layer.slot,
241
+ };
242
+ pins.set(key, entry);
243
+ if (!readOnly) {
244
+ pin(epoch, key, entry);
245
+ }
246
+ }
247
+ }
248
+ servePins({
249
+ out,
250
+ epoch,
251
+ pins,
252
+ anchored,
253
+ byId,
254
+ store,
255
+ budgets,
256
+ readOnly: readOnly === true,
257
+ });
258
+ collectAdditions({
259
+ out,
260
+ pins,
261
+ anchored,
262
+ byId,
263
+ store,
264
+ budgets,
265
+ readOnly: readOnly === true,
266
+ });
267
+ return out;
268
+ }
269
+ /**
270
+ * An explicit placement always wins. A layer whose `recall` changed state is
271
+ * forced live regardless: replaying an older render would discard the very
272
+ * thing that call committed.
273
+ */
274
+ function bandFor(layer, result, epoch) {
275
+ if (result.mutatedState) {
276
+ return 'live';
277
+ }
278
+ if (layer.placement === 'anchor' || layer.placement === 'live') {
279
+ return layer.placement;
280
+ }
281
+ return epoch.autoBand.get(pinKey(layer)) ?? 'anchor';
282
+ }
283
+ /**
284
+ * Serve every pin, in pinned order, and note which have gone stale.
285
+ *
286
+ * A pin is served even when its layer produced nothing this turn, or has since
287
+ * moved to the live band: dropping it would shorten the prefix and cost the
288
+ * cache far more than the tokens it saves. The model is told to disregard it
289
+ * instead, every turn, until the next re-anchor clears it.
290
+ */
291
+ function servePins(args) {
292
+ const { out, epoch, pins, anchored, byId, store, budgets, readOnly } = args;
293
+ for (const [key, held] of pins) {
294
+ out.anchorItems.push(...held.items);
295
+ const layer = byId.get(held.layerId);
296
+ const fresh = anchored.get(key);
297
+ const churn = churnFor(store, key);
298
+ // The pin is what the model sees, changed or not — that is the whole point.
299
+ out.servedPerLayer.push({
300
+ layerId: held.layerId,
301
+ items: held.items,
302
+ tokenCount: held.tokenCount,
303
+ });
304
+ if (!fresh || !layer) {
305
+ // Gone quiet, or moved to the live band. Either way the pinned block no
306
+ // longer reflects reality and must carry a standing retraction.
307
+ if (layer) {
308
+ out.stale.push({
309
+ layer,
310
+ action: 'retract',
311
+ prev: held.items,
312
+ next: [],
313
+ prevState: held.state,
314
+ budget: budgets.get(layer.id) ?? 0,
315
+ });
316
+ out.serveInfo.set(layer.id, {
317
+ placement: 'anchor',
318
+ served: 'pinned',
319
+ changed: true,
320
+ churnRate: churnRate(churn),
321
+ rebillTokens: churn.rebillTokens,
322
+ });
323
+ }
324
+ continue;
325
+ }
326
+ const changed = held.contentHash !== hashItems(fresh.items);
327
+ if (!readOnly) {
328
+ churn.observed += 1;
329
+ if (changed) {
330
+ churn.changed += 1;
331
+ churn.rebillTokens += epoch.anchorTokens;
332
+ }
333
+ }
334
+ if (changed) {
335
+ out.stale.push({
336
+ layer,
337
+ action: 'replace',
338
+ prev: held.items,
339
+ next: fresh.items,
340
+ prevState: held.state,
341
+ budget: budgets.get(layer.id) ?? 0,
342
+ });
343
+ }
344
+ out.serveInfo.set(layer.id, {
345
+ placement: 'anchor',
346
+ served: 'pinned',
347
+ changed,
348
+ churnRate: churnRate(churn),
349
+ rebillTokens: churn.rebillTokens,
350
+ });
351
+ }
352
+ }
353
+ /**
354
+ * Publish anchored output that has no pin — a layer first seen mid-epoch. It
355
+ * cannot be spliced into a frozen prefix, so it rides in the supersede message
356
+ * until the next re-anchor folds it into the band. Its tokens count toward
357
+ * delta pressure, so a run of additions re-anchors on its own.
358
+ */
359
+ function collectAdditions(args) {
360
+ const { out, pins, anchored, byId, store, budgets, readOnly } = args;
361
+ for (const [key, result] of anchored) {
362
+ if (pins.has(key)) {
363
+ continue;
364
+ }
365
+ const layer = byId.get(result.layerId);
366
+ if (!layer) {
367
+ continue;
368
+ }
369
+ const churn = churnFor(store, key);
370
+ if (!readOnly) {
371
+ churn.observed += 1;
372
+ churn.changed += 1;
373
+ }
374
+ out.stale.push({
375
+ layer,
376
+ action: 'add',
377
+ prev: [],
378
+ next: result.items,
379
+ prevState: undefined,
380
+ budget: budgets.get(layer.id) ?? 0,
381
+ });
382
+ out.servedPerLayer.push(result);
383
+ out.serveInfo.set(layer.id, {
384
+ placement: 'anchor',
385
+ served: 'fresh',
386
+ changed: true,
387
+ churnRate: churnRate(churn),
388
+ rebillTokens: churn.rebillTokens,
389
+ });
390
+ }
391
+ }
392
+ //#endregion
393
+ //#region Supersedes
394
+ /**
395
+ * Render every stale anchor into ONE developer message.
396
+ *
397
+ * A layer may describe its own change compactly via `renderDelta`; otherwise the
398
+ * full new content is republished, which costs more tokens but is always right.
399
+ * A hook that throws or hangs falls back to the default — a supersede is a
400
+ * correctness obligation, so it is never skipped because a hook misbehaved.
401
+ */
402
+ async function buildDeltaItems(stale, epochId, ctx) {
403
+ if (stale.length === 0) {
404
+ return {
405
+ items: [],
406
+ undescribed: false,
407
+ };
408
+ }
409
+ const blocks = [];
410
+ let undescribed = false;
411
+ for (const entry of stale) {
412
+ const body = await renderBody(entry, ctx);
413
+ if (body === null) {
414
+ // Nothing renderable — a layer emitting non-text items, say. The pinned
415
+ // block is already in the view, so staying silent would present it as
416
+ // current. Report it and let the caller re-anchor.
417
+ undescribed = true;
418
+ continue;
419
+ }
420
+ blocks.push(`<update layer="${entry.layer.id}" action="${entry.action}">\n${body}\n</update>`);
421
+ }
422
+ if (blocks.length === 0) {
423
+ return {
424
+ items: [],
425
+ undescribed,
426
+ };
427
+ }
428
+ const text = [
429
+ `<context_updates epoch="${epochId}">`,
430
+ 'These supersede the blocks with the same layer id earlier in this context.',
431
+ 'Where they disagree, these are correct.',
432
+ ...blocks,
433
+ '</context_updates>',
434
+ ].join('\n');
435
+ return {
436
+ items: [
437
+ createMessage(text, 'developer'),
438
+ ],
439
+ undescribed,
440
+ };
441
+ }
442
+ async function renderBody(entry, ctx) {
443
+ if (entry.action === 'retract') {
444
+ return 'This block no longer applies. Disregard it.';
445
+ }
446
+ const custom = await tryRenderDelta(entry, ctx);
447
+ if (custom !== null) {
448
+ return custom;
449
+ }
450
+ const full = itemsToText(entry.next);
451
+ if (full.length > 0) {
452
+ return full;
453
+ }
454
+ // A layer that rendered nothing this turn is saying its block is gone, which
455
+ // is a retraction however it was classified.
456
+ return entry.next.length === 0 ? 'This block no longer applies. Disregard it.' : null;
457
+ }
458
+ async function tryRenderDelta(entry, ctx) {
459
+ const hook = entry.layer.hooks.renderDelta;
460
+ if (!hook) {
461
+ return null;
462
+ }
463
+ try {
464
+ const timeout = entry.layer.timeouts?.recall ?? 5e3;
465
+ const rendered = await withTimeout(hook({
466
+ prev: entry.prev,
467
+ next: entry.next,
468
+ prevState: entry.prevState,
469
+ state: ctx.readLayerState(entry.layer.id),
470
+ ctx,
471
+ budget: entry.budget,
472
+ }), timeout);
473
+ return rendered && rendered.length > 0 ? rendered : null;
474
+ }
475
+ catch {
476
+ // Fall back to republishing in full — see `buildDeltaItems`.
477
+ return null;
478
+ }
479
+ }
480
+ function withTimeout(promise, ms) {
481
+ return Promise.race([
482
+ promise,
483
+ new Promise((_, reject) => setTimeout(() => reject(new Error('renderDelta timed out')), ms)),
484
+ ]);
485
+ }
486
+ //#endregion
487
+ //#region Helpers
488
+ /** Everything before history, as it was assembled before bands existed. */
489
+ function legacyView(recallResults) {
490
+ return {
491
+ anchorItems: recallResults.flatMap((r) => r.items),
492
+ liveItems: [],
493
+ deltaItems: [],
494
+ servedPerLayer: [
495
+ ...recallResults,
496
+ ],
497
+ serveInfo: new Map(),
498
+ epoch: undefined,
499
+ };
500
+ }
501
+ /** A throwaway epoch for a read-only pass on a lineage that has none yet. */
502
+ function emptyEpoch(key, instructionsHash) {
503
+ return {
504
+ id: `${key}#preview`,
505
+ pins: new Map(),
506
+ autoBand: new Map(),
507
+ instructionsHash,
508
+ assemblies: 0,
509
+ anchorTokens: 0,
510
+ deltaTokens: 0,
511
+ cacheBlind: false,
512
+ misses: 0,
513
+ lastReanchorReason: 'cold-start',
514
+ };
515
+ }
516
+ function summarize(epoch, liveTokens, deltaTokens) {
517
+ return {
518
+ id: epoch.id,
519
+ age: epoch.assemblies,
520
+ anchorTokens: epoch.anchorTokens,
521
+ liveTokens,
522
+ deltaTokens,
523
+ reanchorReason: epoch.assemblies <= 1 ? epoch.lastReanchorReason : undefined,
524
+ };
525
+ }
526
+ function itemsToText(items) {
527
+ const parts = [];
528
+ for (const item of items) {
529
+ if (item.type !== 'message' || !('content' in item)) {
530
+ continue;
531
+ }
532
+ for (const part of item.content ?? []) {
533
+ if ('text' in part && typeof part.text === 'string') {
534
+ parts.push(part.text);
535
+ }
536
+ }
537
+ }
538
+ return parts.join('\n');
539
+ }
540
+ function totalTokens(items) {
541
+ let total = 0;
542
+ for (const item of items) {
543
+ total += estimateTokens(JSON.stringify(item));
544
+ }
545
+ return total;
546
+ }
547
+ //#endregion
@@ -4,10 +4,11 @@
4
4
  import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, isOutputCodec, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
5
5
  import { ZodError } from 'zod';
6
6
  import { emitToolUi } from '../runtime/tool-ui.js';
7
- import { allocateBudgets, assembleView, buildToolExecutionContext, ContextImpl, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, defaultItemSchemaRegistry, emitFrameworkEvent, getBroadcaster, resolveLayerTools, returnLayers, shouldEmit, snapshotCwdState, spawnLayers, } from './action-deps.js';
7
+ import { allocateBudgets, assembleView, buildToolExecutionContext, ContextImpl, commitLayerUsage, computeLayerUsage, contextToExecCtx, createContextCacheStore, DEFAULT_PROJECTION, defaultItemSchemaRegistry, emitFrameworkEvent, getBroadcaster, lineageKey, noteCacheOutcome, resolveCacheConfig, resolveLayerTools, returnLayers, shouldEmit, snapshotCwdState, spawnLayers, } from './action-deps.js';
8
8
  import { isServerToolSpec, SteeringAction } from './action-types.js';
9
9
  import { cloneWithGuard } from './clone-guard.js';
10
10
  import { collectAllTools, deduplicateTools } from './collect-tools.js';
11
+ import { prepareBandedView, stampAnchoringAttributes } from './context-assembly.js';
11
12
  import { trackUsage } from './message-helpers.js';
12
13
  import { getContextChannelStore, isContextImpl, isFunctionCall, isMutableContext, } from './typeguards.js';
13
14
  //#region run
@@ -364,8 +365,31 @@ export async function executeLLM(step, input, ctx, layers) {
364
365
  ], rerendered, slotOf);
365
366
  }
366
367
  }
367
- const layerOutputItems = recallResults.flatMap((r) => r.items);
368
+ // Split recall output into the cache-stable anchor band and the live band,
369
+ // replaying pinned anchors and gathering any changes into one supersede
370
+ // message. Runs once, outside the retry loop, for the same reason recall does:
371
+ // a retry replays this view rather than building a new one.
372
+ // A harness that predates anchoring carries no store. Give it a throwaway:
373
+ // nothing survives the turn, so its layers render fresh every time — the
374
+ // behaviour it had before the bands existed.
375
+ const cacheStore = baseCtx.harness.contextCache ?? createContextCacheStore();
376
+ const banded = await prepareBandedView({
377
+ recallResults,
378
+ layers: layers ?? [],
379
+ execCtx: contextToExecCtx(baseCtx),
380
+ store: cacheStore,
381
+ config: baseCtx.harness.config.contextCache,
382
+ instructions: resolvedInstructions,
383
+ policy: viewPolicy,
384
+ systemPromptItems: [],
385
+ budgets: budgetMap,
386
+ });
387
+ stampAnchoringAttributes(baseCtx.span, banded);
368
388
  let retries = 0;
389
+ // Guidance from a steering retry rides at the very end of the view, after the
390
+ // live band and the supersedes, rather than wherever history happens to put it.
391
+ let steeringTail = [];
392
+ let cacheJudged = false;
369
393
  while (retries <= MAX_STEERING_RETRIES) {
370
394
  const rawHistoryItems = baseCtx.itemLog.items;
371
395
  const projectedHistoryItems = hasLayers
@@ -377,17 +401,26 @@ export async function executeLLM(step, input, ctx, layers) {
377
401
  // budget (drops highest-slot layer output, then oldest history).
378
402
  const systemItems = [];
379
403
  const nonSystemHistory = [];
404
+ const tailIds = new Set(steeringTail.map((i) => i.id));
380
405
  for (const item of projectedHistoryItems) {
381
406
  if (item.type === 'message' && item.role === 'system') {
382
407
  systemItems.push(item);
383
408
  continue;
384
409
  }
410
+ // Steering guidance is re-placed at the tail; skip it here so the retry
411
+ // does not see the same correction twice.
412
+ if ('id' in item && typeof item.id === 'string' && tailIds.has(item.id)) {
413
+ continue;
414
+ }
385
415
  nonSystemHistory.push(item);
386
416
  }
387
417
  assembledItems = assembleView({
388
418
  systemPromptItems: systemItems,
389
- layerOutputItems,
419
+ layerOutputItems: banded.anchorItems,
390
420
  historyItems: nonSystemHistory,
421
+ liveLayerItems: banded.liveItems,
422
+ deltaItems: banded.deltaItems,
423
+ tailItems: steeringTail,
391
424
  policy: viewPolicy,
392
425
  });
393
426
  }
@@ -440,6 +473,23 @@ export async function executeLLM(step, input, ctx, layers) {
440
473
  // real, and until.maxCost / HarnessResponse.usage must see it (spec 07:
441
474
  // tokens accumulate across all LLM calls).
442
475
  trackUsage(baseCtx, response);
476
+ // Ask the model's own token report whether the prompt prefix survived. A
477
+ // clear miss records a re-anchor that the next assembly picks up — pins are
478
+ // never touched here, so a response steering later rejects cannot leave the
479
+ // epoch half-rebuilt.
480
+ // Judged once per assembly, not once per call: a steering retry re-sends
481
+ // the same view, so counting its response again would reach the give-up
482
+ // threshold inside a single turn and blind the lineage for good.
483
+ if (banded.epoch && !cacheJudged) {
484
+ cacheJudged = true;
485
+ noteCacheOutcome({
486
+ store: cacheStore,
487
+ key: lineageKey(contextToExecCtx(baseCtx)),
488
+ response,
489
+ config: resolveCacheConfig(baseCtx.harness.config.contextCache),
490
+ expectedTokens: systemPromptTokens + banded.epoch.anchorTokens,
491
+ });
492
+ }
443
493
  // An abort that landed mid-call returns whatever rounds completed before
444
494
  // the signal fired. The spend is charged above, but a truncated generation
445
495
  // is not this step's output — surface cancellation instead.
@@ -458,7 +508,16 @@ export async function executeLLM(step, input, ctx, layers) {
458
508
  });
459
509
  }
460
510
  if (decision.action === SteeringAction.Guide && retries < MAX_STEERING_RETRIES) {
461
- baseCtx.itemLog.append(createMessage(decision.guidance ?? 'Please adjust your response.', 'developer'));
511
+ const guidance = createMessage(decision.guidance ?? 'Please adjust your response.', 'developer');
512
+ // Logged so it persists into the conversation, and carried as the tail
513
+ // so the retry's view ends on the correction rather than burying it
514
+ // ahead of the live band. Assembly drops it from history to avoid
515
+ // showing it twice.
516
+ baseCtx.itemLog.append(guidance);
517
+ steeringTail = [
518
+ ...steeringTail,
519
+ guidance,
520
+ ];
462
521
  retries++;
463
522
  continue;
464
523
  }
@@ -484,7 +543,11 @@ export async function executeLLM(step, input, ctx, layers) {
484
543
  modelId: resolvedModel,
485
544
  instructions: resolvedInstructions,
486
545
  tools: resolvedTools,
487
- recallResults,
546
+ // What the model was actually shown, pinned replays included — raw
547
+ // recall output would report content it never saw.
548
+ recallResults: banded.servedPerLayer,
549
+ serveInfo: banded.serveInfo,
550
+ epoch: banded.epoch,
488
551
  }));
489
552
  if (hasLayers) {
490
553
  await baseCtx.harness.storeLayers(layers, response, baseCtx);
@@ -3,6 +3,10 @@ export declare const GenAI: {
3
3
  readonly REQUEST_MODEL: 'gen_ai.request.model';
4
4
  readonly USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens';
5
5
  readonly USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens';
6
+ /** Prompt tokens served from the provider's cache. Absent if unreported. */
7
+ readonly USAGE_CACHED_INPUT_TOKENS: 'gen_ai.usage.cached_input_tokens';
8
+ /** Prompt tokens written into the provider's cache. Absent if unreported. */
9
+ readonly USAGE_CACHE_WRITE_TOKENS: 'gen_ai.usage.cache_write_tokens';
6
10
  readonly COST: 'gen_ai.cost';
7
11
  };
8
12
  export declare const ToolAttr: {
@@ -30,4 +34,20 @@ export declare const NoeticAttr: {
30
34
  readonly SESSION_ID: 'noetic.session.id';
31
35
  /** Resource the session is scoped to (the run's `ctx.resourceId`), when set. */
32
36
  readonly RESOURCE_ID: 'noetic.resource.id';
37
+ /** Id of the anchoring epoch the assembled view belongs to. */
38
+ readonly CONTEXT_EPOCH_ID: 'noetic.context.epoch.id';
39
+ /** Assemblies served by the current epoch, including this one. */
40
+ readonly CONTEXT_EPOCH_AGE: 'noetic.context.epoch.age';
41
+ /** Why the epoch re-anchored on this assembly, when it did. */
42
+ readonly CONTEXT_REANCHOR_REASON: 'noetic.context.reanchor_reason';
43
+ /** Tokens in the anchor band (the cache-stable prefix). */
44
+ readonly CONTEXT_ANCHOR_TOKENS: 'noetic.context.anchor_tokens';
45
+ /** Tokens in the live band (rendered after history). */
46
+ readonly CONTEXT_LIVE_TOKENS: 'noetic.context.live_tokens';
47
+ /** Tokens spent superseding stale anchors. */
48
+ readonly CONTEXT_DELTA_TOKENS: 'noetic.context.delta_tokens';
49
+ /** JSON array of `{ id, placement, served }` per contributing layer. */
50
+ readonly CONTEXT_LAYER_PLACEMENTS: 'noetic.context.layer_placements';
51
+ /** JSON array of `{ id, rate, rebillTokens }` per contributing layer. */
52
+ readonly CONTEXT_LAYER_CHURN: 'noetic.context.layer_churn';
33
53
  };
@@ -4,6 +4,10 @@ export const GenAI = {
4
4
  REQUEST_MODEL: 'gen_ai.request.model',
5
5
  USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens',
6
6
  USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens',
7
+ /** Prompt tokens served from the provider's cache. Absent if unreported. */
8
+ USAGE_CACHED_INPUT_TOKENS: 'gen_ai.usage.cached_input_tokens',
9
+ /** Prompt tokens written into the provider's cache. Absent if unreported. */
10
+ USAGE_CACHE_WRITE_TOKENS: 'gen_ai.usage.cache_write_tokens',
7
11
  COST: 'gen_ai.cost',
8
12
  };
9
13
  export const ToolAttr = {
@@ -33,4 +37,20 @@ export const NoeticAttr = {
33
37
  SESSION_ID: 'noetic.session.id',
34
38
  /** Resource the session is scoped to (the run's `ctx.resourceId`), when set. */
35
39
  RESOURCE_ID: 'noetic.resource.id',
40
+ /** Id of the anchoring epoch the assembled view belongs to. */
41
+ CONTEXT_EPOCH_ID: 'noetic.context.epoch.id',
42
+ /** Assemblies served by the current epoch, including this one. */
43
+ CONTEXT_EPOCH_AGE: 'noetic.context.epoch.age',
44
+ /** Why the epoch re-anchored on this assembly, when it did. */
45
+ CONTEXT_REANCHOR_REASON: 'noetic.context.reanchor_reason',
46
+ /** Tokens in the anchor band (the cache-stable prefix). */
47
+ CONTEXT_ANCHOR_TOKENS: 'noetic.context.anchor_tokens',
48
+ /** Tokens in the live band (rendered after history). */
49
+ CONTEXT_LIVE_TOKENS: 'noetic.context.live_tokens',
50
+ /** Tokens spent superseding stale anchors. */
51
+ CONTEXT_DELTA_TOKENS: 'noetic.context.delta_tokens',
52
+ /** JSON array of `{ id, placement, served }` per contributing layer. */
53
+ CONTEXT_LAYER_PLACEMENTS: 'noetic.context.layer_placements',
54
+ /** JSON array of `{ id, rate, rebillTokens }` per contributing layer. */
55
+ CONTEXT_LAYER_CHURN: 'noetic.context.layer_churn',
36
56
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/core",
3
- "version": "5.1.0",
3
+ "version": "5.3.0",
4
4
  "type": "module",
5
5
  "description": "Step primitives, interpreter, runtime, and context layer contract for Noetic agents.",
6
6
  "license": "Apache-2.0",
@@ -66,8 +66,8 @@
66
66
  }
67
67
  },
68
68
  "dependencies": {
69
- "@noetic-tools/context": "^1.0.0",
70
- "@noetic-tools/types": "^3.0.0",
69
+ "@noetic-tools/context": "^1.2.0",
70
+ "@noetic-tools/types": "^3.1.0",
71
71
  "@openrouter/agent": "^0.6.0",
72
72
  "zod": "^4.0.0"
73
73
  },