DEV Community

Quinn Li
Quinn Li

Posted on

The Call Graph Is What You Owe

You are not paying for a prompt. You are paying for every hop the agent takes after that prompt, including the ones you never wrote down.

A user types “fix the failing test.” That looks like one request. In the runtime it becomes a tree: list files, read three of them, propose a patch, run the suite, read the failure, patch again. Each hop has tokens, wall time, and a chance to fan out. If your budget lives on the first call, the tree ignores you.

Think of a restaurant tab that starts with a coffee and then keeps adding plates because nobody closed the table. The coffee was cheap. The table was not. Dashboards that count HTTP requests stay polite while that table fills up. You need a budget object that travels with the job, not with the client.

This is an ops problem, not a model-quality problem. A stronger model can still wander. A weaker one can still finish if the graph is short. Your control point is the graph: how many hops, how wide the fan-out, and when the wrapper says stop.

A job that carries its own meter

The example below is a local runner, not a live agent. It does not call a network model. Each hop returns a fake token count so you can watch the kill switch trip on purpose. Treat the numbers as fixtures. Do not treat them as a benchmark.

# save as graph_budget.py — worked example, not production telemetry
from __future__ import annotations

from dataclasses import dataclass, field, asdict
import json
import time


@dataclass(frozen=True)
class Hop:
    name: str
    tokens_in: int
    tokens_out: int
    fanout: int
    kind: str  # "explore" | "mutate" | "verify"


@dataclass
class JobBudget:
    max_tokens: int
    max_hops: int
    max_seconds: float
    tokens_used: int = 0
    hops_used: int = 0
    started_at: float = field(default_factory=time.monotonic)
    events: list = field(default_factory=list)

    def _seconds(self) -> float:
        return time.monotonic() - self.started_at

    def decide(self, hop: Hop) -> str:
        next_tokens = self.tokens_used + hop.tokens_in + hop.tokens_out
        next_hops = self.hops_used + max(1, hop.fanout)
        if next_tokens > self.max_tokens or next_hops > self.max_hops:
            return "abort"
        if self._seconds() > self.max_seconds:
            return "abort"
        if hop.kind == "explore" and next_tokens > int(self.max_tokens * 0.6):
            return "skip-explore"
        return "run"

    def commit(self, hop: Hop, decision: str) -> None:
        if decision == "run":
            self.tokens_used += hop.tokens_in + hop.tokens_out
            self.hops_used += max(1, hop.fanout)
        self.events.append(
            {
                "hop": hop.name,
                "kind": hop.kind,
                "decision": decision,
                "tokens_used": self.tokens_used,
                "hops_used": self.hops_used,
                "seconds": round(self._seconds(), 4),
            }
        )


# A compact stand-in for "fix the failing test"
GRAPH = [
    Hop("list_files", 800, 120, 1, "explore"),
    Hop("read_a", 2200, 400, 1, "explore"),
    Hop("read_b", 1800, 350, 1, "explore"),
    Hop("read_c", 2600, 500, 1, "explore"),  # extra read you did not budget
    Hop("draft_patch", 3100, 900, 1, "mutate"),
    Hop("run_tests", 400, 1800, 3, "verify"),  # fan-out: 3 failing files
    Hop("patch_again", 3500, 1100, 1, "mutate"),
]


def run_job(max_tokens: int = 12000, max_hops: int = 8, max_seconds: float = 2.0) -> dict:
    budget = JobBudget(max_tokens=max_tokens, max_hops=max_hops, max_seconds=max_seconds)
    for hop in GRAPH:
        decision = budget.decide(hop)
        budget.commit(hop, decision)
        if decision == "abort":
            break
    return {
        "tokens_used": budget.tokens_used,
        "hops_used": budget.hops_used,
        "aborted": any(e["decision"] == "abort" for e in budget.events),
        "events": budget.events,
    }


