NOETIC
Framework

AgentHarness

The AgentHarness executes agent steps, manages context, and coordinates context layers and channels.

Quick Example

import { AgentHarness, any, callModel, loop, until } from '@noetic-tools/core';
import type { ContextData } from '@noetic-tools/core';
import { z } from 'zod';

const searchTool = {
  name: 'search',
  description: 'Search the web for information',
  input: z.object({ query: z.string() }),
  output: z.object({ result: z.string() }),
  execute: async (args: { query: string }) => ({ result: `Results for: ${args.query}` }),
};

const agent = loop({
  id: 'researcher-loop',
  steps: [callModel<ContextData, string, string>({
    id: 'researcher-llm',
    model: 'openai/gpt-4o',
    instructions: 'You are a research assistant.',
    tools: [searchTool],
  })],
  until: any(until.noToolCalls(), until.maxSteps(10)),
});

const harness = new AgentHarness({
  name: 'researcher',
  agentGraph: agent,
  params: {},
  callModelDefaults: { provider: 'openrouter' }, // reads OPENROUTER_API_KEY from the env
});

await harness.execute('Find recent AI news');
const response = await harness.getAgentResponse();
console.log(response.text);

The AgentHarness is the execution engine at the heart of Noetic. It creates contexts, manages per-thread sessions with a message queue, dispatches steps, coordinates channels and context layers, and provides tracing infrastructure.

Calls to execute() enqueue the input on the session identified by options.threadId (a single default thread is used when omitted). Stream accessors are session-scoped — subscribe once and events flow across every turn in that session.

Constructor Options

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

const harness = new AgentHarness({
  name: 'my-agent',
  agentGraph: myStep,
  params: { model: 'openai/gpt-4o' },
});
OptionTypeDefaultDescription
namestringrequiredAgent name (populates config.name).
agentGraphStep<TContext, string, string>undefinedRoot step tree executed for every turn submitted via execute(). TContext defaults to ContextData.
paramsTParamsrequiredArbitrary key-value parameters (populates config.params).
contextLayersContextLayer[]undefinedDefault context layers applied to every context created via createContext() / execute().
hooksAgentHooksundefinedBefore/after step hooks.
toolsTool[]undefinedHarness-wide tool pool, merged (identity-deduplicated) with tools collected from agentGraph to form every context's unifiedTools.
paramsSchemaZodTypeundefinedOptional Zod schema to validate params at construction time.
environmentAgentEnvironmentConfigin-memory adaptersExecution environment: storage, filesystem, shell, and subprocess surfaces. See Environment below.
callModelDefaultsLlmProviderConfigundefinedDefault provider configuration for model calls made through this harness. Supports { provider: 'noetic' | 'openrouter', apiKey?, baseUrl? }. For 'openrouter', apiKey defaults to process.env.OPENROUTER_API_KEY.
itemSchemasItemSchemaConfigundefinedHarness-wide item schema extensions and validation strictness: { schemas?: ItemSchemaExtensions, strict?: boolean }. schemas is global by design; tool-contributed toolResults schemas are owner-scoped to each tool's own result items. Mismatches raise NoeticError kind item_schema_mismatch. strict (default true) controls whether unknown extension item types must match a registered schema.
traceExporterTraceExporterNoopExporterWhere to send completed spans.
defaultDeliveryModeDeliveryMode'next-turn'Default delivery mode for messages that don't specify one.
initialCwdstringprocess.cwd()Initial working directory seeded into every root context this harness creates.

Environment

The environment option groups the surfaces the agent runs against. Every field is optional and defaults to an in-memory adapter, so a zero-config harness is fully sandboxed.

