NOETIC
Framework
Context Layers

Task State

Persistent agent checkpoints that survive crashes and enable long-running task recovery.

Overview

Task State tracks checkpoints, file artifacts, and arbitrary key-value data across an agent's execution. It is designed for long-running tasks where you need crash recovery and audit trails.

  • Slot: 110 (Slot.SCRATCHPAD + 10)
  • Scope: thread (state persists across executions within the same thread)
  • Default budget: { min: 100, max: 800 }
  • Store timeout: 30000 ms

Usage

import { taskState } from '@noetic-tools/core';

const layer = taskState();

// Fan-out: keep every worker's `data` instead of last-writer-wins.
const coordinatorLayer = taskState({ mergeData: 'namespace' });
OptionTypeDefaultPurpose
mergeData'shallow' | 'namespace''shallow'How a child's data merges into the parent at a spawn/inParallel boundary.

State Type

interface TaskState {
  checkpoints: Array<{
    timestamp: number;
    depth: number;
  }>;
  files: string[];
  data: Record<string, unknown>;
}
FieldTypePurpose
checkpointsArray<{ timestamp, depth }>Ordered list of checkpoint timestamps and execution depths
filesstring[]Paths to file artifacts produced during execution
dataRecord<string, unknown>Arbitrary key-value store for task-specific data

How It Works

init

Loads saved TaskState from scoped storage. Falls back to empty checkpoints, files, and data.

recall

Serializes the state as JSON inside a <task_state> XML block and injects it as a developer message, trimmed to the allocated token budget: the oldest checkpoints are halved away while the render is over budget, and a final guard char-slices while preserving the closing tag. A zero budget is fail-open (full render).

store

Appends a new checkpoint with the current timestamp and execution depth on every store cycle. Checkpoints are capped at the newest 50 (also enforced in onReturn and onComplete), so the durably persisted state cannot grow without bound over a long-lived thread.

onSpawn

Deep-clones the parent state to the child. Unlike other layers, Task State always provides child state to spawned agents.

onReturn

Merges child artifacts back into the parent:

  • Checkpoints are concatenated (capped at the newest 50 after the merge)
  • File lists are deduplicated via Set
  • Data objects merge per mergeData:
    • 'shallow' (default) -- { ...parent.data, ...child.data }. With several children running concurrently, two workers writing the same key means the last one to return wins.
    • 'namespace' -- the child's whole data map is stored under its execution id (parent.data[childExecutionId]), so no worker's result is lost.

Each parallel path is a child boundary too, so the same merge runs for inParallel as for spawn. Paths merge one at a time, and a failed path is not merged.

onComplete

Records the execution outcome ('success', 'failure', or 'aborted') in data.__outcome and appends a final checkpoint stamped with the completing execution's depth.

Writing to the layer

The layer exposes two functions to the model as tools, so an agent can report what it produced. They are also callable from code via ctx.context['task-state'].

ToolInputEffect
task-state/recordArtifact{ path: string }Appends a file path to files. Recording the same path twice is a no-op.
task-state/setTaskData{ key: string, value: unknown }Sets data[key]. The reserved key __outcome is refused -- onComplete owns it.

Anything recorded this way crosses the child boundary: a spawned worker's artifacts merge back into the coordinator when the worker returns.

StorageAdapter

Task State relies on the StorageAdapter provided in your AgentConfig (via storage on the harness constructor). The adapter must implement:

interface StorageAdapter {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T): Promise<void>;
  delete(key: string): Promise<void>;
  list(prefix: string): Promise<string[]>;
  // Optional batch read -- see below.
  getMany?<T>(keys: string[]): Promise<Map<string, T>>;
}

You can use the built-in AgentHarness for development or implement a persistent adapter backed by a database or file system.

getMany (optional, but implement it on a networked backend)

list(prefix) returns keys, so anything that wants the values behind a prefix reads them afterwards. On a database- or network-backed adapter that is one round trip per key, and the framework does it in exactly the places you would least like a burst of them -- the step ledger's restore path, the semantic-condition embedding cache. Implementing getMany collapses those into a single query.

It is optional: an adapter that omits it still works, because callers go through storageGetMany(storage, keys), which falls back to a parallel get sweep.

Two rules for an implementation:

  • Keys with no stored value are absent from the map -- never mapped to null. A falsy stored value (0, '', false) is present.
  • Ordering is not promised, so return rows in whatever order your query produced. Callers that need order iterate their own key list.
async getMany<T>(keys: string[]): Promise<Map<string, T>> {
  const rows = await db.query('SELECT key, value FROM kv WHERE key IN (?)', [keys]);
  const found = new Map<string, T>();
  for (const row of rows) {
    found.set(row.key, JSON.parse(row.value));
  }
  return found;
}

Example: Task Recovery

import { taskState } from '@noetic-tools/core';

const layer = taskState();

// After a crash, the agent resumes with full checkpoint history
// The agent harness calls init(), which loads the saved state
// The LLM sees all previous checkpoints and can continue from where it left off

Next Steps

On this page