LangENKO

Loop closure

Pure detector + emitter framework that recognizes stall patterns in agent output (stdout / stderr + exit code) and composes an advisory `nextPrompt`. No I/O. Deterministic. Safe to call on untrusted output.

since v0.25
On this page

Loop closure

A "loop closure" detector recognizes stall patterns in agent output (stdout/stderr + exit code) and emits a structured nextPrompt that a human or orchestrator can feed back into the agent.

The framework lives in packages/skills/src/loop-closure/ and is exposed as:

  • Library@mandujs/skills/loop-closure (closeLoop(), detectors, emitter)
  • MCP toolmandu.loop.close (see MCP tools)

Safety invariants

The framework is pure by design. The caller can always rely on:

  • Detectors are pure: (DetectorInput) => Evidence[]. No fs, no spawn, no fetch, no Math.random(), no time-dependent logic.
  • The emitter is pure: (Evidence[], exitCode) => LoopClosureReport.
  • closeLoop() wraps the two in a single public call. It performs no I/O.
  • Output is deterministic: identical inputs yield identical reports.
  • Output is advisory: the returned nextPrompt is plain text. It is never auto-executed and contains no shell commands to evaluate.

This matters because the framework's whole job is to process untrusted agent output. Treating it as data, never as code, is how we keep it safe.

Public API

import {
  closeLoop,
  type LoopClosureReport,
} from "@mandujs/skills/loop-closure";

const report: LoopClosureReport = closeLoop({
  stdout: child.stdout,
  stderr: child.stderr,
  exitCode: child.exitCode,
});

// {
//   stallReason: "3 test failures detected",
//   nextPrompt: "# Stall detected: 3 test failures detected (exit 1)\n...",
//   evidence: [
//     { kind: "test-failure", label: "math > divide", snippet: "math > divide" },
//     ...
//   ],
// }

Lower-level primitives are also exported:

Export Purpose
runDetectors(input, only?) Run all or a subset of detectors
emitReport(evidence, exitCode) Compose a report from pre-computed evidence
emitNoStallReport(exitCode) Zero-evidence fallback
listDetectorIds() Introspection
DEFAULT_DETECTORS The ordered registry

Individual detector functions (detectTypecheckErrors, detectTestFailures, etc.) are also exported for callers composing their own pipelines.

Detectors

The default set covers ten stall categories, in priority-descending order:

ID Evidence kind What it matches
typecheck-error typecheck-error path/file.ts(line,col): error TSxxxx: … from tsc/Bun
test-failure test-failure (fail) <describe> > <case> from bun test
missing-module missing-module Cannot find module 'x', Could not resolve 'x', bundler variants
syntax-error syntax-error SyntaxError: … banners from parsers
not-implemented not-implemented Error: not implemented, throw new Error("not implemented"), NotImplementedError
unhandled-rejection unhandled-rejection Node/Bun Unhandled Promise Rejection / UnhandledPromiseRejectionWarning
incomplete-function incomplete-function Empty function bodies, TODO-only arrow expressions
todo-marker todo-marker TODO: / TODO(reviewer): markers
fixme-marker fixme-marker FIXME: / FIXME(reviewer): markers
stack-trace stack-trace at fn (/path:line:col) frames — gated on non-zero exit, capped at 3 frames

Evidence shape

interface Evidence {
  kind: EvidenceKind;
  file?: string;
  line?: number;
  snippet: string;
  label?: string;
}

Detectors err on the side of low false-positives — well-formed Mandu source text produces zero matches. The unhandled-rejection detector uses word boundaries so identifiers containing "UnhandledRejection" (like detectUnhandledRejection) don't match their own source code.

Emitter

The emitter composes a multi-section nextPrompt:

# Stall detected: <reason> (exit <code>)

## Fix by:
<a conservative suggestion aligned with the primary evidence kind>

## Primary evidence (<label>):
- [<kind>] <file>:<line> — <snippet>
- ...

## Other signals:
- <other kind>: <count>
- ...

## Files touched:
- <sorted list of unique files, capped at 25>

## Next step:
Re-read the failing output, patch the listed files, then re-run the
failing command to verify.

Priority selection

The dominant evidence kind wins stallReason. Example: if a run contains both typecheck errors AND test failures, the typecheck errors are reported as primary — fixing them is a prerequisite for the tests to even compile.

Determinism

  • Primary evidence is sorted by (file, line) before rendering.
  • File lists are alphabetically sorted.
  • No timestamps, no random IDs, no environment reads.

Edge cases

  • Zero evidence + exit 0stallReason: "no-stall-detected", nextPrompt says to proceed.
  • Zero evidence + non-zero exitstallReason: "no-patterns-matched", prompt invites manual inspection or a new detector.
  • Empty input → treated as exit 0.
  • Oversized streamscloseLoop() truncates each stream to the last 1,000,000 characters (tails are kept — error banners live at the end).
  • Malformed input (non-string stdout, non-finite exit code) → defaults are applied silently; closeLoop() is total.

