DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

Declarative Tool Prerequisites: Enforce Tool Ordering Before the Agent Calls in the Wrong Order

Your agent has a tool that creates a ticket. Another tool that assigns a user to a ticket. A third tool that closes the ticket. The model calls assign_ticket before create_ticket. The call fails with "ticket not found." The model tries again. Same error. The model loops.

The ordering constraint is obvious from the domain: you cannot assign or close a ticket that does not exist. But the model does not know this unless you tell it in the system prompt. And even then, LLMs sometimes call tools in the wrong order.

agent-tool-graph lets you declare tool prerequisites and enforces them at dispatch time.


The Shape of the Fix

from agent_tool_graph import ToolGraph, PrerequisiteNotMet

graph = ToolGraph()

# Declare prerequisites
graph.requires("assign_ticket", after=["create_ticket"])
graph.requires("close_ticket", after=["create_ticket"])
graph.requires("add_comment", after=["create_ticket"])
graph.requires("reopen_ticket", after=["close_ticket"])

# In your agent dispatcher
def dispatch_tool(name: str, args: dict, session_id: str) -> dict:
    try:
        graph.check(name, session_id=session_id)
    except PrerequisiteNotMet as e:
        return {
            "error": f"Cannot call {name} before {e.required_tool}. "
                     f"Call {e.required_tool} first.",
        }

    result = tools[name](**args)
    graph.record(name, session_id=session_id)
    return result
Enter fullscreen mode Exit fullscreen mode

graph.check() raises PrerequisiteNotMet if a required tool has not been called yet in this session. graph.record() marks a tool as called so its dependents can proceed.


What It Does NOT Do

agent-tool-graph does not infer prerequisites automatically. You declare them. The model does not tell the library which tools depend on which. You write the requires() calls once at startup.

It does not handle data-flow dependencies. It tracks which tool names have been called, not whether a specific call produced the ID that the next call needs. For data-flow (call A to get ticket_id, pass ticket_id to call B), your tool functions handle that — the graph handles ordering only.

It does not enforce exactly-once or max-call constraints. You can call create_ticket multiple times in a session. The graph only cares about "at least once" ordering: was the prerequisite called at any point before this call in this session?


Inside the Library

The graph stores prerequisites as a dict of sets and tracks completed calls per session:

class ToolGraph:
    def __init__(self):
        self._prereqs: dict[str, set[str]] = {}
        self._completed: dict[str, set[str]] = {}  # session_id -> set of called tools
        self._lock = threading.Lock()

    def requires(self, tool: str, after: list[str]) -> None:
        if tool not in self._prereqs:
            self._prereqs[tool] = set()
        self._prereqs[tool].update(after)

    def check(self, tool: str, session_id: str) -> None:
        prereqs = self._prereqs.get(tool, set())
        if not prereqs:
            return  # No prerequisites

        with self._lock:
            completed = self._completed.get(session_id, set())

        for req in prereqs:
            if req not in completed:
                raise PrerequisiteNotMet(
                    tool=tool,
                    required_tool=req,
                    session_id=session_id,
                )

    def record(self, tool: str, session_id: str) -> None:
        with self._lock:
            if session_id not in self._completed:
                self._completed[session_id] = set()
            self._completed[session_id].add(tool)

    def reset_session(self, session_id: str) -> None:
        with self._lock:
            self._completed.pop(session_id, None)
Enter fullscreen mode Exit fullscreen mode

The PrerequisiteNotMet exception carries enough context for the model to understand the error:

class PrerequisiteNotMet(Exception):
    def __init__(self, tool: str, required_tool: str, session_id: str):
        self.tool = tool
        self.required_tool = required_tool
        self.session_id = session_id
        super().__init__(
            f"Cannot call '{tool}': prerequisite '{required_tool}' not yet called in session {session_id}"
        )
Enter fullscreen mode Exit fullscreen mode

When you return this error to the model, the model can read the message and call the prerequisite first. Most models will self-correct on the first error.


When to Use It

Use it when your tools have a clear state machine: create before assign, login before query, initialize before write. These constraints are obvious to humans but not to the model. Enforcing them at dispatch time prevents the error instead of hoping the prompt conveys the ordering.

Use it for tools with side effects that depend on prior state. If delete_record requires fetch_record first (to confirm you are deleting the right thing), declare that prerequisite. The model cannot delete without first fetching.

Use it as a guard for destructive tools. requires("delete_account", after=["confirm_deletion"]) ensures the model calls a confirmation tool before any deletion. The confirmation tool can ask the user for approval.

Skip it for tools that are truly independent. If your tools can be called in any order and each is self-contained, the overhead of a prerequisite graph adds complexity without value.


Install

pip install git+https://github.com/MukundaKatta/agent-tool-graph

# Or from PyPI
pip install agent-tool-graph
Enter fullscreen mode Exit fullscreen mode
from agent_tool_graph import ToolGraph, PrerequisiteNotMet
from agent_run_id import RunContext

graph = ToolGraph()

# Declare the state machine for your domain
graph.requires("search_within_folder", after=["open_folder"])
graph.requires("read_file", after=["open_folder"])
graph.requires("write_file", after=["open_folder"])
graph.requires("close_folder", after=["open_folder"])
graph.requires("commit_changes", after=["write_file"])

async def handle_tool_call(block, run_id: str) -> dict:
    try:
        graph.check(block.name, session_id=run_id)
    except PrerequisiteNotMet as e:
        # Return a structured error the model can act on
        return {
            "success": False,
            "error": "prerequisite_not_met",
            "message": f"You must call {e.required_tool!r} before {e.tool!r}.",
            "call_first": e.required_tool,
        }

    result = await execute_tool(block.name, block.input)
    graph.record(block.name, session_id=run_id)
    return result
Enter fullscreen mode Exit fullscreen mode

Sibling Libraries

Library What it solves
tool-call-dedup Prevent exact-duplicate tool calls within a session
tool-side-effects-tag Tag tools as READ/WRITE/IDEMPOTENT/DESTRUCTIVE
agent-step-log Log every tool call for prerequisite audit
tool-error-classify Classify tool errors including PRECONDITION_FAILED
agent-fn-registry Central registry combining schema, side effects, and dispatching

The tool safety stack: agent-tool-graph for ordering enforcement, tool-side-effects-tag for effect classification, tool-call-dedup for cycle prevention, tool-error-classify for structured error handling.


What's Next

Cycle detection in the graph: graph.validate() that checks the prerequisite declarations for cycles (A requires B, B requires A) and raises CyclicPrerequisite at startup rather than at runtime. Prevents misconfigured graphs from causing infinite loops.

Transitive prerequisites: if A requires B and B requires C, then A implicitly requires C. Currently the caller must declare all transitive prerequisites explicitly. Auto-expansion would simplify declaration for deep chains.

Visualization: graph.to_dot() that outputs a Graphviz DOT representation of the tool dependency graph. Useful for documentation and for visually reviewing the state machine during design.


Built as part of the agent-stack family: composable Python primitives for production LLM agents.

Top comments (0)