four

functions compose. the loop is the evaluator. · the engineering answer to the agentic category error

The manifesto on the front page ends in a trap: a probabilistic function approximator cannot verify itself, so an "agent" that grades its own thinking is a closed loop with no external oracle. four is what you build once you accept that. Don't make the model smarter. Make everything around the model deterministic code — and make the loop itself small enough to read in one sitting.

An agent that runs bash. A generator that produces notebooks. A tool that writes Python. A supervisor that rescues the agent. Same loop. Different functions.

from four import run, litellm_invoke, regex_parse, local_env, save_trajectory

run(
    G=litellm_invoke("anthropic/claude-sonnet-4-5-20250929"),
    V1=regex_parse(),
    V2=local_env(),
    emit=save_trajectory(),
    system="You are a helpful assistant that executes bash commands.",
    prompt="Find all Python files in /tmp and count lines in each",
    max_steps=50,
)
47
lines — the entire evaluator
4
functions — the minimum closure
41
tests, zero LLM calls required
12
repos built by the loop itself

Four typed boundaries, nothing else:

invoke   : G   -- messages → Result[raw]
parse    : V1  -- raw → Result[list[action]]
validate : V2  -- action → Result[observation | Exit]
emit     : IO  -- (messages, outcome) → Path
( G → V1 → [V2, V2, …] )* → emit each step: G queries the model · V1 extracts all actions · V2 executes each one · format errors re-enter as user messages

Why four? It is the minimum closure. Remove any one and the loop breaks: no G → nothing to evaluate; no V1 → no actions in raw text; no V2 → no execution, no observation; no emit → nothing persists. Format recovery is built into the loop — no fifth function needed.

def run(G, V1, V2, emit, system, prompt, max_steps=100, max_format_errors=3):
    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": prompt},
    ]
    consecutive_format_errors = 0

    for step in range(max_steps):
        raw = G(messages)                              # G: invoke
        if isinstance(raw, Err):
            return emit(messages, f"model_error: {raw.error}")
        messages.append({"role": "assistant", "content": raw.value})

        actions = V1(raw.value)                        # V1: parse
        if isinstance(actions, Err):
            if actions.error.startswith("exit:"):       # terminal, not an error
                return emit(messages, actions.error)
            consecutive_format_errors += 1
            if 0 < max_format_errors <= consecutive_format_errors:
                return emit(messages, f"repeated_format_error: {actions.error}")
            messages.append({"role": "user", "content":
                f"Format error: {actions.error}. Respond with one command."})
            continue
        consecutive_format_errors = 0

        for action in actions.value:                   # V2: validate / execute
            command = action["command"] if isinstance(action, dict) else action
            result = V2(command)
            if isinstance(result, Err):
                return emit(messages, result.error)
            messages.append(result.value)

    return emit(messages, "max_steps_reached")

47 lines. No framework. No config files. No YAML. No SDK. No Pydantic models. Termination is a value, not an exception — note the exits: the model fails (model_error), the model is done (exit:* from V1 or V2), the budget runs out (max_steps_reached). The category error said the model can't tell you when it's finished; so completion is never the model's word — it is a signal the deterministic boundary parses, or a budget the harness enforces. Self-correction is likewise not trust in the model: a parse failure becomes a user message, and the model sees its mistake from the outside.

YAML config, 40+ parametersFour function arguments.
Pydantic model configsPlain functions.
Jinja2 templates in configTemplates passed as strings.
FormatError / InterruptAgentFlow hierarchyOk | Err.
Inner retry loop for format errorsError as user message; outer loop continues.
1000+ lines of boilerplateOne 47-line function. Same capability, different shape.
G — invokelitellm_invoke · litellm_toolcall_invoke · http_response_invoke (Responses API) · context_aware_invoke (fast model on lean turns, escalates past a token limit) · summarizing_invoke (summarizes history instead of truncating) · retry_invoke wraps any G with exponential backoff.
V1 — parseregex_parse — fenced blocks, returns all matches; plain text means the model is finished → exit:task_complete. toolcall_parse — JSON tool-call payloads.
V2 — validatelocal_env — subprocess with output truncation and exit-signal detection. super_env — larger limits for long-running work.
emit — IOsave_trajectory — JSON with outcome and full message history. These artifacts are what the deterministic toolchain reads.
Ok/Err is the error monad. G, V1, V2 are Kleisli arrows over it: each takes a value and returns a computation that may fail. Binding feeds the value forward, or short-circuits on Err.
run is a fixed-point combinator. The star in (G → V1 → V2*)* is the Kleene star — the loop computes the least fixed point of the step function on message state. max_steps truncates the approximation; Err is the sink. That is why termination is a value: the absorbing state, not the exception.
The evaluator is universal. It doesn't know what it evaluates — it sees only the shapes of values flowing through it. Swap V1/V2/emit and it's a different machine; the loop is unchanged. And it is closed under self-application: the package ships generators whose output is the same 47-line loop with new functions. The system can evaluate itself.

Every repository below was produced by a pipeline run of the loop — not hand-written. The first four gate a launch; the last three observe and rescue a run:

mission-compilerfree-text mission → complete validated launch
spoke-lintstatic validator: prompt's spoke invocations vs real argparse
loop-doctorpre-launch readiness auditor, GO / NO-GO
launch-gatelaunch-moment gate: redirect safety, endpoint contention, wall sizing
fourseerper-cycle metrics, failure taxonomy, plan drift
sentryrescue supervisor: driver death, wall-kills, stalls → relaunch or kill
fleetportfolio scanner: one-page status over every project

The load-bearing line: the model is the only non-deterministic component in the system. Everything that verifies a run, observes it, and rescues it is deterministic code reading the artifacts the loop writes. The loop built its own observer and its own supervisor. That is the external oracle the category error demands — not inside the model, around it.

the manifesto · / ice graph — semantic IoT lakehouse hire me — business card github.com/belarusian
READ THE 47 LINES DISCUSS FOUR