Skip to content

Tutorial 3: Deterministic Gates

Recovery (Tutorial 2) handles failures after they happen. Gates catch a whole class of problems before an LLM is ever called, following what the paper calls Stripe's "Minions" pattern:

Anything rule-bound is kept out of the probabilistic model. Reliability comes from the constraints, not model size.

If a check can be written as a deterministic rule - valid syntax, no eval(), matches a schema, within budget - write the rule. Don't spend a model call finding out.

The gate interface

Every gate is a DeterministicGate with one method:

def check(self, context: GateContext) -> GateResult

GateResult.passed is a plain boolean, and GateResult.severity (error / warning / info) controls whether a failure blocks execution or just gets logged. loop_engine.gates ships seven gates out of the box - SyntaxGate, LintGate, TypeCheckGate, SchemaGate, BudgetGate, TestGate, and SecurityGate - and a GateRunner that runs a list of them and fails fast on the first error-severity failure.

Run a couple of gates directly

from loop_engine.gates import GateRunner, GateContext, SyntaxGate, SecurityGate

def check(label, code):
    runner = GateRunner()
    runner.add_gate(SyntaxGate())
    runner.add_gate(SecurityGate())

    result = runner.run_all(GateContext(content=code))
    print(f"{label}: passed={result.passed}")
    for gate_result in result.results:
        print(f"  [{gate_result.gate_name}] passed={gate_result.passed} - {gate_result.message}")

check("safe_code", "def add(a, b):\n    return a + b\n")
check("broken_syntax", "def add(a, b)\n    return a + b\n")
check("dangerous_code", "def run_command(cmd):\n    return eval(cmd)\n")

Real, unedited output:

safe_code: passed=True
  [syntax] passed=True - Python syntax valid
  [security] passed=True - No obvious security issues found

broken_syntax: passed=False
  [syntax] passed=False - Syntax error: expected ':' at line 2

dangerous_code: passed=False
  [syntax] passed=True - Python syntax valid
  [security] passed=False - Security issues found: 1

SecurityGate matches a fixed list of dangerous patterns (eval(, exec(, os.system(, subprocess with shell=True, unsafe pickle/yaml.load, ...) - it's intentionally simple regex matching, not a substitute for a real static analyzer, but it's free, instant, and catches the obvious cases before anything expensive runs.

Gate an Actor before it "spends a token"

The real value of gates is inside a component, rejecting bad input before the expensive step happens. Here's an actor that gate-checks generated code before "accepting" it - in a real actor, the accept path is where you'd call the model or apply the patch:

class GatedActor:
    def __init__(self):
        self.gates = GateRunner()
        self.gates.add_gate(SyntaxGate())
        self.gates.add_gate(SecurityGate())
        self.llm_calls = 0

    async def execute(self, step, observations, context):
        generated_code = step.metadata["generated_code"]
        gate_result = self.gates.run_all(GateContext(content=generated_code))

        if not gate_result.passed:
            failed = [r.message for r in gate_result.failed_gates]
            raise ValueError(f"Gate check failed, not calling the model: {failed}")

        self.llm_calls += 1
        return {"accepted_code": generated_code}

Feeding it the dangerous snippet from above:

Rejected: Gate check failed, not calling the model: ['Security issues found: 1']
llm_calls made: 0

The raised ValueError becomes a Failure the way any actor exception does - which means Tutorial 2's recovery strategies apply here too. A gate rejection isn't a dead end; it's a normal failure your RECOVERY component can act on (retry with feedback, escalate to a human, or circuit-break if it keeps happening).

Pre-built gate sets

StandardGates bundles common combinations so you don't hand-assemble a GateRunner every time:

from loop_engine.gates import StandardGates

StandardGates.python_code()          # Syntax + Lint + Security
StandardGates.python_with_tests()    # + TypeCheck + Test
StandardGates.json_output(my_schema) # SchemaGate against a JSON Schema dict
StandardGates.budget_protected(max_tokens=50_000, max_cost=5.0)

LintGate and TypeCheckGate shell out to ruff/mypy; if the tool isn't installed, the gate passes with severity="warning" rather than blocking your loop on a missing dev dependency.

What's next

Gates and recovery both operate within a single run. Tutorial 4 covers what survives between runs - state persistence - and how to keep a human in the loop at the moments that actually matter.