NOETIC
Framework
Context Layers

Context Layer System

How Noetic's context layers inject long-term and short-term context into every LLM call through a slot-based, scoped architecture.

Overview

Noetic's context system is built around ContextLayer objects. Each layer occupies a numbered slot, declares a scope, and implements lifecycle hooks that run before and after every LLM call. The runtime merges all layer outputs into a single prompt view via assembleView().

ContextLayer Interface

Every context layer implements this interface:

interface ContextLayer<TState = unknown> {
  id: string;
  name?: string;
  slot: number;
  scope: ContextScope;
  budget?: BudgetConfig;
  hooks: ContextLayerHooks<TState>;
  timeouts?: Partial<LayerTimeouts>;
  onInitError?: 'throw' | 'disable';
  recallMode?: 'atomic' | 'eventual';
  placement?: LayerPlacement;
}
FieldTypePurpose
idstringUnique identifier for the layer
namestring (optional)Human-readable display name
slotnumberOrdering priority -- lower slots appear first within a band
scopeContextScopePersistence boundary for stored state
budgetBudgetConfigToken budget allocation for this layer
hooksContextLayerHooks<TState>Lifecycle callbacks
timeoutsPartial<LayerTimeouts>Per-hook timeout overrides in milliseconds
onInitError'throw' | 'disable'What to do when init throws: 'throw' (default) aborts the execution; 'disable' skips this layer for the run
recallMode'atomic' | 'eventual'Whether recall() blocks the model call ('atomic', default) or is served from a cache that refreshes after store() ('eventual')
placement'anchor' | 'live' | 'auto'Which band of the assembled view the layer renders into. See Prompt Cache Anchoring

Slot Constants

Slots determine the order in which layer outputs are injected into the prompt, within their band. Lower numbers appear first.

const Slot = {
  REMINDER: 80,
  STEERING: 90,
  SCRATCHPAD: 100,
  ENTITY: 150,
  OBSERVATIONS: 200,
  PROCEDURAL: 250,
  EPISODIC: 300,
  RAG: 350,
  SEMANTIC_RECALL: 400,
} as const;

You can use any number for custom layers. The built-in constants are guidelines, not hard constraints.

ContextScope

Scope controls the persistence boundary of a layer's state:

ScopeMeaning
'thread'State is isolated per conversation thread
'resource'State is shared across threads tied to the same resource (e.g., a project or document)
'global'State is shared across all executions
'execution'State lives only for the duration of a single agent run
type ContextScope = 'thread' | 'resource' | 'global' | 'execution';

BudgetConfig

Controls how many tokens a layer can consume when injecting items into the prompt:

type BudgetConfig =
  | number                    // fixed token count
  | { min: number; max: number }  // range -- allocator distributes spare tokens
  | 'auto';                   // let the runtime decide

When using a { min, max } range, the budget allocator guarantees at least min tokens and distributes remaining capacity up to max. An omitted budget behaves like 'auto' — the layer splits the proportional pool with the other auto layers after finite layers take their share. The pool is conserved: finite shares plus the auto layers' split always account for the full layer pool. NaN budget inputs throw a NoeticConfigError (INVALID_BUDGET_INPUT); Infinity means uncapped.

ProjectionPolicy

The projection policy governs how the runtime assembles all layer outputs into the final prompt:

interface ProjectionPolicy {
  tokenBudget: number;
  responseReserve: number;
  overflow: 'truncate' | 'summarize' | 'sliding_window';
  overflowModel?: string;
  windowSize?: number;
}
FieldTypePurpose
tokenBudgetnumberTotal token budget for all context layers combined
responseReservenumberTokens reserved for the model's response
overflow'truncate' | 'summarize' | 'sliding_window'Strategy when total recall exceeds the budget
overflowModelstringModel used for summarization overflow (when overflow is 'summarize')
windowSizenumberNumber of recent items to keep (when overflow is 'sliding_window')

Hook Lifecycle

Layer hooks fire in a deterministic order during agent execution:

