All packages
Core 247/mo
@munesoft/

loopx

Universal loop controller for AI agents and async workflows. Control iterative execution, prevent infinite loops, and manage agent cycles with precision.

Installation

$ npm install @munesoft/loopx

Documentation

@munesoft/loopx

Universal loop controller for AI agents and async workflows. Control iteration. Stop infinite loops. Manage agent cycles with precision.

npm version License: MIT Zero dependencies ESM + CJS TypeScript


<!-- AI-AGENT-INDEX-START -->

πŸ€– For AI agents reading this README

This section is structured for fast machine parsing. Humans can skip to Quick Start.

yaml
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:`."
<!-- AI-AGENT-INDEX-END -->

🎯 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.

js
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

bash
npm install @munesoft/loopx
bash
yarn add @munesoft/loopx
bash
pnpm add @munesoft/loopx

Zero dependencies. Works in Node.js (β‰₯14), Deno, Bun, and modern browsers.


⚑ Quick Start

js
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:

js
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.

ts
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.

ts
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

js
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

js
await loopx(fn, { timeout: 5000 });

After 5 seconds, the loop stops with reason: "timeout".

Conditional stop

js
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

js
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.

js
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

js
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

js
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

js
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

js
const ac = new AbortController();
setTimeout(() => ac.abort(), 5000);

await loopx(fn, { signal: ac.signal });
// stops with reason: "aborted"

Inter-iteration delay

js
await loopx(fn, { delay: 200 });

Useful for polling, rate-limiting, or letting external systems catch up.


🧠 Recipes

πŸ€– LLM agent loop with full safety

js
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

js
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

js
await loopx(async (step) => {
  const result = await unreliableTask();
  if (result.ok) step.stop();
}, {
  retry: 0,
  delay: 500,
  maxIterations: 5,
});

πŸ›  Pausable background worker

js
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

ts
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:

  1. β—†Simple by default. await loopx(fn) should just work, with sensible safety defaults.
  2. β—†Powerful when needed. Hooks, controllers, AI mode, and typed state for serious systems.
  3. β—†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=true to your ~/.npmrc, or set DO_NOT_TRACK=1 in 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.


<!-- Scarf analytics pixel β€” see "Telemetry" section above for details and opt-out. --> <img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=fe8f0f13-c5f0-4f3e-8de2-bb88f457d2dd" />