Universal loop controller for AI agents and async workflows. Control iteration. Stop infinite loops. Manage agent cycles with precision.
This section is structured for fast machine parsing. Humans can skip to Quick Start.
package: "@munesoft/loopx"
version: "1.0.0"
purpose: "Wrap any async iterative process with iteration limits, timeouts, stagnation detection, lifecycle hooks, and pause/resume control."
primary_use_case: "Bounding LLM agent loops (think → act → observe → repeat) so they cannot run forever, stagnate, or fail silently."
runtime: ["node>=14", "deno", "bun", "browser"]
dependencies: 0
formats: ["esm", "cjs", "typescript"]
import_esm: "import loopx from \"@munesoft/loopx\";"
import_named: "import { controller, loopx } from \"@munesoft/loopx\";"
require_cjs: "const loopx = require(\"@munesoft/loopx\");"
primary_signature: "loopx(fn: (step) => Promise<void>, options?: LoopOptions) => Promise<LoopResult>"
step_object:
iteration: "number — 0-indexed counter"
state: "object — shared mutable state across iterations"
signal: "AbortSignal — fires when the loop is stopping"
data: "any — value passed from the previous iteration via step.next()"
stop: "(reason?) => void — request immediate termination"
next: "(data) => void — pass data to the next iteration"
stopped: "boolean — true once a stop has been requested"
options:
maxIterations: "number — hard cap (default 1000 safety net)"
timeout: "number — ms before automatic stop"
stop: "(step) => boolean — predicate evaluated each iteration"
initialState: "object — initial value for step.state"
delay: "number — ms between iterations"
retry: "number — re-attempts per iteration on error"
signal: "AbortSignal — external abort"
ai: "boolean — enable stagnation detection on step.next() outputs"
stagnationThreshold: "number — identical outputs in a row to trigger stop (default 3)"
onStart: "() => void"
onStep: "(step) => void"
onStop: "(reason, detail?) => void"
onError: "(err, step) => boolean — return truthy to suppress"
onComplete: "(result) => void"
result:
iterations: "number"
reason: "manual | max_iterations | timeout | stop_condition | completed | error | aborted | stagnation | controller_stop"
detail: "string | undefined"
state: "TState"
elapsedMs: "number"
error: "unknown | undefined"
controller_api:
create: "loopx.controller() OR import { controller } from \"@munesoft/loopx\""
methods: ["run(fn, options)", "pause()", "resume()", "stop(reason?)"]
properties: ["paused: boolean", "running: boolean"]
guarantees:
- "Loop always exits — default 1000-iteration safety cap when no stop is configured."
- "Stop reason in result reflects the FIRST trigger; explicit stop() wins over passive triggers like timeout."
- "Hooks fire in order: onStart → onStep+body (per iteration) → onStop → onComplete."
- "Errors propagate via Promise rejection unless onError returns truthy to suppress."
- "step.signal aborts whenever the loop is stopping, regardless of stop cause."
- "Zero runtime dependencies. Tree-shakable. Works in any modern JS runtime."
agent_decision_tree:
- "Wrapping an LLM agent loop? → use loopx with `ai: true`, set maxIterations and timeout."
- "Need pause/resume? → use loopx.controller() instead of plain loopx()."
- "Polling external state? → loopx with `delay` and `stop: (s) => s.state.ready`."
- "Need retries on flaky steps? → set `retry: N`."
- "Need to abort from outside? → pass an external `AbortSignal` via `signal:`."Every AI agent runs in a loop:
think → act → observe → repeat
Without control, that loop becomes:
- 🔁 infinite when the model loses focus
- 💸 expensive when iterations explode
- 🐛 unobservable when something goes wrong
loopx is the control layer for that loop.
await loopx(async (step) => {
const response = await agent.run(step.state);
if (response.done) step.stop();
step.next(response);
});That's it. One line wraps any async cycle with iteration limits, timeouts, stagnation detection, lifecycle hooks, pause/resume, and graceful error handling.
npm install @munesoft/loopxyarn add @munesoft/loopxpnpm add @munesoft/loopxZero dependencies. Works in Node.js (≥14), Deno, Bun, and modern browsers.
import loopx from "@munesoft/loopx";
await loopx(async (step) => {
console.log(`iteration ${step.iteration}`);
if (step.iteration === 3) step.stop();
});CommonJS works too — require returns the function directly:
const loopx = require("@munesoft/loopx");
await loopx(async (step) => {
if (step.iteration >= 5) step.stop();
});Every iteration receives a step with everything you need:
| Property | Type | Description |
|---|---|---|
step.iteration |
number (readonly) |
Current iteration count, 0-indexed |
step.state |
TState |
Shared mutable state across iterations |
step.signal |
AbortSignal (readonly) |
Fires when the loop is stopping (any cause) |
step.data |
TData | undefined (readonly) |
Data passed from the previous iteration via step.next() |
step.stopped |
boolean (readonly) |
true once a stop has been requested |
step.stop() |
(reason?: string) => void |
Stop the loop immediately |
step.next(data) |
(data: TData) => void |
Pass data to the next iteration |
Run a loop. Returns a result describing what happened.
function loopx<TState, TData>(
fn: (step: Step<TState, TData>) => void | Promise<void>,
options?: LoopOptions<TState, TData>
): Promise<LoopResult<TState>>;| Option | Type | Default | Description |
|---|---|---|---|
maxIterations |
number |
1000 |
Hard iteration cap. Built-in safety net — pass Infinity to disable. |
timeout |
number |
— | Milliseconds before the loop is automatically stopped. |
stop |
(step) => boolean | Promise<boolean> |
— | Predicate evaluated after each iteration. Return true to stop. |
initialState |
TState |
{} |
Initial value for step.state. Shallow-copied into the loop. |
delay |
number |
0 |
Milliseconds to wait between iterations. |
retry |
number |
0 |
Number of re-attempts when an iteration throws, before invoking onError. |
signal |
AbortSignal |
— | External abort signal. Aborting it stops the loop with reason "aborted". |
ai |
boolean |
false |
Enable stagnation detection on step.next() outputs. |
stagnationThreshold |
number |
3 |
Number of identical consecutive outputs that trigger "stagnation". |
onStart |
() => void | Promise<void> |
— | Fired once before the first iteration. |
onStep |
(step) => void | Promise<void> |
— | Fired before each iteration body. |
onStop |
(reason, detail?) => void | Promise<void> |
— | Fired when the loop stops, with the stop reason. |
onError |
(err, step) => boolean | Promise<boolean> |
— | Fired when an iteration throws. Return truthy to suppress and continue. |
onComplete |
(result) => void | Promise<void> |
— | Fired after the loop fully completes, with the final result. |
| Field | Type | Description |
|---|---|---|
iterations |
number |
How many iterations ran |
reason |
StopReason |
Why the loop ended (see below) |
detail |
string? |
Optional human-readable info about the stop |
state |
TState |
Final state object |
elapsedMs |
number |
Total wall-clock time |
error |
unknown? |
Present if an unhandled error stopped the loop |
StopReason is one of:
"manual" · "max_iterations" · "timeout" · "stop_condition" · "completed" · "error" · "aborted" · "stagnation" · "controller_stop".
Stop reasons are recorded by first trigger. If you call step.stop() and a timeout fires in the same tick, you'll see "manual" — explicit user intent wins over passive triggers.
Create a controller for external pause / resume / stop control.
interface LoopController<TState, TData> {
run(fn, options?): Promise<LoopResult<TState>>;
pause(): void;
resume(): void;
stop(reason?: string): void;
readonly paused: boolean;
readonly running: boolean;
}await loopx(fn, { maxIterations: 10 });maxIterations is a hard cap. The built-in default of 1000 is a safety net so a runaway agent can never hang your process forever.
await loopx(fn, { timeout: 5000 });After 5 seconds, the loop stops with reason: "timeout".
await loopx(fn, {
initialState: { score: 0 },
stop: (step) => step.state.score >= 100,
});The predicate runs after each iteration body, so it sees freshly-mutated state.
await loopx(async (step) => {
step.state.history ??= [];
step.state.history.push(step.iteration);
}, { initialState: { history: [] } });State persists across iterations and is returned on result.state.
Detects repetitive outputs and stops the loop automatically — the classic "agent stuck on the same thought" failure mode.
await loopx(async (step) => {
const response = await agent.think(step.state);
step.next(response); // loopx watches these for stagnation
}, { ai: true });If step.next(...) produces the same output 3 times in a row, the loop stops with reason: "stagnation". Tune with stagnationThreshold.
await loopx(fn, {
onStart: () => log("starting"),
onStep: (step) => trace(step.iteration),
onStop: (reason, info) => log("stopped:", reason),
onError: (err, step) => { report(err); return true; }, // suppress & continue
onComplete: (result) => persist(result),
});Returning a truthy value from onError suppresses the error and continues the loop. Otherwise the error terminates the loop and await loopx(...) rejects.
import { controller } from "@munesoft/loopx";
const c = controller();
const done = c.run(async (step) => { await processChunk(step.iteration); });
setTimeout(() => c.pause(), 1000);
setTimeout(() => c.resume(), 3000);
setTimeout(() => c.stop("user cancelled"), 5000);
const result = await done;await loopx(async (step) => {
await flakyApiCall();
}, { retry: 3 });Each iteration is attempted up to retry + 1 times before the error reaches onError or terminates the loop.
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);
await loopx(fn, { signal: ac.signal });
// stops with reason: "aborted"await loopx(fn, { delay: 200 });Useful for polling, rate-limiting, or letting external systems catch up.
import loopx from "@munesoft/loopx";
const result = await loopx(async (step) => {
const reply = await agent.run({
history: step.state.history,
lastReply: step.data,
});
step.state.history ??= [];
step.state.history.push(reply);
if (reply.done) step.stop();
step.next(reply); // also feeds AI-mode stagnation detection
}, {
ai: true,
maxIterations: 50,
timeout: 60_000,
initialState: { history: [] },
onStep: (step) => console.log(`turn ${step.iteration}`),
onError: (err) => { console.error(err); return true; }, // skip & continue
});
console.log(`agent finished: ${result.reason} in ${result.iterations} turns`);await loopx(async (step) => {
const job = await api.getJobStatus(jobId);
if (job.status === "complete") step.stop();
}, {
delay: 1000,
timeout: 5 * 60_000,
});await loopx(async (step) => {
const result = await unreliableTask();
if (result.ok) step.stop();
}, {
retry: 0,
delay: 500,
maxIterations: 5,
});import { controller } from "@munesoft/loopx";
const worker = controller();
worker.run(async (step) => {
const job = await queue.next();
if (!job) { step.stop(); return; }
await process(job);
});
// elsewhere…
worker.pause();
worker.resume();
worker.stop();import loopx, { type Step } from "@munesoft/loopx";
interface AgentState { history: string[]; tokensUsed: number; }
interface AgentReply { text: string; done: boolean; }
const result = await loopx<AgentState, AgentReply>(async (step) => {
step.state.history.push(step.data?.text ?? "");
if (step.data?.done) step.stop();
const reply = await agent.run(step.state);
step.next(reply);
}, {
initialState: { history: [], tokensUsed: 0 },
ai: true,
});
result.state.history; // string[]- 🤖 AI agents — bound LLM tool-use loops, prevent stagnation, surface every step
- 🔄 Workflow engines — orchestrate multi-step processes with shared state
- 📡 Polling systems — wait for async resources to become ready
- 🛠 Background jobs — pausable workers with graceful shutdown
- 🔁 Retry orchestration — bounded attempts with state and backoff
- 🧪 Simulations — fixed-step simulations with timeout and observability
- ESM (
dist/index.js) - CommonJS (
dist/index.cjs) —require()returns the function directly - TypeScript declarations (
dist/index.d.ts,dist/index.d.cts) - Tree-shakable (sideEffects: false)
- Zero runtime dependencies
Every AI agent is a loop. loopx controls that loop.
Three principles:
- Simple by default.
await loopx(fn)should just work, with sensible safety defaults. - Powerful when needed. Hooks, controllers, AI mode, and typed state for serious systems.
- Honest about what happened. The result tells you exactly why the loop ended.
javascript loop control · ai agent loop · async loop controller · prevent infinite loops javascript · agent loop manager · llm agent runtime · iteration controller · node async loop · typescript loop library · agent workflow controller · pause resume async loop · abortcontroller loop · polling loop · retry loop
MIT © munesoft
This package's README includes a Scarf pixel that anonymously counts README views on registries that render HTML (npmjs.com, GitHub).
What's collected: package name, anonymized IP-derived region, and user-agent — used solely to understand adoption and prioritize maintenance.
What's not collected: no personal data, no cookies, no tracking across sites, no telemetry from the package code itself at install or runtime. Installing or using @munesoft/loopx in your application sends nothing to Scarf or anyone else.
Opt out:
- Globally on your machine: add
disable-telemetry=trueto your~/.npmrc, or setDO_NOT_TRACK=1in your environment. Scarf respects both. - Per-render: GitHub and most viewers honor the
referrerpolicy="no-referrer-when-downgrade"attribute on the pixel; some viewers strip remote images entirely, in which case nothing is sent.
See Scarf's privacy policy for full details.
The control layer behind every AI agent.