init → recall → [LLM call] → store → ... (loop) → onComplete → dispose

                                  onSpawn (child)

                                  onReturn (parent)

Hook Summary

HookWhenPurpose
initAgent startsLoad persisted state from storage
recallBefore each LLM callInject items into the prompt
renderDeltaWhen an anchored layer's pinned output goes staleDescribe the change compactly instead of republishing the whole block
projectHistoryBefore each LLM call, after recallCap or transform the history items projected to the LLM (read-side; never mutates itemLog)
onItemAppendInput items about to be appendedFilter, transform, or inject user/tool input items; may request a context re-render. Layers compose in slot order. Not called for LLM response items
beforeToolCallBefore each tool executionSteering: allow, deny, or guide the pending tool call
afterModelCallAfter each LLM responseSteering: review the response and allow, deny, or guide
storeAfter each LLM responseExtract and persist new knowledge
onSpawnChild agent spawnedDecide what state the child inherits
onReturnChild agent completesMerge child results back into parent state
onCompleteAgent finishesFinal persistence with outcome metadata
disposeCleanupRelease resources

Hook Type Signatures

interface ContextLayerHooks<TState = unknown> {
  init?: (params: InitParams) => Promise<InitResult<TState>>;
  recall?: (params: RecallParams<TState>) => Promise<RecallResult<TState> | string | null>;
  renderDelta?: (params: RenderDeltaParams<TState>) => Promise<string | null>;
  projectHistory?: (params: ProjectHistoryParams<TState>) => Promise<ProjectHistoryResult>;
  onItemAppend?: (params: OnItemAppendParams<TState>) => Promise<OnItemAppendResult<TState>>;
  beforeToolCall?: (params: BeforeToolCallParams<TState>) => Promise<BeforeToolCallResult<TState>>;
  afterModelCall?: (params: AfterModelCallParams<TState>) => Promise<AfterModelCallResult<TState>>;
  store?: (params: StoreParams<TState>) => Promise<StoreResult<TState> | undefined>;
  onSpawn?: (params: SpawnParams<TState>) => Promise<SpawnResult<TState> | null>;
  onReturn?: (params: ReturnParams<TState>) => Promise<ReturnResult<TState> | undefined>;
  onComplete?: (params: CompleteParams<TState>) => Promise<void | { state: TState }>;
  dispose?: (params: DisposeParams<TState>) => Promise<void>;
}

recall may return a bare string as shorthand — the runtime wraps it in a developer message and estimates its token count — or null to contribute nothing this turn.

Prompt Cache Anchoring

Providers cache the prompt by prefix: the first token that differs from the last turn invalidates everything after it. Layer output that re-renders every turn therefore re-bills the whole conversation sitting behind it. Noetic assembles the context window in bands so the volatile parts sit after the stable ones:

[system] [anchor layers, slot asc] [history] [live layers, slot asc] [context updates] [tail]

Anchoring is on by default. Pass contextCache: { enabled: false } to the harness to fall back to the old single band, where every layer rendered before history.

Asking for the cache

A stable prefix is necessary but not enough. Anthropic caching is opt-in: without a cache_control breakpoint on the request, Claude models cache nothing however byte-identical the prefix is. OpenAI and Gemini cache on their own and ignore the directive. Measured through this code path against a fixed 18,925-token prefix:

ModelBands onlyBands + breakpoint
anthropic/claude-haiku-4.50 cached, every turn18,922 cached from turn 2
anthropic/claude-sonnet-4.50 cached, every turn18,922 cached from turn 2
openai/gpt-4o-mini17,024 of 17,126unchanged

So holding the prefix still bought nothing at all on Claude until the runtime started asking for the cache. It now sends cache_control: { type: 'ephemeral' } on every model request while anchoring is enabled, and OpenRouter places the breakpoints.

The breakpoint rides on contextCache.enabled rather than going out unconditionally: a cache write costs more than a plain read, so it only pays off when something is deliberately holding the prefix still. contextCache: { enabled: false } turns off the banding and the breakpoint together.

