Skip to main content

Stack-safe async tail-call optimization for Python agent loops and state machines.

Project description

Loom

PyPI Loom stack

Stack-safe async state machines for Python. Full stack overview: kroq86.github.io/loom-stack

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

Loom stack

Overview: kroq86.github.io/loom-stack — packages, flow, audience, quick start.

Three composable packages for long-running async agent loops. Each does one job; compose them as needed.

Package Install Job
loom-tailcallsthis repo pip install loom-tailcalls Write stack-safe transition loops (@tailrec, @tailstream)
flow-xray pip install flow-xray Export local HTML traces (LLM/tool calls, branches, errors)
loom-runner pip install loom-runner Checkpoint/resume in SQLite; CLI inspect (explain, history, …)
@tailrec agent loop  →  loom-runner run/resume  →  --trace trace.html
     (shape)                  (durability)              (flow-xray)

This repo is the bottom layer: stack-safe async transitions. Pair with loom-runner for durable runs and flow-xray for local HTML debug — see demo-loom-flow/.

What's new in 0.2

  • **kwargs in tail-callreturn await agent_loop(state, **bindings) without hand-written while
  • Structured tail positions — tail-call inside try/except, with/async with, and loops
  • Integration labdemo-loom-flow/ with cases 01–08 (Ollama agent, 100k stress, explain_tailcalls smoke)

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 in try finally / with context expressions / loop tests, and recursive calls that are not returned. Error messages include line/column and a fix hint.

Integration lab

demo-loom-flow/ runs loom-tailcalls + flow-xray + optional Ollama. For durable checkpoint/resume on the same loop shape, see loom-runner.

cd demo-loom-flow && python run_all_cases.py

See demo-loom-flow/README.md and demo-loom-flow/ROADMAP.md.

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. In demo-loom-flow, case 07 runs automatically when Ollama is reachable. To run the unittest directly:

LOOM_OLLAMA_FUZZ=1 python3 -m unittest tests.test_ollama_contract

Skip Ollama in the demo runner: LOOM_SKIP_OLLAMA=1 python demo-loom-flow/run_all_cases.py

API

  • tailrec: optimizes async self-tail recursion via return 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

loom_tailcalls-0.2.1.tar.gz (25.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

loom_tailcalls-0.2.1-py3-none-any.whl (13.6 kB view details)

Uploaded Python 3

File details

Details for the file loom_tailcalls-0.2.1.tar.gz.

File metadata

  • Download URL: loom_tailcalls-0.2.1.tar.gz
  • Upload date:
  • Size: 25.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for loom_tailcalls-0.2.1.tar.gz
Algorithm Hash digest
SHA256 48361dc8407ca76bc74a0b2420b7823a5594fd2a86c3bd0d5d7359e484ad5d7e
MD5 2e645883b0f64616adaa4a850395fb0f
BLAKE2b-256 cafe0acad50f10d09a85139627b145f2f9c21f549b260beacec0e4f7aa24f279

See more details on using hashes here.

File details

Details for the file loom_tailcalls-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: loom_tailcalls-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 13.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for loom_tailcalls-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 0a4191a5885511db86beb84be1687c7ad2be0f6b02cbc342fe7f7fd743e0ff81
MD5 8c002e63055fe35187be5fa9874350f8
BLAKE2b-256 ab7be726f25b500bc948bc0c5f3fcc97c4180433838c89509c6e3e9bba3a3cda

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page