const harness = new AgentHarness({
  name: 'my-agent',
  params: {},
  environment: {
    storage: { adapter, checkpointStore, layerStateStore, stepLedgerRetention },
    fs,
    shell,
    subprocess,
  },
});
FieldTypeDefaultDescription
storage.adapterStorageAdapterin-memoryKey-value storage backing context-layer persistence and the step-completion ledger.
storage.checkpointStoreCheckpointStoreundefinedStore used by checkpoint() / restore(). When absent, checkpoint/restore are no-ops and the harness has no durable-execution guarantees. Construct with createCheckpointStore. See Durability.
storage.layerStateStoreLayerStateStorein-memory storeBacking store for context layer state.
storage.stepLedgerRetentionStepLedgerRetention{ maxEntryBytes: 131072, maxEntries: 1000 }Bounds on the step-completion ledger that backs step-level resume. Only meaningful alongside checkpointStore. A non-positive cap throws NoeticConfigError (STEP_LEDGER_RETENTION_INVALID) at construction. See Durability.
fsFsAdapterin-memoryFilesystem adapter exposed as harness.fs. Context layers, tools, and skill discovery all route through this adapter. Pass createLocalFsAdapter() from @noetic-tools/platform-node for real disk access.
shellShellAdapterin-memoryShell adapter exposed as harness.shell. Tools that execute commands route through this adapter. createLocalShellAdapter() runs raw sh -c; pass createLocalShellAdapter({ useRtk: true }) to wrap each command through rtk rewrite for token-efficient output. The @noetic-tools/cli package enables this by default (controlled by shell.useRtk in noetic.config.ts).
subprocessSubprocessAdapterin-memoryAdapter used to dispatch every runCode, spawn, and detachedSpawn. Set to createLocalSubprocessAdapter({storage}) to run children out-of-process with durable handle manifests. See Durability.

execute()

Enqueues input on the session identified by options.threadId (or the default thread). Returns a Promise<void> that resolves once the message is accepted into the queue — not when the model finishes responding. Use the session-scoped accessors to observe the response.

declare const harness: AgentHarness;

await harness.execute('What is 2+2?');
const response = await harness.getAgentResponse();
console.log(response.text);

// Stream text across all turns in the session
for await (const delta of harness.getTextStream()) {
  process.stdout.write(delta);
}

// With options
await harness.execute('Hello', {
  threadId: 'thread-1',
  resourceId: 'user-1',
});

// Submit a second message while the first turn is still running — it queues
// and runs as a new turn after the current turn completes.
await harness.execute('follow-up', { threadId: 'thread-1' });
ParameterTypeDescription
inputstring | Item | Item[]The input to the agent.
optionsExecuteOptionsOptional threadId, resourceId, state, contextLayers, deliveryMode, messageId.

Rejects with NoeticConfigError code NO_STEP_CONFIGURED if no agentGraph was provided in the constructor.

Session Accessors

Stream accessors are keyed by threadId (via SessionScope), are safe to call before the first execute(), and stay alive across every turn in the session.

MethodReturnsDescription
getAgentResponse(scope?)Promise<HarnessResponse>Resolves once the session drains its queue and returns to idle.
getTextStream(scope?)AsyncIterable<string>Text deltas from the model, across all turns.
getReasoningStream(scope?)AsyncIterable<string>Reasoning token deltas (reasoning models).
getItemStream(scope?)AsyncIterable<StreamingItem>Cumulative item snapshots with isComplete flag. Carries the full session log — framework-authored items (turn inputs, tool results) included.
getFullStream(scope?)AsyncIterable<StreamEvent>All raw events (SDK + framework) for the session.
abort(scope?)Promise<void>Cancel the in-flight turn. Queued messages are preserved and trigger a fresh turn once abort completes.
getStatus(scope?)HarnessStatusSnapshot: { kind: 'idle' | 'generating' | 'aborting' }.
getQueueSize(scope?)numberCount of messages queued on the session.

Delivery Modes

Each message carries a DeliveryMode. Override per-call via options.deliveryMode, or set defaultDeliveryMode on the harness.

ModeBehaviour
next-turn (default)Queue until the current turn completes, then run as a new turn.
between-roundsInject as a user item before the next tool-round LLM call within the active turn. Matches Claude Code's inbox-attachment pattern.
interruptAbort the in-flight turn, place at head of queue, restart.

HarnessResponse

interface HarnessResponse {
  readonly items: ReadonlyArray<Item>;
  readonly usage: { inputTokens: number; outputTokens: number; cachedTokens?: number };
  readonly cost?: number;
  readonly text: string;
  readonly lastLayerUsage?: LastLayerUsage;
}

StreamEvent

Events have a source discriminant: 'sdk' for OpenResponses SSE events, 'framework' for Noetic lifecycle events. Framework events use the harness config.name as prefix.

type StreamEvent = SdkStreamEvent | FrameworkStreamEvent;

Framework events emitted during execution:

