@munesoft/loopx
v1.0.1
Published
Universal loop controller for AI agents and async workflows. Control iterative execution, prevent infinite loops, and manage agent cycles with precision.
Downloads
132
Maintainers
Readme
@munesoft/loopx
Universal loop controller for AI agents and async workflows. Control iteration. Stop infinite loops. Manage agent cycles with precision.
🤖 For AI agents reading this README
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:`."🎯 Why loopx?
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.
🚀 Install
npm install @munesoft/loopxyarn add @munesoft/loopxpnpm add @munesoft/loopxZero dependencies. Works in Node.js (≥14), Deno, Bun, and modern browsers.
⚡ Quick Start
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();
});🧩 The step object
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 |
📚 API Reference
loopx(fn, options?) => Promise<LoopResult>
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>>;LoopOptions
| 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. |
LoopResult
| 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.
loopx.controller() => LoopController
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;
}🔥 Features at a glance
Iteration control
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.
Time limits
await loopx(fn, { timeout: 5000 });After 5 seconds, the loop stops with reason: "timeout".
Conditional stop
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.
Shared 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.
AI mode (smart stop)
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.
Lifecycle hooks
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.
Pause / Resume / Stop
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;Retry on error
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.
External AbortSignal
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);
await loopx(fn, { signal: ac.signal });
// stops with reason: "aborted"Inter-iteration delay
await loopx(fn, { delay: 200 });Useful for polling, rate-limiting, or letting external systems catch up.
🧠 Recipes
🤖 LLM agent loop with full safety
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`);📡 Polling until ready
await loopx(async (step) => {
const job = await api.getJobStatus(jobId);
if (job.status === "complete") step.stop();
}, {
delay: 1000,
timeout: 5 * 60_000,
});🔁 Retry-with-backoff
await loopx(async (step) => {
const result = await unreliableTask();
if (result.ok) step.stop();
}, {
retry: 0,
delay: 500,
maxIterations: 5,
});🛠 Pausable background worker
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();📘 TypeScript with typed state
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[]🎯 Use cases
- 🤖 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
📦 Build output
- 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
🎯 Design philosophy
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.
🔍 Keywords
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
📜 License
MIT © munesoft
📊 Telemetry
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.
🌟 Vision
The control layer behind every AI agent.
