From 9d734f437390abc30806eb0250eaf2287ecab963 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Wed, 1 Apr 2026 07:59:46 -0400 Subject: [PATCH 1/2] feat(core): wire assembleView into executeLLM with atomic/eventual recall modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect the dormant memory pipeline (allocateBudgets, recallLayers, assembleView, storeLayers) into the LLM execution path. Layers now participate in every callModel cycle: init → budget → recall → assemble → call → store. Add recallMode to MemoryLayer: atomic (default, blocks callModel) vs eventual (uses cached results, refreshes on state change). Add ProjectionPolicy config at harness and step levels. --- .../references/api-reference.md | 44 ++++ packages/core/src/interpreter/execute-llm.ts | 127 ++++++++++- packages/core/src/memory/layer-lifecycle.ts | 95 +++++++++ .../src/memory/layers/observational-memory.ts | 1 + packages/core/src/runtime/agent-harness.ts | 52 ++++- packages/core/src/types/memory.ts | 2 + packages/core/src/types/runtime.ts | 16 +- packages/core/src/types/step.ts | 4 +- packages/core/test/_helpers.ts | 2 + .../core/test/interpreter/execute-llm.test.ts | 157 +++++++++++++- .../core/test/memory/layer-lifecycle.test.ts | 198 ++++++++++++++++++ specs/11-memory-layer-system.md | 42 +++- 12 files changed, 717 insertions(+), 23 deletions(-) diff --git a/.claude/skills/noetic-agent-builder/references/api-reference.md b/.claude/skills/noetic-agent-builder/references/api-reference.md index ecc35d2c..92b36e58 100644 --- a/.claude/skills/noetic-agent-builder/references/api-reference.md +++ b/.claude/skills/noetic-agent-builder/references/api-reference.md @@ -208,6 +208,26 @@ const compiled = compilePlan(plan, agents, undefined, harness.run.bind(harness)) ## Memory Layers +### MemoryLayer Interface + +```typescript +interface MemoryLayer { + id: string; + name?: string; + slot: number; + scope: MemoryScope; + budget?: BudgetConfig; + recallMode?: 'atomic' | 'eventual'; + hooks: MemoryHooks; + timeouts?: Partial; + provides?: LayerProvides; +} +``` + +**`recallMode`** controls how the layer's `recall()` participates in View assembly: +- `'atomic'` (default) -- recall blocks `callModel`. The agent harness waits for all atomic layers before assembling the View. Use for layers whose output the model needs immediately (working memory, static content, steering). +- `'eventual'` -- recall uses cached results, never blocks `callModel`. The cache refreshes asynchronously when `store()` produces new state. Use for slow layers where slight staleness is acceptable (observational memory, RAG). + ### workingMemory Thread/resource-scoped structured state, updated via `updateWorkingMemory` tool call. @@ -395,6 +415,30 @@ Layer functions in `provides` are automatically exposed as tools to any `step.ll `AgentHarness` is generic over `TParams`. The `config` property exposes `AgentConfig`, and steps/tools access params via `ctx.harness.config.params`. +### ProjectionPolicy + +Controls how the runtime projects conversation items into the model's context window. Set at harness level (default for all steps) or per-step via `StepLLM.projection`. + +```typescript +interface ProjectionPolicy { + tokenBudget: number; // Total context window budget + responseReserve: number; // Tokens reserved for the response + overflow: 'truncate' | 'summarize' | 'sliding_window'; + overflowModel?: string; // Model for summarize overflow + windowSize?: number; // For sliding_window: max history items +} + +// Harness-level default +const harness = new AgentHarness({ + name: 'agent', + params: {}, + projection: { tokenBudget: 128e3, responseReserve: 4096, overflow: 'sliding_window' }, +}); + +// Per-step override +step.llm({ id: 'summarize', model: 'gpt-4', projection: { ... } }) +``` + ```typescript // High-level API: execute() returns HarnessResult with streaming accessors const harness = new AgentHarness({ diff --git a/packages/core/src/interpreter/execute-llm.ts b/packages/core/src/interpreter/execute-llm.ts index 0697a425..e20f303d 100644 --- a/packages/core/src/interpreter/execute-llm.ts +++ b/packages/core/src/interpreter/execute-llm.ts @@ -1,17 +1,28 @@ import { ZodError } from 'zod'; import { NoeticErrorImpl } from '../errors/noetic-error'; +import { allocateBudgets } from '../memory/budget'; import { resolveLayerTools } from '../memory/layer-api'; +import { assembleView } from '../memory/projector'; import type { StepMeta, Tool } from '../types/common'; import type { Context } from '../types/context'; -import type { FunctionCallItem } from '../types/items'; -import type { ContextMemory, MemoryLayer } from '../types/memory'; +import type { FunctionCallItem, Item } from '../types/items'; +import type { ContextMemory, MemoryLayer, ProjectionPolicy } from '../types/memory'; import { SteeringAction } from '../types/steering'; import type { StepLLM } from '../types/step'; import { frameworkCast } from './framework-cast'; -import { createMessage, extractAssistantText, trackUsage } from './message-helpers'; +import { createMessage, estimateTokens, extractAssistantText, trackUsage } from './message-helpers'; import { isMutableContext } from './typeguards'; const MAX_STEERING_RETRIES = 3; +const LAYERS_INIT_SENTINEL = '__layers_initialized'; + +const DEFAULT_PROJECTION: ProjectionPolicy = { + tokenBudget: 128e3, + responseReserve: 4e3, + overflow: 'sliding_window', +}; + +//#region Helper Functions function mergeTools( stepTools: Tool[] | undefined, @@ -28,6 +39,31 @@ function mergeTools( ]; } +function partitionItems(items: ReadonlyArray): { + systemItems: Item[]; + historyItems: Item[]; +} { + const systemItems: Item[] = []; + const historyItems: Item[] = []; + + for (const item of items) { + if (item.type === 'message' && item.role === 'system') { + systemItems.push(item); + continue; + } + historyItems.push(item); + } + + return { + systemItems, + historyItems, + }; +} + +//#endregion + +//#region Public API + export async function executeLLM( step: StepLLM, input: I, @@ -41,13 +77,83 @@ export async function executeLLM( } const allTools = mergeTools(step.tools, layers, baseCtx); + const hasLayers = layers !== undefined && layers.length > 0; + + // Memory pipeline setup (once, before retry loop) + let budgetMap = new Map(); + + // Resolve projection policy once: step > harness > default + const policy: ProjectionPolicy = + step.projection ?? baseCtx.harness.config.projection ?? DEFAULT_PROJECTION; + + if (hasLayers) { + // Init layers on first LLM call in this execution + if (!baseCtx.harness.getLayerState(baseCtx.id, LAYERS_INIT_SENTINEL)) { + const storage = baseCtx.harness.config.storage; + if (storage) { + await baseCtx.harness.initLayers(layers, baseCtx, storage); + } + baseCtx.harness.setLayerState(baseCtx.id, LAYERS_INIT_SENTINEL, true); + } + + const systemTokenEstimate = step.system ? estimateTokens(step.system) : 0; + const { allocations } = allocateBudgets({ + layers, + totalBudget: policy.tokenBudget, + systemPromptTokens: systemTokenEstimate, + responseReserve: policy.responseReserve, + }); + budgetMap = new Map( + allocations.map((a) => [ + a.layerId, + a.allocated, + ]), + ); + } + let retries = 0; while (retries <= MAX_STEERING_RETRIES) { + // Recall + Assemble + let requestItems: ReadonlyArray; + + if (hasLayers) { + const query = typeof input === 'string' ? input : ''; + + const atomicResults = await baseCtx.harness.recallLayersAtomic( + layers, + query, + baseCtx, + budgetMap, + ); + const eventualResults = await baseCtx.harness.recallLayersEventual( + layers, + query, + baseCtx, + budgetMap, + ); + + const layerOutputItems = [ + ...atomicResults, + ...eventualResults, + ].flatMap((r) => r.items); + + const { systemItems, historyItems } = partitionItems(baseCtx.itemLog.items); + + requestItems = assembleView({ + systemPromptItems: systemItems, + layerOutputItems, + historyItems, + policy, + }); + } else { + requestItems = baseCtx.itemLog.items; + } + const request = allTools ? { model: step.model, - items: baseCtx.itemLog.items, + items: requestItems, tools: allTools, params: step.params, outputSchema: step.output, @@ -57,7 +163,7 @@ export async function executeLLM( } : { model: step.model, - items: baseCtx.itemLog.items, + items: requestItems, params: step.params, outputSchema: step.output, emit: step.emit, @@ -65,7 +171,7 @@ export async function executeLLM( }; const response = await baseCtx.harness.callModel(request); - if (layers && layers.length > 0) { + if (hasLayers) { const decision = await baseCtx.harness.afterModelCall(layers, response, baseCtx); if (decision.action === SteeringAction.Deny) { @@ -92,6 +198,11 @@ export async function executeLLM( } } + // Store layers after response + if (hasLayers) { + await baseCtx.harness.storeLayers(layers, response, baseCtx); + } + const meta: StepMeta = { toolCalls: toolCalls.length > 0 ? toolCalls : undefined, usage: response.usage, @@ -137,8 +248,6 @@ export async function executeLLM( } // Safety net: the loop above always returns or throws within the body. - // This throw is unreachable but protects against future refactors that - // might break the loop invariant. throw new NoeticErrorImpl({ kind: 'step_failed', stepId: step.id, @@ -146,3 +255,5 @@ export async function executeLLM( retriesExhausted: true, }); } + +//#endregion diff --git a/packages/core/src/memory/layer-lifecycle.ts b/packages/core/src/memory/layer-lifecycle.ts index 8c8920a9..7f8166e5 100644 --- a/packages/core/src/memory/layer-lifecycle.ts +++ b/packages/core/src/memory/layer-lifecycle.ts @@ -40,6 +40,8 @@ interface StoreLayersParams { ctx: ExecutionContext; log: ItemLog; store: LayerStateStore; + /** When provided, layers whose store produces new state are marked stale for eventual recall refresh. */ + recallCache?: RecallCache; } interface SpawnLayersParams { @@ -114,6 +116,28 @@ export function createLayerStateStore( }; } +export interface RecallCache { + entries: Map; + stale: Set; +} + +interface RecallCacheEntry { + layerId: string; + items: Item[]; + tokenCount: number; +} + +export function createRecallCache(): RecallCache { + return { + entries: new Map(), + stale: new Set(), + }; +} + +function _recallCacheKey(executionId: string, layerId: string): string { + return `${executionId}:${layerId}`; +} + async function withTimeout(promise: Promise, ms: number): Promise { if (ms <= 0) { return promise; @@ -258,12 +282,80 @@ export async function recallLayers({ return results; } +interface RecallLayersWithCacheParams extends RecallLayersParams { + cache: RecallCache; +} + +export async function recallLayersAtomic(params: RecallLayersParams): Promise< + { + layerId: string; + items: Item[]; + tokenCount: number; + }[] +> { + const atomicLayers = params.layers.filter((l) => l.recallMode !== 'eventual'); + return recallLayers({ + ...params, + layers: atomicLayers, + }); +} + +export async function recallLayersEventual({ + cache, + ...params +}: RecallLayersWithCacheParams): Promise< + { + layerId: string; + items: Item[]; + tokenCount: number; + }[] +> { + const eventualLayers = params.layers.filter((l) => l.recallMode === 'eventual'); + if (eventualLayers.length === 0) { + return []; + } + + const results: { + layerId: string; + items: Item[]; + tokenCount: number; + }[] = []; + + for (const layer of eventualLayers) { + const key = _recallCacheKey(params.ctx.executionId, layer.id); + const cached = cache.entries.get(key); + const isStale = cache.stale.has(key); + + // First call (no cache) or stale: recall and await + if (!cached || isStale) { + const recalled = await recallLayers({ + ...params, + layers: [ + layer, + ], + }); + for (const entry of recalled) { + cache.entries.set(key, entry); + results.push(entry); + } + cache.stale.delete(key); + continue; + } + + // Cached and fresh: return cached + results.push(cached); + } + + return results; +} + export async function storeLayers({ layers, response, ctx, log, store, + recallCache, }: StoreLayersParams): Promise { // Concurrent via Promise.allSettled — each layer gets its own state snapshot const snapshots: { @@ -302,6 +394,9 @@ export async function storeLayers({ ); if (result?.state !== undefined) { store.set(ctx.executionId, layer.id, result.state); + if (recallCache && layer.recallMode === 'eventual') { + recallCache.stale.add(_recallCacheKey(ctx.executionId, layer.id)); + } } } catch (e) { store.diagnostic(layer.id, 'store', e); diff --git a/packages/core/src/memory/layers/observational-memory.ts b/packages/core/src/memory/layers/observational-memory.ts index cbdac185..490d7bae 100644 --- a/packages/core/src/memory/layers/observational-memory.ts +++ b/packages/core/src/memory/layers/observational-memory.ts @@ -40,6 +40,7 @@ export function observationalMemory(config?: ObservationalMemoryConfig) { name: 'Observational Memory', slot: Slot.OBSERVATIONS, scope: config?.scope ?? 'resource', + recallMode: 'eventual', budget: { min: 500, max: 2_500, diff --git a/packages/core/src/runtime/agent-harness.ts b/packages/core/src/runtime/agent-harness.ts index 9f583472..7c67b94d 100644 --- a/packages/core/src/runtime/agent-harness.ts +++ b/packages/core/src/runtime/agent-harness.ts @@ -11,14 +11,17 @@ import { } from '../adapters/openrouter'; import { NoeticConfigError } from '../errors/noetic-config-error'; import { execute } from '../interpreter/execute'; -import type { LayerStateStore } from '../memory/layer-lifecycle'; +import type { LayerStateStore, RecallCache } from '../memory/layer-lifecycle'; import { afterModelCallLayers, beforeToolCallLayers, createLayerStateStore, + createRecallCache, disposeLayers, initLayers, recallLayers, + recallLayersAtomic, + recallLayersEventual, storeLayers, } from '../memory/layer-lifecycle'; import { SpanImpl } from '../observability/span-impl'; @@ -29,7 +32,13 @@ import type { Context } from '../types/context'; import type { DetachedHandle } from '../types/detached'; import type { HarnessResult } from '../types/harness-result'; import type { ExecuteInput, Item } from '../types/items'; -import type { ContextMemory, ExecutionContext, MemoryLayer, StorageAdapter } from '../types/memory'; +import type { + ContextMemory, + ExecutionContext, + MemoryLayer, + ProjectionPolicy, + StorageAdapter, +} from '../types/memory'; import type { Span, TraceExporter } from '../types/observability'; import type { AgentConfig, @@ -62,6 +71,8 @@ interface AgentHarnessOpts = Record; llm?: LlmProviderConfig; + /** Default projection policy for all LLM steps. Individual steps can override via `step.projection`. */ + projection?: ProjectionPolicy; traceExporter?: TraceExporter; layerStateStore?: LayerStateStore; /** @internal Test-only escape hatch to inject a mock callModel implementation. */ @@ -159,6 +170,7 @@ export class AgentHarness = Record Promise; readonly layerStateStore: LayerStateStore; readonly traceExporter: TraceExporter; + readonly recallCache: RecallCache; constructor(opts: AgentHarnessOpts) { const validatedParams = opts.paramsSchema ? opts.paramsSchema.parse(opts.params) : opts.params; @@ -168,6 +180,7 @@ export class AgentHarness = Record = Record { @@ -489,6 +503,39 @@ export class AgentHarness = Record, + ): Promise { + return recallLayersAtomic({ + layers, + query: input, + ctx: this.toExecCtx(ctx), + log: ctx.itemLog, + budgets, + store: this.layerStateStore, + }); + } + + async recallLayersEventual( + layers: MemoryLayer[], + input: string, + ctx: Context, + budgets: Map, + ): Promise { + return recallLayersEventual({ + layers, + query: input, + ctx: this.toExecCtx(ctx), + log: ctx.itemLog, + budgets, + store: this.layerStateStore, + cache: this.recallCache, + }); + } + async storeLayers(layers: MemoryLayer[], response: LLMResponse, ctx: Context): Promise { await storeLayers({ layers, @@ -496,6 +543,7 @@ export class AgentHarness = Record { hooks: MemoryHooks; /** Per-hook timeout overrides in ms. */ timeouts?: Partial; + /** Recall mode: `'atomic'` blocks callModel until recall completes (default); `'eventual'` uses cached results and never blocks. */ + recallMode?: 'atomic' | 'eventual'; /** Typed functions and data exposed to code steps via `ctx.memory['layerId']` and automatically as LLM tools. */ provides?: LayerProvides; } diff --git a/packages/core/src/types/runtime.ts b/packages/core/src/types/runtime.ts index 83f664d3..1002ecac 100644 --- a/packages/core/src/types/runtime.ts +++ b/packages/core/src/types/runtime.ts @@ -5,7 +5,7 @@ import type { Context } from './context'; import type { DetachedHandle } from './detached'; import type { HarnessResult } from './harness-result'; import type { ExecuteInput, Item } from './items'; -import type { ContextMemory, MemoryLayer, StorageAdapter } from './memory'; +import type { ContextMemory, MemoryLayer, ProjectionPolicy, StorageAdapter } from './memory'; import type { Span } from './observability'; import type { SteeringDecision } from './steering'; import type { Step } from './step'; @@ -22,6 +22,8 @@ export interface AgentConfig = Record(channel: ExternalChannel, executionId: string): ChannelHandle; initLayers(layers: MemoryLayer[], ctx: Context, storage: StorageAdapter): Promise; recallLayers(layers: MemoryLayer[], input: string, ctx: Context): Promise; + recallLayersAtomic( + layers: MemoryLayer[], + input: string, + ctx: Context, + budgets: Map, + ): Promise; + recallLayersEventual( + layers: MemoryLayer[], + input: string, + ctx: Context, + budgets: Map, + ): Promise; storeLayers(layers: MemoryLayer[], response: LLMResponse, ctx: Context): Promise; disposeLayers(layers: MemoryLayer[], ctx: Context): Promise; checkpoint(ctx: Context): Promise; diff --git a/packages/core/src/types/step.ts b/packages/core/src/types/step.ts index e780d6c6..64afa936 100644 --- a/packages/core/src/types/step.ts +++ b/packages/core/src/types/step.ts @@ -3,7 +3,7 @@ import type { Channel } from './channel'; import type { ModelParams, RetryPolicy, StepMeta, Tool } from './common'; import type { Context } from './context'; import type { NoeticError } from './error'; -import type { ContextMemory, MemoryConfig, MemoryLayer } from './memory'; +import type { ContextMemory, MemoryConfig, MemoryLayer, ProjectionPolicy } from './memory'; /** * Cumulative execution snapshot passed to loop `until` predicates. @@ -95,6 +95,8 @@ export interface StepLLM<_TMemory = ContextMemory, _I = unknown, O = unknown> { params?: ModelParams; /** Controls framework event emission for this step. Defaults to `true`. Set `false` to suppress all framework events. A filter function receives `(eventType, data)` and returns `boolean`. */ emit?: boolean | ((eventType: string, data: Record) => boolean); + /** Projection policy for this step. Overrides the harness-level default. */ + projection?: ProjectionPolicy; } /** @public A step that invokes a single tool directly, bypassing the LLM. */ diff --git a/packages/core/test/_helpers.ts b/packages/core/test/_helpers.ts index 7deba129..06911b4b 100644 --- a/packages/core/test/_helpers.ts +++ b/packages/core/test/_helpers.ts @@ -333,6 +333,8 @@ export function makeMockHarness(): AgentHarnessContract { }, initLayers: async () => {}, recallLayers: async () => [], + recallLayersAtomic: async () => [], + recallLayersEventual: async () => [], storeLayers: async () => {}, disposeLayers: async () => {}, checkpoint: async () => {}, diff --git a/packages/core/test/interpreter/execute-llm.test.ts b/packages/core/test/interpreter/execute-llm.test.ts index 0c33a4db..18a656e4 100644 --- a/packages/core/test/interpreter/execute-llm.test.ts +++ b/packages/core/test/interpreter/execute-llm.test.ts @@ -3,9 +3,16 @@ import assert from 'node:assert'; import { z } from 'zod'; import { isNoeticError } from '../../src/errors/noetic-error'; import { executeLLM } from '../../src/interpreter/execute-llm'; -import type { ContextMemory } from '../../src/types/memory'; +import type { ContextMemory, MemoryLayer } from '../../src/types/memory'; +import { Slot } from '../../src/types/memory'; import type { StepLLM } from '../../src/types/step'; -import { makeLLMResponse, makeMockContextWithClient } from '../_helpers'; +import { + createScriptedCallModel, + makeLLMResponse, + makeMockContext, + makeMockContextWithClient, + makeMockHarness, +} from '../_helpers'; describe('executeLLM', () => { it('calls the client and returns text output', async () => { @@ -302,4 +309,150 @@ describe('executeLLM', () => { // No error means empty tools were handled gracefully expect(ctx.lastStepMeta).not.toBeNull(); }); + + describe('memory layer integration', () => { + it('calls recallLayersAtomic and assembles view when layers present', async () => { + const recallCalls: string[] = []; + const layers: MemoryLayer[] = [ + { + id: 'wm', + slot: Slot.WORKING_MEMORY, + scope: 'thread', + hooks: {}, + }, + ]; + + const harness = makeMockHarness(); + harness.callModel = createScriptedCallModel([ + makeLLMResponse('done'), + ]); + harness.recallLayersAtomic = async () => { + recallCalls.push('atomic'); + return [ + { + layerId: 'wm', + items: [ + { + type: 'message', + id: 'wm-1', + role: 'developer', + content: [ + { + type: 'input_text', + text: 'test', + }, + ], + }, + ], + tokenCount: 10, + }, + ]; + }; + harness.recallLayersEventual = async () => []; + const ctx = makeMockContext({ + harness, + layers, + }); + + const step: StepLLM = { + kind: 'llm', + id: 'test', + model: 'gpt-4', + }; + + await executeLLM(step, 'hello', ctx, layers); + expect(recallCalls).toEqual([ + 'atomic', + ]); + }); + + it('calls storeLayers after model response', async () => { + let storeLayersCalled = false; + const layers: MemoryLayer[] = [ + { + id: 'wm', + slot: Slot.WORKING_MEMORY, + scope: 'thread', + hooks: {}, + }, + ]; + + const harness = makeMockHarness(); + harness.callModel = createScriptedCallModel([ + makeLLMResponse('done'), + ]); + harness.recallLayersAtomic = async () => []; + harness.recallLayersEventual = async () => []; + harness.storeLayers = async () => { + storeLayersCalled = true; + }; + const ctx = makeMockContext({ + harness, + layers, + }); + + const step: StepLLM = { + kind: 'llm', + id: 'test', + model: 'gpt-4', + }; + + await executeLLM(step, 'hello', ctx, layers); + expect(storeLayersCalled).toBe(true); + }); + + it('skips memory pipeline when no layers provided', async () => { + const step: StepLLM = { + kind: 'llm', + id: 'test', + model: 'gpt-4', + }; + const ctx = makeMockContextWithClient([ + makeLLMResponse('hello'), + ]); + + const result = await executeLLM(step, 'hi', ctx); + expect(result).toBe('hello'); + // No layers = itemLog.items passed directly (existing behavior) + expect(ctx.itemLog.items.length).toBeGreaterThan(0); + }); + + it('calls recallLayersEventual for eventual layers', async () => { + const eventualCalls: string[] = []; + const layers: MemoryLayer[] = [ + { + id: 'obs', + slot: Slot.OBSERVATIONS, + scope: 'resource', + recallMode: 'eventual', + hooks: {}, + }, + ]; + + const harness = makeMockHarness(); + harness.callModel = createScriptedCallModel([ + makeLLMResponse('done'), + ]); + harness.recallLayersAtomic = async () => []; + harness.recallLayersEventual = async () => { + eventualCalls.push('eventual'); + return []; + }; + const ctx = makeMockContext({ + harness, + layers, + }); + + const step: StepLLM = { + kind: 'llm', + id: 'test', + model: 'gpt-4', + }; + + await executeLLM(step, 'hello', ctx, layers); + expect(eventualCalls).toEqual([ + 'eventual', + ]); + }); + }); }); diff --git a/packages/core/test/memory/layer-lifecycle.test.ts b/packages/core/test/memory/layer-lifecycle.test.ts index fd212ef9..21df2dbe 100644 --- a/packages/core/test/memory/layer-lifecycle.test.ts +++ b/packages/core/test/memory/layer-lifecycle.test.ts @@ -3,15 +3,213 @@ import assert from 'node:assert'; import { completeLayers, createLayerStateStore, + createRecallCache, disposeLayers, initLayers, recallLayers, + recallLayersAtomic, + recallLayersEventual, storeLayers, } from '../../src/memory/layer-lifecycle'; import type { LLMResponse } from '../../src/types/common'; import type { MemoryLayer } from '../../src/types/memory'; import { makeCtx, makeItemLog, makeStorage } from '../_helpers'; +describe('recallLayersAtomic', () => { + it('filters to atomic layers only', async () => { + const store = createLayerStateStore(); + const ctx = makeCtx(); + const layers: MemoryLayer[] = [ + { + id: 'atomic-layer', + slot: 100, + scope: 'thread', + hooks: { + async recall() { + return 'atomic-data'; + }, + }, + }, + { + id: 'eventual-layer', + slot: 200, + scope: 'thread', + recallMode: 'eventual', + hooks: { + async recall() { + return 'eventual-data'; + }, + }, + }, + ]; + + const results = await recallLayersAtomic({ + layers, + query: 'test', + ctx, + log: makeItemLog(), + budgets: new Map(), + store, + }); + + expect(results).toHaveLength(1); + expect(results[0].layerId).toBe('atomic-layer'); + }); + + it('includes layers with no explicit recallMode (default atomic)', async () => { + const store = createLayerStateStore(); + const ctx = makeCtx(); + const layers: MemoryLayer[] = [ + { + id: 'default-layer', + slot: 100, + scope: 'thread', + hooks: { + async recall() { + return 'data'; + }, + }, + }, + ]; + + const results = await recallLayersAtomic({ + layers, + query: 'test', + ctx, + log: makeItemLog(), + budgets: new Map(), + store, + }); + + expect(results).toHaveLength(1); + expect(results[0].layerId).toBe('default-layer'); + }); +}); + +describe('recallLayersEventual', () => { + it('filters to eventual layers only', async () => { + const store = createLayerStateStore(); + const ctx = makeCtx(); + const cache = createRecallCache(); + const layers: MemoryLayer[] = [ + { + id: 'atomic-layer', + slot: 100, + scope: 'thread', + hooks: { + async recall() { + return 'atomic-data'; + }, + }, + }, + { + id: 'eventual-layer', + slot: 200, + scope: 'thread', + recallMode: 'eventual', + hooks: { + async recall() { + return 'eventual-data'; + }, + }, + }, + ]; + + const results = await recallLayersEventual({ + layers, + query: 'test', + ctx, + log: makeItemLog(), + budgets: new Map(), + store, + cache, + }); + + expect(results).toHaveLength(1); + expect(results[0].layerId).toBe('eventual-layer'); + }); + + it('returns cached results on second call without stale mark', async () => { + const store = createLayerStateStore(); + const ctx = makeCtx(); + const cache = createRecallCache(); + let callCount = 0; + const layers: MemoryLayer[] = [ + { + id: 'obs', + slot: 200, + scope: 'thread', + recallMode: 'eventual', + hooks: { + async recall() { + callCount++; + return `call-${callCount}`; + }, + }, + }, + ]; + + const params = { + layers, + query: 'test', + ctx, + log: makeItemLog(), + budgets: new Map(), + store, + cache, + }; + + const first = await recallLayersEventual(params); + expect(first).toHaveLength(1); + expect(callCount).toBe(1); + + const second = await recallLayersEventual(params); + expect(second).toHaveLength(1); + // Should NOT have re-called recall — used cache + expect(callCount).toBe(1); + }); + + it('re-recalls when cache is marked stale', async () => { + const store = createLayerStateStore(); + const ctx = makeCtx(); + const cache = createRecallCache(); + let callCount = 0; + const layers: MemoryLayer[] = [ + { + id: 'obs', + slot: 200, + scope: 'thread', + recallMode: 'eventual', + hooks: { + async recall() { + callCount++; + return `call-${callCount}`; + }, + }, + }, + ]; + + const params = { + layers, + query: 'test', + ctx, + log: makeItemLog(), + budgets: new Map(), + store, + cache, + }; + + await recallLayersEventual(params); + expect(callCount).toBe(1); + + // Mark stale + cache.stale.add(`${ctx.executionId}:obs`); + + await recallLayersEventual(params); + expect(callCount).toBe(2); + }); +}); + describe('layer-lifecycle', () => { it('init sequential, sets state', async () => { const store = createLayerStateStore(); diff --git a/specs/11-memory-layer-system.md b/specs/11-memory-layer-system.md index 11a9c5aa..587d79d9 100644 --- a/specs/11-memory-layer-system.md +++ b/specs/11-memory-layer-system.md @@ -48,9 +48,21 @@ The layer system is loosely inspired by reactive programming — not in the form **Loose pattern, not strict formalism.** The reactive inspiration is a mental model, not a contract. Formal reactive concepts (observables, subscriptions, schedulers) do not appear in this API. The goal is the insight — always-fresh context from converging layers — without the boilerplate or jargon. -### Stale Context (Non-Default) +### Recall Modes: Atomic vs Eventual -Context can be explicitly marked stale, causing the next request to block until all layers finish revalidating. This is opt-in for layers that need guaranteed consistency before the LLM call proceeds. The default `recall()` model is sufficient for most layers. +Each layer declares a `recallMode` that controls how its `recall()` hook participates in View assembly. + +**Atomic (default):** The layer's `recall()` blocks `callModel` — the agent harness waits for all atomic layers to complete before assembling the View. Use atomic mode for layers whose output the model needs immediately: working memory, static content, steering rules, entity facts. + +**Eventual:** The layer's `recall()` uses cached results and never blocks `callModel`. The cache refreshes asynchronously when `store()` produces new state, ensuring the next iteration sees updated content. Use eventual mode for slow layers where slight staleness is acceptable: observational memory, RAG retrieval, semantic recall. + +The agent harness executes recall in two phases: +1. `recallLayersAtomic()` — runs all `recallMode: 'atomic'` layers sequentially in slot order. Blocks until complete. +2. `recallLayersEventual()` — resolves all `recallMode: 'eventual'` layers from cache. Non-blocking. + +Both phases complete before `assembleView`. The distinction is whether the layer's `recall()` runs synchronously in the hot path (atomic) or asynchronously in the background (eventual). + +Among the built-in layers, `observationalMemory()` is the only layer that defaults to `recallMode: 'eventual'`. All other built-in layers default to `recallMode: 'atomic'`. --- @@ -63,6 +75,7 @@ interface MemoryLayer { slot: number; scope: MemoryScope; budget?: BudgetConfig; + recallMode?: 'atomic' | 'eventual'; hooks: MemoryHooks; timeouts?: Partial; provides?: LayerProvides; @@ -129,7 +142,8 @@ EXECUTION START ▼ LOOP ITERATION ───────────────────────────────────────────────── │ -├─ recall() Sequential, SLOT ORDER (ascending). Ties by array index. +├─ recallLayersAtomic() Sequential, SLOT ORDER. Blocks until all atomic layers complete. +├─ recallLayersEventual() Resolves eventual layers from cache. Non-blocking. │ ├─ [VIEW ASSEMBLY] Projector assembles system prompt item + layer output items + history items. │ @@ -588,15 +602,25 @@ interface ProjectionPolicy { } ``` +`ProjectionPolicy` can be set at two levels: + +1. **Harness-level default** — `AgentConfig.projection` sets the default policy for all LLM steps in the agent. +2. **Per-step override** — `StepLLM.projection` overrides the harness default for a single step. Use this when a step has different context budget needs (e.g., a summarization step that needs a larger window). + +The per-step policy wins when both are set. When neither is set, the agent harness uses a built-in default. + ### Assembly Algorithm ``` -1. Count system prompt tokens -2. Allocate budgets to layers -3. Run recall() hooks (sequential, slot order) -4. Assemble: system prompt item (role: system) + layer output items (role: developer) + conversation history items -5. Conversation history gets remaining budget after layers, with overflow policy applied -6. Result is Item[] — directly passable to the LLM provider +1. initLayers() — sequential, array order (once per execution) +2. Count system prompt tokens +3. allocateBudgets() — distribute token budget across layers +4. recallLayersAtomic() — run recall() for all atomic layers (sequential, slot order) +5. recallLayersEventual() — resolve eventual layers from cache (non-blocking) +6. assembleView() — system prompt item (role: system) + layer output items (role: developer) + conversation history items +7. Conversation history gets remaining budget after layers, with overflow policy applied +8. callModel() — result is Item[] directly passable to the LLM provider +9. storeLayers() — concurrent via Promise.allSettled() ``` ### Conversation History is Not a Memory Layer From bf8811d8b2c2da3a6790b9fe08b98441d4da7e52 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Wed, 1 Apr 2026 09:08:06 -0400 Subject: [PATCH 2/2] fix(core): address PR review findings for memory pipeline - Move init sentinel inside storage guard so layers aren't falsely marked initialized when no storage adapter is configured - Make recallLayersEventual fully non-blocking: first call returns empty and seeds cache in background, stale calls return cached value immediately - Evict cache entries when stale refresh yields empty results - Add .catch() to fire-and-forget recall promises to prevent unhandled rejections - Sort eventual layers by slot order for consistent prompt assembly - Count system messages from item log in budget allocation - Add clearRecallCache and call it in disposeLayers to prevent leaks --- packages/core/src/interpreter/execute-llm.ts | 13 +- packages/core/src/memory/layer-lifecycle.ts | 66 +++++- packages/core/src/runtime/agent-harness.ts | 5 +- .../core/test/interpreter/execute-llm.test.ts | 104 +++++++- .../core/test/memory/layer-lifecycle.test.ts | 223 +++++++++++++++++- 5 files changed, 381 insertions(+), 30 deletions(-) diff --git a/packages/core/src/interpreter/execute-llm.ts b/packages/core/src/interpreter/execute-llm.ts index e20f303d..49c24faf 100644 --- a/packages/core/src/interpreter/execute-llm.ts +++ b/packages/core/src/interpreter/execute-llm.ts @@ -92,11 +92,20 @@ export async function executeLLM( const storage = baseCtx.harness.config.storage; if (storage) { await baseCtx.harness.initLayers(layers, baseCtx, storage); + baseCtx.harness.setLayerState(baseCtx.id, LAYERS_INIT_SENTINEL, true); } - baseCtx.harness.setLayerState(baseCtx.id, LAYERS_INIT_SENTINEL, true); } - const systemTokenEstimate = step.system ? estimateTokens(step.system) : 0; + let systemTokenEstimate = step.system ? estimateTokens(step.system) : 0; + for (const item of baseCtx.itemLog.items) { + if (item.type === 'message' && item.role === 'system') { + for (const part of item.content) { + if (part.type === 'input_text') { + systemTokenEstimate += estimateTokens(part.text); + } + } + } + } const { allocations } = allocateBudgets({ layers, totalBudget: policy.tokenBudget, diff --git a/packages/core/src/memory/layer-lifecycle.ts b/packages/core/src/memory/layer-lifecycle.ts index 7f8166e5..37d323f3 100644 --- a/packages/core/src/memory/layer-lifecycle.ts +++ b/packages/core/src/memory/layer-lifecycle.ts @@ -134,6 +134,22 @@ export function createRecallCache(): RecallCache { }; } +export function clearRecallCache(cache: RecallCache, executionId: string): void { + const prefix = `${executionId}:`; + for (const key of cache.entries.keys()) { + if (!key.startsWith(prefix)) { + continue; + } + cache.entries.delete(key); + } + for (const key of cache.stale) { + if (!key.startsWith(prefix)) { + continue; + } + cache.stale.delete(key); + } +} + function _recallCacheKey(executionId: string, layerId: string): string { return `${executionId}:${layerId}`; } @@ -310,7 +326,9 @@ export async function recallLayersEventual({ tokenCount: number; }[] > { - const eventualLayers = params.layers.filter((l) => l.recallMode === 'eventual'); + const eventualLayers = params.layers + .filter((l) => l.recallMode === 'eventual') + .sort((a, b) => a.slot - b.slot); if (eventualLayers.length === 0) { return []; } @@ -326,19 +344,47 @@ export async function recallLayersEventual({ const cached = cache.entries.get(key); const isStale = cache.stale.has(key); - // First call (no cache) or stale: recall and await - if (!cached || isStale) { - const recalled = await recallLayers({ + // First call (no cache): fire background recall, return empty for this turn + if (!cached) { + void recallLayers({ ...params, layers: [ layer, ], - }); - for (const entry of recalled) { - cache.entries.set(key, entry); - results.push(entry); - } - cache.stale.delete(key); + }) + .then((recalled) => { + for (const entry of recalled) { + cache.entries.set(key, entry); + } + }) + .catch((e) => { + params.store.diagnostic(layer.id, 'recall', e); + }); + continue; + } + + // Stale cache: return stale value immediately, refresh in background + if (isStale) { + results.push(cached); + void recallLayers({ + ...params, + layers: [ + layer, + ], + }) + .then((recalled) => { + if (recalled.length === 0) { + cache.entries.delete(key); + } else { + for (const entry of recalled) { + cache.entries.set(key, entry); + } + } + cache.stale.delete(key); + }) + .catch((e) => { + params.store.diagnostic(layer.id, 'recall', e); + }); continue; } diff --git a/packages/core/src/runtime/agent-harness.ts b/packages/core/src/runtime/agent-harness.ts index 7c67b94d..c43106bd 100644 --- a/packages/core/src/runtime/agent-harness.ts +++ b/packages/core/src/runtime/agent-harness.ts @@ -15,6 +15,7 @@ import type { LayerStateStore, RecallCache } from '../memory/layer-lifecycle'; import { afterModelCallLayers, beforeToolCallLayers, + clearRecallCache, createLayerStateStore, createRecallCache, disposeLayers, @@ -548,11 +549,13 @@ export class AgentHarness = Record { + const execCtx = this.toExecCtx(ctx); await disposeLayers({ layers, - ctx: this.toExecCtx(ctx), + ctx: execCtx, store: this.layerStateStore, }); + clearRecallCache(this.recallCache, execCtx.executionId); } async checkpoint(_ctx: Context): Promise { diff --git a/packages/core/test/interpreter/execute-llm.test.ts b/packages/core/test/interpreter/execute-llm.test.ts index 18a656e4..78b2872d 100644 --- a/packages/core/test/interpreter/execute-llm.test.ts +++ b/packages/core/test/interpreter/execute-llm.test.ts @@ -3,12 +3,15 @@ import assert from 'node:assert'; import { z } from 'zod'; import { isNoeticError } from '../../src/errors/noetic-error'; import { executeLLM } from '../../src/interpreter/execute-llm'; +import type { Item } from '../../src/types/items'; import type { ContextMemory, MemoryLayer } from '../../src/types/memory'; import { Slot } from '../../src/types/memory'; import type { StepLLM } from '../../src/types/step'; import { createScriptedCallModel, + makeItemLog, makeLLMResponse, + makeMessage, makeMockContext, makeMockContextWithClient, makeMockHarness, @@ -332,17 +335,7 @@ describe('executeLLM', () => { { layerId: 'wm', items: [ - { - type: 'message', - id: 'wm-1', - role: 'developer', - content: [ - { - type: 'input_text', - text: 'test', - }, - ], - }, + makeMessage('developer', 'test', 'wm-1'), ], tokenCount: 10, }, @@ -417,6 +410,95 @@ describe('executeLLM', () => { expect(ctx.itemLog.items.length).toBeGreaterThan(0); }); + it('passes assembled view (system + layers + history) to callModel', async () => { + const layers: MemoryLayer[] = [ + { + id: 'wm', + slot: Slot.WORKING_MEMORY, + scope: 'thread', + hooks: {}, + }, + ]; + + const harness = makeMockHarness(); + + // Capture the request passed to callModel + let capturedItems: ReadonlyArray | undefined; + harness.callModel = async (request) => { + capturedItems = request.items; + return makeLLMResponse('done'); + }; + + harness.recallLayersAtomic = async () => [ + { + layerId: 'wm', + items: [ + makeMessage('developer', 'context', 'layer-recall-1'), + ], + tokenCount: 10, + }, + ]; + harness.recallLayersEventual = async () => []; + + const systemItem: Item = { + id: 'sys-1', + status: 'completed', + type: 'message', + role: 'system', + content: [ + { + type: 'input_text', + text: 'You are a helpful assistant.', + }, + ], + }; + + const ctx = makeMockContext({ + harness, + layers, + itemLog: makeItemLog([ + systemItem, + ]), + }); + + const step: StepLLM = { + kind: 'llm', + id: 'test', + model: 'gpt-4', + }; + + await executeLLM(step, 'hello', ctx, layers); + + assert(capturedItems !== undefined, 'callModel should have been called'); + + // Verify system items come first + const firstItem = capturedItems[0]; + assert(firstItem.type === 'message' && firstItem.role === 'system'); + expect(firstItem.id).toBe('sys-1'); + + // Verify the layer output item is present + const layerItem = capturedItems.find( + (i) => i.type === 'message' && i.role === 'developer' && i.id === 'layer-recall-1', + ); + assert(layerItem !== undefined, 'layer recall item should be in request'); + assert(layerItem.type === 'message'); + expect(layerItem.content[0]).toEqual({ + type: 'input_text', + text: 'context', + }); + + // Verify history (user message from input) comes after layers + const userItem = capturedItems.find((i) => i.type === 'message' && i.role === 'user'); + assert(userItem !== undefined, 'user message should be in request'); + + // Order check: system index < layer index < user index + const systemIdx = capturedItems.indexOf(firstItem); + const layerIdx = capturedItems.indexOf(layerItem); + const userIdx = capturedItems.indexOf(userItem); + expect(systemIdx).toBeLessThan(layerIdx); + expect(layerIdx).toBeLessThan(userIdx); + }); + it('calls recallLayersEventual for eventual layers', async () => { const eventualCalls: string[] = []; const layers: MemoryLayer[] = [ diff --git a/packages/core/test/memory/layer-lifecycle.test.ts b/packages/core/test/memory/layer-lifecycle.test.ts index 21df2dbe..379b675e 100644 --- a/packages/core/test/memory/layer-lifecycle.test.ts +++ b/packages/core/test/memory/layer-lifecycle.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import assert from 'node:assert'; import { + clearRecallCache, completeLayers, createLayerStateStore, createRecallCache, @@ -87,10 +88,11 @@ describe('recallLayersAtomic', () => { }); describe('recallLayersEventual', () => { - it('filters to eventual layers only', async () => { + it('filters to eventual layers only and returns empty on first call (non-blocking)', async () => { const store = createLayerStateStore(); const ctx = makeCtx(); const cache = createRecallCache(); + let recallCalled = false; const layers: MemoryLayer[] = [ { id: 'atomic-layer', @@ -109,6 +111,7 @@ describe('recallLayersEventual', () => { recallMode: 'eventual', hooks: { async recall() { + recallCalled = true; return 'eventual-data'; }, }, @@ -125,8 +128,15 @@ describe('recallLayersEventual', () => { cache, }); - expect(results).toHaveLength(1); - expect(results[0].layerId).toBe('eventual-layer'); + // First call returns empty — recall fires in background + expect(results).toHaveLength(0); + // Background recall was kicked off + expect(recallCalled).toBe(true); + + // Wait for background to settle cache + await new Promise((r) => setTimeout(r, 10)); + const key = `${ctx.executionId}:eventual-layer`; + expect(cache.entries.has(key)).toBe(true); }); it('returns cached results on second call without stale mark', async () => { @@ -159,10 +169,15 @@ describe('recallLayersEventual', () => { cache, }; + // First call: non-blocking, returns empty, fires background const first = await recallLayersEventual(params); - expect(first).toHaveLength(1); + expect(first).toHaveLength(0); expect(callCount).toBe(1); + // Wait for background to populate cache + await new Promise((r) => setTimeout(r, 10)); + + // Second call: returns cached data const second = await recallLayersEventual(params); expect(second).toHaveLength(1); // Should NOT have re-called recall — used cache @@ -174,6 +189,7 @@ describe('recallLayersEventual', () => { const ctx = makeCtx(); const cache = createRecallCache(); let callCount = 0; + let resolveRefresh: (() => void) | undefined; const layers: MemoryLayer[] = [ { id: 'obs', @@ -183,6 +199,12 @@ describe('recallLayersEventual', () => { hooks: { async recall() { callCount++; + if (callCount > 1) { + // Background refresh: wait until test signals + await new Promise((r) => { + resolveRefresh = r; + }); + } return `call-${callCount}`; }, }, @@ -199,14 +221,150 @@ describe('recallLayersEventual', () => { cache, }; + // Seed the cache via first call + wait await recallLayersEventual(params); + await new Promise((r) => setTimeout(r, 10)); expect(callCount).toBe(1); // Mark stale - cache.stale.add(`${ctx.executionId}:obs`); + const key = `${ctx.executionId}:obs`; + cache.stale.add(key); - await recallLayersEventual(params); + // Should return stale cached value immediately without blocking + const staleResult = await recallLayersEventual(params); + expect(staleResult).toHaveLength(1); + expect(staleResult[0].items[0]).toMatchObject({ + content: [ + { + text: 'call-1', + }, + ], + }); + // Background refresh started but not yet resolved expect(callCount).toBe(2); + expect(cache.stale.has(key)).toBe(true); + + // Let the background refresh complete + resolveRefresh!(); + await new Promise((r) => setTimeout(r, 10)); + + // Cache should now be updated with fresh value and stale mark cleared + expect(cache.stale.has(key)).toBe(false); + const freshEntry = cache.entries.get(key); + expect(freshEntry).toBeDefined(); + expect(freshEntry!.items[0]).toMatchObject({ + content: [ + { + text: 'call-2', + }, + ], + }); + }); + + it('evicts cache when stale refresh yields empty results', async () => { + const store = createLayerStateStore(); + const ctx = makeCtx(); + const cache = createRecallCache(); + let callCount = 0; + const layers: MemoryLayer[] = [ + { + id: 'obs', + slot: 200, + scope: 'thread', + recallMode: 'eventual', + hooks: { + async recall() { + callCount++; + // First call returns data, subsequent calls return nothing + if (callCount === 1) { + return 'has-data'; + } + return null; + }, + }, + }, + ]; + + const params = { + layers, + query: 'test', + ctx, + log: makeItemLog(), + budgets: new Map(), + store, + cache, + }; + + // Seed cache + await recallLayersEventual(params); + await new Promise((r) => setTimeout(r, 10)); + const key = `${ctx.executionId}:obs`; + expect(cache.entries.has(key)).toBe(true); + + // Mark stale + cache.stale.add(key); + + // Returns stale cached value + const staleResult = await recallLayersEventual(params); + expect(staleResult).toHaveLength(1); + + // Wait for background refresh (returns empty) + await new Promise((r) => setTimeout(r, 10)); + + // Cache entry should be evicted + expect(cache.entries.has(key)).toBe(false); + expect(cache.stale.has(key)).toBe(false); + }); + + it('sorts eventual layers by slot order', async () => { + const store = createLayerStateStore(); + const ctx = makeCtx(); + const cache = createRecallCache(); + const order: string[] = []; + const layers: MemoryLayer[] = [ + { + id: 'high-slot', + slot: 300, + scope: 'thread', + recallMode: 'eventual', + hooks: { + async recall() { + order.push('high-slot'); + return 'high'; + }, + }, + }, + { + id: 'low-slot', + slot: 100, + scope: 'thread', + recallMode: 'eventual', + hooks: { + async recall() { + order.push('low-slot'); + return 'low'; + }, + }, + }, + ]; + + // First call seeds cache in background (slot order) + await recallLayersEventual({ + layers, + query: 'test', + ctx, + log: makeItemLog(), + budgets: new Map(), + store, + cache, + }); + + // Background fires low-slot first (slot 100) then high-slot (slot 300) + await new Promise((r) => setTimeout(r, 10)); + expect(order).toEqual([ + 'low-slot', + 'high-slot', + ]); }); }); @@ -876,3 +1034,56 @@ describe('layer-lifecycle', () => { expect(results).toHaveLength(0); }); }); + +describe('clearRecallCache', () => { + it('removes entries and stale marks for a given execution', () => { + const cache = createRecallCache(); + + cache.entries.set('exec-1:layer-a', { + layerId: 'layer-a', + items: [], + tokenCount: 10, + }); + cache.entries.set('exec-1:layer-b', { + layerId: 'layer-b', + items: [], + tokenCount: 20, + }); + cache.entries.set('exec-2:layer-a', { + layerId: 'layer-a', + items: [], + tokenCount: 30, + }); + cache.stale.add('exec-1:layer-a'); + cache.stale.add('exec-2:layer-a'); + + clearRecallCache(cache, 'exec-1'); + + // exec-1 entries removed + expect(cache.entries.has('exec-1:layer-a')).toBe(false); + expect(cache.entries.has('exec-1:layer-b')).toBe(false); + // exec-2 entries preserved + expect(cache.entries.has('exec-2:layer-a')).toBe(true); + expect(cache.entries.get('exec-2:layer-a')?.tokenCount).toBe(30); + + // exec-1 stale marks removed + expect(cache.stale.has('exec-1:layer-a')).toBe(false); + // exec-2 stale marks preserved + expect(cache.stale.has('exec-2:layer-a')).toBe(true); + }); + + it('is a no-op for an unknown execution id', () => { + const cache = createRecallCache(); + cache.entries.set('exec-1:layer-a', { + layerId: 'layer-a', + items: [], + tokenCount: 10, + }); + cache.stale.add('exec-1:layer-a'); + + clearRecallCache(cache, 'exec-unknown'); + + expect(cache.entries.size).toBe(1); + expect(cache.stale.size).toBe(1); + }); +});