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.
0. Already on reactifact?¶
This page is for porting other frameworks. If you are upgrading reactifact itself from 0.1.x, use the dedicated 0.1.x → 0.20.0 migration guide.
1. Concept map¶
| You have (LangGraph / LangChain / CrewAI / LlamaIndex) | reactifact |
|---|---|
node + add_edge / add_conditional_edges |
a Step + 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]) |
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 reactifact.tools.native / 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 consumes/produces:
from pydantic import BaseModel
from reactifact import StepCall, step
class Question(BaseModel):
text: str
class Route(BaseModel):
thread_id: str
kind: str
class Reply(BaseModel):
text: str
@step(produces=Route, consumes=Question)
async def classify(call: StepCall) -> None:
question = call.trigger
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}")
@step(produces=Reply, consumes=Route)
async def answer(call: StepCall) -> None:
route = call.trigger # this step runs because a Route was created
# ... call the model with run-scoped input ...
call.effects.create(Reply(text="…"), id=f"reply:{route.data.thread_id}")
from reactifact import Context, Runtime, create_agent
classifier = create_agent("classifier", steps=[classify])
answerer = create_agent("answerer", steps=[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 = 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 reactifact.recipes.changed_fields / downstream_fields for the recompute argument
made concrete.
4. Checkpointing, threads, time-travel¶
- Persist conversations with
SessionStore(FileKVBackend(...))+ChatAssistant(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
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 ToolUseHITL (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). 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.tools.native(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
Stepthat 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. Writeeffects.*and returnNone; the runtime compiles the effect set into one atomic commit (Stepreturning 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.