Context & Event Log
The Context object tracks execution state, metrics, and conversation history.
Quick Example
import { runCode } from '@noetic-tools/core';
const greet = runCode({ id: 'greet', execute: async (input: string, ctx) => {
console.log(`Execution ${ctx.id}, step #${ctx.stepCount}`);
console.log(`Tokens so far: ${ctx.tokens.total}`);
// Append a user message to the event log
ctx.itemLog.append({
id: crypto.randomUUID(),
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: input }],
status: 'completed',
});
return `Hello, ${input}!`;
}});Every step in Noetic receives a Context object as its second argument. The context carries execution metadata, token budgets, the conversation item log, context layer handles, channel methods, and lifecycle controls. It is the single source of truth for everything that has happened during a run.
Context Interface
type ContextShape<TContext = ContextData, TState = unknown> = Context<TContext, TState>;The TContext generic defaults to ContextData (an untyped record). Use InferContext<typeof config> to supply a fully typed shape.
| Property | Type | Description |
|---|---|---|
id | string | Unique identifier for this execution. |
stepCount | number | Number of steps executed so far. |
tokens | TokenUsage | Cumulative token counts (input, output, total). |
elapsed | number | Wall-clock milliseconds since context creation. |
cost | number | Cumulative cost estimate for LLM calls. |
state | TState | Mutable, generic state object. You can read and write to this freely. |
context | TContext | Layer provides keyed by layer ID. See Context below. |
parent | Context | null | Parent context when running inside a spawn. |
depth | number | Nesting depth (0 for root). |
span | Span | The active tracing span for this execution. |
threadId | string | Conversation thread identifier. |
resourceId | string | undefined | Optional resource identifier (e.g., a user or tenant ID). |
itemLog | ItemLog | The conversation event log. |
lastStepMeta | StepMeta | null | Metadata from the most recently completed step (tool calls, usage, cost). |
Context
The ctx.context property is a readonly object keyed by layer ID. Each key maps to a handle containing the data projections and callable functions that the layer declared in its provides field.
declare const ctx: Context<Record<string, { snapshot: unknown; update: (next: unknown) => Promise<void> }>>;
const snap = ctx.context['scratchpad'].snapshot;
await ctx.context['scratchpad'].update({ key: 'value' });At runtime, data entries use getters (reading live state on each access), while function entries are async closures that validate input via Zod and may update the layer's internal state.
When layers are wrapped with the context() builder, use InferContext<typeof config> to get compile-time types:
import { context, scratchpad, type InferContext } from '@noetic-tools/core';
const mem = context([scratchpad()]);
type Mem = InferContext<typeof mem>;
// ctx: Context<Mem> gives fully typed ctx.contextTokenUsage
interface TokenUsage {
input: number;
output: number;
total: number;
cached?: number;
}cached accumulates the prompt tokens providers served from their cache across the execution. Per-round counts live on ctx.lastStepMeta.usage, which also carries cacheWriteTokens — the tokens written into the cache — whenever the provider reports it.
Both cache fields are undefined — not 0 — when nothing was reported, and the two mean different things: absent is "the provider said nothing", zero is "the provider said nothing was cached". The re-anchor logic reads the distinction, so keep it if you write an adapter. Treat a missing cacheWriteTokens as no answer rather than no write: the streaming path used for ordinary calls does not surface writes today, so it is undefined there even on a turn that wrote the cache.
Item Log
The ItemLog is an append-only log of every message, tool call, and reasoning trace produced during an execution.
interface ItemLog {
readonly items: ReadonlyArray<Item>;
append(item: Item): void;
}items-- read the full history at any time.append(item)-- add a new item. The runtime also appends items automatically after each callModel step.
Channel Methods
Context exposes three methods for communicating over Channels:
| Method | Signature | Description |
|---|---|---|
recv | recv<T>(channel: Channel<T>, opts?: { timeout?: number }): Promise<T> | Wait for the next value. Throws channel_timeout if the timeout expires; rejects with cancelled if the context is aborted while waiting. |
send | send<T>(channel: Channel<T>, value: T): Promise<void> | Push a value into a channel. Resolves immediately for value/topic channels and for queue channels below capacity; when a queue channel is full, the promise parks until a consumer frees a slot (back-pressure; default 30s timeout → channel_timeout, abort → cancelled). |
tryRecv | tryRecv<T>(channel: Channel<T>): T | null | Non-blocking read. Returns null if nothing is available. |
import { channel } from '@noetic-tools/core';
import type { Context } from '@noetic-tools/core';
import { z } from 'zod';
const approvals = channel('approvals', {
schema: z.boolean(),
mode: 'queue',
});
declare const ctx: Context;
await ctx.send(approvals, true);
const approved = await ctx.recv(approvals, { timeout: 5_000 });
const maybe = ctx.tryRecv(approvals);Lifecycle Controls
Checkpoint
declare const ctx: Context;
await ctx.checkpoint();Persists the current execution state so it can be restored later. In the AgentHarness this is a no-op; durable agent harnesses use it for crash recovery.
Complete
declare const ctx: Context;
declare const finalValue: unknown;
ctx.complete(finalValue);Signals that the execution has a result and should stop. After calling complete:
| Property | Value |
|---|---|
ctx.completed | true |
ctx.completionValue | The value you passed |
Abort
declare const ctx: Context;
ctx.abort('user cancelled');Signals that the execution should be cancelled. After calling abort:
| Property | Value |
|---|---|
ctx.aborted | true |
ctx.abortReason | The reason string you passed |
Loops check ctx.aborted at the top of every iteration and throw a cancelled error if set. Aborting also promptly rejects any blocked recv waiters and parked send calls on this context with a cancelled error, so channel-blocked steps unwind immediately instead of waiting out their timeouts. The in-flight model call or ACP agent turn is cut short too — the context's abort signal reaches the provider stream and the coding-agent adapter, so a long generation does not have to finish first.
The first abort wins: later calls are no-ops, so ctx.abortReason reflects the reason that actually stopped the execution.
Abort cascades to children
Abort travels down the execution tree. Every live inParallel path and spawn child of the aborted context is aborted with it, recursively — so cancelling a parent stops nested sub-agents instead of leaving them running:
// Aborting the parent reaches the spawn child mid-flight.
ctx.abort('user pressed stop');It never travels up: aborting a parallel path or spawn child leaves the parent (and its siblings) running, which is what lets inParallel treat one failed path as a recoverable outcome. A child context created after its parent was aborted is aborted at construction, so a spawn already in flight cannot escape the cancellation.
ctx.abort() does not run context-layer teardown. Use harness.cancel(ctx, reason) when the layers' onComplete / dispose hooks should also run.
Item Types
Every entry in the item log is one of these discriminated union variants. All items share a base shape:
interface ItemBase {
readonly id: string;
readonly status: 'in_progress' | 'completed' | 'incomplete' | 'failed';
}MessageItem
interface MessageItem extends ItemBase {
readonly type: 'message';
readonly role: 'user' | 'assistant' | 'system' | 'developer';
readonly content: ContentPart[];
}FunctionCallItem
interface FunctionCallItem extends ItemBase {
readonly type: 'function_call';
readonly callId: string;
readonly name: string;
readonly arguments: string;
}FunctionCallOutputItem
interface FunctionCallOutputItem extends ItemBase {
readonly type: 'function_call_output';
readonly callId: string;
readonly output: string;
}ReasoningItem
interface ReasoningItem extends ItemBase {
readonly type: 'reasoning';
readonly content: ContentPart[];
readonly summary?: ContentPart[];
readonly encryptedContent?: string;
}ExtensionItem
Custom item types using the prefix:name convention (e.g., noetic:analytics, openrouter:web_search):
interface ExtensionItem extends ItemBase {
readonly type: `${string}:${string}`;
readonly data: Record<string, unknown>;
}For the complete reference of all item shapes, streaming events, and how they map to the OpenResponses specification, see Items & Events.
ContentPart
Content arrays use these discriminated variants:
type | Fields | Purpose |
|---|---|---|
output_text | text: string | Model-generated text |
input_text | text: string | User-provided text |
input_image | imageUrl: string, detail?: 'auto' | 'low' | 'high' | User-provided image |
input_file | fileData?: string, fileId?: string | null, fileUrl?: string, filename?: string | User-provided file |
refusal | refusal: string | Model refusal message |
Per-Layer Usage Breakdown
ctx.lastLayerUsage exposes how the most recent callModel decomposed the context window across its contributors. The runtime captures this snapshot after every successful callModel step and overwrites it on the next call.
declare const ctx: Context;
const usage = ctx.lastLayerUsage;
if (usage) {
for (const layer of usage.layers) {
console.log(`${layer.layerId}: ${layer.tokenCount} tokens (${layer.placement}, ${layer.served})`);
}
console.log(`system: ${usage.systemPromptTokens}, history: ${usage.historyTokens}`);
}Each entry's tokenCount is self-reported by the layer's recall() output; systemPromptTokens, toolsTokens, and historyTokens are estimates derived from the rendered request. The sum is totalUsedTokens. The same snapshot is also surfaced on HarnessResponse.lastLayerUsage so external callers (CLIs, dashboards) can read it after the run completes without holding a Context reference.
Anchoring fields
Each entry also reports what prompt-cache anchoring did with the layer:
| Field | Type | Description |
|---|---|---|
placement | 'anchor' | 'live' | The band the layer rendered into. This is the resolved band, so an 'auto' layer reports where it actually landed. |
served | 'fresh' | 'pinned' | 'pinned' when the items are a replay of an earlier render, held byte-stable for the prompt cache. |
changed | boolean | Whether the layer's fresh output differed from its pin, and so was superseded this turn. |
churnRate | number | Share of watched assemblies in which the layer's output changed, 0–1. Drives 'auto' placement. |
rebillTokens | number | Tokens the layer's changes would have re-billed had it not been pinned — what anchoring saved. |
The snapshot's epoch field covers the assembly as a whole. It is absent when contextCache.enabled is false.
| Field | Type | Description |
|---|---|---|
id | string | Identifies the current run of assemblies sharing one set of pins. |
age | number | Assemblies served by this epoch, including the one just made. |
anchorTokens | number | Tokens in the anchor band — the cache-stable prefix. |
liveTokens | number | Tokens in the live band, rendered after history. |
deltaTokens | number | Tokens spent superseding stale anchors. |
reanchorReason | ReanchorReason | undefined | Set only on an assembly that re-anchored. |
declare const ctx: Context;
const epoch = ctx.lastLayerUsage?.epoch;
if (epoch?.reanchorReason) {
console.log(`re-anchored: ${epoch.reanchorReason} (epoch ${epoch.id})`);
}An age that never climbs past 1 means every turn is re-anchoring — read reanchorReason to find out why.
See Context Types for the LastLayerUsage, LayerUsageEntry, and EpochUsage interfaces.
Related Pages
- AgentHarness -- creates and manages contexts.
- Channels -- typed messaging between steps.
- Observability -- the
spanproperty and tracing. - Error Model -- errors thrown when abort or timeout occurs.