Loop & Until
Repeat a step until a termination predicate says stop.
Quick Example
import { loop, step, until } from '@noetic/core';
const agent = loop({
id: 'chat-loop',
steps: [step.llm({
id: 'chat',
model: '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, piping the output of one step into the next. 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
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | -- | Unique step identifier |
steps | ReadonlyArray<Step<TMemory, I, O>> | Yes | -- | Steps to execute sequentially on each iteration |
until | Until | Yes | -- | Predicate that decides when to stop |
maxIterations | number | No | 1000 | Hard ceiling on total iterations (including retries) |
maxHistorySize | number | No | 100 | Max entries kept in the snapshot history array |
prepareNext | (output: O, verdict: Verdict, ctx: Context) => I | No | -- | 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.
| Field | Type | Description |
|---|---|---|
stepCount | number | Number of successful step executions |
tokens | { input: number; output: number; total: number } | Cumulative token usage |
elapsed | number | Milliseconds since the loop started |
cost | number | Cumulative cost in USD |
lastOutput | unknown | The most recent step output |
lastText | string | Text representation of the last output |
history | unknown[] | Array of all step outputs (bounded by maxHistorySize) |
depth | number | Current nesting depth |
lastStepMeta | StepMeta | null | Metadata from the last step (tool calls, usage, etc.) |
The Verdict
The until predicate returns a Verdict telling the loop what to do.
| Field | Type | Description |
|---|---|---|
stop | boolean | true to exit the loop, false to continue |
reason | string | undefined | Human-readable explanation of why the loop stopped |
feedback | string | undefined | Feedback 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 step 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 secondsuntil.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 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 { loop, step, until } from '@noetic/core';
const refiner = loop({
id: 'refine-loop',
steps: [step.llm({
id: 'refiner',
model: '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.
| Property | Type | Default | Description |
|---|---|---|---|
inbox | Channel<string> | -- | Channel for external messages |
parkTimeout | number | 0 | Milliseconds to wait on inbox before stopping (0 = non-blocking check) |
When until returns { stop: true } and inbox is set:
- The loop checks the inbox for messages
- If a message is found, it's injected as a
developermessage in the context and the loop continues - If no message is found (or timeout expires), the loop truly stops
import { channel, loop, step, until } from '@noetic/core';
import { z } from 'zod';
const inbox = channel('agent-inbox', { schema: z.string(), mode: 'queue' });
const agent = loop({
id: 'async-agent',
steps: [step.llm({
id: 'agent-llm',
model: '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 Value | Behavior |
|---|---|
'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/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.
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',
instructions: 'You are a helpful assistant.',
tools: [searchTool, calculatorTool],
maxSteps: 10,
maxCost: 1.00,
});This is equivalent to:
import { any, loop, step, until } from '@noetic/core';
const agent = loop({
id: 'react-loop',
steps: [step.llm({
id: 'react-step',
model: 'gpt-4o',
instructions: 'You are a helpful assistant.',
tools: [searchTool, calculatorTool],
})],
until: any(
until.noToolCalls(),
until.maxSteps(10),
until.maxCost(1.00),
),
});Related
- Steps -- the step types you can use in a loop.
- Spawn -- run a loop in an isolated child context.
- Control Flow -- branch and fork for non-iterative operations.