Event-driven agents for Python developers — tasks wake on typed artifacts, like Celery tasks wake on messages. No graph to draw.
Python developers already know this model from Celery: define a task, declare what triggers it, let the runtime run it. reactifact applies it to agents — a task reacts to a typed, versioned artifact appearing in the context, not to a queue message you push or a graph edge you draw. The runtime derives what runs next from state.
| Celery | reactifact |
|---|---|
| a task | @produce(Model) — a unit of work that writes an artifact |
delay() / apply_async() |
you don't call it: creating the input artifact is the trigger |
| routing key / queue | Consume(Type) — which artifact type wakes the task |
| chain / group / chord | several consumes / produces; the runtime derives the order |
retries, acks_late |
guards + Budget, an honest None instead of a wrong result |
| result backend | the Context — typed, versioned artifacts |
| worker | Runtime |
Single process today (no broker, no worker pool) — the model is Celery-shaped, not its distributed runtime.
On top of that model you get something a task queue doesn't: every artifact is
versioned with provenance, so a run is reproducible (context_hash) and
auditable (audit.report) for free. The model reasons; the arithmetic stays
deterministic; every claim carries provenance.
On top: a provable answer
examples/fintech_audit — a transactions CSV, a budget
CSV and a policy doc, no API key. The model never produces the number; plain
Python does, and the answer is linked to its evidence:
$ .venv/bin/python -m examples.fintech_audit.main
cloud spend: $45,000 (2026-04 $12k, 2026-05 $15k, 2026-06 $18k)
variance vs budget: +12.5% budget $40,000, policy threshold 10% → over
answer: Q2 cloud spend was $45,000 against a $40,000 budget (+12.5%) —
exceeds the 10% policy threshold. CFO approval is required.
citations: budget.csv, transactions.csv, policy.md
audit: Answer —supported_by→ {Variance, Spend, Table, Policy}
answer sha256 5461290d… · context sha256 24449f6f…
>>> re-running the pipeline hashes identically — or verify a saved run:
>>> reactifact replay <store> --session <id> --verify 24449f6f…
That hash is the whole point: the answer is reproducible and its provenance is
a queryable graph (context.related(answer.id, "supported_by")), not a log line.
The number is computed, and its provenance recorded, in plain Python — the model
is never the source of truth (trimmed from
examples/fintech_audit/produce.py):
@produce(Variance, reacts_to=Spend) # wakes when a Spend artifact exists
async def compute_variance(call: ProduceCall) -> None:
spend = call.trigger # the artifact that triggered this run
budget = cloud_budget(call.context) # read from budget.csv
pct = (spend.data.total - budget) / budget # deterministic — never the model
variance = call.effects.create_once_from(
spend, # stable id → idempotent re-runs (§42)
Variance(actual=spend.data.total, budget=budget, pct=pct,
threshold=0.10, within_policy=abs(pct) <= 0.10),
)
variance.link("calculated_from", spend) # provenance edge, queryable
answer = ctx.latest(AuditAnswer)
print(report_to_markdown(build_report(ctx, answer))) # hash per artifact + edges
print(context_hash(ctx)) # reproducible fingerprint
Two agents explore independently on their own forks and merge back automatically — and when they disagree, reactifact refuses to merge silently:
pip install reactifact
Runs offline, no API key needed — paste this straight into a .py file. Two
agents, no graph edge declared between them — the second reacts because the
first one's output exists, and the answer carries proof of where it came
from:
from pydantic import BaseModel
from reactifact import Budget, Consume, Context, Runtime, RuntimeResources, create_agent, produce
class Question(BaseModel):
text: str
class Evidence(BaseModel):
text: str
class Answer(BaseModel):
text: str
DOCS = {
"refund": "Refunds are available within 14 days of purchase.",
"pricing": "The Pro plan is $49/month, billed annually.",
}
@produce(Evidence)
async def find_evidence(call):
question = next((a for a in call.inputs if isinstance(a.data, Question)), None)
if question is None:
return None
hit = next((v for k, v in DOCS.items() if k in question.data.text.lower()), None)
if hit is not None:
call.effects.create(Evidence(text=hit))
@produce(Answer)
async def answer_from_evidence(call):
evidence = next((a for a in call.inputs if isinstance(a.data, Evidence)), None)
if evidence is None:
return None
call.effects.create(Answer(text=evidence.data.text)).link("supported_by", evidence)
search_agent = create_agent("search", consumes=[Consume(Question)], produces=[find_evidence])
answer_agent = create_agent("answer", consumes=[Consume(Evidence)], produces=[answer_from_evidence])
ctx = Context(resources=RuntimeResources())
runtime = Runtime(ctx, agents=[search_agent, answer_agent], budget=Budget(max_runs=10))
ctx.create(Question(text="what's your refund policy?"))
runtime.run() # search_agent and answer_agent both react — nobody wired them together
answer = ctx.latest(Answer)
evidence = ctx.related(answer.id, "supported_by")[0]
print(answer.data.text) # "Refunds are available within 14 days of purchase."
print("supported_by:", evidence.data.text) # provenance you can trace, not just a string in a log
The same idea, live — the knowledge example's CLI answering a
harder, multi-source question (docs + a CSV) with a real computed number and
its sources, no LLM key required:
How it works
ARTIFACT CREATED / UPDATED
│
▼
AGENTS REACT ──self.effects──► Effects ──compile──► Patch
▲ │
└──────────────────────────────────────────────────────┘
Context v+1
A produce writes what should change (self.effects.create/update/link/ask) and
returns None; the runtime compiles the effect set into one atomic Patch
and moves the context to the next version. The event that wakes an agent is
derived from that same change — the causal chain can never drift from the
actual state.
What makes it different
| Traditional agent (LangGraph / CrewAI / LangChain) | reactifact |
|---|---|
| A program follows a graph / plan | Agents react to state changes |
| Messages are strings | Typed, versioned artifacts (Claim, Evidence, Answer) |
| Orchestration is explicit wiring | Orchestration falls out of the state |
| A unit of work returns a change | An agent writes effects; the runtime compiles them |
| Retries/rollback are manual | Context is git-like versioned (diff, rollback, branch, merge) |
| "Who produced this?" is lost | Provenance links every derived artifact to its inputs |
| The model guesses the numbers | Calculations are calculated — the LLM is a reasoning component, not the source of truth |
| Tracing needs a SaaS add-on | Native trace store (SQLite + dashboard), exportable to Langfuse/Postgres |
| MCP via a framework adapter | MCP both ways built in — call any server, or expose your own Context as one |
| Pulls in a framework's dependency tree | 3 core deps: pydantic, httpx, python-dotenv |
Reactive. Deterministic. Accountable.
Full breakdown, including where reactifact is not the right choice: docs/en/comparison.md.
Proof, not a claim — examples/ledger is a 4-artifact
billing calc (LaborCost, Tax, Discount, Total) with no LLM, fully
offline. Edit one fact and see what actually reruns:
>>> editing ONLY TaxRate (0.08 -> 0.12) — a fact nothing about
>>> LaborCost or Discount ever consumed.
LaborCost value=500.0 version=0 # untouched
Tax value=60.0 version=1 # recomputed
Discount value=25.0 version=0 # untouched
Total value=535.0 version=1 # recomputed
2 of 4 artifacts recompute — the 2 that actually depend on TaxRate —
because Artifact.version tracks real consumption, not a graph edge you drew
by hand. Run it yourself: uv run python -m examples.ledger.main.
Core primitives
- Context — versioned working state, git-like commits,
diff/rollback/merge. - Artifact — a first-class typed object (
Claim,Evidence,Answer), not a string blob. - Effects — an agent states its change via
self.effects.create/update/link/ask; the runtime compiles it. - Patch — the compiled, validated change-set applied as one atomic commit.
- Agent — a thin container declaring
consumes/produces; logic lives in aProduce. - Source — retrieval is a capability: vector search is one strategy, not the only one; filesystem, CSV, and the web are equally first-class today (direct API, keyword, and SQL sources are on the roadmap, not yet shipped).
- Provenance — every derived artifact links to what produced it
(
Answer —supported_by→ Claim —derived_from→ Evidence —extracted_from→ Doc). - HITL — humans as
effects.ask(...)→PendingQuestion, answered viaeffects.resume(...)like any agent.
In the box
- Deterministic by design — calculations over structured data, honest
Nonefallbacks instead of hallucinated answers; the model reasons, never "knows". - Observability — every run traces agent spans, reads/writes, LLM calls, tokens: SQLite store + web dashboard, exportable to Langfuse/Postgres (async sinks).
- MCP, both ways — call any MCP server's tools as a
Tool(mcp_stdio_tools/mcp_http_tools), or expose your ownTools and a liveContextas an MCP server (create_mcp_server) for Claude Desktop, Claude Code, or another agent to call into (mcpextra). - Budgets & replanning — cap by runs/time/iterations/tool-calls, replan on decline.
- Branching & replay —
context.branch(), three-waymerge(), deterministicReplayLLM, all for audit and safe alternative states. - Sessions —
SessionStoreoverFileKVBackend/SQLiteKVBackend(andPostgreSQLKVBackend) for durable chat memory. - Web layer —
ChatAssistant+create_chat_routermount a canonical SSE chat on your FastAPI app; errors degrade to a logged fallback, never a 500. - Recipes —
find/find_all(typed lookup ininputs),fan_out_sources,materialize_doc,StatusMachine,WindowSummarizer/WindowPruner(bounded conversation memory), change→rebuild rollback helpers,Skill/match_skills(Claude-Skills-shaped instructions, keyword-triggered) — pure and LLM-free, except the summarizer, which takes your callback. - Viz & CLI — Mermaid
blueprint/context_to_mermaid/trace_to_mermaid;reactifactwithgraph/context/trace/replay/branch.
Run a demo
Offline-capable, no API keys required (deterministic fallbacks):
uv run python ./examples/llm_ladder/level1.py # the simplest LLM turn (offline too)
uv run python ./examples/repair/web.py # room renovation: plan, estimate, CSV export
uv run python ./examples/devops/web.py # HITL ops assistant + trace dashboard
Classic-pattern ports run as one-liners too:
python -m examples.{reflection,map_reduce,supervisor,summarize,time_travel,plan_execute,adaptive,ledger}.main.
Prefer a notebook?
—
examples/quickstart.ipynb covers the four quick cases (agent/rag/tools_agent/chat_agent), offline by default.
Examples (in-repo, not shipped)
knowledge— multi-source chat: search → evidence → claim verification → answer, with CSV calculation.research— goes to the web (WebSource): lazy page fetch → evidence → verified claims → answer with URL provenance.medic-lab— hypothesis laboratory: competing hypotheses scored, HITL steering, honest report.devops— HITL tool agents + LLM tool router + trace dashboard.repair— budget-aware replanning (chat/data in Russian by design).forklab— deterministic branch & merge: two strategies on their own forks, three-way merge.ledger— offline proof of reactive recompute: edit one fact, only its realConsumers re-run.llm_ladder— the workflow from one LLM call to state-changing patches (3 levels).adaptive— hybrid scheduler: rule filters + deterministic rank + LLM tie-break +rank_limit.{reflection,map_reduce,supervisor,summarize,time_travel,plan_execute}— canonical ports (see port-matrix).
Documentation
- English · Русский — concepts, sources, providers, recipes, patterns, observability, eval, branching, replay, viz/CLI, API.
- Quickstart — the four
quickcases (structured call, RAG, tools, chat) in a few lines; also as a Colab notebook. - Migrating — from LangGraph / CrewAI / LlamaIndex / plain Python: concept map, port-one-node, interop, checklist.
- Why reactifact — the design argument: why effects, why no graph, why determinism.
- Comparison — reactifact vs LangGraph/CrewAI, feature by feature, and when not to use reactifact.
- Tutorial · llm-ladder — learn the workflow.
- docs/constitution.md — the full design rationale and invariants.
- Roadmap — what's next, and what's deliberately out of scope.
Development
uv sync --extra dev --extra web
.venv/bin/python -m pytest
.venv/bin/mypy
.venv/bin/ruff check
License
MIT — see LICENSE.
Release files for reactifact 0.11.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| reactifact-0.11.1.tar.gz | 371.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| reactifact-0.11.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 669.8 kB
Release files / reactifact-0.11.1.tar.gz
| Download URL | reactifact-0.11.1.tar.gz |
|---|---|
| Size | 371.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0c0a00531f369e10f4d0422c6717d467880dff4854b23f66e72b37fe59b76cf9
|
|
BLAKE2b-256 checksum How to use checksums |
0db2ea957eb8ced6c8d83c4cc2809cca93b850da0d9a74ae2d52127edc2045e2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / reactifact-0.11.1-py3-none-any.whl
| Download URL | reactifact-0.11.1-py3-none-any.whl |
|---|---|
| Size | 298.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2b7a3409be6a33a0b51e79bc62de2cb25f0e9fafa1406e89aa98f68f9124d42d
|
|
BLAKE2b-256 checksum How to use checksums |
e8abea0004c4493f2a44ab8a09df08c85796f60c6a697ed1482416868822e891
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|