Loop & Until

Repeat a step until a termination predicate says stop.

Quick Example

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

const agent = {
  kind: 'loop',
  id: 'chat-loop',
  body: step.llm({
    id: 'chat',
    model: 'gpt-4o',
    system: 'You are a helpful assistant.',
    tools: [searchTool],
  }),
  until: until.noToolCalls(),
  maxIterations: 10,
} as const;

The loop executes its body step repeatedly. 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
bodyStep<I, O>Yes--The step to repeat
untilUntilYes--Predicate that decides when to stop
maxIterationsnumberNo1000Hard ceiling on total iterations (including retries)
maxHistorySizenumberNo100Max entries kept in the snapshot history array
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 step

The Snapshot

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

FieldTypeDescription
stepCountnumberNumber of successful body executions
tokens{ input: number; output: number; total: number }Cumulative token usage
elapsednumberMilliseconds since the loop started
costnumberCumulative cost in USD
lastOutputunknownThe most recent body output
lastTextstringText representation of the last output
historyunknown[]Array of all body outputs (bounded by maxHistorySize)
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 | undefinedHuman-readable explanation of why the loop stopped
feedbackstring | undefinedFeedback injected into the next iteration (used by until.verified)

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/core';

const predicate = until.noToolCalls();

until.maxSteps(n)

Stop after n successful body executions.

const predicate = until.maxSteps(5);

until.maxCost(usd)

Stop when cumulative cost reaches the threshold.

const predicate = until.maxCost(0.50);

until.maxDuration(ms)

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.',
  };
});

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.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/core';

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

all(...predicates)

Stop when every predicate says stop. This is a logical AND.

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

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

Preparing the Next Iteration

By default, the body'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 { until } from '@noetic/core';

const loop = {
  kind: 'loop',
  id: 'refine-loop',
  body: step.llm({
    id: 'refiner',
    model: 'gpt-4o',
    system: '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,
} as const;

Error Handling

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

Return ValueBehavior
'retry'Re-execute the body step immediately (counts toward maxIterations)
'skip'Skip this iteration, use the previous output, and continue
'abort'Re-throw the error, terminating the loop
const loop = {
  kind: 'loop',
  id: 'resilient-loop',
  body: 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.

The ReAct Pattern

The built-in react() helper is just a loop with until.noToolCalls().

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

const agent = react({
  model: 'gpt-4o',
  system: 'You are a helpful assistant.',
  tools: [searchTool, calculatorTool],
  maxSteps: 10,
  maxCost: 1.00,
});

This is equivalent to:

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

const agent = {
  kind: 'loop',
  id: 'react-loop',
  body: step.llm({
    id: 'react-step',
    model: 'gpt-4o',
    system: '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 as a loop body.
  • Spawn -- run a loop in an isolated child context.
  • Control Flow -- branch and fork for non-iterative control flow.

On this page