Your agent doesn't need a bigger graph — it needs a typed reaction contract
Ask a finance agent: "What is the Q2 cloud spend variance, and does policy require approval?" The answer needs three sources: transactions.csv, budget.csv and a policy document. It also needs arithmetic and a policy che
Ask a finance agent: "What is the Q2 cloud spend variance, and does policy require approval?"
The answer needs three sources: transactions.csv, budget.csv and a policy document. It also needs arithmetic and a policy check. A capable agent returns the number:
Q2 cloud spend was $45,000 against a $40,000 budget (+12.5%) — exceeds the 10% policy threshold. CFO approval is required before the quarter is closed.
A trustworthy one also answers two harder questions:
- Why? Which rows, which budget line, which policy rule, which calculation?
- Again? If you run it tomorrow, do you get the same state — and can you prove it?
In the common message-passing model, both answers live in logs and traces rather than in the data itself. The answer is a string in a message log; the "reasoning" is prose; the numbers are whatever the model emitted. When a finance answer goes wrong in production, your audit trail is a transcript you read by eye.
That is the scenario behind examples/fintech_audit in reactifact — a small Python library for agents that transform typed, versioned artifacts in a shared Context. The rest of this post is about the machinery that makes those two answers cheap, and what changed in 0.20.
TL;DR — 0.20 makes the reaction contract explicit and typed: a
Stepowns itsconsumes/produces, inputs arrive as typed parameters, effects are injected, and artifact versions are content-addressed state instead of a mutable side list.
The graph you were asked to draw
Many agent frameworks start with the same instruction: connect the nodes, wire memory, declare the branches. It works when the task is a fixed pipeline.
Then the task stops looking like a graph. One question needs a spreadsheet and a policy doc; the next needs a web page and a verification step; a follow-up needs a different subset again. For open-ended knowledge work there is no universal fixed graph, and asking a developer to draw one for every possible question is asking them to predict the future.
reactifact takes the other direction: you describe what artifacts exist and what agents can do with them; the runtime derives the order from state. Agents react to typed, versioned artifacts appearing in a shared Context — no graph to draw, no wiring between agents. Version 0.20 is the release where the authoring surface finally matches that model.
The model is easy to state. The old API made it harder to author than it needed to be.
The failure mode: a contract declared three times
The pre-0.20 line worked, but the authoring surface had accumulated glue. A produce named the type twice (artifact_type, reacts_to), and the body re-checked it a third time:
class Estimate(Produce):
artifact_type = Project # a pydantic model
reacts_to = Project
async def produce(self, call: ProduceCall) -> None:
if call.event is None or not isinstance(call.trigger.data, Project):
return None
self.effects.update(call.trigger, stage="estimate")
Three places to keep in sync, and nothing forces them to agree. Correlation was hand-rolled as stable-id lookups. Application state mutated through self.effects while Artifact quietly kept a second history list. None of this is visible in a small example; all of it shows up in a production port — as app code, not framework code.
The 0.20 contract: a step owns its types
In 0.20, the Step is the canonical unit and the type annotation is the contract:
from reactifact import Artifact, Effects, Step
class Estimate(Step):
consumes = (Project,)
produces = (Project,)
async def run(self, project: Artifact[Project], effects: Effects) -> None:
effects.update(project, stage="estimate")
No isinstance guard. No call.trigger.data. No self.effects. The contract now lives in one place, not three: the step says what wakes it, the signature says what it receives, and the runtime validates the commit against produces.
The function form gets the same treatment:
from reactifact import Answer, Evidence, Effects, step
@step(produces=Answer, consumes=Evidence)
async def answer(evidence: Evidence, effects: Effects) -> Answer:
return Answer(text=evidence.text)
Returning a model is shorthand for creating the declared output. Anything beyond that — updates, links, extra creates, human-in-the-loop — goes through effects, which the runtime compiles into one atomic patch:
async def run(self, evidence: Evidence, effects: Effects) -> None:
answer = effects.create(Answer(text=evidence.text))
answer.link("supported_by", evidence)
Nothing applies until the step returns. No half-written state, no manual rollback ladder.
Artifacts stopped being mutable objects
This is the part that changes how the whole system reasons. In 0.20 an Artifact is a frozen handle into a content-addressed version store:
- each version has a
content_hash; -
artifact.update()is gone — updates go throughContext/Effectsand return a new handle; -
artifact.history/.at()/.diff()are replaced byContext.version_history,artifact_versionand version diffs; - compaction is authoritative:
ctx.compact(keep_versions=...)decides which versions remain readable.
Sessions serialize as a version index plus blobs keyed by (data_type, content_hash), with v1 payloads migrating on load. The commit DAG remains the provenance layer; the version store is now the state.
Why bother? Because if artifacts are immutable and content-addressed, then the commit is the only way state changes — and events can be derived mechanically from that change instead of being emitted by hand. The causal chain can never drift from the actual state, because it is the state's changelog.
The runtime derives the order
You declare types. The runtime matches events to steps. Creating an input artifact is the trigger — the Celery-shaped model Python developers already know, applied to agents:
| Celery | reactifact |
|---|---|
| a task |
Step / @step(produces=Model, consumes=Input)
|
delay() / apply_async()
|
you don't call it: creating the input artifact is the trigger |
| routing key | Consume(Type) |
| chain / group / chord | several step Consumes / produces; order is derived from state |
| result backend | the Context — typed, versioned artifacts |
The honest caveat: reactifact is a single-process library today — a Celery-shaped model, not its distributed broker/worker runtime.
The payoff: a finance answer you can check
Run it from a checkout (setup is in Try it below), offline, no API key:
# from a checkout, after `pip install -e .`
python -m examples.fintech_audit.main
computed figures (plain Python — not the model):
cloud spend: $45,000 by month: {'2026-04': 12000.0, '2026-05': 15000.0, '2026-06': 18000.0}
variance vs budget: +12.5% (threshold 10%, within policy: False)
answer: Q2 cloud spend was $45,000 against a $40,000 budget (+12.5%) — exceeds
the 10% policy threshold. CFO approval is required before the quarter is closed.
citations: ['budget.csv', 'transactions.csv', 'policy.md']
# … audit report trimmed …
context sha256: 24449f6f…
>>> re-running the same pipeline to prove reproducibility…
[confirmed] the second run hashes identically — the answer is reproducible.
Nothing here calls a model. Each of those nouns — Table, Policy, Spend, Variance, AuditAnswer — is a typed pydantic model, an artifact in the context. The pipeline is stated as reactions:
-
search_sourcesturns the question intoSourceRefs. -
materializeresolves each ref into aTable(CSV) orPolicy(Markdown) and links it to the ref withmaterialized_from. -
compute_spendsums thecloudrows — deterministic arithmetic — and links theSpendartifact to the table withcalculated_from. -
compute_variancereads the budget line, computes the percentage, and links theVarianceto both the spend and the budget table. -
answerstates the figures in prose and links theAuditAnswerto the variance withsupported_by.
None of the agents know about each other. They are containers of steps, and the wiring is the artifact types. The example's whole orchestration is four lines:
ctx = Context(resources=build_resources())
ctx.create(Question(text=QUESTION), id="q2")
runtime = Runtime(ctx, agents=AGENTS, budget=Budget(max_runs=50))
await runtime.arun() # no edges: search → materialize → analyze → variance → answer
The numbers are not generated. They are computed (imports and helper functions elided):
@step(produces=Variance, consumes=(Spend, Consume(Table, condition=_is_budget_table,
wakes=False),
Consume(Question, wakes=False)))
async def compute_variance(
spend: Artifact[Spend],
budget_table: Artifact[Table],
question: Artifact[Question],
effects: Effects,
) -> None:
budget = _budget_for(budget_table.data, spend.data.category)
pct = (spend.data.total - budget) / budget # deterministic — never the model
variance = effects.create_once_from(
question, # stable id → idempotent re-runs
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
variance.link("calculated_from", budget_table)
And because every derived artifact is linked to its inputs, the audit is a graph walk, not a transcript:
Answer —supported_by→ Variance —calculated_from→ Spend —calculated_from→ Table —materialized_from→ SourceRef
"Why did it say that?" becomes context.related(answer.id, "supported_by"). "Is this the same run?" becomes a context_hash comparison: context_hash ignores timestamps and depends only on ids, content and relations, so reactifact replay <store> --session <id> --verify <hash> proves a saved run matches.
With an LLM in the loop, replay uses recorded calls (ReplayLLM), and verify_run re-runs the pipeline under that recording with stable ids and compares the hashes. A mismatch is real nondeterminism in your code — an unstable id, a timestamp, a random value — not model variance.
One honest note about the demo: it is a fixed pipeline, and a graph would express it fine. What it demonstrates is the state the run leaves behind — provenance and a reproducible hash — not dynamic routing. The reactive model earns its keep when the path varies; the audit model earns it even when the path doesn't.
The design rule behind it
0.20 leans hard on one rule: if it can be computed, compute it; the LLM only does what requires language and judgement.
- Calculations over CSVs are deterministic code.
- Eligibility is a decision of state (
consumes,When,Scope) — not a prompt. - On failure a step returns
Noneinstead of a confident guess.
This is why the run can be replayed and verified at all. Determinism here is not a mode you enable; it is a constraint the authoring surface pushes you toward.
What actually changed in 0.20
If you are evaluating 0.20 (or migrating from the old API), this is the surface that changed. 0.20.0 is a breaking release; the migration is mechanical for most apps, and the old names remain importable through the 0.20 compatibility window (removal planned for 0.21.0).
Breaking:
-
Step/StepCall/@stepare canonical (Produceand friends are aliases). -
Agent.stepsis canonical;Agent.producesis a compatibility property. -
self.effectsis removed; effects are injected. -
Artifactis a frozen handle; updates go throughContext/Effects. - Session payloads are v2; v1 migrates on load.
-
ProduceRunis nowStepRun(alias kept).
Added:
- Typed inputs:
Artifact[T],Latest[T],list[Artifact[T]],Annotated[T, When(...)],Annotated[T, Wakes(False)]. -
Scope(field, owner=Type, key=fn)for correlated inputs andStatusStatefor key-scoped state machines. - Content-addressed
ArtifactStoreandContext.find_versions. - An architecture split (
context/,runtime/,branching/,step/,tools/) withimport-lintercontracts in CI. - Hierarchical trace waterfalls, accurate parallel timing, and a native causal chain in the trace UI.
-
examples/tour: four small offline scenes, including SQLite resume/replay.
0.20.1 adds on_result to aevaluate()/evaluate() so long eval suites can stream per-case progress instead of waiting for the final report.
What reactifact is not
Worth saying plainly, because "another agent framework" deserves skepticism:
- It is not a distributed task queue. One process, in-process runtime.
- It is not a replacement for a graph when your workflow is a fixed graph. If you know the path up front, draw it.
- It is not a platform. It is a Python library with three core dependencies (
pydantic,httpx,python-dotenv), and it runs offline. - It does not let the LLM be the source of truth for computed data. It constrains where the model is allowed to decide.
If your problem is "route a request through five known steps", use a graph. If your problem is "answer a question whose path I cannot predict — and prove where the answer came from", state-derived reactions are a better fit.
Try it
pip install reactifact==0.20.1
The finance demo lives in the repo, not in the wheel:
git clone https://github.com/bzdvdn/reactifact && cd reactifact
pip install -e .
python -m examples.fintech_audit.main # prints the audit, re-hashes to prove reproducibility
- Repo: github.com/bzdvdn/reactifact
- Docs: bzdvdn.github.io/reactifact
- Migration guide: 0.1.x → 0.20.0
- PyPI: pypi.org/project/reactifact
If you are porting from LangGraph, CrewAI or LlamaIndex, the port matrix maps the concepts one by one — including where reactifact is the wrong tool.
If you try it and something breaks, open an issue. That is the most useful feedback right now.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.