Stack-safe async tail-call optimization for Python agent loops and state machines.
Project description
Loom
Stack-safe async state machines for Python.
Loom lets you write long-running async processes as explicit state transitions:
from loom import tailrec
@tailrec
async def agent_loop(state):
if state.done:
return state
event = await run_next_step(state)
return await agent_loop(state.apply(event))
@tailrec rewrites the tail-position return await agent_loop(...) into an
async while loop. You keep the recursive state-machine shape, but runtime
uses constant stack.
Install
pip install loom-tailcalls
Why Loom
Python already has while, but long-running async systems often want a
different shape:
state -> await step -> event -> next state -> ... -> result
That is the natural shape of agent runners, workflow engines, retry/backoff systems, protocol sessions, streaming parsers, and resumable state machines. Without Loom, Python forces these systems into manual mutable loops:
async def run_job(state):
if state.done:
return state.result
while True:
event = await execute_next_step(state)
state = state.apply(event)
if state.done:
return state.result
That works, but as workflows grow, the loop often accumulates flags, mutable
locals, nested continue/break, retry counters, checkpoints, and stream
state. Loom lets the transition stay explicit:
@tailrec
async def run_job(state):
if state.done:
return state.result
event = await execute_next_step(state)
return await run_job(state.apply(event))
This is not recursion for recursion's sake. It is a way to model a long-running process as a pure-ish transition:
delta : State -> Result + State
Concrete jobs this unlocks in Python:
- Agent/tool runtimes: conversation state -> tool/model call -> updated state -> final answer.
- Retry/backoff workflows: attempt state -> async call -> delay/update -> next attempt.
- Polling monitors: snapshot -> await next check -> compare/update -> continue.
- Protocol/session handlers: session state -> receive message -> transition -> respond/continue.
- Workflow/saga engines: workflow state -> run step -> persist checkpoint -> continue/compensate/finish.
- Streaming parsers and agents: parser state -> read chunk -> emit events -> next parser state.
- Small interpreters/evaluators: machine state -> execute instruction -> next machine state.
Plain async recursion can express the same transition style, but it grows the Python call stack. Loom keeps the transition style and removes the stack growth.
Run the comparison:
python3 examples/deep_agent_loop_comparison.py
Expected output:
plain recursion failed: RecursionError
loom recursion survived: AgentState(remaining=0, total=100000)
Who It Is For
Loom is for long-running async workflows that are naturally written as tail-recursive state transitions:
- AI agent and tool loops: think, call a tool, update state, continue.
- Async orchestration and workflow engines with many sequential steps.
- Retry/backoff jobs, polling monitors, protocol sessions, and resumable workflows.
- Streaming agents that yield events while moving to the next state.
- Explicit state machines shaped as
state -> next_state -> result. - Libraries that need a small, testable, semantics-preserving transform rather than an ad hoc recursion trick.
The goal is:
write async tail recursion
run it as a loop
keep O(1) stack
preserve observable behavior
Loom is not a general Python speed optimizer. If the code is already a simple
CPU-bound for or while loop, write the loop directly. For numeric or
maximum-throughput CPU work, use the usual tools: vectorization, native
extensions, Cython, Rust, or similar.
Loom is also intentionally narrow: non-tail recursion such as
return 1 + await fn(...) is rejected rather than transformed unsafely.
In one sentence:
Loom is a stack-safety compiler for async tail-recursive state machines.
For streaming agents, @tailstream optimizes this terminal async-generator pattern:
from loom import tailstream
@tailstream
async def stream_agent(state):
if state.done:
yield {"type": "final", "state": state}
return
async for event in run_step(state):
yield event
async for event in stream_agent(state.next()):
yield event
return
Comparison
Loom is a stack-safety transform, not a full agent or workflow framework.
| Approach | Async | Stack-safe | Streaming | Role |
|---|---|---|---|---|
| Loom | yes | yes | @tailstream |
Write tail-recursive async state machines |
Hand-written while |
yes | yes | manual | Same runtime shape, more mutable loop state |
| tacopy | no | yes | no | Sync-only AST tail-call optimization |
| LangGraph / Temporal | yes | n/a | yes | Orchestration, persistence, tools — different layer |
Loom is primarily about stack safety and explicit transitions, not beating a
hand-written loop on speed. On CPython 3.11+ with direct rebinding, a local
benchmark at n=100000 typically shows roughly 1.1–1.2x overhead versus an
equivalent hand-written while loop. See
docs/performance-tracing.md for the measurement
helper and expected overhead model.
Hooks And Guardrails
Hooks, iteration budgets, and checkpoints belong outside @tailrec. Loom
transforms the loop body; cross-cutting concerns compose around the step
function or live in immutable state:
async def run_step_with_hooks(state, hooks):
for hook in hooks:
await hook.before_step(state)
event = await run_step(state)
for hook in hooks:
event = await hook.after_step(state, event)
return event
Runaway-loop protection works the same way: keep a remaining_steps (or similar)
field in state and terminate when the budget is exhausted. See
examples/agent_loop_with_budget.py and
examples/agent_loop_with_hooks.py.
Debugging Transforms
If @tailrec or @tailstream rejects a function, call explain_tailcalls(fn)
to inspect optimized sites, binding mode, and rejected recursive calls:
from loom import explain_tailcalls, tailrec
@tailrec
async def agent_loop(state):
...
print(explain_tailcalls(agent_loop))
Rejected shapes include non-tail returns such as return 1 + await fn(...),
recursive calls inside try/with blocks, and **kwargs expansion in tail
calls. Error messages include line/column and a fix hint.
Run
python3 -m unittest tests.test_loom_tailcalls
python3 -m unittest discover -s tests
python3 examples/agent_tool_loop.py
python3 examples/agent_loop_with_budget.py
python3 examples/agent_loop_with_hooks.py
python3 examples/deep_agent_loop_comparison.py
python3 examples/streaming_agent.py
python3 scripts/bench_tailcalls.py --n 100000 --samples 5
Optional local Ollama fuzzing chooses trusted test templates and bounded parameters; it does not execute model-written Python:
LOOM_RUN_OLLAMA=1 python3 -m unittest tests.test_ollama_contract
API
tailrec: optimizes async self-tail recursion viareturn await fn(...).tailstream: optimizes terminal recursive async-generator streaming loops.explain_tailcalls: returns readable or JSON-serializable transform metadata.
Formal Core
Loom's correctness contract is specified in docs/formal-core.md. Future transforms should preserve that model or extend it explicitly before accepting new source shapes.
Mathematically, Loom is grounded in operational semantics, transition systems, partial functions, induction, and semantics-preserving program transformation. Set theory supplies the domains; operational semantics supplies the execution model.
Opcode tracing and performance expectations are described in docs/performance-tracing.md.
Concurrency expectations are described in docs/concurrency-contract.md.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file loom_tailcalls-0.1.0.tar.gz.
File metadata
- Download URL: loom_tailcalls-0.1.0.tar.gz
- Upload date:
- Size: 21.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3597295efb364cd9a9e53cf70bb5e46c3ffee0e77d01242f4d6c840b33411f84
|
|
| MD5 |
429f7ec558ff3c861e4b2023b69458e0
|
|
| BLAKE2b-256 |
9b49ff89a70784e256c3f4beaaa80779659282c2a33721c78a5a0159c5e7de5b
|
File details
Details for the file loom_tailcalls-0.1.0-py3-none-any.whl.
File metadata
- Download URL: loom_tailcalls-0.1.0-py3-none-any.whl
- Upload date:
- Size: 11.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a55213bbcde41982f711bf2dc28ddbdf105f2ebf30de57e7c6a6f3a7e821e7cb
|
|
| MD5 |
be03c71052f171e4f22439e537033f6f
|
|
| BLAKE2b-256 |
c268530df4ff75061946c96d11882091caeca300e30ca683fa2fc31072a201c0
|