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,
)
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
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+ parameters | Four function arguments. |
| Pydantic model configs | Plain functions. |
| Jinja2 templates in config | Templates passed as strings. |
| FormatError / InterruptAgentFlow hierarchy | Ok | Err. |
| Inner retry loop for format errors | Error as user message; outer loop continues. |
| 1000+ lines of boilerplate | One 47-line function. Same capability, different shape. |
| G — invoke | litellm_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 — parse | regex_parse — fenced blocks, returns all matches; plain text means the model is finished → exit:task_complete. toolcall_parse — JSON tool-call payloads. |
| V2 — validate | local_env — subprocess with output truncation and exit-signal detection. super_env — larger limits for long-running work. |
| emit — IO | save_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-compiler | free-text mission → complete validated launch |
| spoke-lint | static validator: prompt's spoke invocations vs real argparse |
| loop-doctor | pre-launch readiness auditor, GO / NO-GO |
| launch-gate | launch-moment gate: redirect safety, endpoint contention, wall sizing |
| fourseer | per-cycle metrics, failure taxonomy, plan drift |
| sentry | rescue supervisor: driver death, wall-kills, stalls → relaunch or kill |
| fleet | portfolio 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.