bounded-loops is built as a hexagonal (ports-and-adapters) system. The
motivation is not academic: the one invariant the whole project exists to
guarantee — the engine never trusts the agent's own claim of "done" — has
to be true no matter which agent, which gate, or which storage backend a
given loop uses. Hexagonal architecture is what makes that provable instead
of merely documented: the rule lives in exactly one file
(bounded_loops/application/run_loop.py), every adapter is swappable behind
a Protocol, and there is exactly one place in the codebase allowed to wire
a concrete adapter in.
Domain (bounded_loops/domain/) is pure data and pure functions: no I/O,
no framework, stdlib imports only. models.py defines the frozen
dataclasses that flow through the whole system — Spec, Bounds,
Verdict, RunResult, LoopContext, LedgerEntry, Outcome — plus the
Rung (L1/L2/L3) and Status (DONE/HALT/PAUSE/KILLED/ERROR) enums. rules.py
holds the three pure predicates the engine calls: stop_condition_met,
no_progress, rung_requires_approval. errors.py defines the exception
taxonomy (ManifestError, RunnerError, GateError,
KillSwitchTripped) — notably, a gate that runs and reports failure is
not an error; Verdict(passed=False, ...) is the normal shape of "the
agent hasn't fixed it yet." GateError is reserved for the gate itself
being unable to execute (binary missing, timeout, crash).
Application (bounded_loops/application/) is the orchestration layer.
ports.py is the seam: nine @runtime_checkable Protocols
(RunnerPort, GatePort, MemoryPort, LedgerPort, TracerPort,
BudgetMeterPort, KillSwitchPort, ApprovalPort, ClockPort) that any
adapter must satisfy structurally — no inheritance required. run_loop.py
implements RunLoopUseCase, the single lap loop described below.
bounds.py's BoundsEnforcer owns the no-progress history (the sliding
window of "did the workspace change this lap"), delegating the actual
predicate to domain.rules.no_progress. manifest.py loads and validates
loop.yaml + bounds.yaml into one frozen LoopManifest — the single
shape composition.py consumes. Application code imports domain and ports
only; it never imports a concrete adapter class.
Adapters (bounded_loops/adapters/) are the concrete implementations:
adapters/runners/ (stub, shell, python_callable, claude-code, codex,
antigravity, docker, worktree, plus the AnchorGuardRunner decorator),
adapters/gates/ (command, pytest, jsonschema, composite, osv, checkov, and
the typed external gates: gitleaks, semgrep, trivy, promptfoo,
great_expectations, axe), and adapters/io/ (file-based
ledger and memory, OTel/no-op tracer, the token/wallclock budget meter, the
env-var kill switch, CLI/auto approval, the UTC clock). Every adapter
implements exactly one port and imports nothing from application/ beyond
the port protocols and domain models it needs.
bounded_loops/composition.py is the only module in the codebase permitted
to import concrete adapter classes — its own docstring states the rule:
"THIS FILE imports everything (that's its job)... No other file may import
from adapters/runners/* or adapters/gates/* directly." Its public function,
wire(manifest, ...), takes a validated LoopManifest and returns a fully
assembled RunLoopUseCase ready to call .run(). Concretely, wire():
resolves the runner (from RUNNER_REGISTRY, or a CLI --runner override),
resolves the gate (from GATE_REGISTRY, or a CLI --gate-override that
wraps any shell command as a CommandGate), applies a --max-iterations
override by constructing a new frozen Bounds (never mutating the existing
one), builds an isolated scratch workspace by copying the loop's seed/
into a fresh tempfile.mkdtemp() directory, wraps whichever runner was
chosen in AnchorGuardRunner (workspace-integrity enforcement for every
runner, not just the stub), and finally constructs the I/O adapters
(ledger, memory, tracer, budget meter, kill switch, approval, clock) before
assembling the RunLoopUseCase.
Two things about this wiring are deliberate, not incidental. First, ledger
and memory files live at the loop-dir level, outside the scratch
workspace the agent can write to — an adversarial or buggy agent cannot
rewrite its own audit trail, because the audit trail isn't in its sandbox.
Second, the runner is instantiated by bare global class name inside
_instantiate_runner, not via a dict lookup on RUNNER_REGISTRY — a dict
literal captures the class object once at import time, which
unittest.mock.patch on the module attribute can't retroactively rebind;
referencing the bare name resolves from the module's live globals at call
time, which is exactly what mock.patch needs to work.
RunResult (what a runner reports after one turn) carries an
agent_claimed_done: bool field. It is recorded — every RunResult is
consumed by BoundsEnforcer.record_lap() for no-progress tracking, and its
log/tokens fields feed the ledger and budget meter — but it is never
read when deciding whether the loop exits. Read run_loop.py step by step:
after d.runner.run_once(...) returns a RunResult, the very next
consequential line is verdict = d.gate.check(ctx) — the GatePort is
called independently, against the workspace, with no visibility into what
the runner said about itself. GatePort.check()'s own docstring is
explicit: "NEVER call the agent; NEVER see RunResult." Only
verdict.passed and stop_condition_met(spec, verdict) can produce a DONE
or PAUSE outcome. agent_claimed_done is stored in the ledger as
metadata (via the cassette/log path) purely for audit — a human or a
future gate can see "the agent said it was done on lap 2" next to "the
gate did not agree until lap 4" — but it is structurally incapable of
short-circuiting the loop, because no code path branches on it.
README PNG:
diagrams/ports-and-adapters.png· regenerate withpython scripts/render_architecture_diagram.py. Detailed editable source:diagrams/ports-and-adapters.mmd· regenerate the SVG withmmdc -i diagrams/ports-and-adapters.mmd -o diagrams/ports-and-adapters.svg.
One call to RunLoopUseCase.run() executes an unbounded number of laps
until a terminal Outcome is reached. Every lap starts with the
kill-switch poll — the highest-priority stop, checked before anything else,
including the budget check.
Editable source:
diagrams/loop-flow-sequence.mmd· regenerate withmmdc -i diagrams/loop-flow-sequence.mmd -o diagrams/loop-flow-sequence.svg
Editable source:
diagrams/lap-outcomes-state.mmd· regenerate withmmdc -i diagrams/lap-outcomes-state.mmd -o diagrams/lap-outcomes-state.svg
The hexagonal, ports-and-adapters design above is not limited to a single
loop. The bounded-loops graph engine (bl graph) composes independently
gated nodes into a DAG, and the identical invariant run_loop.py enforces
for one loop — a worker never grades its own output (worker≠gate) — is
controller-enforced (GraphRunController; acceptance_gate.py;
approval_gate.py) once per node instead of once per run. Every run's
activity is captured in an append-only, hash-chained receipt log
(controller-events.jsonl) that bl graph arena renders as a read-only
projection — an audit trail a human inspects, not a node's self-report.
Start with graph-quickstart.md to run the built-in demo, see graph-capabilities.md for the full shipped-vs-deferred capability breakdown, and graph-reference-composition.md for how graph nodes compose against this same ports-and-adapters engine.
- NINE-BOUNDS.md — each bound, its
bounds.yamlfield, and the exact component that enforces it. - WRITING-A-LOOP.md — the concrete scaffold and verify protocol for authoring a new loop.
