DEV Community

bzdvdn
bzdvdn

Posted on

Stop drawing the graph: reactive agents over versioned artifacts

Stop drawing the graph: reactive agents over versioned artifacts

Most agent frameworks make you draw the graph: connect nodes, wire memory, declare control flow. But a knowledge problem is not a workflow.

Take a realistic question: "Why did infrastructure costs increase in Q2?"

The answer may need Confluence docs, GitLab merge requests, CSV spend data, a calculation, source verification — and a clarifying question. The next question needs a different path. There is no universal graph here, and asking a developer to draw one for every possible question is asking them to predict the future.

So we built an agent runtime where you don't describe execution at all. You describe what artifacts exist and what agents can do with them; the runtime derives what runs next from state changes. Agents react to events. There is no graph and no node pipeline.

This is ctxloom — a reactive, artifact-driven agent runtime, now open source.


What it looks like

The whole loop is: create an artifact → agents react → one atomic patch → context advances.

A knowledge question — say, "how much does GPU inference cost?" — becomes a chain of typed artifacts: UserQuery → TypedDoc → Evidence → Claim → Answer. Each is produced by an agent that reacts to the previous artifact. No graph describes this chain; it falls out of what each agent consumes and produces.

ARTIFACT CREATED / UPDATED
       │
       ▼
     AGENTS REACT ──self.effects──► Effects ──compile──► Patch
       ▲                                                      │
       └──────────────────────────────────────────────────────┘
                                                        Context v+1
Enter fullscreen mode Exit fullscreen mode

The event that wakes an agent is derived from that same change — the causal chain can never drift from the actual state.

from pydantic import BaseModel

from ctxloom import Budget, Consume, Context, Runtime, RuntimeResources, create_agent, produce, structured_llm


class Question(BaseModel):
    text: str


class FindingBody(BaseModel):
    text: str


class Finding(BaseModel):
    text: str
    source: str


class Conclusion(BaseModel):
    text: str


@produce(Finding)
async def analyze_question(context, inputs, event, effects):
    """React to a Question; deterministic fallback when the LLM is absent (§67)."""
    question = next((a for a in inputs if isinstance(a.data, Question)), None)
    if question is None:
        return None
    body = await structured_llm(
        context, schema=FindingBody, user=question.data.text
    )
    effects.create(
        Finding(text=body.text if body else "no answer", source="LLM"),
        id=f"finding:{question.id}",
    )
Enter fullscreen mode Exit fullscreen mode

Now add a second agent that reacts to Finding and links it back — provenance for free:

@produce(Conclusion)
async def conclude(context, inputs, event, effects):
    finding = next((a for a in inputs if isinstance(a.data, Finding)), None)
    if finding is None:
        return None
    conclusion = effects.create(
        Conclusion(text="Summarized.", supported_by=[finding.id]),
        id="conclusion",
    )
    conclusion.link("supported_by", finding)   # provenance edge (§34)


agents = [
    create_agent("analyzer", consumes=[Consume(Question)], produces=[analyze_question]),
    create_agent("concluder", consumes=[Consume(Finding)], produces=[conclude]),
]

ctx = Context(resources=RuntimeResources())   # no LLM wired: offline deterministic run
ctx.create(Question(text="how much does GPU inference cost?"))
Runtime(ctx, agents=agents, budget=Budget(max_runs=20)).run()

answer = ctx.latest(Conclusion)                       # find the terminal artifact
finding = ctx.related(answer.id, "supported_by")[0]   # provenance: what supports it
print(finding.data.text)                              # "GPUs accelerate inference."
Enter fullscreen mode Exit fullscreen mode

Two agents, no graph. The runtime matches Question → analyze_question → Finding → conclude → Conclusion by reacting to state changes — and if the LLM is missing, structured_llm returns None and the run degrades gracefully instead of hallucinating.

You can see this fully worked out in the knowledge demo: search fan-out, evidence extraction, deterministic claim verification, and a supported_by answer with sources.


Why this isn't just another framework

1. Effects instead of "return a change"

Most frameworks ask a unit of work to return its result, and some orchestrator applies it. ctxloom inverts this: a producer states what should change and returns None.

async def produce(self, context, inputs, event=None) -> None:
    evidence = self.effects.create(Evidence(...), id="evidence:q1")
    answer = self.effects.create(Answer(...), id="answer:q1")
    evidence.link("extracted_from", doc)
    answer.link("supported_by", evidence)
    self.effects.update(turn, status="answered")
    return None
Enter fullscreen mode Exit fullscreen mode
  • One atomic commit. Nothing applies until the produce returns — the whole step lands or none of it does. No half-applied states, no manual rollback ladders.
  • Handles are objects, not ids. evidence is created above and linked below — the same object, no lookup by number.
  • Human-in-the-loop is just another effect. self.effects.ask(...) poses a question; effects.resume(...) resolves it. A human is not a special case bolted onto the side.

2. Determinism by default

The dominant pattern is "let the LLM do everything". ctxloom takes the opposite stance:

  • Calculations are actually calculated. CSVSource → Spreadsheet → Calculation over structured data is deterministic code — not a hallucinated number.
  • Eligibility is a decision of the state. A produce's guard decides whether it reacts; the LLM is called only on genuinely generative steps.
  • Honesty beats pretending. On failure a produce returns None rather than a confident guess. The model is a reasoning component, never the source of truth.

The rule of thumb: if it can be computed, compute it; the LLM only does what requires language and judgment.

3. Provenance and versioning for free

Every run is a commit. You get diff, rollback, branch() and merge() for the state of a conversation, plus deterministic replay for auditing.

And every derived artifact links back to what produced it:

Answer → supported_by → Claim → derived_from → Evidence → extracted_from → Doc
Enter fullscreen mode Exit fullscreen mode

"Why did the agent say that?" is not a hand-wave — it's a walk across these links. That is accountability, and it's structural, not cosmetic.


What's in the box

  • 13 runnable examples, all offline-capable (no API keys required): a multi-source knowledge chat, a web researcher, a hypothesis laboratory, an ops assistant, budget-aware replanning, branch-and-merge exploration, and more.
  • A port matrix mapping canonical agent patterns — ReAct, reflection, map-reduce, supervisor, summarize, time-travel, plan-and-execute — onto this idiom. You don't throw away what you already know; you see how it falls out of state instead of a diagram.
  • Observability built in. Every run records agent spans, reads/writes, LLM calls and token usage — a local web dashboard, exportable to Langfuse or Postgres.
  • A thin web layer. ChatAssistant + create_chat_router mount a session-persisted SSE chat on your FastAPI app, with logged fallbacks instead of 500s.

When would you not want this?

Honesty matters:

  • Fixed pipelines. If your task really is a fixed sequence of five steps and nothing reacts, a plain function or a classic graph is the right tool. Pick what fits.
  • Pure playgrounds. If you only want the model to "figure it out" and don't care about determinism, provenance or accountability, ctxloom adds ceremony you can skip.

For everything in between — agents that gather evidence, verify claims, calculate, respect budgets, ask humans, and can be audited — reactivity beats drawing a graph.


If this resonates, try it — every example runs offline, no API keys needed:

pip install "ctxloom[web]"
python ./examples/knowledge/web.py    # multi-source chat with sourced answers
python ./examples/repair/web.py       # room renovation: plan, estimate, CSV export
Enter fullscreen mode Exit fullscreen mode

The 30-second takeaway: describe artifacts, not wiring. The knowledge demo
turns a question into evidence → claims → a supported_by answer in minutes,
offline — and the same loop scales to research, ops, or anything where the
answer has to be accountable.

Top comments (0)