DEV Community

Cover image for We stopped drawing graphs: an event-driven runtime for agents
Bogdan
Bogdan

Posted on

We stopped drawing graphs: an event-driven runtime for agents

TL;DR — Most agent frameworks ask you to draw a graph. A knowledge question
doesn't have a fixed path, and you can't ship an answer you can't audit. We built
reactifact: think Celery, but tasks wake on typed artifacts, not messages.
It's pip install reactifact, 3 core deps, and a run you can reproduce.


We kept drawing graphs we couldn't finish

We were building a devops knowledge assistant. Ask it a real question —
"why did our infra costs jump in Q2?" — and answering it needs a Confluence
page, two GitLab MRs, a CSV of cloud costs, some arithmetic, a verification step,
and maybe a follow-up question. The next question needs a different subset of
all that.

Every framework we tried asked us to draw that path: nodes, edges,
conditional routing, a shared state dict. But there is no universal graph. The
path depends on the question and on the data. Encoding every combination as edges
turned into a wiring exercise — and the framework still couldn't tell us why it
produced the answer it produced.

So we stopped describing execution and described state instead.

The idea: state is primary, execution is derived

Instead of "node A then node B", you declare:

  • what artifacts exist — typed objects like Question, Evidence, Claim, Calculation, Answer;
  • what each agent consumes and produces — Consume(Evidence), produces=Answer.

The runtime looks at what just changed and derives what runs next. Nothing calls
anything. There is no graph to draw — the schedule is a function of state.

If you know Celery, you already know the model:

Celery reactifact
a task @produce(Model)
delay() / apply_async() you don't call it — creating the input artifact is the trigger
routing key / queue Consume(Type)
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) — it's a Celery-shaped model,
not its distributed runtime.

The second thing we couldn't get: an answer you can audit

The other half of the problem is trust. An LLM writes confident prose and
unreliable arithmetic, and most frameworks leave "who computed this number, from
which source?" to whatever logs you remembered to add.

In reactifact, the answer is an artifact like everything else:

  • Calculations are computed, not generated. A Produce doing arithmetic over a CSV computes it in plain Python and hands the model the result to explain — never the raw numbers to guess from.
  • Every derived artifact links to its inputs — Answer —supported_by→ Variance —calculated_from→ Spend, Table. That's a queryable graph, not a log line.
  • A run is reproducible. Artifacts are versioned, and a content hash (context_hash) is identical across deterministic runs, so you can re-run and verify: reactifact replay <store> --session <id> --verify <hash>.
  • The audit is a report. reactifact.audit renders the answer with a sha256 per artifact, its producing author, and the provenance edges.

Proof, offline, in one command

examples/fintech_audit answers a finance question — "what's the Q2 cloud spend
variance, and does policy require approval?"
— from a transactions CSV, a budget
CSV, and a policy document. No API key, nothing leaves the machine:

$ python -m examples.fintech_audit.main   # from a repo checkout, no API key

variance vs budget: +12.5%   ($45,000 actual vs $40,000 budget, 10% policy threshold → over)
answer:  Q2 cloud spend was $45,000 against a $40,000 budget (+12.5%) —
         exceeds the 10% policy threshold. CFO approval is required.
audit:   Answer —supported_by→ {Variance, Spend, Table, Policy}
         answer sha256 5461290d…  ·  context sha256 24449f6f…
Enter fullscreen mode Exit fullscreen mode

fintech_audit demo: the variance, the answer, and the reproducible context hash — a second run prints the same hash.

Run it twice: the hash is the same. Change a fact, and only what actually
consumed it recomputes — because Artifact.version tracks real reads, not a
graph edge someone drew by hand.

