Four functions compose. The loop is the evaluator.
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,
)invoke : G -- messages → Result[raw]
parse : V1 -- raw → Result[list[action]]
validate : V2 -- action → Result[observation | Exit]
emit : IO -- (messages, outcome) → Path
The loop chains them: (G → V1 → [V2, V2, ...])* → emit
Each step: G queries the model, V1 extracts all actions, V2 executes each one. If V1 fails, the error becomes a user message and the loop continues — the model sees its mistake and self-corrects on the next turn. Four functions compose.
The entire evaluator, verbatim from src/four/core.py:
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):
# G: invoke
raw = G(messages)
if isinstance(raw, Err):
return emit(messages, f"model_error: {raw.error}")
# Append assistant message (for chat variant to see its own output)
messages.append({"role": "assistant", "content": raw.value})
# V1: parse
actions = V1(raw.value)
if isinstance(actions, Err):
# exit:* signals are terminal, not format errors
if actions.error.startswith("exit:"):
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}. Please respond with exactly one bash command in the expected format.",
})
continue
consecutive_format_errors = 0
# V2: validate / execute each action
for action in actions.value:
command = action["command"] if isinstance(action, dict) else action
tool_call_id = action.get("tool_call_id") if isinstance(action, dict) else None
result = V2(command)
if isinstance(result, Err):
return emit(messages, result.error)
observation = result.value
if tool_call_id:
observation["tool_call_id"] = tool_call_id
messages.append(observation)
return emit(messages, "max_steps_reached")That's the whole thing. 47 lines, no framework. No config files. No YAML. No SDK. No Pydantic models. No Jinja2 templates baked into the code. Four functions that take and return well-typed values, chained in a loop. Read it once and note the exits: the model fails (model_error), the model is done (exit:* from V1 or V2), or the budget runs out (max_steps_reached). Termination is a value, not an exception.
The test suite runs this loop with mock G functions — 41 tests, no LLM required.
Four is the minimum. Remove any one and the loop breaks:
- No G → nothing to evaluate
- No V1 → can't extract actions from raw text
- No V2 → can't execute or observe
- No emit → can't persist results
Format recovery is built into the loop — no separate function needed.
| mini-swe-agent | four |
|---|---|
| YAML config with 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 | Format error as user message, outer loop continues |
| 1000+ lines of boilerplate | One 47-line function |
Same capability. Different shape.
G — invoke. Queries the LLM. Returns Ok(text) or Err(reason).
litellm_invoke()— plain text with markdown code blockslitellm_toolcall_invoke()— structured tool callshttp_response_invoke()— Responses APIcontext_aware_invoke()— fast model for lean turns, escalates to a large-context model past a token limitsummarizing_invoke()— progressively summarizes history instead of truncating itretry_invoke(fn)— wraps any G: exponential backoff on transient errors, immediate abort on auth errors
V1 — parse. Extracts actions from raw output. Returns Ok(list[action]) or Err(reason).
regex_parse()— fenced code blocks (```mswea_bash_command,```bash,```sh) plus<code>tags; returns all matches. Plain text with no block means the model is finished →exit:task_completetoolcall_parse()— JSON tool-call payloads → list of{command, tool_call_id}
V2 — validate. Executes each action. Returns Ok(observation) or Err(exit).
local_env()— subprocess execution with output truncation and exit-signal detectionsuper_env()— same, with larger limits for long-running work
emit — IO. Saves the trajectory. Returns Path.
save_trajectory()— JSON files with outcome and full message history
A fair question, and the right one to ask first. Here is the claim, precisely:
Ok/Erris the error monad.G,V1,V2are Kleisli arrows over it: each takes a value and returns a computation that may fail. Binding is: feed the value forward, or short-circuit onErr.runis 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 the message state. Each pass is an approximation;max_stepsis where the approximation sequence is truncated;Erris where the step maps into a sink. That's why termination is a value: the absorbing state, not the exception.- The evaluator is universal: it doesn't know what it's evaluating. It sees the shapes of the values flowing through it and nothing else. Swap
V1/V2/emitand it's a different machine; the loop is unchanged. - And it is closed under self-application. The package ships generators (
src/four/generators/) that emit new spoke programs — and each generated program is the same 47-line loop with new functions. The system can evaluate itself.
That last line is not a metaphor. It's why the next section exists.
The loop is not a demo framework. It is a production builder, and the toolchain around it is its output. Every repository below was produced by a pipeline run of the loop — not hand-written:
| Repo | What the loop built |
|---|---|
| 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 | loop intelligence: per-cycle metrics, failure taxonomy, plan drift |
| sentry | deterministic rescue supervisor: detects driver death, wall-kills, stalls; relaunches or kills |
| fleet | portfolio scanner: one-page status over every project |
The first four gate a launch. The last three observe and rescue a run. The same algebra, applied to the loop itself.
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 (trajectories, logs, git history). The loop built its own observer and its own supervisor.
The same algebra, different domains:
| Domain | V2 validates | emit produces |
|---|---|---|
| Notebooks | AST + chart execution | .ipynb with embedded PNGs |
| Agents | bash execution | JSON trajectory |
| Python code | type checking | .py files |
| CLI tools | compilation | binary + man page |
| Observers | artifact parsing | metrics + taxonomy (fourseer) |
| Supervisors | process state | rescue actions (sentry) |
The loop doesn't know what it's evaluating. It only chains Result types.
Evidence you can check right now:
examples/agents/*.report.json— real trajectories from live runs, checked into this repo.- This repo's own git history — the loop's experiments on itself: a text-transform pipeline, a defrag of the delivery path, a dual stream/PR pipeline. One commit is just the word "sorry."
- The per-project build records (how each of the seven repos above was produced) are held privately by the operator. The artifacts are the repos.
Every component is swappable. The loop doesn't care:
# Tool-calling instead of regex
G=litellm_toolcall_invoke("openai/gpt-4o"),
V1=toolcall_parse(),
# Two-model escalation: fast for lean turns, deep for large context
G=context_aware_invoke(
fast_model="openai/fast-qwen",
large_model="openai/qwen",
context_limit=50_000,
),
# Summarize history instead of truncating
G=summarizing_invoke(litellm_invoke("openai/gpt-4o"),
summarize_model="openai/fast-qwen"),
# Retry on transient errors
G=retry_invoke(litellm_invoke("openai/gpt-4o")),
# Larger limits for long-running work
V2=super_env(),
# Abort after 2 format errors instead of 3
max_format_errors=2,The framework doesn't call itself category theory. It calls itself algebra. Four functions compose; the loop is the evaluator — a fixed point, and a system that can evaluate itself.
This repository is the algebra, complete and self-contained. The production drivers that run it against real projects are private; the artifacts they produce are the repos above.
#agenticcoding #functional-programming #python #llm #agents #monads #fixed-point-combinator