if __name__ == "__main__":
    result = run_job()
    print(json.dumps(result, indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it from a shell. You should see the job die on the extra read or on the test fan-out, depending on the caps you pass.

python graph_budget.py
python - <<'PY'
from graph_budget import run_job
loose = run_job(max_tokens=50000, max_hops=20)
tight = run_job(max_tokens=9000, max_hops=6)
assert tight["aborted"] is True
assert tight["tokens_used"] < loose["tokens_used"]
print("tight hops", tight["hops_used"], "loose hops", loose["hops_used"])
PY
Enter fullscreen mode Exit fullscreen mode

The assertion is the point. A looser cap finishes more of the tree. A tighter cap leaves work on the table and also leaves money on the table. You choose that trade in code, before the agent chooses it for you in a retry fog.

Read the JSON as an incident note. When kind stays on explore while tokens_used climbs, the job is still shopping for context. When fanout jumps on verify, one failing assertion just cloned itself. Neither of those events looks dramatic in a per-request chart. Both of them are the invoice.

Route hops, do not bless the whole tree

You can put exploratory hops on a free lane without putting the whole graph there. Listing files and drafting a plan are recoverable if they stall. Applying a patch into CI is not. The wrapper above already has a skip-explore branch. That is the cheap door. Keep the mutate and verify hops behind a hard abort.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project with free model access and a free server option. Those two facts matter here only as a place to run the hops you have already capped in software. They do not flatten the call graph. They do not promise queue time, model names, or a quota I am not going to invent. If you use that free lane, use it for the explore steps your budget still allows, then stop.

A useful rule of thumb, still a rule of thumb: if a hop can be thrown away, it may sit on free capacity. If a hop mutates a branch or gates a merge, it needs a cap you would defend in a postmortem. Free compute is a lane. It is not a contract with your on-call rotation.

Try a second command that pretends the explore phase got greedy. You will watch skip-explore fire before abort, which is the shape you want in staging.

python - <<'PY'
from graph_budget import Hop, JobBudget

budget = JobBudget(max_tokens=10000, max_hops=10, max_seconds=3)
greedy = [
    Hop("repo_dump", 7000, 200, 1, "explore"),
    Hop("more_dump", 4000, 200, 1, "explore"),
    Hop("draft_patch", 2000, 800, 1, "mutate"),
]
for hop in greedy:
    d = budget.decide(hop)
    budget.commit(hop, d)
    print(hop.name, d, budget.tokens_used)
PY
Enter fullscreen mode Exit fullscreen mode

Notice what did not happen. The mutate hop never ran. That is not elegance. That is an adult closing the tab before dessert. Context stuffing feels like diligence when you are staring at a red test. It is how a one-call estimate becomes a tree you cannot explain to finance.

What this does not fix

A circuit breaker on hops will not save a bad spec. If you ask the agent to “make it work” with no file list and no failing assertion, the graph has to go hunting. Hunting is fan-out with a friendly name. Write the failing command into the job record first. Then let the agent propose a patch against that record. The budget object can only bound a search you were willing to bound.

Do not use this pattern for hard real-time work, for customer-facing agents with a latency SLO, or for anything that must finish because a human is blocked on a call. A free server and a free model path are the wrong bet when the cost of waiting exceeds the cost of the tokens. They are also the wrong bet when a skipped explore hop would have prevented a bad mutate. Caps create false confidence if you never inspect the skip log.

This also does not replace tracing. If two CI jobs spawn the same graph against the same SHA, you will pay twice and the breaker on each job will still say “within budget.” Dedup lives one layer up, in the workflow that decides whether the agent should run at all. Pair the meter with a job key. Without that key, you are just dying cheaper, more often.

Keep the title of the work honest in your own runbooks. You did not ship “an AI code fix.” You shipped a bounded graph with an abort. That sentence survives if every product name in this article is deleted. The product, if you use it, is only a lane for the hops you already decided were disposable.

If you want to feel the abort on a throwaway repo before you wire it into CI, run the scripts above, then point only the explore hops at MonkeyCode’s free model access or free server and leave mutate/verify behind the same cap. One experiment is enough. The graph will tell you whether the next hop is work or just another plate on the table.

Top comments (0)