Package Exports
- @munesoft/loopx
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.