Repository: openai/openai-agents-python

Issue:
Infinite recursion in src/agents/extensions/visualization.py due to circular references
## Issue Description
The functions `get_all_nodes` and `get_all_edges` in `src/agents/extensions/visualization.py` can lead to infinite recursion when there are circular references between agents (when agents form a cycle through handoffs).

## Current Behavior
When agents have circular references through handoffs, the visualization functions will recursively traverse the agent graph without any cycle detection, leading to infinite recursion and eventual stack overflow.

## Expected Behavior
The visualization functions should handle circular references gracefully by detecting cycles and preventing repeated traversal of the same agents.

## Proposed Solution
Add a `visited` set to track already visited agents and prevent repeated recursion. Here's a sketch of the fix:

```python
def get_all_nodes(agent: Agent, parent: Optional[Agent] = None, visited: Optional[set] = None) -> str:
    if visited is None:
        visited = set()
    if agent.name in visited:  # Prevent infinite recursion
        return ""
    visited.add(agent.name)
    # ... rest of the function ...
```

Similar changes would be needed for `get_all_edges`.


Discussion:
Although this is technically possible(once the bug gets fixed), what sense does it make if agents keep forever turning in circle ? They might need a sort of memory context that allows to break the circle somehow. Note that it's always possible to use an Agent as a tool, and that agent could have many as a tools, the difference is that the Agent as a tool will Always return and the handoff "never". So I'd say maybe for your use case the framework needs to have a sort of "conditional return", which means it could then dynamically decide if it should handoff/keep the hand, or return like being called as a tool, that seems to me safer than handoff circles.
Hi @wassfila,