EventDescription
{name}:turn_startedA session turn began. Data: { turnId, messageIds }.
{name}:turn_completedA session turn completed. Data: { turnId, durationMs }.
{name}:turn_abortedA session turn was aborted or errored. Data: { turnId, reason }.
{name}:inbox_injectedBetween-rounds messages were injected. Data: { round, count, messageIds }.
{name}:step_startedBefore each step executes.
{name}:step_completedAfter each step completes.
{name}:tool_round_startedBefore a tool execution round.
{name}:tool_call_startedBefore each tool call.
{name}:tool_call_completedAfter each tool call.
{name}:tool_round_completedAfter all tool calls in a round.
{name}:model_call_startedBefore each provider call. Data: { round, messageCount, toolCount }.
{name}:model_call_first_eventFirst SDK event received (time-to-first-token marker). Only emitted when a broadcaster is attached to the context. Data: { round }.
{name}:model_call_completedProvider response fully received. Data: { round, itemCount }.
{name}:model_call_stalledStream-idle watchdog fired; the round is about to abort. Data: { round, idleTimeoutMs }.

callModel steps support an emit option to control step/tool framework event emission — set false to suppress or pass a filter function. Turn-level events are emitted by the session runner and are not gated by step.emit.

Stream Idle Timeout

Each provider call is guarded by a watchdog that aborts the round if no SSE event arrives for 120 seconds. The watchdog re-arms on every stream event, so slow-but-alive responses are never cut short. On timeout, {name}:model_call_stalled is emitted and the surrounding turn fails with turn_aborted { reason: "model stream idle timeout after <N>ms" }, so upstream providers that quietly drop a connection surface as errors rather than hangs.

run()

Low-level step execution. Use this when you need full control over context creation and step dispatch.

const ctx = harness.createContext({ state: { count: 0 } });
const result = await harness.run(myStep, input, ctx);
ParameterTypeDescription
stepStep<TContext, I, O>The step to execute.
inputIInput value for the step.
ctxContextExecution context.

When the harness is built with contextLayers + a storage adapter (environment.storage.adapter), run() runs the layers' init() hooks once per context (keyed by ctx.id) before executing — so prior layer state rehydrates from storage, recalls into the model context, and updates persist back. The same memory guarantee execute() gives, without the session/seedSessionHistory ceremony. This also makes parseAndRunWorkflow (which dispatches through run()) memory-aware. The init is idempotent: nested/repeated run() calls never re-init (re-init would clobber accumulated in-memory state).

createContext Options

OptionTypeDescription
parentContextParent context (for spawn).
itemsItem[]Pre-seed the item log.
stateunknownInitial mutable state.
threadIdstringConversation thread identifier.
resourceIdstringOptional resource/tenant identifier.
contextLayersContextLayer[]Context layers for this context. Overrides harness-level contextLayers if provided.

Channel Methods

MethodDescription
send(channel, value, ctx)Push a value into a channel.
recv(channel, ctx, opts?)Wait for a value from a channel. Supports timeout.
tryRecv(channel, ctx)Non-blocking read. Returns null if nothing is available.
getChannelHandle(channel, executionId)Get a ChannelHandle for an external channel, enabling outside processes to push values in.
getChannelStream(channel, executionId)Subscribe to an external channel from outside the execution as an AsyncIterable; ends when the root execution completes.

Context Methods

The agent harness coordinates context layer lifecycle hooks:

MethodDescription
initLayersInitialize layers at the start of an agent run. Loads persisted state from storage.
recallLayersQuery layers for relevant items given an input string. Returns RecallLayerOutput[].
previewRequestItemsReturn the Item[] that would be sent on the next turn — accumulated history plus harness-level layer recall outputs assembled via assembleView. Inits layers on a throwaway context so init-bearing layers contribute; safe to call between turns.
storeLayersWrite new items from an LLM response into layers.
disposeLayersClean up layers at the end of a run.

RecallLayerOutput

interface RecallLayerOutput {
  layerId: string;
  items: Item[];
  tokenCount: number;
}

Durability Methods

When the harness is constructed with an environment.storage.checkpointStore (and, typically, a durable environment.subprocess adapter), checkpoint and restore become real crash-recovery hooks. When they are absent every call is a no-op — zero-config harnesses preserve in-memory semantics unchanged.

MethodDescription
checkpoint(ctx)Snapshot execution state (frontier, layer states, cwd, ask-user queue, item log) through the configured checkpointStore. Fires automatically after every execute(), detachedSpawn() settle, ask-user enqueue, and runAppendPipeline. Keyed by ctx.id; idempotent.
restore(executionId, opts?)Rebuild a Context from a previously saved snapshot. Returns null when no snapshot exists. Throws NoeticConfigError with code: 'CHECKPOINT_SCHEMA_MISMATCH' when the persisted schemaVersion is unrecognised. opts is a RestoreContextOptions (parent / state / context) carrying back the host wiring a snapshot cannot serialise — see Restoring a decorated context.
cancel(ctx, reason?)Cancel a running execution: abort ctx and every live descendant context (parallel paths, spawn children), then run context-layer teardown — onComplete with outcome: 'aborted', then dispose — bottom-up. A no-op on an already-cancelled context.