The number is computed, and its provenance recorded, in plain Python (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
        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
Enter fullscreen mode Exit fullscreen mode

What you get beyond the model

A task queue gives you execution. Layering state, versions, and provenance on top
of the type system gives you a few things for free:

  • Determinism where it matters — compute in code, let the model reason.
  • Branching and merge — fork an exploration, run different agents on each fork, three-way merge() with an explicit conflict instead of silent last-write-wins.
  • Human-in-the-loop as state — effects.ask(...) is just another effect; a human is another reaction, not a special case.
  • Replay — reproduce a past run from its recorded model calls and commit chain, offline.
  • MCP both ways — call any MCP server's tools, or expose your own Context as one.

And it's small: three core dependencies (pydantic, httpx, python-dotenv),
fully typed, no framework-of-the-week dependency tree.

Honest limits

reactifact is pre-1.0 (0.10.0), maintained by one person, and
single-process today — no broker, no worker pool, no managed platform. If you
need a mature ecosystem and hosted execution right now, use LangGraph; it's a
better fit. reactifact is for teams building open-ended, auditable knowledge
agents — internal assistants over docs, ops/incident tooling, finance/compliance
workflows — where "why did it say that?" has to have an answer.

Try it

pip install reactifact
Enter fullscreen mode Exit fullscreen mode

Repo: https://github.com/bzdvdn/reactifact · Docs and offline demos:
https://bzdvdn.github.io/reactifact/

If you build agents over docs, CSVs, or repos, I want to hear what breaks for
you. That's more useful to me than a star.

Top comments (4)

Collapse
 
bzdvdn profile image
Bogdan •

Author here — some background that didn't fit in the post.

We didn't set out to build a runtime. We were building a devops knowledge
assistant and kept hitting two walls:

  1. Frameworks wanted a graph, but the question dictates the path — and the next question needs a different one.
  2. "Why did it say that?" had no answer we could hand to anyone.

So we inverted it: declare typed artifacts and what each agent consumes and
produces, and let the runtime derive the schedule from state changes. The Celery
mapping in the post is the honest mental model — a Celery-shaped model, not a
Celery-shaped runtime (it's single-process today).

Two things I'd genuinely like your take on:

  • Does "artifacts, not nodes" match how you actually decompose an agent task — or does it just move the complexity somewhere else?
  • Is provenance/reproducibility something you need, or a nice-to-have you'd never pay for in latency or code?

If you run examples/fintech_audit, tell me if the offline demo works on your
machine — that's the part I'm most worried about breaking. Happy to answer
anything about the internals: versioning, merge conflicts, replay, MCP.

Collapse
 
bzdvdn profile image
Bogdan •

Thanks — you've put your finger on the exact seam where the model is strong in one half and deliberately incomplete in the other.

The split already exists at the state layer. A produce never mutates anything; it emits effects (create/update/link/ask) and the runtime compiles them into one atomic Patch. Commits are deterministic and revisioned, so replay is state reconstruction, not re-execution: replay walks the commit chain and rebuilds the Context at a version without running any agent. The transfer unit you describe is literally that snapshot — artifacts, versions, relations, provenance, pending triggers — not a transcript.

The half that's not modeled yet is the environment effect: an email, a webhook, a deploy. Today those are ordinary I/O inside a produce, and replay doesn't re-fire them because it doesn't run produces — but a live re-run (crash resume is at-least-once) could, which is exactly the gap.

The direction I'd take before distributing execution is to make the distinction first-class:

pulls (resolve a Reference, fetch a page) are already idempotent via stable ids (SourceRef.stable_id, create_once/upsert);
pushes (notify, pay, act) become a recorded intent artifact with a stable id derived from content/context, with dispatch gated on that id (create_once(id=…) folds in the "already done" guard). The action becomes a function of state, and replay reads the record instead of re-sending.
That's a transactional outbox in artifact clothing, and it's what makes your merge case tractable: a content-derived key means two branches that independently reach "notify X" converge to a single intent after merge; two different actions surface instead of being silently duplicated. Three-way merge already refuses conflicting writes; an explicit merge policy for outbound actions is the piece still missing.

Caveat, as on the page: single-process, at-least-once, no cross-process exactly-once. But your framing — the unit you transfer is state, not a transcript — is the property I most want to protect. An Action/intent artifact with a stable key may be the right primitive; how would you draw that boundary?

Collapse
 
bzdvdn profile image
Bogdan •

Quick follow-up, since this is now shipped: reactifact 0.14.0 is out, and your question is exactly what the new outbox is built around.

You can now make the two explicit instead of blurred:

a replayable computation is a normal effect (create/update/link) — committed state, reconstructed on replay;
an external side effect is recorded as an intent, not performed: self.effects.act(kind=..., key=..., payload=...) writes a PendingAction under a stable id (action:{key}), atomically with the rest of the patch, and Runtime(dispatcher=...) performs it after the commit, once per id, recording dispatched/failed as state.
Which gives exactly what you asked for:

Replay reconstructs, never re-sends — replay walks the commit chain and rebuilds the Context without running the runtime, so a recorded notification is read back as state, not re-fired.
Retries don't duplicate — a re-run re-derives the same stable id, so act returns None and no second intent exists.
Two merged branches converge — the same intent on both forks shares the id and merges to one; the dispatch bookkeeping is excluded from the three-way signature, and the dispatched side wins whichever branch is the merge target, so a merge never resurrects an already-sent action. Divergent payloads under one id are an explicit MergeConflict, not a silent pick.
It stays single-process and at-least-once — the framework can't make the I/O exactly-once, so the intent carries idempotency_key for the external system to dedupe, and it deliberately ships no background relay or backoff (that policy stays with the app). There's an offline, deterministic demo, examples/outbox, that walks seven cases: commit→dispatch, re-derivation, same-generation duplicate, merged branches, replay, failure→retry, and an app-owned retry wrapper. Release notes: github.com/bzdvdn/reactifact/relea...

Also in the release, for the ops side you'll hit next: correlated structured logging (configure_logging()), and a hard per-turn deadline with graceful shutdown (request_stop()/ashutdown(), plus an in-flight registry).