NOETIC
Framework
Operators

Loop & Until

Repeat a step until a termination predicate says stop.

Quick Example

import { callModel, loop, until } from '@noetic-tools/core';

const agent = loop({
  id: 'chat-loop',
  steps: [callModel({
    id: 'chat',
    model: 'openai/gpt-4o',
    instructions: 'You are a helpful assistant.',
    tools: [searchTool],
  })],
  until: until.noToolCalls(),
  maxIterations: 10,
});

The loop executes its steps array sequentially on each iteration, in array order: each step waits for the previous one and receives its output as input, and the iteration's output is the last step's output. Steps in the array never run concurrently -- use inParallel for that. To run a chain of steps exactly once, use until: until.maxSteps(1) (see Running Steps in Order). After each iteration, a Snapshot of the current state is passed to the until predicate. When the predicate returns { stop: true }, the loop exits and returns the last output.

Loop API Reference

PropertyTypeRequiredDefaultDescription
idstringYes--Unique step identifier
stepsReadonlyArray<Step<TContext, I, O>>Yes--Steps to execute sequentially on each iteration
untilUntil | StepDecideYes--Predicate that decides when to stop, or a decide step asking one boolean question (see Ending a loop with a decision)
maxIterationsnumberNo1000Hard ceiling on total iterations (including retries). A positive integer, or Infinity for none (see Uncapped loops)
maxHistorySizenumberNo100Max entries kept in the snapshot history array; oldest drop first
prepareNext(output: O, verdict: Verdict, ctx: Context) => INo--Transform output into next iteration's input
onError(error: NoeticError, ctx: Context) => 'retry' | 'skip' | 'abort'No--Handle errors from the body steps

The Snapshot

Every time the until predicate is called, it receives a Snapshot of the loop's current state.

FieldTypeDescription
stepCountnumberNumber of successful step executions
tokens{ input: number; output: number; total: number }Cumulative token usage
elapsednumberMilliseconds since the loop started
costnumberCumulative cost in USD
lastOutputunknownThe most recent step output
lastTextstringText representation of the last output
historyunknown[]Step outputs, oldest first; bounded by maxHistorySize (default 100, oldest dropped first)
depthnumberCurrent nesting depth
lastStepMetaStepMeta | nullMetadata from the last step (tool calls, usage, etc.)

The Verdict

The until predicate returns a Verdict telling the loop what to do.

FieldTypeDescription
stopbooleantrue to exit the loop, false to continue
reasonstring | undefinedWhy the loop stopped or continued; recorded in traces (see below)
feedbackstring | undefinedFeedback injected into the next iteration (used by until.verified)

Verdicts in traces