If you write your own adapter, note that the directive has to be injected where the outbound body is final — the OpenRouter SDK validates the request against a generated schema that silently drops keys it does not know, and cache_control is one of them. Noetic's adapter adds it in a beforeRequest hook for that reason.

Placement

A layer's placement picks its band. slot still orders layers within a band.

ValueRendersUse for
'anchor'before historyLarge, stable content: loaded instructions, tracked files, a retrieved corpus.
'live'after historyContent that changes every turn, or whose recall() mutates state.
'auto' (default)starts anchoredEverything else. The runtime watches how often the layer changes and moves it between bands.

Built-in layers ship with the placement that suits them:

LayerPlacement
instructions'anchor'
filesystem'anchor', and implements renderDelta
steering'live'
temporal'live' with groundDateTime, otherwise 'auto'
everything else'auto'

A layer whose recall() returns new state is never pinned, whatever its placement says: replaying an older render would drop the very thing that call committed. Steering drains its guidance queue as it renders, which is why it is live.

Epochs and supersedes

An anchored layer is pinned — the items it rendered on the first assembly are re-sent byte-for-byte on every later assembly. A run of assemblies sharing one set of pins is an epoch.

When a pinned layer's fresh output stops matching its pin, the runtime does not rewrite the prefix. It appends one developer message after history instead:

<context_updates epoch="t:thread-1#0">
These supersede the blocks with the same layer id earlier in this context.
Where they disagree, these are correct.
<update layer="filesystem" action="replace">
...
</update>
</context_updates>

action is replace, add (a layer that first produced output mid-epoch), or retract (a pinned layer that produced nothing this turn).

The epoch re-anchors — fresh pins, no supersedes — only when the cache is already lost:

ReasonTrigger
cold-startFirst assembly for this thread, or for a child execution.
instructions-changedThe resolved instructions differ from what the epoch anchored against.
cache-missThe provider's own token report shows the prefix was not served from cache.
delta-pressureSupersedes cost more than deltaBudgetFraction of the anchor band.
delta-overflowSupersedes no longer fit the token budget.
max-agemaxEpochAssemblies reached.

'auto' layers change band only at an epoch boundary, so the prefix cannot shift under the model mid-run. The choice reads observed churn — the share of assemblies in which the layer's output changed. A layer at or above autoDemoteChurn goes live; at or below autoPromoteChurn it goes back to the anchor band. The gap between the two thresholds is deliberate: a layer hovering near the line keeps the band it had rather than flipping every epoch.

A provider that reports no cache figures, or that misses persistently, stops being consulted — age and delta pressure still bound the epoch.

Where the cache still breaks

Once conversation history outgrows its share of the token budget, the oldest items start dropping each turn. That moves the boundary between the anchor band and history, so the history portion of the cache is lost on every turn even though [system][anchor] still caches. This is how the projector has always worked; anchoring does not change it, and a run that looks like it "stopped working" after a long conversation is usually this.

Cap history with history so the drop happens at a size you chose, and the layer bands keep their full share of the budget instead of competing with an unbounded transcript.

ContextCacheConfig

Tuning lives on the harness as contextCache:

const harness = new AgentHarness({
  name: 'my-agent',
  params: {},
  contextCache: {
    maxEpochAssemblies: 100,
    autoDemoteChurn: 0.4,
  },
});
FieldDefaultPurpose
enabledtrueMaster switch. When off, every layer renders before history as it did before bands existed, and no cache_control breakpoint is sent.
minCachedTokens100Re-anchor when the first round reports fewer cached tokens than this.
minEpochAssemblies2Assemblies an epoch must reach before its cache figures are judged. The assembly right after a re-anchor writes the cache rather than reading it.
maxEpochAssemblies50Assemblies after which an epoch re-anchors regardless of cache figures.
deltaBudgetFraction0.15Re-anchor once supersedes cost more than this fraction of the anchor band.
autoDemoteChurn0.5An 'auto' layer changing at least this often moves to the live band.
autoPromoteChurn0.2An 'auto' layer changing at most this often moves back to the anchor band.
minChurnSamples3Assemblies a layer must be watched for before its placement moves.
churnDecay0.5Fraction of the churn counters carried across a re-anchor.

