How it works
This page is for when you want to know what happens under the hood:
how the source is organised, what one agent.run() call does step by
step, and which interfaces you implement to plug in your own model,
memory store or other component. You don’t need any of it to use
loomflow.
Where things live
How the layers depend on each other
Dependencies point downward. Higher layers use lower ones, not the other way round:
agent/, workflow/, team.py the public API
↓
architecture/ loop styles
↓
governance/, observability/, security/ cross-cutting policies
↓
mcp/, tools/, skills/ tool dispatch
↓
runtime/, memory/, model/ execution, storage, providers
↓
core/ types and protocols, depends on nothingEvery cross-layer call goes through an interface in
core/protocols.py, which is what lets tests replace any layer with a
fake. (Model wrappers are the one notable exception: RetryingModel
and FallbackModel reuse the retry policy from governance/.)
Lifecycle: what happens during agent.run("hi")
Agent.run()checks its arguments (an unknowneffort, a negativeoutput_validation_retriesor a non-stringuser_idfail here), applies the agent’stimeout, and calls the internal_loop(prompt, ...);stream()runs the same loop and forwards its events.- Open runtime session:
async with self._runtime.session(session_id):sets a contextvar that journaled runtimes use to key their cache, scoped to the run’s user and the session’s current run epoch (a run inside another run keys under its parent). See Runs and epochs. - Open root span:
async with self._telemetry.trace("loom.run"):(exported to OpenTelemetry asinvoke_agent). The budget’s run clock starts here too (budget.running()), so wall-clock caps count only time spent running. - Audit: write
run_startedentry. - Seed context: pull working blocks, recall recent episodes,
recall facts (when memory exposes
.facts), build themessages: list[Message]to send to the model. - Loop (shown here for the default ReAct architecture; other
architectures drive the same steps in a different order):
- Stop if
max_turnsis reached. Check budget. Block / warn as needed. - With
auto_compact_at_tokensset, compact the conversation if it has grown past the threshold, so the run’s own tool results are summarised while it runs. - Open
loom.turnspan. - Call the model inside a
loom.model.completespan (run()), or stream it insideloom.model.stream(stream()), through the runtime so a journaled runtime can replay the step later. OpenTelemetry exports this span aschat {model}(kindCLIENT). - Aggregate text + tool_calls + usage from chunks. Emit each chunk
as
MODEL_CHUNKevent. - If no tool calls, append assistant message and break.
- Otherwise, dispatch tools in parallel inside an
anyio.create_task_group. Each_run_single_toolopens its ownloom.toolspan (execute_tool {tool}in OpenTelemetry), runs hooks → permissions → sandboxedruntime.step("tool_call_<turn>_<slot>", tool_host.call, ...), journaled by that position plus the tool name and arguments. Audittool_callandtool_resultper call. - Append tool result messages to the conversation; loop again.
- Stop if
- Persist episode:
runtime.step("persist_episode_<turns>", memory.remember, episode). Withpersist_tool_transcripts=Truethe episode carries this run’s tool calls. When auto-extraction is on (the default for real models), the write also starts fact extraction as a background task that keeps running afterrun()returns;await agent.aclose()waits for it. - Compute session-duration metric.
- Consolidate: run
memory.consolidate()ifTuning(auto_consolidate=True). Failures surface as ERROR events but don’t break the run. - Audit: write
run_completedentry with token / cost / elapsed payload. - Emit:
Event.completed(...).
Every milestone hits four boundaries: events, telemetry, audit, runtime
journal. Each can be independently configured; _noop_emit,
NoTelemetry, audit_log=None, InProcRuntime all let you turn off
anything you don’t need.
Streaming: how agent.stream() works
stream() runs the loop as a producer and yields what it sends:
async def _produce(send):
try:
await self._loop_guarded(prompt, emit=send, session_id=session_id, ...)
except Exception as exc:
# Shielded send so the consumer always sees ERROR before the error is raised
with anyio.CancelScope(shield=True):
await send(Event.error(session_id, exc))
raise
events = stream_from(_produce, buffer_size=128) # loomflow/core/streaming.py
try:
async for event in events:
yield event
finally:
await events.aclose()stream_from connects the two through a bounded memory stream, so
backpressure is automatic: a slow consumer blocks send once 128
events are queued. On asyncio it runs the producer as a separate
task, not inside a task group held open across yield. That matters
when the consumer breaks out early: the generator is closed later,
possibly from another task, and tearing down a task group’s cancel
scope there would cancel the caller instead (its next await would
raise CancelledError). Closing the stream cancels the producer, even
mid-tool-call, and waits for it to unwind, so aclose() returns after
the run’s cleanup has happened. If the producer raised, the events it
already sent are yielded first and then its exception is raised as-is,
not wrapped in an ExceptionGroup. trio has no unstructured spawn, so
there a task group is still used; close the stream with
contextlib.aclosing before abandoning it.
Extension points
Every cross-layer interface is a Protocol in core/protocols.py. To
add a new backend, write a class with the methods the protocol lists;
no base class is needed:
| Protocol | Implement to add a new… |
|---|---|
Model | LLM provider (LiteLLM, Ollama, Together, …) |
Memory | Storage backend (DuckDB, Pinecone, Weaviate, …) |
Embedder | Embedding model (Cohere, Voyage, sentence-transformers) |
Runtime | Durable executor (DBOS, Temporal, custom replay) |
ToolHost | Tool registry (LangChain bridge, custom protocol) |
Sandbox | Isolation backend (Bubblewrap, Seatbelt, Docker, gVisor) |
Permissions | Permission policy (RBAC, geofencing, …) |
HookHost | Lifecycle hook aggregator |
Budget | Resource governance (per-org limits, prepaid pools) |
Telemetry | Observability backend (custom span/metric exporter) |
Secrets | Secret resolution (Vault, AWS Secrets Manager, 1Password) |
The framework only calls the methods the protocol defines, so any implementation works without forking loomflow.
Running the test suite
ruff check loomflow
mypy --strict loomflow
pytest tests/CI runs all three on Python 3.11 and 3.12.
The live integration tests for the Postgres / Redis backends skip
unless JEEVES_TEST_PG_DSN and JEEVES_TEST_REDIS_URL are set.
Test patterns by module:
tests/test_smoke.py:Agent.run()end to endtests/test_streaming.py: backpressure and cancellationtests/test_tools.py: parallel dispatch, hook denials, max turnstests/test_journaled_runtime.py,tests/test_sqlite_runtime.py: replaytests/test_facts.py: supersession,valid_at, the consolidatortests/test_*_memory.py,tests/test_*_facts.py: one file per backendtests/test_telemetry.py: span hierarchy, metric routingtests/test_audit.py: HMAC verification, file persistencetests/test_sandbox.py: symlink escape detectiontests/test_lineage.py: freshness and lineage policiestests/test_anthropic.py,tests/test_openai.py: chunk normalisation with fake clientstests/test_mcp.py: fake MCP sessions
Reading the source
A good order for reading the code:
core/types.py: the value objects everything else passes around.core/protocols.py: the interfaces.agent/api.py: theAgentclass and its loop. It is long (about 3,700 lines); start atrun()and_loop().runtime/journaled.py: how replay works. Small but central.memory/facts.py: how a new fact closes an older, contradicting one.memory/consolidator.py: the fact-extraction prompt and parser.
Together that’s about 6,400 lines, and gives you a complete picture of how a run works.