Most multi-agent Python frameworks converge on the same primitive: an explicit
graph. LangGraph draws it as nodes and edges. CrewAI draws it as roles in a
crew. Either way, you decide, ahead of time, which step can follow which.
reactifact (formerly ctxloom) doesn't have a graph. With 0.7.0 just out — a
pre-1.0 API freeze, MCP OAuth, and two new context-scaling features — this
felt like the right moment to write down why, concretely, and where that
tradeoff actually pays off versus where it doesn't.
TL;DR
-
No graph to draw. Control flow falls out of
consumes/producesdeclarations, not hand-wired edges — two agents that never heard of each other compose correctly as long as one produces what the other consumes. - Typed, versioned artifacts instead of a runtime dict. Every artifact is a pydantic model with an id and a version; nothing is overwritten in place.
-
Provenance and rollback are the same mechanism, not two features.
effects.link(...)is what the runtime also uses to decide what needs to re-run after a rollback or acontext.merge(). - The trade-off is real. No managed platform, a much smaller ecosystem, pre-1.0, single maintainer. Fixed, linear pipelines don't benefit from any of the above.
The graph is the orchestration — and the bottleneck
A LangGraph graph is the control flow: add_edge, add_conditional_edges,
by hand. That's fine when there's genuinely one path through the problem.
It stops being fine once the paths multiply. A real "why did infra costs jump
in Q2?" question might need Confluence and GitLab and a CSV calculation
and a human confirmation step — and the next question needs a different
subset. Encoding every combination as graph edges turns into a combinatorial
wiring exercise that someone has to keep in sync by hand.
reactifact agents don't declare edges. They declare consumes/produces —
the artifact types they react to, and the ones they can create. The models
are plain pydantic:
from pydantic import BaseModel
class Evidence(BaseModel):
query_id: str
text: str
score: float
class Answer(BaseModel):
query_id: str
text: str
An Agent is a thin container; the actual logic lives in a Produce. Its
authoring surface is self.effects — a produce writes what should change
(create/update/link/ask) and returns None. The runtime compiles
whatever effects it recorded into one atomic patch:
from reactifact import Agent, Consume, Produce
class AnswerStage(Produce[Answer]):
artifact_type = Answer
async def produce(self, context, inputs, event=None) -> None:
evidence = inputs[0]
text = await self.llm.ask(evidence.data.text)
answer = self.effects.create(
Answer(query_id=evidence.data.query_id, text=text),
id=f"answer:{evidence.data.query_id}",
)
self.effects.link(answer, "supported_by", evidence.id)
class AnswerAgent(Agent):
consumes = [Consume(Evidence)]
produces = [AnswerStage()]
Short one-offs skip the class entirely with the @produce decorator — same
effects slot, passed in by parameter name:
from reactifact import produce
@produce(Answer)
async def answer_stage(context, inputs, event, effects):
evidence = inputs[0]
text = await llm.ask(evidence.data.text)
answer = effects.create(
Answer(query_id=evidence.data.query_id, text=text),
id=f"answer:{evidence.data.query_id}",
)
effects.link(answer, "supported_by", evidence.id)
The runtime derives execution from which artifacts actually exist, not
from a pre-declared path. Two agents that have never heard of each other
compose correctly as long as one produces what the other consumes. There's no
viz.blueprint() diagram to keep in sync, because the wiring isn't drawn
anywhere — it falls out of the type declarations.
State: a dict vs. typed, versioned artifacts
LangGraph's state is a shared dict (or TypedDict) every node can read and
mutate. Flexible, but "what shape does the state have right now" is a runtime
fact, not something a type checker can verify — and "who last touched this
field" isn't tracked unless you add it yourself.
reactifact artifacts are pydantic models. Every artifact has a type, an id, a
version, a created_at. Nothing is overwritten in place — an Update
produces a new version, so context.diff(v1, v2) is a real, inspectable
call, not something reconstructed from logs.
Provenance is the same mechanism as replay, not a bolt-on
"Why did the agent answer this?" in most frameworks means reading logs or a
trace you added yourself. In reactifact, linking is a normal part of writing a
produce, as above: effects.link(answer, "supported_by", evidence.id).
The resulting graph is queryable (context.related(answer.id,) or renderable as Mermaid. This isn't a logging convenience —
"supported_by")
it's the exact mechanism the runtime uses to decide what needs to re-run after
a rollback or a three-way context.merge().
Where a graph framework is still the right call
Being upfront about this matters more than the pitch:
- Genuinely fixed pipeline. Steps always A → B → C, no branching by data — a graph (or a plain function) is less ceremony than modeling artifacts.
- You need a managed platform today. reactifact is a library, not a SaaS. LangGraph Platform and CrewAI Enterprise exist for a reason.
-
You need a large pre-built ecosystem. LangGraph/CrewAI have more
integrations and more production war stories. reactifact narrows this via
MCP (any MCP server becomes a
Tool), but doesn't try to out-integrate them onSource/retrieval. - Deep existing investment. Rewriting a working system for architectural purity is rarely worth it.
What's new in 0.7.0
Two features came out of thinking hard about where reactifact's model runs
into real friction at scale, alongside the pre-1.0 API-freeze pass and MCP
client_credentials OAuth support:
Deferred tool groups. Wiring in many MCP servers at once means dumping
every tool's schema into the system prompt whether the agent needs it this
turn or not — real, wasted context on every single call. DeferredToolGroup
lets you register a group of tools behind a name and a description; the model
sees a one-line catalog entry and a load_tools(group_id) call, and the
actual schemas only load in when the model asks for them:
docs_group = DeferredToolGroup(
group_id="docs",
display_name="Documentation tools",
description="Search and read internal docs (Confluence, wiki).",
tool_names=["search_docs", "read_doc"],
loader=lambda: mcp_stdio_tools(...),
)
agent = LLMAgent(deferred_tool_groups=[docs_group], ...)
The loader runs at most once per run, on demand.
OTLPTracer. reactifact already had a Langfuse-backed tracer emitting real
OTLP/HTTP spans over plain httpx. 0.7.0 extracts the shared payload code and
adds a vendor-neutral OTLPTracer using GenAI semantic conventions
(gen_ai.operation.name, gen_ai.usage.input_tokens, ...) with no
langfuse.* namespace and no new dependency — point it at any OTLP collector.
Try it
pip install reactifact
Docs and the forklab branch/merge example: https://github.com/bzdvdn/reactifact
reactifact is pre-1.0 and single-maintainer — feedback on the artifact model,
especially from people running real multi-source agents in production, is
genuinely useful right now.
Top comments (0)