AgentHarness Types
Type definitions for the AgentHarnessContract interface, AgentConfig, and related types in Noetic.
AgentHarnessContract
The core execution engine interface. The exported AgentHarness class implements this contract. Use the contract type when you want to be agnostic about the concrete implementation (e.g., for tests or custom harnesses).
declare interface HarnessApi<TParams extends Record<string, unknown> = Record<string, unknown>> {
readonly config: AgentConfig<TParams>;
readonly fs: FsAdapter;
readonly shell: ShellAdapter;
readonly subprocess: SubprocessAdapter;
readonly rootCwdState: { cwd: string; previousCwd?: string };
callModel(request: CallModelRequest): Promise<LLMResponse>;
decide(request: DecideRequest): Promise<DecideResponse>;
execute(input: ExecuteInput, options?: ExecuteOptions): Promise<void>;
getAgentResponse(scope?: SessionScope): Promise<HarnessResponse>;
getItemStream(scope?: SessionScope): AsyncIterable<StreamingItem>;
getTextStream(scope?: SessionScope): AsyncIterable<string>;
getReasoningStream(scope?: SessionScope): AsyncIterable<string>;
getFullStream(scope?: SessionScope): AsyncIterable<StreamEvent>;
abort(scope?: SessionScope & CancelOptions): Promise<DrainReport | undefined>;
getStatus(scope: ExecutionScope): ExecutionStatus;
getStatus(scope?: SessionScope): HarnessStatus;
getQueueSize(scope?: SessionScope): number;
seedSessionHistory(threadId: string, items: ReadonlyArray<Item>): void;
setRootCwd(nextCwd: string): void;
run<I, O>(step: Step<ContextData, I, O>, input: I, ctx: Context): Promise<O>;
detachedSpawn<I, O>(
step: Step<ContextData, I, O>,
input: I,
parentCtx: Context,
overrides?: { threadId?: string; resourceId?: string; cwdInit?: string },
): DetachedHandle<O>;
createContext(opts?: {
parent?: Context;
items?: Item[];
state?: unknown;
threadId?: string;
resourceId?: string;
contextLayers?: ContextLayer[];
cwdInit?: string;
}): Context;
send<T>(channel: Channel<T>, value: NoInfer<T>, ctx: Context): Promise<void>;
recv<T>(channel: Channel<T>, ctx: Context, opts?: { timeout?: number }): Promise<T>;
tryRecv<T>(channel: Channel<T>, ctx: Context): T | null;
getChannelHandle<T>(channel: ExternalChannel<T>, executionId: string): ChannelHandle<T>;
getChannelStream<T>(channel: ExternalChannel<T>, executionId: string): AsyncIterable<T>;
initLayers(layers: ContextLayer[], ctx: Context, storage: StorageAdapter): Promise<void>;
recallLayers(layers: ContextLayer[], input: string, ctx: Context): Promise<RecallLayerOutput[]>;
recallLayersAtomic(
layers: ContextLayer[],
input: string,
ctx: Context,
budgets: Map<string, number>,
opts?: RecallOptions,
): Promise<RecallLayerOutput[]>;
recallLayersEventual(
layers: ContextLayer[],
input: string,
ctx: Context,
budgets: Map<string, number>,
opts?: RecallOptions,
): Promise<RecallLayerOutput[]>;
projectHistory(
layers: ContextLayer[],
items: ReadonlyArray<Item>,
ctx: Context,
): Promise<ReadonlyArray<Item>>;
runAppendPipeline(
layers: ContextLayer[],
items: Item[],
ctx: Context,
): Promise<{ items: Item[]; rerenderRequests: RerenderRequest[] }>;
executeRerender(
requests: RerenderRequest[],
layers: ContextLayer[],
ctx: Context,
budgets: Map<string, number>,
query?: string,
): Promise<RecallLayerOutput[]>;
previewRequestItems(scope?: SessionScope): Promise<ReadonlyArray<Item>>;
storeLayers(layers: ContextLayer[], response: LLMResponse, ctx: Context): Promise<void>;
disposeLayers(layers: ContextLayer[], ctx: Context): Promise<void>;
getLayerState<T>(executionId: string, layerId: string): T | undefined;
setLayerState<T>(executionId: string, layerId: string, state: T): void;
beforeToolCall(
layers: ContextLayer[],
toolName: string,
toolArgs: unknown,
ctx: Context,
): Promise<unknown>;
afterModelCall(layers: ContextLayer[], response: LLMResponse, ctx: Context): Promise<unknown>;
notifyDecision(layers: ContextLayer[], record: DecisionRecord, ctx: Context): Promise<void>;
checkpoint(ctx: Context): Promise<void>;
restore(executionId: string): Promise<Context | null>;
cancel(ctx: Context, options?: string | CancelOptions): Promise<DrainReport | undefined>;
createSpan(name: string, parent: Span | null): Span;
}
declare interface RerenderRequest {
layerId: string;
slot: number;
timing: 'immediate' | 'batched';
scope: 'self' | 'slot-after' | 'all';
}AgentHarnessContract Methods
| Method | Description |
|---|---|
config | The AgentConfig<TParams> the harness was constructed with. |
fs | The FsAdapter the harness was constructed with (defaults to createLocalFsAdapter()). All filesystem operations — CLI tools, skill discovery, and context layers — route through this adapter. |
shell | The ShellAdapter (defaults to createLocalShellAdapter()). Tools that need a real shell go through this. |
subprocess | The SubprocessAdapter used by runCode.subprocess requests and detached spawns. Defaults to an in-memory adapter. |
rootCwdState | Long-lived shared cwd state, seeded into every root context the harness creates so successive runs and the TUI observe each other's cds. |
callModel(request) | Issue a single LLM call, bypassing the session/queue runner. Used by plugins that need one-shot generation. |
decide(request) | Answer a DecideRequest. Decision models answer natively; any other model answers through a structured-output call that estimates the probabilities. Used by decide steps and aiCondition. See Model Paths. |
execute(input, options?) | Enqueue a message on the session identified by options.threadId. Resolves once the message is accepted into the queue — not when the model responds. |
getAgentResponse(scope?) | Resolves with the accumulated response once the session drains. |
getItemStream(scope?) | Yields cumulative Item snapshots across every turn in the session. |
getTextStream(scope?) | Yields text deltas across every turn. |
getReasoningStream(scope?) | Yields reasoning-token deltas. |
getFullStream(scope?) | Yields all raw SDK + framework events. |
abort(scope?) | Cancel the in-flight turn. Queued messages are preserved. With drainMs, in-flight model calls get that long to finish first (graceful stop), and it resolves with the DrainReport. |
getStatus(scope?) | Session status snapshot (HarnessStatus). Pass { executionId } for a run() execution's ExecutionStatus. |
getQueueSize(scope?) | Messages currently queued on the session. |
seedSessionHistory(threadId, items) | Pre-populate a session's accumulated history with prior items. Used by resume flows. |
setRootCwd(nextCwd) | Update the harness root cwd (e.g., from a TUI ! cd). |
run(step, input, ctx) | Run a step with the given input and context. |
detachedSpawn(step, input, parentCtx, overrides?) | Launch a step concurrently, returning a DetachedHandle. Optional overrides.threadId / overrides.resourceId / overrides.cwdInit decouple the child's session-scoped state from the parent's. |
createContext(opts?) | Create a new execution context. Accepts optional context to override harness-level defaults. |
send(channel, value, ctx) | Send a value to a channel. Returns a Promise<void> that resolves immediately unless the target queue channel is at capacity, in which case the send parks until a consumer frees a slot (back-pressure; default 30s timeout → channel_timeout, abort → cancelled). |
recv(channel, ctx, opts?) | Receive from a channel (blocking). Rejects with kind 'cancelled' when the context is aborted while waiting. |
tryRecv(channel, ctx) | Non-blocking receive. |
getChannelHandle(channel, executionId) | Get external channel handle. |
getChannelStream(channel, executionId) | Subscribe to an external channel from outside the execution; ends when the root execution completes. |
initLayers(layers, ctx, storage) | Initialize context layers. |
recallLayers(layers, input, ctx) | Run recall on all layers. |
recallLayersAtomic(layers, input, ctx, budgets, opts?) | Run recall on the atomic-mode layers only, blocking until each completes within its budget. Pass { peek: true } to render without keeping returned state (see RecallOptions). |
recallLayersEventual(layers, input, ctx, budgets, opts?) | Serve recall for eventual-mode layers from cache without blocking; the cache refreshes after store(). A peek renders a cold or stale layer without updating the cache. |
projectHistory(layers, items, ctx) | Run each layer's projectHistory hook in slot order to project (cap, transform) the history portion of the context window. Read-side only. |
runAppendPipeline(layers, items, ctx) | Run input items through each layer's onItemAppend hook in slot order, returning the transformed items plus any re-render requests. |
executeRerender(requests, layers, ctx, budgets, query?) | Re-run recall for the layers selected by the given re-render requests and return their fresh outputs. |
previewRequestItems(scope?) | Return the Item[] that would be sent on the next turn — accumulated history plus harness-level layer recall outputs. Inits layers on a throwaway context (hydrating scoped state from storage) and drops its state entries afterwards; no session is allocated for unknown thread ids. |
storeLayers(layers, response, ctx) | Run store on all layers. |
disposeLayers(layers, ctx) | Dispose all layers. |
getLayerState(executionId, layerId) | Read a layer's state for a given execution. |
setLayerState(executionId, layerId, state) | Overwrite a layer's state for a given execution. |
beforeToolCall(layers, toolName, toolArgs, ctx) | Invoke each layer's beforeToolCall steering hook and aggregate the decision. |
afterModelCall(layers, response, ctx) | Invoke each layer's afterModelCall steering hook. |
onSteer?(layers, params, ctx) | Run a steer through each layer's onSteer hook in slot order. Returns a SteerVerdict ({ action, items }). A harness without it applies each steer at its delivery mode's default point and appends it unchanged. |
notifyDecision(layers, record, ctx) | Run every layer's onDecision hook in slot order, one at a time. Called by decide steps after they validate their answers. |
checkpoint(ctx) | Persist current execution state. |
restore(executionId) | Restore a previously checkpointed context. |
cancel(ctx, options?) | Cancel an execution: abort ctx and every live descendant context, then run layer teardown (onComplete with outcome: 'aborted', then dispose) bottom-up. No-op if already cancelled. options is a reason string or a CancelOptions; with drainMs it is a graceful stop. Resolves with the DrainReport when a drain ran, undefined otherwise. |
createSpan(name, parent) | Create an observability span. |
DeliveryMode
type DeliveryMode = 'next-turn' | 'between-rounds' | 'next-item' | 'interrupt';| Mode | Behaviour |
|---|---|
next-turn (default) | Queue and run after the current turn completes. |
between-rounds | Steer: add the message before the next model call within the active turn. |
next-item | Steer: apply once the model finishes its current output item, stopping the stream there. |
interrupt | Abort the in-flight turn, keep the items it finished, place at head of queue, restart. |
ExecuteOptions
interface ExecuteOptions {
threadId?: string;
resourceId?: string;
state?: unknown;
contextLayers?: ContextLayer[];
deliveryMode?: DeliveryMode;
messageId?: string;
}
interface SessionScope {
threadId?: string;
}HarnessStatus
type HarnessStatus =
| { readonly kind: 'idle' }
| { readonly kind: 'generating'; readonly startedAt: number; readonly turnId: string }
| { readonly kind: 'draining'; readonly turnId: string; readonly deadline: number }
| { readonly kind: 'aborting'; readonly turnId: string };draining is a graceful stop in progress; deadline is when it cuts off whatever is still in flight (epoch ms).
ExecutionStatus
interface ExecutionScope {
executionId: string; // a root context's id
}
type ExecutionStatus =
| { readonly kind: 'idle' }
| { readonly kind: 'generating'; readonly startedAt: number }
| { readonly kind: 'draining'; readonly deadline: number }
| { readonly kind: 'aborting' };What getStatus({ executionId }) returns for a run() execution: generating while a run is in flight on the root context, draining during a graceful stop, aborting from the cancel until the run returns, and idle otherwise.
CancelOptions
interface CancelOptions {
readonly reason?: string;
/** Let in-flight model calls finish for up to this many ms before cancelling. */
readonly drainMs?: number;
}DrainReport
interface DrainReport {
readonly executionId: string;
readonly reason: 'settled' | 'deadline' | 'stopped';
readonly durationMs: number;
readonly finished: ReadonlyArray<DrainCall>;
readonly cutOff: ReadonlyArray<DrainCall>;
}
interface DrainCall {
readonly kind: 'model_round' | 'decide';
readonly model: string;
readonly executionId: string; // the context that made the call
readonly round?: number;
readonly dispatchId?: string;
readonly generationId?: string;
readonly cost?: number;
readonly usage?: RoundUsage;
}What cancel and abort resolve with after a graceful stop, and the data of {name}:drain_finished. A finished call the provider billed carries cost (USD) and usage exactly as charged to its context, a round rejected and asked again included; a cutOff call never does.
HarnessResponse
interface HarnessResponse {
readonly items: ReadonlyArray<Item>;
readonly usage: { inputTokens: number; outputTokens: number; cachedTokens?: number };
readonly cost?: number; // USD the turn's work cost, replayed work included
readonly billedCost?: number; // USD the turn actually paid — 0 for replayed work
readonly heldCost?: number; // USD held for cut-off paid calls with no settled bill yet
readonly text: string;
readonly lastLayerUsage?: LastLayerUsage;
}StreamEvent
type StreamEvent = SdkStreamEvent | FrameworkStreamEvent;
interface SdkStreamEvent {
readonly source: 'sdk';
readonly type: string;
readonly data: Record<string, unknown>;
readonly outputIndex?: number;
readonly contentIndex?: number;
}
interface FrameworkStreamEvent {
readonly source: 'framework';
readonly type: `${string}:${string}`;
readonly data: Record<string, unknown>;
}StreamingItem
type StreamingItem = Item & { readonly isComplete: boolean };AgentConfig
Declarative configuration for an agent harness. Generic over TParams, an arbitrary key-value record accessible via ctx.harness.config.params. Filesystem and shell adapters are passed to the AgentHarness constructor via environment and exposed as harness.fs / harness.shell; they are not part of AgentConfig itself.
declare interface AgentConfigShape<
TParams extends Record<string, unknown> = Record<string, unknown>,
> {
name: string;
storage?: StorageAdapter;
hooks?: AgentHooks;
params: TParams;
itemSchemas?: { schemas?: unknown; strict?: boolean };
projection?: ProjectionPolicy;
forceAtomicRecall?: boolean;
contextCache?: unknown;
decisionModel?: string;
emitItemsOnReplay?: boolean;
approveToolCall?: ToolApprover;
}| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Agent name. |
storage | StorageAdapter | no | Storage backend for context persistence. Defaults to an in-memory adapter. |
hooks | AgentHooks | no | Before/after step hooks. |
params | TParams | yes | Arbitrary key-value parameters available to every step and tool via ctx.harness.config.params. |
itemSchemas | ItemSchemaConfig | no | 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. Extension schemas are gates, not normalizers — the original item is returned on match. Mismatches raise NoeticError kind item_schema_mismatch. strict (default true) controls whether unknown extension item types must match a registered schema. |
projection | ProjectionPolicy | no | Default projection policy for all callModel steps. Individual steps override it via step.projection. |
forceAtomicRecall | boolean | no | Recall every layer atomically regardless of its recallMode, disabling cached/eventual recall. |
contextCache | ContextCacheConfig | no | Tuning for prompt-cache anchoring. On by default; pass { enabled: false } to render every layer before history as it did before bands existed. |
decisionModel | string | no | Default model for decide steps that set no model of their own, e.g. 'typesafe/jev-1.14'. |
emitItemsOnReplay | boolean | no | Defaults to false. When true, a resumed run streams again, as item_appended, the items its replayed steps emitted through emitItems. |
approveToolCall | ToolApprover | no | (params: { toolName, toolArgs, callId?, ctx }) => SteeringDecision | Promise<SteeringDecision>. Asked before every call to a tool that declares needsApproval, after the beforeToolCall layers allow it; allow runs the call, anything else refuses it. Without one, those calls run unapproved and the harness warns once per tool name. See Human-in-the-Loop Approval. |
DecideRequest
What harness.decide() takes. It uses Noetic's vocabulary; the adapter translates it to each provider's wire format.
interface DecideRequest {
model: string;
instructions?: string;
state: ReadonlyArray<DecideStatePart>;
questions: DecideQuestions;
samples?: number;
signal?: AbortSignal;
}
type DecideStatePart =
| { type: 'text'; text: string; name?: string }
| { type: 'json'; name: string; value: unknown }
| { type: 'image'; imageUrl: string };| Field | Type | Description |
|---|---|---|
model | string | Decision or chat model id |
instructions | string | Shared framing for every question (optional) |
state | ReadonlyArray<DecideStatePart> | What the decision is made against: text, named JSON values, and images |
questions | DecideQuestions | Questions keyed by id. See Step Types |
samples | number | Runs averaged on the estimated path. Ignored on the native path. Default 1 (optional) |
signal | AbortSignal | Cancels the call (optional) |
DecideResponse
interface DecideResponse {
answers: Readonly<Record<string, DecideAnswer>>;
source: 'native' | 'estimated';
modelId: string;
usage: { inputTokens: number; outputTokens: number };
cost?: number;
}| Field | Type | Description |
|---|---|---|
answers | Readonly<Record<string, DecideAnswer>> | One answer or Refusal per question id |
source | 'native' | 'estimated' | 'native': a decision model's own distribution. 'estimated': a chat model's self-reported probabilities, which are not calibrated |
modelId | string | Model that answered, as the provider reports it |
usage | { inputTokens, outputTokens } | Token usage, summed across samples |
cost | number | Cost in USD (optional) |
harness.decide() returns the response as the provider gave it. A decide step then checks it (every question answered or refused, every choice and level known, every probability in [0, 1]) and throws model_parse_error on a mismatch. On the native path, only an explicit provider refusal becomes a Refusal; a missing or mistyped answer is left out, so the step's check fails with model_parse_error. Every answer's probabilities has an entry for each outcome (0 when the provider sent none). On the estimated path, samples that fail or do not parse are dropped and the rest are averaged; when none parse, harness.decide() throws DecideOutputError, which carries the samples' usage and cost. A decide step counts that spend and turns the error into model_parse_error; aiCondition turns it into model_parse_error with step id 'aiCondition'.
AgentHooks
interface AgentHooks {
beforeStep?: (step: Step, ctx: Context) => Promise<void>;
afterStep?: (step: Step, result: unknown, ctx: Context) => Promise<void>;
}| Hook | Parameters | Description |
|---|---|---|
beforeStep | (step, ctx) | Called before each step executes |
afterStep | (step, result, ctx) | Called after each step completes |
DetachedHandle
A handle returned by harness.detachedSpawn() to track a concurrently running child step.
interface DetachedHandle<O> {
readonly id: string;
readonly status: DetachedStatus;
readonly result: O | undefined;
readonly error: string | undefined;
await(timeout?: number): Promise<O>;
}| Property | Type | Description |
|---|---|---|
id | string | Unique handle identifier (child context ID) |
status | DetachedStatus | Current execution status |
result | O | undefined | Child output (set when completed) |
error | string | undefined | Error message (set when failed) |
await(timeout?) | Promise<O> | Wait for completion, optionally with timeout in ms |
DetachedStatus
const DetachedStatus = {
Running: 'running',
Completed: 'completed',
Failed: 'failed',
} as const;
type DetachedStatus = (typeof DetachedStatus)[keyof typeof DetachedStatus];| Value | Description |
|---|---|
'running' | Child step is still executing |
'completed' | Child step finished successfully |
'failed' | Child step threw an error |
RecallLayerOutput
The output from a single context layer's recall hook.
interface RecallLayerOutput {
layerId: string;
items: Item[];
tokenCount: number;
mutatedState?: boolean;
}| Field | Type | Description |
|---|---|---|
layerId | string | Which layer produced this output |
items | Item[] | Items to inject into the prompt |
tokenCount | number | Token cost of the items |
mutatedState | boolean | Set when the layer's recall returned new state. Such a layer is never pinned for the prompt cache — serving an older render would drop what the call committed |
LLMResponse
The response from an LLM call.
interface RoundUsage {
inputTokens: number;
outputTokens: number;
cachedTokens?: number;
cacheWriteTokens?: number;
}
interface LLMResponse {
items: Item[];
usage: RoundUsage;
rounds?: ReadonlyArray<RoundUsage>;
cost?: number;
billedCost?: number;
recall?: boolean;
}usage totals every round of the call, including a round the harness rejected and asked again (a provider error status in a 2xx body, or no output): the provider billed it. rounds breaks it down oldest first, adding a rejected round's usage to the round after it: rounds after the first replay the same assembled view plus appended tool traffic, so they hit the prompt cache regardless of whether the first round did — only rounds[0] answers "was my prefix cached?". cachedTokens and cacheWriteTokens are undefined, not 0, when the provider reports no cache figures at all.
| Field | Type | Description |
|---|---|---|
items | Item[] | Response items (messages, function calls, etc.) |
usage.inputTokens | number | Input tokens consumed |
usage.outputTokens | number | Output tokens generated |
usage.cachedTokens | number | Cached tokens used (optional) |
cost | number | USD the work behind the response cost, including rounds replayed from a recorded run (optional) |
billedCost | number | USD this call actually paid: cost less what replayed rounds supplied, so 0 when every round replayed. Absent means the whole cost was paid now (optional) |
recall | boolean | The call ended early after applying a steer whose onSteer layers asked for recall. The callModel step runs recall again and continues the call (optional) |