5. Reachable Tools in a Multi-Step Tool-Use Chain
Problem
An agent can call tools, and some tools' outputs feed into other tool calls (e.g. a "search" result gets passed to "summarize"). The dependency chain is modeled as a directed graph where an edge tool_a -> tool_b means tool_a's execution can lead to tool_b being called next.
Given a starting tool, the agent's runtime needs to know every tool that could possibly execute during that chain, including cases where tools call back into each other in a loop.
Source: src/5_reachable_tools_chain.py
def reachable_tools(start_tool: str, dependencies: dict[str, list[str]]) -> set[str]:
...
Examples:
>>> deps = {"search": ["browse", "calculator"], "browse": ["summarize"], "calculator": [], "summarize": ["search"]}
>>> sorted(reachable_tools("search", deps))
['browse', 'calculator', 'search', 'summarize']
>>> sorted(reachable_tools("calculator", deps))
['calculator']
Step-by-Step Approach
- Recognize this as graph reachability from a single source: which nodes can you reach by following directed edges from start_tool?
- Initialize a visited set containing just start_tool, and a stack (or queue) seeded with start_tool.
- Pop a node, look up its successors in the dependencies map (defaulting to an empty list for tools with no outgoing edges), and push any successor not already in the visited set.
- Add every newly discovered successor to the visited set at the moment it's discovered, not when it's popped — this is what prevents infinite loops on cyclic graphs.
- Continue until the stack/queue is empty; the visited set is exactly the answer.
The key insight is that marking a node visited at discovery time (not processing time) is what makes this safe on cyclic graphs — a tool that calls back into an earlier tool never gets pushed a second time, so the traversal always terminates in O(V + E).
Reference solution
def reachable_tools(start_tool: str, dependencies: dict[str, list[str]]) -> set[str]:
# iterative DFS with a visited set guards against cycles; O(V + E)
seen = {start_tool}
stack = [start_tool]
while stack:
node = stack.pop()
for nxt in dependencies.get(node, []):
if nxt not in seen:
seen.add(nxt)
stack.append(nxt)
return seen
Key Functions & Tricks
seen = {start_tool}— visited set doubles as the accumulating answerdependencies.get(node, [])— safely handles tools with no recorded outgoing edgesmark-on-discovery— adding to seen before pushing (not after popping) is what prevents cycles from causing infinite loopsstack.pop() / stack.append()— iterative DFS avoids Python's recursion depth limit on long chains
How to Recognize This Pattern
The signal is "what can I reach from here" over a graph described as an adjacency list or edge list, especially when the graph might contain cycles. BFS and DFS both solve pure reachability in O(V + E); pick DFS with an explicit stack when recursion depth could be a concern, or BFS with a queue when you also need shortest hop-count. Common variations ask for the reachable set from *multiple* start nodes at once (seed the frontier with all of them), or ask you to detect whether a specific target is reachable at all (short-circuit as soon as it's found). A common pitfall is marking nodes visited only when popped/processed instead of when first discovered, which lets the same node get pushed onto the stack multiple times and, on a cyclic graph, can blow up the queue/stack size well beyond O(V).