Error Model

Noetic uses structured NoeticError objects with error codes and recovery hints.

Quick Example

import { isNoeticError } from '@noetic/core';

try {
  const result = await runtime.execute(myStep, input, ctx);
} catch (e) {
  if (isNoeticError(e)) {
    console.log(e.noeticError.kind); // e.g. 'step_failed'
    console.log(e.message);         // Human-readable description
  }
}

Noetic wraps every framework-level failure in a structured NoeticError discriminated union. Instead of catching generic exceptions and guessing what went wrong, you can match on the kind field and access typed metadata for each error variant.

NoeticError Type

NoeticError is a discriminated union with a kind field. Each variant carries context-specific data:

kindKey FieldsWhen it occurs
step_failedstepId, cause: Error, retriesExhaustedA step throws after exhausting retries.
llm_refusedstepId, refusal: stringThe model returns a refusal instead of content.
llm_parse_errorstepId, raw: string, schema: ZodType, zodError: ZodErrorStructured output fails Zod validation.
llm_rate_limitstepId, retryAfter?: numberThe LLM provider returns a rate-limit response.
fork_partialstepId, succeeded[], failed[]Some fork paths succeeded and others failed (settle mode).
spawn_summary_failedstepId, childOutput, summaryCause: ErrorA spawn's summary step failed after the child completed.
channel_timeoutchannelName, timeout: numberA recv() call exceeded its timeout.
channel_closedchannelNameAttempted to send on a closed external channel.
cancelledreason?: stringThe execution was cancelled via ctx.abort() or runtime.cancel().
budget_exceededfield: 'cost' | 'steps' | 'duration', limit, actualA budget guard detected the run exceeded its limits.

Full Type Definition

type NoeticError =
  | { kind: 'step_failed'; stepId: string; cause: Error; retriesExhausted: boolean }
  | { kind: 'llm_refused'; stepId: string; refusal: string }
  | { kind: 'llm_parse_error'; stepId: string; raw: string; schema: ZodType; zodError: ZodError }
  | { kind: 'llm_rate_limit'; stepId: string; retryAfter?: number }
  | { kind: 'fork_partial'; stepId: string; succeeded: Array<{ stepId: string; value: unknown }>; failed: Array<{ stepId: string; error: NoeticError }> }
  | { kind: 'spawn_summary_failed'; stepId: string; childOutput: unknown; summaryCause: Error }
  | { kind: 'channel_timeout'; channelName: string; timeout: number }
  | { kind: 'channel_closed'; channelName: string }
  | { kind: 'cancelled'; reason?: string }
  | { kind: 'budget_exceeded'; field: 'cost' | 'steps' | 'duration'; limit: number; actual: number };

NoeticErrorImpl Class

NoeticErrorImpl extends the standard Error class, so it works with try/catch and stack traces. The structured error data lives on the .noeticError property:

class NoeticErrorImpl extends Error {
  readonly noeticError: NoeticError;
  constructor(error: NoeticError);
}

The message string is auto-generated from the error kind and its fields. For example, a step_failed error produces:

Step 'fetch-data' failed: Connection refused

isNoeticError Type Guard

Use isNoeticError() to narrow an unknown catch value:

import { isNoeticError } from '@noetic/core';

function isNoeticError(e: unknown): e is NoeticErrorImpl;

This is an instanceof check against NoeticErrorImpl.

Handling Errors in Loops

Loops accept an onError callback that receives the structured error and returns a recovery action:

import { step, loop, until } from '@noetic/core';

const resilientLoop = loop({
  id: 'retry-loop',
  body: step.llm({ id: 'generate', model: 'gpt-4o' }),
  until: until.noToolCalls(),
  onError(error, ctx) {
    if (error.kind === 'llm_rate_limit') {
      return 'retry';  // Try the same iteration again
    }
    if (error.kind === 'step_failed' && !error.retriesExhausted) {
      return 'skip';   // Move to the next iteration
    }
    return 'abort';    // Re-throw the error
  },
});
Return valueBehavior
'retry'Re-execute the same iteration immediately.
'skip'Skip this iteration and continue with the previous output.
'abort'Re-throw the error, ending the loop.

Matching on Error Kind

Because NoeticError is a discriminated union, TypeScript narrows the type after checking kind:

if (isNoeticError(e)) {
  const err = e.noeticError;

  switch (err.kind) {
    case 'llm_parse_error':
      // TypeScript knows: err.raw, err.schema, err.zodError
      console.log('Validation errors:', err.zodError.errors);
      break;

    case 'fork_partial':
      // TypeScript knows: err.succeeded, err.failed
      console.log(`${err.succeeded.length} paths ok, ${err.failed.length} failed`);
      break;

    case 'budget_exceeded':
      // TypeScript knows: err.field, err.limit, err.actual
      console.log(`${err.field} limit: ${err.limit}, used: ${err.actual}`);
      break;
  }
}

On this page