Migrating from LangGraph / CrewAI / LlamaIndex / plain Python¶
You don't move everything at once. The recommended path is port one piece, keep it running next to the old code, and grow from there — reactifact is a library, it composes with whatever already works. This page is the how; the port matrix is the what maps to what, and each pattern below links to a runnable example.
If you only take one idea away:
State is primary, execution is derived. Instead of drawing what runs next, you declare what artifacts exist and what each agent consumes/produces; the runtime decides what runs from the state changes.
1. Concept map¶
| You have (LangGraph / LangChain / CrewAI / LlamaIndex) | reactifact |
|---|---|
node + add_edge / add_conditional_edges |
a Produce + a Consume(Type) — the edge is "who consumes this type" |
a shared TypedDict/dict state, mutated by nodes |
typed, versioned Artifacts in the Context |
reducer (Annotated[list, add]) |
self.effects.create/update/link(...) compiled into one atomic Patch |
| checkpointer / thread state | Context (git-like), Session/SessionStore, context.branch()/merge() |
interrupt() / Command(resume=...) |
effects.ask(...) → PendingQuestion, answered with effects.resume(...) |
| subgraph / nested agent | AgentAsTool (isolated nested runtime) or context.branch() |
| conditional router / supervisor | Consume.condition / Consume.by_field, or recipes.Router |
| RAG chain (retriever → prompt → LLM) | Source + fan_out_sources + materialize_doc + produces (quick.rag for the simple case) |
tool loop (create_react_agent) |
LLMAgent/ToolUse (or ToolUseHITL for approvals), or native_tool_use / recipes.run_tool_loop |
MemorySaver + summary node |
Msg artifacts + context.view + recipes.WindowSummarizer/RollingDigestSummarizer |
| callbacks / LangSmith / Langfuse | native traces (reactifact.tracing) + redactor=, export to Langfuse/OTLP/Postgres |
2. Port one node (before → after)¶
A LangGraph node that classifies then answers:
def classify(state): # node
return {"route": "billing" if "invoice" in state["text"] else "general"}
def answer(state): # node
return {"reply": llm(state["text"], route=state["route"])}
graph.add_edge(START, "classify")
graph.add_edge("classify", "answer")
On reactifact the edges disappear — they become consume/produce:
from pydantic import BaseModel
from reactifact import Consume, Produce, ProduceCall, produce
class Question(BaseModel):
text: str
class Route(BaseModel):
thread_id: str
kind: str
class Reply(BaseModel):
text: str
@produce(Route)
async def classify(call: ProduceCall) -> None:
question = call.trigger
if question is None or not isinstance(question.data, Question):
return None
kind = "billing" if "invoice" in question.data.text.lower() else "general"
call.effects.create(Route(thread_id=question.id, kind=kind), id=f"route:{question.id}")
@produce(Reply)
async def answer(call: ProduceCall) -> None:
route = call.trigger # this produce runs because a Route was created
if route is None or not isinstance(route.data, Route):
return None
# ... call the model with run-scoped input ...
call.effects.create(Reply(text="…"), id=f"reply:{route.data.thread_id}")
from reactifact import Consume, Context, Runtime, create_agent
answerer = create_agent(
"answerer",
consumes=[Consume(Reply)], # the "edge" from classify to answer
produces=[answer],
)
Two things to notice, because they're the migration's real work:
- Eligibility is a state decision, not a placement. The old graph said
"answer runs after classify". Here
answerruns because aRouteexists — so a guard (return Nonewhen the input isn't ready) is the sequencing mechanism, not an edge. - Stable ids.
f"route:{question.id}"makes re-runs idempotent (§42) — the same event twice never duplicates state. This replaces most hand-written "already done?" bookkeeping.
3. State dict → artifacts¶
# before: one growing dict
state["facts"].append({"text": "...", "source": url})
# after: typed artifacts + provenance
fact = self.effects.create(Fact(text="...", source=url))
fact.link("extracted_from", doc)
Nothing is mutated in place: an update is a new version, so context.diff(v1,
v2) is a real operation and provenance (supported_by/derived_from/…) is the
same graph the runtime uses to decide what to re-run — not a logger bolted on.
See examples/ledger for the recompute argument
made concrete.
4. Checkpointing, threads, time-travel¶
- Persist conversations with
SessionStore(FileKVBackend(...))+ChatAssistant(examples/support_copilot,examples/knowledge). - Fork an exploration with
context.branch(), run different agents on each fork, andmerge()them back — with a realMergeConflictwhen both forks touch the same artifact (examples/forklab). - Reproduce a past state with
reactifact.replay/replay_context; pin a run's fingerprint withreactifact.audit.context_hash(examples/fintech_audit).
5. Interrupts → human-in-the-loop¶
# ask
self.effects.ask("Approve the refund?", kind="approve", id=f"approve:{qid}")
# resume (from your HTTP handler / CLI)
context.resume(question.id, "yes")
The human is just another reaction (PendingQuestion is an artifact); tools can
be gated the same way — see examples/repo_agent (a @tool(destructive=True)
that only runs after an approval) and examples/devops.
6. RAG chain → sources¶
from reactifact.quick import rag
r = rag({"docs": "./docs", "costs": "./costs.csv"})
answer = await r.ask("what's the total gpu cost?") # answer.text, answer.sources
For full control, compose the same pipeline by hand with fan_out_sources +
materialize_doc and produce an Answer linked supported_by the documents
(examples/knowledge, examples/research). Retrieval is a Source capability
(filesystem/CSV/embeddings/web), not a hardcoded chain — swap the source, keep
the agents.
7. Tool loops¶
- Model decides which tool, one step at a time:
LLMAgent/HITLLMAgent(examples/devops). - OpenAI-native
tools/tool_calls:reactifact.native_tool_use(composable functions), orrecipes.run_tool_loopfor a bounded, parallel, mandatory-tool-aware loop. - Budgets and honest failure come for free:
Budget(max_runs=…, max_tool_calls=…), and a produce returnsNoneinstead of a confident guess (§59).
8. Don't go all-or-nothing (interop)¶
- Call reactifact from a node. Your LangGraph node can build a
Context, run aRuntime, and return the result — move one auditable/calculating step at a time. - Expose reactifact as MCP.
create_mcp_server(tools, context=ctx)publishes yourTools (and read-onlycontext://artifacts/...resources) so any MCP client — Claude, another agent framework — can call the reactifact part. - Coexist by concern. Keep your orchestration where it works; use reactifact where provenance, determinism, and auditability matter most (the fintech_audit shape).
9. Ops mapping¶
| Concern | reactifact |
|---|---|
| tracing | Tracer(store=TraceStore(...)), LangfuseTracer, OTLPTracer, PostgresStore; scrub with RuntimeResources(redactor=…) |
| sessions | SessionStore over FileKVBackend/SQLiteKVBackend/PostgreSQLKVBackend |
| budgets / limits | Budget(max_runs, max_seconds, max_iterations, max_tool_calls) |
| error policy | fail-loud by default; Runtime(isolate_errors=True, on_agent_error=…) opts into partial progress |
| per-request data | Runtime.arun(request={...}) → call.request (no ContextVar plumbing) |
| resource lifecycle | RuntimeResources.scope(factory) / async with (loop-safe clients) |
10. Migration checklist¶
- [ ] Every intermediate value is a typed
Artifact, not a dict/TypedDict. - [ ] Each unit is a
Producethat declaresconsumes/produces; no node calls another directly. - [ ] Eligibility is a guard (
return None), not a scheduling order. - [ ] Ids are stable and re-derivable (
f"answer:{qid}",effects.create_once(...),effects.create_once_from(...)). - [ ] Derived artifacts
link(...)to their inputs (provenance). - [ ] Model calls go through
structured_llm/llm_replyand returnNoneon failure — the caller shows an honest fallback, never a fake answer. - [ ] A
Budgetis set for production runs. - [ ] Human steps are
effects.ask/resume, not a special case. - [ ] Sessions use
SessionStore; resources are built per loop (RuntimeResources.scope). - [ ] Tracing is wired (
Tracer(store=...)), andredactor=is set if data is sensitive.
11. Gotchas¶
- There is no
add_edge. If you find yourself wanting one, the edge is aConsume(ProducedType)on the downstream agent. effects, notreturn patch. Writeself.effects.*and returnNone; the runtime compiles the effect set into one atomic commit (Producereturning aPatchis the low-level escape hatch, not the idiom).- Artifacts aren't messages. Model input is built from a
context.view(...)/ consumed artifacts, not a free-floating string list. - Determinism is a habit. Use stable ids and keep timestamps/randomness out of
what you hash —
context_hashthen makes runs comparable (examples/fintech_audit).
12. Where to look next¶
- Port matrix — canonical pattern → reactifact example.
- Quickstart — the four
quickcases in a few lines. - Scheduler semantics — the execution contract you're moving onto.
- Patterns / Recipes — reusable building blocks.