Extending the framework

To add a new detector:

  1. Write a pure function conforming to Detector in packages/skills/src/loop-closure/detectors.ts.
  2. Register it in DEFAULT_DETECTORS at the right priority position.
  3. Add a test in packages/skills/src/loop-closure/__tests__/detectors.test.ts — one positive case, one negative (clean-output) case, and a determinism assertion.
  4. Update the emitter's LABELS and suggestFix() helpers with a human-readable label and suggestion for the new EvidenceKind.

A new detector must pass the negative-control test: running it against a real Mandu source file from the repo must produce zero evidence.

Example usage — MCP loop

The primary consumer is the mandu.loop.close MCP tool. The intended flow:

1. Agent runs `mandu test` → exit 1
2. Orchestrator captures stdout/stderr/exitCode
3. Orchestrator calls MCP tool `mandu.loop.close`
     ↓
   stallReason: "3 test failures detected"
   nextPrompt:  "# Stall detected..."
4. Orchestrator concatenates nextPrompt onto the agent's next turn
5. Agent patches the failing tests, reruns, exits 0 this time

mandu.loop.close is declared readOnlyHint: true — orchestrators can call it speculatively with no side effects.

Library vs MCP

Concern Library (@mandujs/skills/loop-closure) MCP (mandu.loop.close)
Call site In-process (Node/Bun test, skill generator) Across MCP boundary (JSON-RPC)
Latency microseconds milliseconds (IPC)
Composition Can use lower-level primitives (runDetectors) Higher-level only — accepts raw streams + exit code
When to use Composing a pipeline that already runs in-process Exposing the detector to Claude/Cursor/Continue

🤖 Agent Prompt

🤖 Agent Prompt — Loop closure
Apply the guidance from the Mandu docs page at https://mandujs.com/docs/ai/loop-closure to my project.

Summary of the page:
Library: `@mandujs/skills/loop-closure` → `closeLoop()`. MCP tool: `mandu.loop.close`. 10 detectors in priority order: typecheck-error > test-failure > missing-module > syntax-error > not-implemented > unhandled-rejection > incomplete-function > todo-marker > fixme-marker > stack-trace. All pure functions — never `fs`/`spawn`/`fetch`/`Math.random`/timestamps.

Required invariants — must hold after your changes:
- Every detector is a pure function: `(DetectorInput) => Evidence[]`
- Emitter is pure: `(Evidence[], exitCode) => LoopClosureReport`
- `closeLoop()` performs NO I/O — never `fs`, `spawn`, `fetch`, `Math.random`, or time-dependent logic
- Output is deterministic — identical inputs yield identical reports
- Oversized streams are truncated to the last 1,000,000 characters (tails kept — banners live at the end)
- Detectors err on LOW false-positive — well-formed Mandu source text produces zero matches

Then:
1. Make the change in my codebase consistent with the page.
2. Run `bun run guard` and `bun run check` to verify nothing
   in src/ or app/ breaks Mandu's invariants.
3. Show me the diff and any guard violations.

For Agents

{
  "schema": "mandu.loop.closure/v0.25",
  "library": "@mandujs/skills/loop-closure",
  "mcp_tool": "mandu.loop.close",
  "purity": {
    "no_fs": true,
    "no_spawn": true,
    "no_fetch": true,
    "no_random": true,
    "no_time": true,
    "deterministic": true
  },
  "detectors_priority_order": [
    "typecheck-error",
    "test-failure",
    "missing-module",
    "syntax-error",
    "not-implemented",
    "unhandled-rejection",
    "incomplete-function",
    "todo-marker",
    "fixme-marker",
    "stack-trace"
  ],
  "truncation_char_limit": 1000000,
  "evidence_shape": "{ kind, file?, line?, snippet, label? }",
  "rules": [
    "Output is advisory — never auto-execute `nextPrompt`",
    "Detectors must pass a negative-control test (zero evidence on real Mandu source)",
    "stack-trace detector is gated on non-zero exit, capped at 3 frames"
  ]
}

For Agents

AI hint

Library: `@mandujs/skills/loop-closure` → `closeLoop()`. MCP tool: `mandu.loop.close`. 10 detectors in priority order: typecheck-error > test-failure > missing-module > syntax-error > not-implemented > unhandled-rejection > incomplete-function > todo-marker > fixme-marker > stack-trace. All pure functions — never `fs`/`spawn`/`fetch`/`Math.random`/timestamps.

Invariants
  • Every detector is a pure function: `(DetectorInput) => Evidence[]`
  • Emitter is pure: `(Evidence[], exitCode) => LoopClosureReport`
  • `closeLoop()` performs NO I/O — never `fs`, `spawn`, `fetch`, `Math.random`, or time-dependent logic
  • Output is deterministic — identical inputs yield identical reports
  • Oversized streams are truncated to the last 1,000,000 characters (tails kept — banners live at the end)
  • Detectors err on LOW false-positive — well-formed Mandu source text produces zero matches
Guard scope
loop-closure