(not committed — local draft, review before pushing to dev.to)
Most agent frameworks — LangGraph included — model a pipeline as nodes
connected by edges, sharing one big state object (a TypedDict). That's a
fine mental model until you ask a very ordinary question:
If I edit one input, which parts of my pipeline actually need to
recompute?
The honest answer in a shared-state graph is: the framework has no
idea. Nodes don't declare "I read field X" anywhere the framework can
see — they just receive the whole state and return updates to it. So
either you re-run everything downstream of wherever you re-enter the
graph (coarse — you recompute things nothing about your edit ever
touched), or you hand-roll the missing piece yourself: dirty flags, a
manual dependency map, comparing old-vs-new values inside every node,
Command(goto=...) to jump back into specific nodes.
That bookkeeping is exactly what you get for free when state lives in
typed, versioned artifacts and a node's dependency is what it declares
it consumes — which is the core idea behind ctxloom, a small
Python agent framework I've been building. This post walks through one
concrete, checkable example of why that matters, with real numbers, not
just an architecture diagram.
The example: a cost model
Hours × Rate → LaborCost → Tax, Discount → Total
Every artifact in ctxloom carries a version number, bumped only when it's
actually recomputed. Start the model, then edit only TaxRate:
--- initial calculation ---
LaborCost value=500.0 version=0
Tax value=40.0 version=0
Discount value=25.0 version=0
Total value=515.0 version=0
>>> editing ONLY TaxRate (0.08 -> 0.12)
--- after editing TaxRate ---
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 (consumes Tax)
LaborCost and Discount don't consume TaxRate — they are never
invoked for this edit, not invoked-and-decided-nothing-changed. Edit
Hours instead and it correctly cascades through everything, because
everything really does depend on it transitively through LaborCost —
the model isn't "isolated by construction," it's precise about what
actually depends on what.
Side by side
Here's the same shape in LangGraph — state is one shared dict, and a node
reads whatever fields it wants from it while the framework can't see
which ones:
class State(TypedDict):
hours: float
rate: float
tax_rate: float
discount_rate: float
labor_cost: float
tax: float
discount: float
total: float
def labor_cost_node(state: State) -> dict:
return {"labor_cost": state["hours"] * state["rate"]}
def tax_node(state: State) -> dict:
return {"tax": state["labor_cost"] * state["tax_rate"]}
def discount_node(state: State) -> dict:
return {"discount": state["labor_cost"] * state["discount_rate"]}
def total_node(state: State) -> dict:
return {"total": state["labor_cost"] + state["tax"] - state["discount"]}
graph = StateGraph(State)
graph.add_node("labor_cost", labor_cost_node)
graph.add_node("tax", tax_node)
graph.add_node("discount", discount_node)
graph.add_node("total", total_node)
graph.add_edge(START, "labor_cost")
graph.add_edge("labor_cost", "tax")
graph.add_edge("labor_cost", "discount")
graph.add_edge("tax", "total")
graph.add_edge("discount", "total")
compiled = graph.compile()
Now the user edits tax_rate and you want to refresh the total. What do
you invoke? compiled.invoke(state) re-enters at START and recomputes
labor_cost too, even though nothing about it changed — the graph has no
concept of "this input changed, that one didn't." To actually skip
labor_cost, you're writing the missing piece by hand: pass an extra
"what changed" flag through the state, add an if at the top of each
node comparing its own inputs to what they were last time, wire a
Command(goto=...) to jump in below labor_cost — a dependency tracker
you now own and maintain, node by node, forever.
Here's the same model in ctxloom. No graph, no state dict, no dirty
flags — each formula is a tiny class, and its consumes list is the
dependency declaration:
class LaborCost(BaseModel):
value: float
class Tax(BaseModel):
value: float
@produce(Tax)
async def compute_tax(context, inputs, effects):
labor_cost = context.latest(LaborCost)
tax_rate = context.latest(TaxRate)
if labor_cost is None or tax_rate is None:
return None
effects.upsert(Tax(value=labor_cost.data.value * tax_rate.data.value), id="tax")
tax_agent = create_agent(
"tax", consumes=[Consume(LaborCost), Consume(TaxRate)], produces=[compute_tax]
)
Editing a fact is a normal call:
context.update(tax_rate_artifact.id, TaxRate(value=0.12))
await runtime.arun()
The runtime fires ARTIFACT_UPDATED for TaxRate; only agents whose
consumes names TaxRate (or something downstream of it) ever run.
labor_cost_agent isn't in that set, so it's never invoked — not
"invoked, compared, decided no-op." Add a fifth formula tomorrow that
depends on Tax, and nothing above changes; it just declares
Consume(Tax) and starts reacting.
One honest caveat: convergence isn't always a single step. Editing
Hours made Total jump from version 0 straight to version 3, not to 1
— because Tax and Discount land their updates in separate passes as
the pipeline reaches a fixpoint, and Total reacts to each one. Same
phenomenon a spreadsheet's own multi-pass recalculation engine has. It
doesn't undermine the core claim (LaborCost/Discount really do stay
untouched when only TaxRate changes), but a sharp reader will ask, so
it's worth stating instead of glossing over.
The other half: branching
The same "state lives in artifacts, not one shared blob" design pays off
a second way that has nothing to do with recompute (see
examples/forklab):
context.branch()
forks an isolated copy of the whole state, you run different
agents/strategies on each fork independently, and merge() reconciles
them — a real three-way merge, raising an explicit conflict if both
forks touched the same artifact differently, instead of silently picking
one.
LangGraph's checkpointer gives you linear time-travel through one
thread's history — genuinely useful, but not the same thing as two
divergent branches getting reconciled back into one. There's no
comparable primitive to point at there; it's not "awkward to do," it's
just not something the framework does.
Where this doesn't matter
If your pipeline is genuinely fixed — step 1 always then step 2 always
then step 3, no branching by data, no expectation that inputs get edited
independently after the fact — none of this buys you anything, and a
drawn graph you can see in one file is more readable. This is
specifically about systems where "what needs to recompute" is a real
question with a data-dependent answer, not a fixed sequence.
Try it
The cost-model example above is a real, runnable file, no LLM required —
it lives at
examples/ledger
in the repo:
git clone https://github.com/bzdvdn/ctxloom
cd ctxloom
pip install -e .
python -m examples.ledger.main
Repo: github.com/bzdvdn/ctxloom. I'd be
curious whether people run into the "which nodes actually need to rerun"
problem often enough for this to matter in their own agent pipelines, or
whether it's a non-issue in practice because most pipelines stay small
enough that a full re-run is cheap either way.
Top comments (0)