ctx.lastLayerUsage reports what anchoring did on the last call — which band each layer landed in, whether it was served fresh or pinned, its churn rate, and the epoch's age and reason. See Per-Layer Usage Breakdown.

assembleView()

The standalone assembleView() function (from context/projector.ts) merges system-prompt items, both layer bands, conversation history, supersedes, and the tail into a single ordered list that is sent to the LLM. The runtime calls it for you on every turn — you rarely need it directly.

Unstable API. assembleView is intended for authors of custom context backends and framework extensions. Import it from @noetic-tools/core/unstable; its signature may change in any minor release.

import type { Item } from '@noetic-tools/core';
import { assembleView } from '@noetic-tools/core/unstable';

declare const systemPromptItems: Item[];
declare const layerOutputItems: Item[]; // anchor band, already sorted by slot
declare const historyItems: Item[];
declare const liveLayerItems: Item[]; // live band, already sorted by slot
declare const deltaItems: Item[]; // supersedes for stale anchors
declare const tailItems: Item[]; // steering guidance; never dropped

const items: Item[] = assembleView({
  systemPromptItems,
  layerOutputItems,
  historyItems,
  liveLayerItems,
  deltaItems,
  tailItems,
});
// items = system + anchor + history + live + supersedes + tail

Only systemPromptItems, layerOutputItems, and historyItems are required; omit the rest for a single-band view.

With no policy the bands are concatenated as-is. With a policy the view is held to a hard token budget, claimed in this order: system items, anchor output, live output, the tail, then supersedes — with history taking whatever remains and keeping the most recent turns. Supersedes are claimed all together or not at all, since a dropped supersede would leave the model reading a block the runtime knows is stale; the runtime re-anchors instead.

LayerTimeouts

Override the default timeout (in milliseconds) for any individual hook:

interface LayerTimeouts {
  init?: number;
  recall?: number;
  store?: number;
  onSpawn?: number;
  onReturn?: number;
  onComplete?: number;
  dispose?: number;
  beforeToolCall?: number;
  afterModelCall?: number;
  onItemAppend?: number;
  projectHistory?: number;
}

If a non-init hook exceeds its timeout, the runtime logs a diagnostic and continues without that layer's contribution for that phase. init is different: an init failure (including timeout) re-throws and aborts the execution by default — context is load-bearing, and silently disabling a layer would hide failures. A layer opts into skip-on-failure by setting onInitError: 'disable', in which case the runtime logs a diagnostic and runs the execution without that layer.

Layer Provides API

Layers can expose typed data and functions via the provides field. Provided entries are accessible in code steps through ctx.context['layerId'], and provided functions are automatically injected as LLM tools (namespaced as layerId/fnName).

Use the context() builder and InferContext<> utility type for compile-time type safety:

import { context, scratchpad, type Context, type InferContext } from '@noetic-tools/core';

const mem = context([scratchpad()]);
type Mem = InferContext<typeof mem>;

declare const ctx: Context<Mem>;

// In a step, ctx.context is fully typed:
const snap = ctx.context['scratchpad'].snapshot; // ScratchpadState
await ctx.context['scratchpad'].update({ key: 'value' });

See Custom Layers for how to define your own provides entries using layerData() and layerFunction().

Next Steps

  • Scratchpad -- working notes that persist across turns
  • Observations -- auto-extracted facts
  • Temporal -- timestamped fact ledger with relative-time recall
  • Steering -- rule-based allow/deny/guide control over tool calls and responses
  • Filesystem -- #path file tracking with priority-scored content injection
  • Instructions -- load-once instructional content
  • History -- cap items sent to the LLM
  • Custom Layers -- build your own
  • Plan -- PRD authoring and plan execution lifecycle
  • Tool Calls -- imperative state access and function-call state

On this page