You’re right that an endless loop is pointless, but short, controlled cycles can be useful. Anthropic’s recent post on “[Building Effective Agents](https://www.anthropic.com/engineering/building-effective-agents)” describes evaluator-optimizer (or “self-critique”) patterns where two agents deliberately hand a task back and forth until a shared stopping condition—accuracy threshold, max-iterations, etc.—is met. That requires:

1. **A recursion-safe graph walk** — I’ll patch get_all_nodes/edges to track a visited set so visualisation never overflows, even with cycles.

2. **Conditional hand-offs** — exposing a max_handoffs/“should return” hook so an agent can decide at runtime whether to hand off again or return like a tool call.

With those guards the framework supports both simple chains and the more autonomous, self-refining loops the blog highlights—without risking infinite spins.

> Although this is technically possible(once the bug gets fixed), what sense does it make if agents keep forever turning in circle ? They might need a sort of memory context that allows to break the circle somehow. Note that it's always possible to use an Agent as a tool, and that agent could have many as a tools, the difference is that the Agent as a tool will Always return and the handoff "never". So I'd say maybe for your use case the framework needs to have a sort of "conditional return", which means it could then dynamically decide if it should handoff/keep the hand, or return like being called as a tool, that seems to me safer than handoff circles.



Patch:
diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py
index 888e262c3..be762a330 100644
--- a/src/agents/extensions/visualization.py
+++ b/src/agents/extensions/visualization.py
@@ -1,4 +1,4 @@
-from typing import Optional
+from __future__ import annotations
 
 import graphviz  # type: ignore
 
@@ -31,7 +31,9 @@ def get_main_graph(agent: Agent) -> str:
     return "".join(parts)
 
 
-def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str:
+def get_all_nodes(
+    agent: Agent, parent: Agent | None = None, visited: set[str] | None = None
+) -> str:
     """
     Recursively generates the nodes for the given agent and its handoffs in DOT format.
 
@@ -41,17 +43,23 @@ def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str:
     Returns:
         str: The DOT format string representing the nodes.
     """
+    if visited is None:
+        visited = set()
+    if agent.name in visited:
+        return ""
+    visited.add(agent.name)
+
     parts = []
 
     # Start and end the graph
-    parts.append(
-        '"__start__" [label="__start__", shape=ellipse, style=filled, '
-        "fillcolor=lightblue, width=0.5, height=0.3];"
-        '"__end__" [label="__end__", shape=ellipse, style=filled, '
-        "fillcolor=lightblue, width=0.5, height=0.3];"
-    )
-    # Ensure parent agent node is colored
     if not parent:
+        parts.append(
+            '"__start__" [label="__start__", shape=ellipse, style=filled, '
+            "fillcolor=lightblue, width=0.5, height=0.3];"
+            '"__end__" [label="__end__", shape=ellipse, style=filled, '
+            "fillcolor=lightblue, width=0.5, height=0.3];"
+        )
+        # Ensure parent agent node is colored
         parts.append(
             f'"{agent.name}" [label="{agent.name}", shape=box, style=filled, '
             "fillcolor=lightyellow, width=1.5, height=0.8];"
@@ -71,17 +79,20 @@ def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str:
                 f"fillcolor=lightyellow, width=1.5, height=0.8];"
             )
         if isinstance(handoff, Agent):
-            parts.append(
-                f'"{handoff.name}" [label="{handoff.name}", '
-                f"shape=box, style=filled, style=rounded, "
-                f"fillcolor=lightyellow, width=1.5, height=0.8];"
-            )
-            parts.append(get_all_nodes(handoff))
+            if handoff.name not in visited:
+                parts.append(
+                    f'"{handoff.name}" [label="{handoff.name}", '
+                    f"shape=box, style=filled, style=rounded, "
+                    f"fillcolor=lightyellow, width=1.5, height=0.8];"
+                )
+            parts.append(get_all_nodes(handoff, agent, visited))
 
     return "".join(parts)
 
 
-def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str:
+def get_all_edges(
+    agent: Agent, parent: Agent | None = None, visited: set[str] | None = None
+) -> str:
     """
     Recursively generates the edges for the given agent and its handoffs in DOT format.
 
@@ -92,6 +103,12 @@ def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str:
     Returns:
         str: The DOT format string representing the edges.
     """
+    if visited is None:
+        visited = set()
+    if agent.name in visited:
+        return ""
+    visited.add(agent.name)
+
     parts = []
 
     if not parent:
@@ -109,7 +126,7 @@ def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str:
         if isinstance(handoff, Agent):
             parts.append(f"""
             "{agent.name}" -> "{handoff.name}";""")
-            parts.append(get_all_edges(handoff, agent))
+            parts.append(get_all_edges(handoff, agent, visited))
 
     if not agent.handoffs and not isinstance(agent, Tool):  # type: ignore
         parts.append(f'"{agent.name}" -> "__end__";')
@@ -117,7 +134,7 @@ def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str:
     return "".join(parts)
 
 
-def draw_graph(agent: Agent, filename: Optional[str] = None) -> graphviz.Source:
+def draw_graph(agent: Agent, filename: str | None = None) -> graphviz.Source:
     """
     Draws the graph for the given agent and optionally saves it as a PNG file.
 


Test patch:
diff --git a/tests/test_visualization.py b/tests/test_visualization.py
index 6aa867743..8bce897e9 100644
--- a/tests/test_visualization.py
+++ b/tests/test_visualization.py
@@ -134,3 +134,18 @@ def test_draw_graph(mock_agent):
         '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, '
         "fillcolor=lightyellow, width=1.5, height=0.8];" in graph.source
     )
+
+
+def test_cycle_detection():
+    agent_a = Agent(name="A")
+    agent_b = Agent(name="B")
+    agent_a.handoffs.append(agent_b)
+    agent_b.handoffs.append(agent_a)
+
+    nodes = get_all_nodes(agent_a)
+    edges = get_all_edges(agent_a)
+
+    assert nodes.count('"A" [label="A"') == 1
+    assert nodes.count('"B" [label="B"') == 1
+    assert '"A" -> "B"' in edges
+    assert '"B" -> "A"' in edges