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.
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 tool —
mandu.loop.close(see MCP tools)
Safety invariants
The framework is pure by design. The caller can always rely on:
- Detectors are pure:
(DetectorInput) => Evidence[]. Nofs, nospawn, nofetch, noMath.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
nextPromptis 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 0 →
stallReason: "no-stall-detected",nextPromptsays to proceed. - Zero evidence + non-zero exit →
stallReason: "no-patterns-matched", prompt invites manual inspection or a new detector. - Empty input → treated as exit 0.
- Oversized streams →
closeLoop()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:
- Write a pure function conforming to
Detectorinpackages/skills/src/loop-closure/detectors.ts. - Register it in
DEFAULT_DETECTORSat the right priority position. - 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. - Update the emitter's
LABELSandsuggestFix()helpers with a human-readable label and suggestion for the newEvidenceKind.
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
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.
Related
- AI — MCP tools —
mandu.loop.closetool spec and input/output shape. - AI — Prompts — the system prompt /
nextPromptlineage. - Skills generator — the wider
@mandujs/skillspackage.
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
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.
- 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