DEV Community

bzdvdn
bzdvdn

Posted on

Edit One Input, Only Its Real Dependents Recompute — No Graph, No Command

(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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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]
)
Enter fullscreen mode Exit fullscreen mode

Editing a fact is a normal call:

context.update(tax_rate_artifact.id, TaxRate(value=0.12))
await runtime.arun()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)