Cancellation reaches inside the work in flight: blocked channel recv/send calls reject with cancelled, the current provider stream and ACP agent turn are cut short, and the next step boundary throws cancelled. Tokens and cost already spent stay charged to the context. Beyond those points it is cooperative — a runCode body that ignores ctx.aborted between awaits runs to its next step boundary.

Use ctx.abort(reason) for the same signal without layer teardown (it is synchronous and returns void); use cancel when the harness owns the layer lifecycle. Note that harness.abort(scope?) is a different operation: it cancels the current session turn by thread, not a specific context tree.

The subprocess adapter is the companion surface: it persists handle manifests for every long-lived child so a restarted host can call harness.subprocess.listLive() + harness.restore(executionId) per live child to rejoin the execution. Full durable-execution model, storage layout, and IPC protocol live on the Durability page.

Tracing

MethodDescription
createSpan(name, parent)Create a new tracing span. Links to a parent span when provided.

AgentConfig

AgentConfig defines the configuration for an agent harness. It is generic over TParams, an arbitrary key-value record that steps and tools access via ctx.harness.config.params.

interface AgentConfig<TParams extends Record<string, unknown> = Record<string, unknown>> {
  name: string;
  storage?: StorageAdapter;
  hooks?: AgentHooks;
  params: TParams;
}
FieldDescription
nameHuman-readable agent name.
storageStorage adapter for context persistence.
hooksLifecycle hooks (see below).
paramsArbitrary key-value parameters accessible via ctx.harness.config.params.

AgentHooks

interface AgentHooks {
  beforeStep?: (step: Step, ctx: Context) => Promise<void>;
  afterStep?: (step: Step, result: unknown, ctx: Context) => Promise<void>;
}
HookWhen it firesTypical use
beforeStepBefore each step executes.Logging, injecting state, guard checks.
afterStepAfter each step completes.Metrics collection, state updates, audit logging.
const harness = new AgentHarness({
  name: 'researcher',
  agentGraph: agent,
  params: { model: 'openai/gpt-4o' },
  hooks: {
    async beforeStep(step, ctx) {
      console.log(`Starting step ${step.id} (depth ${ctx.depth})`);
    },
    async afterStep(step, result, ctx) {
      console.log(`Step ${step.id} used ${ctx.tokens.total} tokens`);
    },
  },
});

Resuming a session from disk

harness.seedSessionHistory(threadId, items) populates a fresh session's accumulated items before the next execute() call. Pair it with append-only persistence (one Item per line in a JSONL file) to make a chat survive subprocess restarts:

import { harness } from './my-harness';
import { readPriorItems } from './my-store';

const threadId = 'task-T-abc123';
harness.seedSessionHistory(threadId, await readPriorItems(threadId));
await harness.execute('continue from here', { threadId });

The Noetic CLI uses this pattern for per-task agent runners — see the "Per-Task IPC for Live Chat" section of specs/08-runtime.md for the end-to-end design.

Subprocess Adapter

The harness always holds a SubprocessAdapter. Every runCode, every spawn, and every harness.detachedSpawn routes through harness.subprocess.spawn(...) — in-process vs out-of-process is a property of the adapter, not the step.

import { AgentHarness } from '@noetic-tools/core';
import { createFileStorage, createLocalSubprocessAdapter } from '@noetic-tools/platform-node';

const subprocess = createLocalSubprocessAdapter({
  storage: createFileStorage({ root: `${process.env.HOME}/.noetic/subprocess` }),
});

const harness = new AgentHarness({
  name: 'durable-agent',
  agentGraph: agent,
  params: {},
  environment: { subprocess },                // real OS subprocess with durable manifests
});

Per-step and per-call overrides let you mix execution backends. A step with subprocess: createInMemorySubprocessAdapter() stays in-process even when the harness default is the local adapter; a detachedSpawn(step, input, ctx, { subprocess: otherAdapter }) overrides both. Resolution order is detachedSpawn-overrides.subprocess ?? step.subprocess ?? harness.subprocess.

  • Context & Event Log -- the context object the agent harness creates.
  • Channels -- channel send/recv methods on the agent harness.
  • Context Layers -- context layers managed by the agent harness.
  • Observability -- tracing spans created by createSpan.
  • Durability -- checkpoint/restore, adapter durability, and the host-restart flow.

On this page