A loop opens no span of its own. After each verdict it adds a loop.verdict event to the span its context carries (inside a spawn child or an inParallel path, that child's span), with attributes loop (the step id), iteration, stop, and reason when the verdict has one. feedback is never recorded, since it can be large or sensitive. A predicate that throws stops the loop with reason Predicate error: <message>, and that verdict is traced too. Two errors are exceptions and fail the loop instead, with no verdict: a predicate that fails because the context was aborted ends the loop with the cancelled error, and a NoeticConfigError (a broken loop definition, such as a JSON workflow expr predicate that gives a non-boolean) propagates as is. See Observability.

Built-in Predicates

Noetic ships with several ready-made predicates on the until object.

until.noToolCalls()

Stop when the LLM responds without calling any tools. This is the core of the ReAct pattern.

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

const predicate = until.noToolCalls();

until.maxSteps(n)

Stop after n successful step executions. Reason: Reached max steps (n).

const predicate = until.maxSteps(5);

until.maxCost(usd)

Stop when cumulative cost reaches the threshold. Reason: Cost $0.52 exceeded budget $0.50 (both rounded to cents). Spend held for paid calls a crash or cancel cut off counts too, so a resumed run cannot overspend — see Paid Calls.

const predicate = until.maxCost(0.50);

until.maxDuration(duration)

Stop when elapsed time exceeds the limit.

const predicate = until.maxDuration(3e4); // 30 seconds

until.verified(fn)

Stop when a verification function confirms the output is correct. The function receives the last output and returns { pass, feedback }.

const predicate = until.verified(async (output) => {
  const isValid = await validate(output);
  return {
    pass: isValid,
    feedback: isValid ? undefined : 'Output did not pass validation. Try again.',
  };
});

The reason is Verification passed or Verification failed. When pass is false, the feedback string is available on the Verdict for use in prepareNext.

until.converged(opts)

Stop when the output stabilizes. By default, uses exact string equality. When an embed function is provided and threshold < 1, uses cosine similarity between consecutive output embeddings.

// Exact match (default)
const exact = until.converged({});

// Semantic similarity with embeddings
const semantic = until.converged({
  threshold: 0.95,
  embed: myEmbedFn,
  cache: myStorageAdapter, // optional: persist across ephemeral invocations
});

until.outputContains(marker)

Stop when the output text contains a specific marker string.

const predicate = until.outputContains('FINAL ANSWER:');

until.outputEquals(sentinel)

Stop when the output text exactly equals the sentinel string. Prefer this over outputContains when the marker is a dedicated exit sentinel produced by a terminator step — exact equality avoids substring collisions with unrelated output that happens to quote the sentinel.

const predicate = until.outputEquals('DONE');

until.never()

Never stop the loop. Pairs with operators like schedule that rely on external cancellation to terminate.

const predicate = until.never();

until.custom(fn)

Pass through a raw Until function for full control.

const predicate = until.custom((snap) => ({
  stop: snap.stepCount > 3 && snap.cost > 0.10,
  reason: 'Custom condition met',
}));

Combinators: all() and any()

Combine multiple predicates with logical operators.

any(...predicates)

Stop when any one of the predicates says stop. This is a logical OR.

import { any, until } from '@noetic-tools/core';

const stopCondition = any(
  until.noToolCalls(),
  until.maxSteps(10),
  until.maxCost(1.00),
);

If none stops, the verdict carries the feedback of every predicate, joined with a newline, so a failing until.verified() inside any() still reaches prepareNext. The first stop verdict is returned as is.

all(...predicates)

Stop when every predicate says stop. This is a logical AND. The first predicate that says continue decides, and its verdict (with its feedback) is returned as is; later predicates do not run. When all stop, reasons are joined with '; ' and feedback with a newline.

import { all, until } from '@noetic-tools/core';

const stopCondition = all(
  until.maxSteps(3),
  until.verified(async (output) => ({
    pass: output !== null,
  })),
);

Ending a loop with a decision

A decide step can serve as until directly. It must ask exactly one question, and that question must be boolean; loop(), any(), and all() throw INVALID_UNTIL_DECIDER otherwise. After each iteration the loop runs the decide step on the iteration's output:

  • yes stops the loop,
  • no runs another iteration,
  • a refusal runs another iteration too.

The verdict's reason names the step, question, answer, and probability, e.g. decide 'finished' answered 'done' yes (p=0.91).

import { callModel, decide, loop } from '@noetic-tools/core';

const finished = decide({
  id: 'finished',
  questions: {
    done: { type: 'boolean', instructions: 'Is the task finished?' },
  },
});

const agent = loop({
  id: 'work',
  steps: [callModel({ id: 'step', model: 'openai/gpt-4o', instructions: 'Take the next step.' })],
  until: finished,
});

The decider follows the decide rules: it reads the context view without writing to the conversation, and merges its metadata into ctx.lastStepMeta. It asks the model afresh every iteration, even when the output and conversation repeat. A decider whose call fails stops the loop like any throwing predicate, unless the failure is a cancellation or a config error such as MISSING_MODEL, which fail the loop.

Mix a decider with other predicates in any() or all(). A backstop is wise, since a model that keeps answering no (or refusing) keeps the loop going:

import { any, decide, until } from '@noetic-tools/core';

const stopCondition = any(
  until.maxSteps(20),
  decide({
    id: 'good-enough',
    questions: {
      ship: { type: 'boolean', instructions: 'Is the draft ready to ship?' },
    },
  }),
);

Uncapped loops

maxIterations defaults to 1000, so a predicate that never fires still ends the loop with a step_failed error. A loop that should run until its predicate says stop opts out with maxIterations: Infinity. It then ends only when until says stop, an iteration's error propagates, or the context is aborted. Any other non-integer or value below 1 (NaN, 0, 1.5) fails the loop before its first iteration.

import { callModel, decide, loop } from '@noetic-tools/core';

const agent = loop({
  id: 'until-done',
  steps: [callModel({ id: 'step', model: 'openai/gpt-4o', instructions: 'Take the next step.' })],
  until: decide({
    id: 'finished',
    questions: { done: { type: 'boolean', instructions: 'Is the task finished?' } },
  }),
  maxIterations: Number.POSITIVE_INFINITY,
});

With a decider and no cap, a model that refuses every time runs the loop until the context is aborted.

Preparing the Next Iteration

By default, the last step's output is fed directly as the next iteration's input (requires I and O to be the same type). Use prepareNext when they differ, or when you want to inject feedback.

import { callModel, loop, until } from '@noetic-tools/core';

const refiner = loop({
  id: 'refine-loop',
  steps: [callModel({
    id: 'refiner',
    model: 'openai/gpt-4o',
    instructions: 'Improve the draft based on feedback.',
  })],
  until: until.verified(async (output) => {
    const score = await evaluate(output);
    return {
      pass: score > 0.9,
      feedback: `Score: ${score}. Improve clarity and detail.`,
    };
  }),
  prepareNext: (output, verdict, ctx) => {
    if (verdict.feedback) {
      return `Previous draft:\n${output}\n\nFeedback: ${verdict.feedback}`;
    }
    return output;
  },
  maxIterations: 5,
});

Inbox Channel

A loop can define an optional inbox channel that prevents the loop from stopping when external messages are waiting. This enables async sub-agent patterns where the loop parks and waits for background work to complete.

PropertyTypeDefaultDescription
inboxChannel<string>--Channel for external messages
parkTimeoutnumber0Milliseconds to wait on inbox before stopping (0 = non-blocking check)

When until returns { stop: true } and inbox is set:

  1. The loop checks the inbox for messages
  2. If a message is found, it's injected as a developer message in the context and the loop continues
  3. If no message is found (or timeout expires), the loop truly stops
import { callModel, channel, loop, until } from '@noetic-tools/core';
import { z } from 'zod';

const inbox = channel('agent-inbox', { schema: z.string(), mode: 'queue' });

const agent = loop({
  id: 'async-agent',
  steps: [callModel({
    id: 'agent-llm',
    model: 'openai/gpt-4o',
    tools: [launchTool, checkTool],
  })],
  until: until.noToolCalls(),
  inbox,
  parkTimeout: 3e4, // wait up to 30s for sub-agent results
});

When a background sub-agent completes, its result is sent to the inbox channel. The loop wakes, the LLM sees the result as a developer message, and can incorporate it in its next response.

See Detached Spawn for the full async delegation pattern.

Error Handling

The onError callback lets you decide what happens when the body steps throws.

Return ValueBehavior
'retry'Re-execute the body steps immediately (counts toward maxIterations)
'skip'Skip this iteration, use the previous output, and continue
'abort'Re-throw the error, terminating the loop
import { loop, until } from '@noetic-tools/core';

const resilientLoop = loop({
  id: 'resilient-loop',
  steps: [myStep],
  until: until.maxSteps(5),
  onError: (error, ctx) => {
    if (error.kind === 'step_failed') {
      return 'retry';
    }
    return 'abort';
  },
});

If onError is not provided, errors propagate immediately.

onError is never consulted for a cancelled error: cancellation is not a retriable error, so it bypasses onError entirely and terminates the loop immediately — 'retry' and 'skip' cannot swallow it.

The ReAct Pattern

A ReAct-style agent is just a loop around a callModel that stops when the model responds without tool calls — with until.maxSteps and until.maxCost as safety rails:

import { any, callModel, loop, until } from '@noetic-tools/core';

const agent = loop({
  id: 'react-loop',
  steps: [callModel({
    id: 'react-step',
    model: 'openai/gpt-4o',
    instructions: 'You are a helpful assistant.',
    tools: [searchTool, calculatorTool],
  })],
  until: any(
    until.noToolCalls(),
    until.maxSteps(10),
    until.maxCost(1.00),
  ),
});
  • Steps -- the step types you can use in a loop.
  • Spawn -- run a loop in an isolated child context.
  • Control Flow -- conditional and inParallel for non-iterative operations.

On this page