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:
30000ms
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' });| Option | Type | Default | Purpose |
|---|---|---|---|
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>;
}| Field | Type | Purpose |
|---|---|---|
checkpoints | Array<{ timestamp, depth }> | Ordered list of checkpoint timestamps and execution depths |
files | string[] | Paths to file artifacts produced during execution |
data | Record<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 wholedatamap 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'].
| Tool | Input | Effect |
|---|---|---|
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 offNext Steps
- Scratchpad -- lightweight notepad alternative
- Custom Layers -- build your own persistence layer