Skip to content

munesoft/loopx

Repository files navigation

@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


🤖 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/loopx
yarn add @munesoft/loopx
pnpm add @munesoft/loopx

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

  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.


About

Universal loop controller for AI agents and async workflows. Bound LLM agent loops, prevent infinite iterations, detect stagnation, manage state, and pause/resume — all with a one-line API. Zero dependencies. ESM + CJS + TypeScript.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors