@noetic-tools/core 5.2.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.
- package/dist/adapters/openrouter.d.ts +8 -1
- package/dist/adapters/openrouter.js +23 -1
- package/dist/harness/agent-harness.d.ts +5 -1
- package/dist/harness/agent-harness.js +82 -19
- package/dist/harness/deps/context.d.ts +1 -1
- package/dist/harness/deps/context.js +1 -1
- package/dist/harness/deps/interpreter.d.ts +1 -0
- package/dist/harness/deps/interpreter.js +1 -0
- package/dist/harness/deps/types.d.ts +1 -1
- package/dist/harness/model-call.js +22 -5
- package/dist/index.d.ts +4 -4
- package/dist/interpreter/action-deps.d.ts +2 -2
- package/dist/interpreter/action-deps.js +1 -1
- package/dist/interpreter/action-types.d.ts +1 -1
- package/dist/interpreter/context-assembly.d.ts +50 -0
- package/dist/interpreter/context-assembly.js +547 -0
- package/dist/interpreter/execute-action.js +68 -5
- package/dist/observability/genai-attributes.d.ts +20 -0
- package/dist/observability/genai-attributes.js +20 -0
- package/package.json +3 -3
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
@@ -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 */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
70
|
-
"@noetic-tools/types": "^3.
|
|
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
|
},
|