From a3d26733688664b147753552b0fa79a3b99718ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Thu, 13 Mar 2025 18:33:08 +0100 Subject: [PATCH 01/63] Add graphviz as a dependency in pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 262ce17c..116671f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "typing-extensions>=4.12.2, <5", "requests>=2.0, <3", "types-requests>=2.0, <3", + "graphviz>=0.17", ] classifiers = [ "Typing :: Typed", From fdfa05c7595bcf6e204c91b96f34fa12024e7feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Thu, 13 Mar 2025 18:33:12 +0100 Subject: [PATCH 02/63] Add visualization functions for agents using Graphviz --- src/agents/visualizations.py | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/agents/visualizations.py diff --git a/src/agents/visualizations.py b/src/agents/visualizations.py new file mode 100644 index 00000000..934647f0 --- /dev/null +++ b/src/agents/visualizations.py @@ -0,0 +1,113 @@ +import graphviz + +from src.agents.agent import Agent + + +def get_main_graph(agent: Agent) -> str: + """ + Generates the main graph structure in DOT format for the given agent. + + Args: + agent (Agent): The agent for which the graph is to be generated. + + Returns: + str: The DOT format string representing the graph. + """ + parts = [""" + digraph G { + graph [splines=true]; + node [fontname="Arial"]; + edge [penwidth=1.5]; + + "__start__" [shape=ellipse, style=filled, fillcolor=lightblue]; + "__end__" [shape=ellipse, style=filled, fillcolor=lightblue]; + """] + parts.append(get_all_nodes(agent)) + parts.append(get_all_edges(agent)) + parts.append("}") + return "".join(parts) + + +def get_all_nodes(agent: Agent, parent: Agent = None) -> str: + """ + Recursively generates the nodes for the given agent and its handoffs in DOT format. + + Args: + agent (Agent): The agent for which the nodes are to be generated. + + Returns: + str: The DOT format string representing the nodes. + """ + parts = [] + + # Ensure parent agent node is colored + if not parent: + parts.append(f""" + "{agent.name}" [label="{agent.name}", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];""") + + # Smaller tools (ellipse, green) + for tool in agent.tools: + parts.append(f""" + "{tool.name}" [label="{tool.name}", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];""") + + # Bigger handoffs (rounded box, yellow) + for handoff in agent.handoffs: + parts.append(f""" + "{handoff.name}" [label="{handoff.name}", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];""") + parts.append(get_all_nodes(handoff)) + + return "".join(parts) + + +def get_all_edges(agent: Agent, parent: Agent = None) -> str: + """ + Recursively generates the edges for the given agent and its handoffs in DOT format. + + Args: + agent (Agent): The agent for which the edges are to be generated. + parent (Agent, optional): The parent agent. Defaults to None. + + Returns: + str: The DOT format string representing the edges. + """ + parts = [] + + if not parent: + parts.append(f""" + "__start__" -> "{agent.name}";""") + + for tool in agent.tools: + parts.append(f""" + "{agent.name}" -> "{tool.name}" [style=dotted, penwidth=1.5]; + "{tool.name}" -> "{agent.name}" [style=dotted, penwidth=1.5];""") + + if not agent.handoffs: + parts.append(f""" + "{agent.name}" -> "__end__";""") + + for handoff in agent.handoffs: + parts.append(f""" + "{agent.name}" -> "{handoff.name}";""") + parts.append(get_all_edges(handoff, agent)) + + return "".join(parts) + + +def draw_graph(agent: Agent, filename: str = None) -> graphviz.Source: + """ + Draws the graph for the given agent and optionally saves it as a PNG file. + + Args: + agent (Agent): The agent for which the graph is to be drawn. + filename (str): The name of the file to save the graph as a PNG. + + Returns: + graphviz.Source: The graphviz Source object representing the graph. + """ + dot_code = get_main_graph(agent) + graph = graphviz.Source(dot_code) + + if filename: + graph.render(filename, format='png') + + return graph \ No newline at end of file From 2f3875562b437b88a9e98f933676a9bff1ff10e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Thu, 13 Mar 2025 18:33:17 +0100 Subject: [PATCH 03/63] Add unit tests for visualization functions in test_visualizations.py --- tests/test_visualizations.py | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/test_visualizations.py diff --git a/tests/test_visualizations.py b/tests/test_visualizations.py new file mode 100644 index 00000000..0062f85b --- /dev/null +++ b/tests/test_visualizations.py @@ -0,0 +1,68 @@ +import pytest +from unittest.mock import Mock +from src.agents.visualizations import get_main_graph, get_all_nodes, get_all_edges, draw_graph +from src.agents.agent import Agent +import graphviz + +@pytest.fixture +def mock_agent(): + tool1 = Mock() + tool1.name = "Tool1" + tool2 = Mock() + tool2.name = "Tool2" + + handoff1 = Mock() + handoff1.name = "Handoff1" + handoff1.tools = [] + handoff1.handoffs = [] + + agent = Mock(spec=Agent) + agent.name = "Agent1" + agent.tools = [tool1, tool2] + agent.handoffs = [handoff1] + + return agent + +def test_get_main_graph(mock_agent): + result = get_main_graph(mock_agent) + assert "digraph G" in result + assert 'graph [splines=true];' in result + assert 'node [fontname="Arial"];' in result + assert 'edge [penwidth=1.5];' in result + assert '"__start__" [shape=ellipse, style=filled, fillcolor=lightblue];' in result + assert '"__end__" [shape=ellipse, style=filled, fillcolor=lightblue];' in result + assert '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' in result + assert '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in result + assert '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in result + assert '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' in result + +def test_get_all_nodes(mock_agent): + result = get_all_nodes(mock_agent) + assert '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' in result + assert '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in result + assert '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in result + assert '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' in result + +def test_get_all_edges(mock_agent): + result = get_all_edges(mock_agent) + assert '"__start__" -> "Agent1";' in result + assert '"Agent1" -> "Tool1" [style=dotted, penwidth=1.5];' in result + assert '"Tool1" -> "Agent1" [style=dotted, penwidth=1.5];' in result + assert '"Agent1" -> "Tool2" [style=dotted, penwidth=1.5];' in result + assert '"Tool2" -> "Agent1" [style=dotted, penwidth=1.5];' in result + assert '"Agent1" -> "Handoff1";' in result + assert '"Handoff1" -> "__end__";' in result + +def test_draw_graph(mock_agent): + graph = draw_graph(mock_agent) + assert isinstance(graph, graphviz.Source) + assert "digraph G" in graph.source + assert 'graph [splines=true];' in graph.source + assert 'node [fontname="Arial"];' in graph.source + assert 'edge [penwidth=1.5];' in graph.source + assert '"__start__" [shape=ellipse, style=filled, fillcolor=lightblue];' in graph.source + assert '"__end__" [shape=ellipse, style=filled, fillcolor=lightblue];' in graph.source + assert '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' in graph.source + assert '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in graph.source + assert '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in graph.source + assert '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' in graph.source From 9edf33525896869e0b77e0279757de8965516dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Thu, 13 Mar 2025 18:48:12 +0100 Subject: [PATCH 04/63] Add documentation and example for agent visualization using Graphviz --- docs/assets/images/graph.png | Bin 0 -> 95039 bytes docs/visualizations.md | 106 +++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 docs/assets/images/graph.png create mode 100644 docs/visualizations.md diff --git a/docs/assets/images/graph.png b/docs/assets/images/graph.png new file mode 100644 index 0000000000000000000000000000000000000000..13e2d6eb4fedc41781631260364386b20d5cc5d6 GIT binary patch literal 95039 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 literal 0 HcmV?d00001 diff --git a/docs/visualizations.md b/docs/visualizations.md new file mode 100644 index 00000000..2a8bb288 --- /dev/null +++ b/docs/visualizations.md @@ -0,0 +1,106 @@ +# Agent Visualization + +Agent visualization allows you to generate a structured graphical representation of agents and their relationships using **Graphviz**. This is useful for understanding how agents, tools, and handoffs interact within an application. + +## Installation + +The visualization functionality relies on the **Graphviz** package. To use it, ensure you have Graphviz installed and add it as a dependency in `pyproject.toml`. Alternatively, install it directly via pip: + +```bash +pip install graphviz +``` + +## Generating a Graph + +You can generate an agent visualization using the `draw_graph` function. This function creates a directed graph where: + +- **Agents** are represented as yellow boxes. +- **Tools** are represented as green ellipses. +- **Handoffs** are directed edges from one agent to another. + +### Example Usage + +```python +from agents import Agent, function_tool +from agents.visualizations import draw_graph + +@function_tool +def get_weather(city: str) -> str: + return f"The weather in {city} is sunny." + +spanish_agent = Agent( + name="Spanish agent", + instructions="You only speak Spanish.", +) + +english_agent = Agent( + name="English agent", + instructions="You only speak English", +) + +triage_agent = Agent( + name="Triage agent", + instructions="Handoff to the appropriate agent based on the language of the request.", + handoffs=[spanish_agent, english_agent], + tools=[get_weather], +) + +draw_graph(triage_agent) +``` + +![Agent Graph](./assets/images/graph.png) + +This generates a graph that visually represents the structure of the **triage agent** and its connections to sub-agents and tools. + + +## Understanding the Visualization + +The generated graph includes: + +- A **start node** (`__start__`) indicating the entry point. +- Agents represented as **rectangles** with yellow fill. +- Tools represented as **ellipses** with green fill. +- Directed edges indicating interactions: + - **Solid arrows** for agent-to-agent handoffs. + - **Dotted arrows** for tool invocations. +- An **end node** (`__end__`) indicating where execution terminates. + +## Customizing the Graph + +### Showing the Graph +By default, `draw_graph` displays the graph inline. To show the graph in a separate window, write the following: + +```python +draw_graph(triage_agent).view() +``` + +### Saving the Graph +By default, `draw_graph` displays the graph inline. To save it as a file, specify a filename: + +```python +draw_graph(triage_agent, filename="agent_graph.png") +``` + +This will generate `agent_graph.png` in the working directory. + +## Testing the Visualization + +The visualization functionality includes test coverage to ensure correctness. Tests are located in `tests/test_visualizations.py` and verify: + +- Node and edge correctness in `get_main_graph()`. +- Proper agent and tool representation in `get_all_nodes()`. +- Accurate relationship mapping in `get_all_edges()`. +- Graph rendering functionality in `draw_graph()`. + +Run tests using: + +```bash +pytest tests/test_visualizations.py +``` + +## Conclusion + +Agent visualization provides a powerful way to **understand, debug, and communicate** how agents interact within an application. By leveraging **Graphviz**, you can generate intuitive visual representations of complex agent structures effortlessly. + +For further details on agent functionality, see the [Agents documentation](agents.md). + From ed7bad55ee9a08e05774896543da3ab200c6b3d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Thu, 13 Mar 2025 18:49:12 +0100 Subject: [PATCH 05/63] Linting --- src/agents/visualizations.py | 32 +++++++------- tests/test_visualizations.py | 85 +++++++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 36 deletions(-) diff --git a/src/agents/visualizations.py b/src/agents/visualizations.py index 934647f0..42019d4d 100644 --- a/src/agents/visualizations.py +++ b/src/agents/visualizations.py @@ -13,7 +13,8 @@ def get_main_graph(agent: Agent) -> str: Returns: str: The DOT format string representing the graph. """ - parts = [""" + parts = [ + """ digraph G { graph [splines=true]; node [fontname="Arial"]; @@ -21,7 +22,8 @@ def get_main_graph(agent: Agent) -> str: "__start__" [shape=ellipse, style=filled, fillcolor=lightblue]; "__end__" [shape=ellipse, style=filled, fillcolor=lightblue]; - """] + """ + ] parts.append(get_all_nodes(agent)) parts.append(get_all_edges(agent)) parts.append("}") @@ -39,23 +41,23 @@ def get_all_nodes(agent: Agent, parent: Agent = None) -> str: str: The DOT format string representing the nodes. """ parts = [] - + # Ensure parent agent node is colored if not parent: parts.append(f""" "{agent.name}" [label="{agent.name}", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];""") - + # Smaller tools (ellipse, green) for tool in agent.tools: parts.append(f""" "{tool.name}" [label="{tool.name}", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];""") - + # Bigger handoffs (rounded box, yellow) for handoff in agent.handoffs: parts.append(f""" "{handoff.name}" [label="{handoff.name}", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];""") parts.append(get_all_nodes(handoff)) - + return "".join(parts) @@ -71,25 +73,25 @@ def get_all_edges(agent: Agent, parent: Agent = None) -> str: str: The DOT format string representing the edges. """ parts = [] - + if not parent: parts.append(f""" "__start__" -> "{agent.name}";""") - + for tool in agent.tools: parts.append(f""" "{agent.name}" -> "{tool.name}" [style=dotted, penwidth=1.5]; "{tool.name}" -> "{agent.name}" [style=dotted, penwidth=1.5];""") - + if not agent.handoffs: parts.append(f""" "{agent.name}" -> "__end__";""") - + for handoff in agent.handoffs: parts.append(f""" "{agent.name}" -> "{handoff.name}";""") parts.append(get_all_edges(handoff, agent)) - + return "".join(parts) @@ -106,8 +108,8 @@ def draw_graph(agent: Agent, filename: str = None) -> graphviz.Source: """ dot_code = get_main_graph(agent) graph = graphviz.Source(dot_code) - + if filename: - graph.render(filename, format='png') - - return graph \ No newline at end of file + graph.render(filename, format="png") + + return graph diff --git a/tests/test_visualizations.py b/tests/test_visualizations.py index 0062f85b..d8139780 100644 --- a/tests/test_visualizations.py +++ b/tests/test_visualizations.py @@ -1,8 +1,11 @@ -import pytest from unittest.mock import Mock -from src.agents.visualizations import get_main_graph, get_all_nodes, get_all_edges, draw_graph -from src.agents.agent import Agent + import graphviz +import pytest + +from src.agents.agent import Agent +from src.agents.visualizations import draw_graph, get_all_edges, get_all_nodes, get_main_graph + @pytest.fixture def mock_agent(): @@ -10,7 +13,7 @@ def mock_agent(): tool1.name = "Tool1" tool2 = Mock() tool2.name = "Tool2" - + handoff1 = Mock() handoff1.name = "Handoff1" handoff1.tools = [] @@ -20,28 +23,55 @@ def mock_agent(): agent.name = "Agent1" agent.tools = [tool1, tool2] agent.handoffs = [handoff1] - + return agent + def test_get_main_graph(mock_agent): result = get_main_graph(mock_agent) assert "digraph G" in result - assert 'graph [splines=true];' in result + assert "graph [splines=true];" in result assert 'node [fontname="Arial"];' in result - assert 'edge [penwidth=1.5];' in result + assert "edge [penwidth=1.5];" in result assert '"__start__" [shape=ellipse, style=filled, fillcolor=lightblue];' in result assert '"__end__" [shape=ellipse, style=filled, fillcolor=lightblue];' in result - assert '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' in result - assert '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in result - assert '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in result - assert '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' in result + assert ( + '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' + in result + ) + assert ( + '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' + in result + ) + assert ( + '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' + in result + ) + assert ( + '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' + in result + ) + def test_get_all_nodes(mock_agent): result = get_all_nodes(mock_agent) - assert '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' in result - assert '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in result - assert '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in result - assert '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' in result + assert ( + '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' + in result + ) + assert ( + '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' + in result + ) + assert ( + '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' + in result + ) + assert ( + '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' + in result + ) + def test_get_all_edges(mock_agent): result = get_all_edges(mock_agent) @@ -53,16 +83,29 @@ def test_get_all_edges(mock_agent): assert '"Agent1" -> "Handoff1";' in result assert '"Handoff1" -> "__end__";' in result + def test_draw_graph(mock_agent): graph = draw_graph(mock_agent) assert isinstance(graph, graphviz.Source) assert "digraph G" in graph.source - assert 'graph [splines=true];' in graph.source + assert "graph [splines=true];" in graph.source assert 'node [fontname="Arial"];' in graph.source - assert 'edge [penwidth=1.5];' in graph.source + assert "edge [penwidth=1.5];" in graph.source assert '"__start__" [shape=ellipse, style=filled, fillcolor=lightblue];' in graph.source assert '"__end__" [shape=ellipse, style=filled, fillcolor=lightblue];' in graph.source - assert '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' in graph.source - assert '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in graph.source - assert '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' in graph.source - assert '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' in graph.source + assert ( + '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' + in graph.source + ) + assert ( + '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' + in graph.source + ) + assert ( + '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' + in graph.source + ) + assert ( + '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' + in graph.source + ) From c7805669bc4b733ace71e36eecf9f6c59c6edfe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 18 Mar 2025 09:53:48 +0100 Subject: [PATCH 06/63] feat: add visualization functions for agent graphs --- src/agents/{ => extensions}/visualizations.py | 2 +- tests/test_visualizations.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/agents/{ => extensions}/visualizations.py (98%) diff --git a/src/agents/visualizations.py b/src/agents/extensions/visualizations.py similarity index 98% rename from src/agents/visualizations.py rename to src/agents/extensions/visualizations.py index 42019d4d..c020708e 100644 --- a/src/agents/visualizations.py +++ b/src/agents/extensions/visualizations.py @@ -1,6 +1,6 @@ import graphviz -from src.agents.agent import Agent +from agents import Agent def get_main_graph(agent: Agent) -> str: diff --git a/tests/test_visualizations.py b/tests/test_visualizations.py index d8139780..ac02cee8 100644 --- a/tests/test_visualizations.py +++ b/tests/test_visualizations.py @@ -3,8 +3,8 @@ from unittest.mock import Mock import graphviz import pytest -from src.agents.agent import Agent -from src.agents.visualizations import draw_graph, get_all_edges, get_all_nodes, get_main_graph +from agents import Agent +from agents.extensions.visualizations import draw_graph, get_all_edges, get_all_nodes, get_main_graph @pytest.fixture From cd96e745dfa25cdee6cadf238f00d77e65a3ec78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 18 Mar 2025 09:53:53 +0100 Subject: [PATCH 07/63] refactor: move graphviz dependency to visualization section --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d4404247..554771ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ dependencies = [ "typing-extensions>=4.12.2, <5", "requests>=2.0, <3", "types-requests>=2.0, <3", - "graphviz>=0.17", ] classifiers = [ "Typing :: Typed", @@ -50,6 +49,10 @@ dev = [ "playwright==1.50.0", "inline-snapshot>=0.20.7", ] +visualization = [ + "graphviz>=0.17", +] + [tool.uv.workspace] members = ["agents"] From 3521ab0e487d1c6e8b9151ae9bea9eec51e1022d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 18 Mar 2025 09:55:23 +0100 Subject: [PATCH 08/63] feat: add documentation for agent visualization using Graphviz --- docs/{visualizations.md => visualization.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename docs/{visualizations.md => visualization.md} (97%) diff --git a/docs/visualizations.md b/docs/visualization.md similarity index 97% rename from docs/visualizations.md rename to docs/visualization.md index 2a8bb288..6bb5ee5d 100644 --- a/docs/visualizations.md +++ b/docs/visualization.md @@ -7,7 +7,7 @@ Agent visualization allows you to generate a structured graphical representation The visualization functionality relies on the **Graphviz** package. To use it, ensure you have Graphviz installed and add it as a dependency in `pyproject.toml`. Alternatively, install it directly via pip: ```bash -pip install graphviz +pip install openai-agents[visualization] ``` ## Generating a Graph @@ -22,7 +22,7 @@ You can generate an agent visualization using the `draw_graph` function. This fu ```python from agents import Agent, function_tool -from agents.visualizations import draw_graph +from agents.extensions.visualization import draw_graph @function_tool def get_weather(city: str) -> str: From dc92970ecafd5a365e68b90a12811ed6daafd27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 18 Mar 2025 09:55:30 +0100 Subject: [PATCH 09/63] feat: add visualization module for agent graphs using Graphviz --- src/agents/extensions/{visualizations.py => visualization.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/agents/extensions/{visualizations.py => visualization.py} (100%) diff --git a/src/agents/extensions/visualizations.py b/src/agents/extensions/visualization.py similarity index 100% rename from src/agents/extensions/visualizations.py rename to src/agents/extensions/visualization.py From 40121559263ded327506a468272ff99e6fca2940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 18 Mar 2025 09:59:47 +0100 Subject: [PATCH 10/63] rename: test_visualization.py --- tests/{test_visualizations.py => test_visualization.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{test_visualizations.py => test_visualization.py} (97%) diff --git a/tests/test_visualizations.py b/tests/test_visualization.py similarity index 97% rename from tests/test_visualizations.py rename to tests/test_visualization.py index ac02cee8..8505b619 100644 --- a/tests/test_visualizations.py +++ b/tests/test_visualization.py @@ -4,7 +4,7 @@ import graphviz import pytest from agents import Agent -from agents.extensions.visualizations import draw_graph, get_all_edges, get_all_nodes, get_main_graph +from agents.extensions.visualization import draw_graph, get_all_edges, get_all_nodes, get_main_graph @pytest.fixture From 4a477780a1d67d28be1532c23f3684000ee8c966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:09:44 +0100 Subject: [PATCH 11/63] style: improve code formatting and readability in visualization functions --- src/agents/extensions/visualization.py | 11 +++--- tests/test_visualization.py | 55 ++++++++++++++------------ 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index c020708e..985731b8 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -19,7 +19,6 @@ def get_main_graph(agent: Agent) -> str: graph [splines=true]; node [fontname="Arial"]; edge [penwidth=1.5]; - "__start__" [shape=ellipse, style=filled, fillcolor=lightblue]; "__end__" [shape=ellipse, style=filled, fillcolor=lightblue]; """ @@ -41,21 +40,23 @@ def get_all_nodes(agent: Agent, parent: Agent = None) -> str: str: The DOT format string representing the nodes. """ parts = [] - # Ensure parent agent node is colored if not parent: parts.append(f""" - "{agent.name}" [label="{agent.name}", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];""") + "{agent.name}" [label="{agent.name}", shape=box, style=filled, + fillcolor=lightyellow, width=1.5, height=0.8];""") # Smaller tools (ellipse, green) for tool in agent.tools: parts.append(f""" - "{tool.name}" [label="{tool.name}", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];""") + "{tool.name}" [label="{tool.name}", shape=ellipse, style=filled, + fillcolor=lightgreen, width=0.5, height=0.3];""") # Bigger handoffs (rounded box, yellow) for handoff in agent.handoffs: parts.append(f""" - "{handoff.name}" [label="{handoff.name}", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];""") + "{handoff.name}" [label="{handoff.name}", shape=box, style=filled, + style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];""") parts.append(get_all_nodes(handoff)) return "".join(parts) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 8505b619..12d67fb0 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -4,7 +4,12 @@ import graphviz import pytest from agents import Agent -from agents.extensions.visualization import draw_graph, get_all_edges, get_all_nodes, get_main_graph +from agents.extensions.visualization import ( + draw_graph, + get_all_edges, + get_all_nodes, + get_main_graph, +) @pytest.fixture @@ -36,40 +41,40 @@ def test_get_main_graph(mock_agent): assert '"__start__" [shape=ellipse, style=filled, fillcolor=lightblue];' in result assert '"__end__" [shape=ellipse, style=filled, fillcolor=lightblue];' in result assert ( - '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' - in result + '"Agent1" [label="Agent1", shape=box, style=filled, ' + "fillcolor=lightyellow, width=1.5, height=0.8];" in result ) assert ( - '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' - in result + '"Tool1" [label="Tool1", shape=ellipse, style=filled, ' + "fillcolor=lightgreen, width=0.5, height=0.3];" in result ) assert ( - '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' - in result + '"Tool2" [label="Tool2", shape=ellipse, style=filled, ' + "fillcolor=lightgreen, width=0.5, height=0.3];" in result ) assert ( - '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' - in result + '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, ' + "fillcolor=lightyellow, width=1.5, height=0.8];" in result ) def test_get_all_nodes(mock_agent): result = get_all_nodes(mock_agent) assert ( - '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' - in result + '"Agent1" [label="Agent1", shape=box, style=filled, ' + "fillcolor=lightyellow, width=1.5, height=0.8];" in result ) assert ( - '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' - in result + '"Tool1" [label="Tool1", shape=ellipse, style=filled, ' + "fillcolor=lightgreen, width=0.5, height=0.3];" in result ) assert ( - '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' - in result + '"Tool2" [label="Tool2", shape=ellipse, style=filled, ' + "fillcolor=lightgreen, width=0.5, height=0.3];" in result ) assert ( - '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' - in result + '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, ' + "fillcolor=lightyellow, width=1.5, height=0.8];" in result ) @@ -94,18 +99,18 @@ def test_draw_graph(mock_agent): assert '"__start__" [shape=ellipse, style=filled, fillcolor=lightblue];' in graph.source assert '"__end__" [shape=ellipse, style=filled, fillcolor=lightblue];' in graph.source assert ( - '"Agent1" [label="Agent1", shape=box, style=filled, fillcolor=lightyellow, width=1.5, height=0.8];' - in graph.source + '"Agent1" [label="Agent1", shape=box, style=filled, ' + "fillcolor=lightyellow, width=1.5, height=0.8];" in graph.source ) assert ( - '"Tool1" [label="Tool1", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' - in graph.source + '"Tool1" [label="Tool1", shape=ellipse, style=filled, ' + "fillcolor=lightgreen, width=0.5, height=0.3];" in graph.source ) assert ( - '"Tool2" [label="Tool2", shape=ellipse, style=filled, fillcolor=lightgreen, width=0.5, height=0.3];' - in graph.source + '"Tool2" [label="Tool2", shape=ellipse, style=filled, ' + "fillcolor=lightgreen, width=0.5, height=0.3];" in graph.source ) assert ( - '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];' - in graph.source + '"Handoff1" [label="Handoff1", shape=box, style=filled, style=rounded, ' + "fillcolor=lightyellow, width=1.5, height=0.8];" in graph.source ) From b97e5dcbe6924e4ecdd93192665dfef295b3e0c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:14:52 +0100 Subject: [PATCH 12/63] style: improve string formatting --- src/agents/extensions/visualization.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index 985731b8..1b5b7e57 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -42,21 +42,24 @@ def get_all_nodes(agent: Agent, parent: Agent = None) -> str: parts = [] # Ensure parent agent node is colored if not parent: - parts.append(f""" - "{agent.name}" [label="{agent.name}", shape=box, style=filled, - fillcolor=lightyellow, width=1.5, height=0.8];""") + parts.append( + f'"{agent.name}" [label="{agent.name}", shape=box, style=filled, ' + 'fillcolor=lightyellow, width=1.5, height=0.8];' + ) # Smaller tools (ellipse, green) for tool in agent.tools: - parts.append(f""" - "{tool.name}" [label="{tool.name}", shape=ellipse, style=filled, - fillcolor=lightgreen, width=0.5, height=0.3];""") + parts.append( + f'"{tool.name}" [label="{tool.name}", shape=ellipse, style=filled, ' + f'fillcolor=lightgreen, width=0.5, height=0.3];' + ) # Bigger handoffs (rounded box, yellow) for handoff in agent.handoffs: - parts.append(f""" - "{handoff.name}" [label="{handoff.name}", shape=box, style=filled, - style=rounded, fillcolor=lightyellow, width=1.5, height=0.8];""") + parts.append( + f'"{handoff.name}" [label="{handoff.name}", shape=box, style=filled, style=rounded, ' + f'fillcolor=lightyellow, width=1.5, height=0.8];' + ) parts.append(get_all_nodes(handoff)) return "".join(parts) From a59728db1f26084782a8ec5eeebbd90183a4da3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:15:41 +0100 Subject: [PATCH 13/63] style: improve string formatting in visualization functions --- src/agents/extensions/visualization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index 1b5b7e57..81b63382 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -44,21 +44,21 @@ def get_all_nodes(agent: Agent, parent: Agent = None) -> str: if not parent: parts.append( f'"{agent.name}" [label="{agent.name}", shape=box, style=filled, ' - 'fillcolor=lightyellow, width=1.5, height=0.8];' + "fillcolor=lightyellow, width=1.5, height=0.8];" ) # Smaller tools (ellipse, green) for tool in agent.tools: parts.append( f'"{tool.name}" [label="{tool.name}", shape=ellipse, style=filled, ' - f'fillcolor=lightgreen, width=0.5, height=0.3];' + f"fillcolor=lightgreen, width=0.5, height=0.3];" ) # Bigger handoffs (rounded box, yellow) for handoff in agent.handoffs: parts.append( f'"{handoff.name}" [label="{handoff.name}", shape=box, style=filled, style=rounded, ' - f'fillcolor=lightyellow, width=1.5, height=0.8];' + f"fillcolor=lightyellow, width=1.5, height=0.8];" ) parts.append(get_all_nodes(handoff)) From 20271a3591a04ad1715030d467a5fd929ece2514 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Thu, 20 Mar 2025 21:22:27 +0800 Subject: [PATCH 14/63] Fix potential infinite tool call loop by resetting tool_choice after tool execution --- src/agents/_run_impl.py | 32 ++++ tests/test_tool_choice_reset.py | 303 ++++++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 tests/test_tool_choice_reset.py diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index 2849538d..a60ae1d6 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -47,6 +47,7 @@ from .items import ( ) from .lifecycle import RunHooks from .logger import logger +from .model_settings import ModelSettings from .models.interface import ModelTracing from .run_context import RunContextWrapper, TContext from .stream_events import RunItemStreamEvent, StreamEvent @@ -206,6 +207,37 @@ class RunImpl: new_step_items.extend([result.run_item for result in function_results]) new_step_items.extend(computer_results) + # Reset tool_choice to "auto" after tool execution to prevent infinite loops + if (processed_response.functions or processed_response.computer_actions): + # Reset agent's model_settings + if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): + # Create a new model_settings to avoid modifying the original shared instance + agent.model_settings = ModelSettings( + temperature=agent.model_settings.temperature, + top_p=agent.model_settings.top_p, + frequency_penalty=agent.model_settings.frequency_penalty, + presence_penalty=agent.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=agent.model_settings.parallel_tool_calls, + truncation=agent.model_settings.truncation, + max_tokens=agent.model_settings.max_tokens, + ) + + # Also reset run_config's model_settings if it exists + if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or + isinstance(run_config.model_settings.tool_choice, str)): + # Create a new model_settings for run_config + run_config.model_settings = ModelSettings( + temperature=run_config.model_settings.temperature, + top_p=run_config.model_settings.top_p, + frequency_penalty=run_config.model_settings.frequency_penalty, + presence_penalty=run_config.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=run_config.model_settings.parallel_tool_calls, + truncation=run_config.model_settings.truncation, + max_tokens=run_config.model_settings.max_tokens, + ) + # Second, check if there are any handoffs if run_handoffs := processed_response.handoffs: return await cls.execute_handoffs( diff --git a/tests/test_tool_choice_reset.py b/tests/test_tool_choice_reset.py new file mode 100644 index 00000000..e01a5f0d --- /dev/null +++ b/tests/test_tool_choice_reset.py @@ -0,0 +1,303 @@ +from unittest import mock +import asyncio +import json +from typing import List + +from agents import Agent, ModelSettings, RunConfig, function_tool, Runner +from agents.models.interface import ModelResponse +from agents.items import Usage +from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall + + +@function_tool +def echo(text: str) -> str: + """Echo the input text""" + return text + + +# Mock model implementation that always calls tools when tool_choice is set +class MockModel: + def __init__(self, tool_call_counter): + self.tool_call_counter = tool_call_counter + + async def get_response(self, **kwargs): + tools = kwargs.get("tools", []) + model_settings = kwargs.get("model_settings") + + # Increment the counter to track how many times this model is called + self.tool_call_counter["count"] += 1 + + # If we've been called many times, we're likely in an infinite loop + if self.tool_call_counter["count"] > 5: + self.tool_call_counter["potential_infinite_loop"] = True + + # Always create a tool call if tool_choice is required/specific + tool_calls = [] + if model_settings and model_settings.tool_choice: + if model_settings.tool_choice in ["required", "echo"] and tools: + # Create a mock function call to the first tool + tool = tools[0] + tool_calls.append( + ResponseFunctionToolCall( + id="call_1", + name=tool.name, + arguments=json.dumps({"text": "This is a test"}), + call_id="call_1", + type="function_call", + ) + ) + + return ModelResponse( + output=tool_calls, + referenceable_id="123", + usage=Usage(input_tokens=10, output_tokens=10, total_tokens=20), + ) + + +class TestToolChoiceReset: + async def test_tool_choice_resets_after_call(self): + """Test that tool_choice is reset to 'auto' after tool call when set to 'required'""" + # Create an agent with tool_choice="required" + agent = Agent( + name="Test agent", + tools=[echo], + model_settings=ModelSettings(tool_choice="required"), + ) + + # Directly modify the model_settings + # Instead of trying to run the full execute_tools_and_side_effects, + # we'll just test the tool_choice reset logic directly + processed_response = mock.MagicMock() + processed_response.functions = [mock.MagicMock()] # At least one function call + processed_response.computer_actions = [] + + # Create a mock run_config + run_config = mock.MagicMock() + run_config.model_settings = None + + # Execute our code under test + if processed_response.functions: + # Reset agent's model_settings + if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): + agent.model_settings = ModelSettings( + temperature=agent.model_settings.temperature, + top_p=agent.model_settings.top_p, + frequency_penalty=agent.model_settings.frequency_penalty, + presence_penalty=agent.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=agent.model_settings.parallel_tool_calls, + truncation=agent.model_settings.truncation, + max_tokens=agent.model_settings.max_tokens, + ) + + # Also reset run_config's model_settings if it exists + if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or + isinstance(run_config.model_settings.tool_choice, str)): + run_config.model_settings = ModelSettings( + temperature=run_config.model_settings.temperature, + top_p=run_config.model_settings.top_p, + frequency_penalty=run_config.model_settings.frequency_penalty, + presence_penalty=run_config.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=run_config.model_settings.parallel_tool_calls, + truncation=run_config.model_settings.truncation, + max_tokens=run_config.model_settings.max_tokens, + ) + + # Check that tool_choice was reset to "auto" + assert agent.model_settings.tool_choice == "auto" + + async def test_tool_choice_resets_from_specific_function(self): + """Test tool_choice reset to 'auto' after call when set to specific function name""" + # Create an agent with tool_choice set to a specific function + agent = Agent( + name="Test agent", + instructions="You are a test agent", + tools=[echo], + model="gpt-4-0125-preview", + model_settings=ModelSettings(tool_choice="echo"), + ) + + # Execute our code under test + processed_response = mock.MagicMock() + processed_response.functions = [mock.MagicMock()] # At least one function call + processed_response.computer_actions = [] + + # Create a mock run_config + run_config = mock.MagicMock() + run_config.model_settings = None + + # Execute our code under test + if processed_response.functions: + # Reset agent's model_settings + if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): + agent.model_settings = ModelSettings( + temperature=agent.model_settings.temperature, + top_p=agent.model_settings.top_p, + frequency_penalty=agent.model_settings.frequency_penalty, + presence_penalty=agent.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=agent.model_settings.parallel_tool_calls, + truncation=agent.model_settings.truncation, + max_tokens=agent.model_settings.max_tokens, + ) + + # Also reset run_config's model_settings if it exists + if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or + isinstance(run_config.model_settings.tool_choice, str)): + run_config.model_settings = ModelSettings( + temperature=run_config.model_settings.temperature, + top_p=run_config.model_settings.top_p, + frequency_penalty=run_config.model_settings.frequency_penalty, + presence_penalty=run_config.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=run_config.model_settings.parallel_tool_calls, + truncation=run_config.model_settings.truncation, + max_tokens=run_config.model_settings.max_tokens, + ) + + # Check that tool_choice was reset to "auto" + assert agent.model_settings.tool_choice == "auto" + + async def test_tool_choice_no_reset_when_auto(self): + """Test that tool_choice is not changed when it's already set to 'auto'""" + # Create an agent with tool_choice="auto" + agent = Agent( + name="Test agent", + tools=[echo], + model_settings=ModelSettings(tool_choice="auto"), + ) + + # Execute our code under test + processed_response = mock.MagicMock() + processed_response.functions = [mock.MagicMock()] # At least one function call + processed_response.computer_actions = [] + + # Create a mock run_config + run_config = mock.MagicMock() + run_config.model_settings = None + + # Execute our code under test + if processed_response.functions: + # Reset agent's model_settings + if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): + agent.model_settings = ModelSettings( + temperature=agent.model_settings.temperature, + top_p=agent.model_settings.top_p, + frequency_penalty=agent.model_settings.frequency_penalty, + presence_penalty=agent.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=agent.model_settings.parallel_tool_calls, + truncation=agent.model_settings.truncation, + max_tokens=agent.model_settings.max_tokens, + ) + + # Also reset run_config's model_settings if it exists + if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or + isinstance(run_config.model_settings.tool_choice, str)): + run_config.model_settings = ModelSettings( + temperature=run_config.model_settings.temperature, + top_p=run_config.model_settings.top_p, + frequency_penalty=run_config.model_settings.frequency_penalty, + presence_penalty=run_config.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=run_config.model_settings.parallel_tool_calls, + truncation=run_config.model_settings.truncation, + max_tokens=run_config.model_settings.max_tokens, + ) + + # Check that tool_choice remains "auto" + assert agent.model_settings.tool_choice == "auto" + + async def test_run_config_tool_choice_reset(self): + """Test that run_config.model_settings.tool_choice is reset to 'auto'""" + # Create an agent with default model_settings + agent = Agent( + name="Test agent", + tools=[echo], + model_settings=ModelSettings(tool_choice=None), + ) + + # Create a run_config with tool_choice="required" + run_config = RunConfig() + run_config.model_settings = ModelSettings(tool_choice="required") + + # Execute our code under test + processed_response = mock.MagicMock() + processed_response.functions = [mock.MagicMock()] # At least one function call + processed_response.computer_actions = [] + + # Execute our code under test + if processed_response.functions: + # Reset agent's model_settings + if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): + agent.model_settings = ModelSettings( + temperature=agent.model_settings.temperature, + top_p=agent.model_settings.top_p, + frequency_penalty=agent.model_settings.frequency_penalty, + presence_penalty=agent.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=agent.model_settings.parallel_tool_calls, + truncation=agent.model_settings.truncation, + max_tokens=agent.model_settings.max_tokens, + ) + + # Also reset run_config's model_settings if it exists + if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or + isinstance(run_config.model_settings.tool_choice, str)): + run_config.model_settings = ModelSettings( + temperature=run_config.model_settings.temperature, + top_p=run_config.model_settings.top_p, + frequency_penalty=run_config.model_settings.frequency_penalty, + presence_penalty=run_config.model_settings.presence_penalty, + tool_choice="auto", # Reset to auto + parallel_tool_calls=run_config.model_settings.parallel_tool_calls, + truncation=run_config.model_settings.truncation, + max_tokens=run_config.model_settings.max_tokens, + ) + + # Check that run_config's tool_choice was reset to "auto" + assert run_config.model_settings.tool_choice == "auto" + + @mock.patch("agents.run.Runner._get_model") + async def test_integration_prevents_infinite_loop(self, mock_get_model): + """Integration test to verify that tool_choice reset prevents infinite loops""" + # Create a counter to track model calls and detect potential infinite loops + tool_call_counter = {"count": 0, "potential_infinite_loop": False} + + # Set up our mock model that will always use tools when tool_choice is set + mock_model_instance = MockModel(tool_call_counter) + # Return our mock model directly + mock_get_model.return_value = mock_model_instance + + # Create an agent with tool_choice="required" to force tool usage + agent = Agent( + name="Test agent", + instructions="You are a test agent", + tools=[echo], + model_settings=ModelSettings(tool_choice="required"), + # Use "run_llm_again" to allow LLM to continue after tool calls + # This would cause infinite loops without the tool_choice reset + tool_use_behavior="run_llm_again", + ) + + # Set a timeout to catch potential infinite loops that our fix doesn't address + try: + # Run the agent with a timeout + async def run_with_timeout(): + return await Runner.run(agent, input="Test input") + + result = await asyncio.wait_for(run_with_timeout(), timeout=2.0) + + # Verify the agent ran successfully + assert result is not None + + # Verify the tool was called at least once but not too many times + # (indicating no infinite loop) + assert tool_call_counter["count"] >= 1 + assert tool_call_counter["count"] < 5 + assert not tool_call_counter["potential_infinite_loop"] + + except asyncio.TimeoutError: + # If we hit a timeout, the test failed - we likely have an infinite loop + assert False, "Timeout occurred, potential infinite loop detected" \ No newline at end of file From 29f39313a353f4f4c71c7aecfff0933342b86e97 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Thu, 20 Mar 2025 21:26:59 +0800 Subject: [PATCH 15/63] Rename test file for better clarity --- ..._tool_choice_reset.py => test_prevent_infinite_tool_loop.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{test_tool_choice_reset.py => test_prevent_infinite_tool_loop.py} (99%) diff --git a/tests/test_tool_choice_reset.py b/tests/test_prevent_infinite_tool_loop.py similarity index 99% rename from tests/test_tool_choice_reset.py rename to tests/test_prevent_infinite_tool_loop.py index e01a5f0d..c60a9514 100644 --- a/tests/test_tool_choice_reset.py +++ b/tests/test_prevent_infinite_tool_loop.py @@ -54,7 +54,7 @@ class MockModel: ) -class TestToolChoiceReset: +class TestPreventInfiniteToolLoop: async def test_tool_choice_resets_after_call(self): """Test that tool_choice is reset to 'auto' after tool call when set to 'required'""" # Create an agent with tool_choice="required" From eaafad41ca3549b7b87abdeef3b1b9b7a489fed5 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Thu, 20 Mar 2025 21:28:24 +0800 Subject: [PATCH 16/63] Revert test file name to match project naming style --- ..._prevent_infinite_tool_loop.py => test_tool_choice_reset.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{test_prevent_infinite_tool_loop.py => test_tool_choice_reset.py} (99%) diff --git a/tests/test_prevent_infinite_tool_loop.py b/tests/test_tool_choice_reset.py similarity index 99% rename from tests/test_prevent_infinite_tool_loop.py rename to tests/test_tool_choice_reset.py index c60a9514..e01a5f0d 100644 --- a/tests/test_prevent_infinite_tool_loop.py +++ b/tests/test_tool_choice_reset.py @@ -54,7 +54,7 @@ class MockModel: ) -class TestPreventInfiniteToolLoop: +class TestToolChoiceReset: async def test_tool_choice_resets_after_call(self): """Test that tool_choice is reset to 'auto' after tool call when set to 'required'""" # Create an agent with tool_choice="required" From 5f430076a38ecddc9375f2f0716cfb482b24c1ad Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Thu, 20 Mar 2025 21:49:38 +0800 Subject: [PATCH 17/63] Update documentation for tool_choice auto-reset feature --- docs/agents.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/agents.md b/docs/agents.md index 1c314739..c9c39aed 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -142,4 +142,6 @@ Supplying a list of tools doesn't always mean the LLM will use a tool. You can f !!! note - If requiring tool use, you should consider setting [`Agent.tool_use_behavior`] to stop the Agent from running when a tool output is produced. Otherwise, the Agent might run in an infinite loop, where the LLM produces a tool call , and the tool result is sent to the LLM, and this infinite loops because the LLM is always forced to use a tool. + To prevent infinite loops, the framework automatically resets `tool_choice` to "auto" after a tool call when it's set to "required" or a specific function name. This allows the model to decide whether to make additional tool calls in subsequent turns. + + If you want the Agent to completely stop after a tool call (rather than continuing with auto mode), you can set [`Agent.tool_use_behavior="stop_on_first_tool"`] which will directly use the tool output as the final response without further LLM processing. From 0746325a678568a4e92ffc4d9d94addd906ffa1f Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sat, 22 Mar 2025 14:10:09 +0800 Subject: [PATCH 18/63] fix: optimize tool_choice reset logic and fix lint errors - Refactor tool_choice reset to target only problematic edge cases - Replace manual ModelSettings recreation with dataclasses.replace - Fix line length and error handling lint issues in tests --- src/agents/_run_impl.py | 63 +++++---- tests/test_tool_choice_reset.py | 221 +++++++++++++++----------------- 2 files changed, 137 insertions(+), 147 deletions(-) diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index a60ae1d6..1f896d75 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import dataclasses import inspect from collections.abc import Awaitable from dataclasses import dataclass @@ -51,7 +52,7 @@ from .model_settings import ModelSettings from .models.interface import ModelTracing from .run_context import RunContextWrapper, TContext from .stream_events import RunItemStreamEvent, StreamEvent -from .tool import ComputerTool, FunctionTool, FunctionToolResult +from .tool import ComputerTool, FunctionTool, FunctionToolResult, Tool from .tracing import ( SpanError, Trace, @@ -208,34 +209,22 @@ class RunImpl: new_step_items.extend(computer_results) # Reset tool_choice to "auto" after tool execution to prevent infinite loops - if (processed_response.functions or processed_response.computer_actions): - # Reset agent's model_settings - if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): - # Create a new model_settings to avoid modifying the original shared instance - agent.model_settings = ModelSettings( - temperature=agent.model_settings.temperature, - top_p=agent.model_settings.top_p, - frequency_penalty=agent.model_settings.frequency_penalty, - presence_penalty=agent.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=agent.model_settings.parallel_tool_calls, - truncation=agent.model_settings.truncation, - max_tokens=agent.model_settings.max_tokens, + if processed_response.functions or processed_response.computer_actions: + tools = agent.tools + # Only reset in the problematic scenarios where loops are likely unintentional + if cls._should_reset_tool_choice(agent.model_settings, tools): + agent.model_settings = dataclasses.replace( + agent.model_settings, + tool_choice="auto" ) - - # Also reset run_config's model_settings if it exists - if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or - isinstance(run_config.model_settings.tool_choice, str)): - # Create a new model_settings for run_config - run_config.model_settings = ModelSettings( - temperature=run_config.model_settings.temperature, - top_p=run_config.model_settings.top_p, - frequency_penalty=run_config.model_settings.frequency_penalty, - presence_penalty=run_config.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=run_config.model_settings.parallel_tool_calls, - truncation=run_config.model_settings.truncation, - max_tokens=run_config.model_settings.max_tokens, + + if ( + run_config.model_settings and + cls._should_reset_tool_choice(run_config.model_settings, tools) + ): + run_config.model_settings = dataclasses.replace( + run_config.model_settings, + tool_choice="auto" ) # Second, check if there are any handoffs @@ -328,6 +317,24 @@ class RunImpl: next_step=NextStepRunAgain(), ) + @classmethod + def _should_reset_tool_choice(cls, model_settings: ModelSettings, tools: list[Tool]) -> bool: + if model_settings is None or model_settings.tool_choice is None: + return False + + # for specific tool choices + if ( + isinstance(model_settings.tool_choice, str) and + model_settings.tool_choice not in ["auto", "required", "none"] + ): + return True + + # for one tool and required tool choice + if model_settings.tool_choice == "required": + return len(tools) == 1 + + return False + @classmethod def process_model_response( cls, diff --git a/tests/test_tool_choice_reset.py b/tests/test_tool_choice_reset.py index e01a5f0d..b47c4d97 100644 --- a/tests/test_tool_choice_reset.py +++ b/tests/test_tool_choice_reset.py @@ -1,13 +1,15 @@ -from unittest import mock import asyncio +import dataclasses import json -from typing import List +from unittest import mock -from agents import Agent, ModelSettings, RunConfig, function_tool, Runner -from agents.models.interface import ModelResponse -from agents.items import Usage from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall +from agents import Agent, ModelSettings, RunConfig, Runner, function_tool +from agents.items import Usage +from agents.models.interface import ModelResponse +from agents.tool import Tool + @function_tool def echo(text: str) -> str: @@ -15,22 +17,39 @@ def echo(text: str) -> str: return text +def should_reset_tool_choice(model_settings: ModelSettings, tools: list[Tool]) -> bool: + if model_settings is None or model_settings.tool_choice is None: + return False + + # for specific tool choices + if ( + isinstance(model_settings.tool_choice, str) and + model_settings.tool_choice not in ["auto", "required", "none"] + ): + return True + + # for one tool and required tool choice + if model_settings.tool_choice == "required": + return len(tools) == 1 + + return False + # Mock model implementation that always calls tools when tool_choice is set class MockModel: def __init__(self, tool_call_counter): self.tool_call_counter = tool_call_counter - + async def get_response(self, **kwargs): tools = kwargs.get("tools", []) model_settings = kwargs.get("model_settings") - + # Increment the counter to track how many times this model is called self.tool_call_counter["count"] += 1 - + # If we've been called many times, we're likely in an infinite loop if self.tool_call_counter["count"] > 5: self.tool_call_counter["potential_infinite_loop"] = True - + # Always create a tool call if tool_choice is required/specific tool_calls = [] if model_settings and model_settings.tool_choice: @@ -46,7 +65,7 @@ class MockModel: type="function_call", ) ) - + return ModelResponse( output=tool_calls, referenceable_id="123", @@ -60,7 +79,7 @@ class TestToolChoiceReset: # Create an agent with tool_choice="required" agent = Agent( name="Test agent", - tools=[echo], + tools=[echo], # Only one tool model_settings=ModelSettings(tool_choice="required"), ) @@ -77,31 +96,22 @@ class TestToolChoiceReset: # Execute our code under test if processed_response.functions: - # Reset agent's model_settings - if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): - agent.model_settings = ModelSettings( - temperature=agent.model_settings.temperature, - top_p=agent.model_settings.top_p, - frequency_penalty=agent.model_settings.frequency_penalty, - presence_penalty=agent.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=agent.model_settings.parallel_tool_calls, - truncation=agent.model_settings.truncation, - max_tokens=agent.model_settings.max_tokens, + # Apply the targeted reset logic + tools = agent.tools + if should_reset_tool_choice(agent.model_settings, tools): + agent.model_settings = dataclasses.replace( + agent.model_settings, + tool_choice="auto" # Reset to auto ) - + # Also reset run_config's model_settings if it exists - if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or - isinstance(run_config.model_settings.tool_choice, str)): - run_config.model_settings = ModelSettings( - temperature=run_config.model_settings.temperature, - top_p=run_config.model_settings.top_p, - frequency_penalty=run_config.model_settings.frequency_penalty, - presence_penalty=run_config.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=run_config.model_settings.parallel_tool_calls, - truncation=run_config.model_settings.truncation, - max_tokens=run_config.model_settings.max_tokens, + if ( + run_config.model_settings and + should_reset_tool_choice(run_config.model_settings, tools) + ): + run_config.model_settings = dataclasses.replace( + run_config.model_settings, + tool_choice="auto" # Reset to auto ) # Check that tool_choice was reset to "auto" @@ -115,7 +125,7 @@ class TestToolChoiceReset: instructions="You are a test agent", tools=[echo], model="gpt-4-0125-preview", - model_settings=ModelSettings(tool_choice="echo"), + model_settings=ModelSettings(tool_choice="echo"), # Specific function name ) # Execute our code under test @@ -129,31 +139,22 @@ class TestToolChoiceReset: # Execute our code under test if processed_response.functions: - # Reset agent's model_settings - if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): - agent.model_settings = ModelSettings( - temperature=agent.model_settings.temperature, - top_p=agent.model_settings.top_p, - frequency_penalty=agent.model_settings.frequency_penalty, - presence_penalty=agent.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=agent.model_settings.parallel_tool_calls, - truncation=agent.model_settings.truncation, - max_tokens=agent.model_settings.max_tokens, + # Apply the targeted reset logic + tools = agent.tools + if should_reset_tool_choice(agent.model_settings, tools): + agent.model_settings = dataclasses.replace( + agent.model_settings, + tool_choice="auto" # Reset to auto ) - + # Also reset run_config's model_settings if it exists - if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or - isinstance(run_config.model_settings.tool_choice, str)): - run_config.model_settings = ModelSettings( - temperature=run_config.model_settings.temperature, - top_p=run_config.model_settings.top_p, - frequency_penalty=run_config.model_settings.frequency_penalty, - presence_penalty=run_config.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=run_config.model_settings.parallel_tool_calls, - truncation=run_config.model_settings.truncation, - max_tokens=run_config.model_settings.max_tokens, + if ( + run_config.model_settings and + should_reset_tool_choice(run_config.model_settings, tools) + ): + run_config.model_settings = dataclasses.replace( + run_config.model_settings, + tool_choice="auto" # Reset to auto ) # Check that tool_choice was reset to "auto" @@ -179,49 +180,40 @@ class TestToolChoiceReset: # Execute our code under test if processed_response.functions: - # Reset agent's model_settings - if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): - agent.model_settings = ModelSettings( - temperature=agent.model_settings.temperature, - top_p=agent.model_settings.top_p, - frequency_penalty=agent.model_settings.frequency_penalty, - presence_penalty=agent.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=agent.model_settings.parallel_tool_calls, - truncation=agent.model_settings.truncation, - max_tokens=agent.model_settings.max_tokens, + # Apply the targeted reset logic + tools = agent.tools + if should_reset_tool_choice(agent.model_settings, tools): + agent.model_settings = dataclasses.replace( + agent.model_settings, + tool_choice="auto" # Reset to auto ) - + # Also reset run_config's model_settings if it exists - if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or - isinstance(run_config.model_settings.tool_choice, str)): - run_config.model_settings = ModelSettings( - temperature=run_config.model_settings.temperature, - top_p=run_config.model_settings.top_p, - frequency_penalty=run_config.model_settings.frequency_penalty, - presence_penalty=run_config.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=run_config.model_settings.parallel_tool_calls, - truncation=run_config.model_settings.truncation, - max_tokens=run_config.model_settings.max_tokens, + if ( + run_config.model_settings and + should_reset_tool_choice(run_config.model_settings, tools) + ): + run_config.model_settings = dataclasses.replace( + run_config.model_settings, + tool_choice="auto" # Reset to auto ) # Check that tool_choice remains "auto" assert agent.model_settings.tool_choice == "auto" - + async def test_run_config_tool_choice_reset(self): """Test that run_config.model_settings.tool_choice is reset to 'auto'""" # Create an agent with default model_settings agent = Agent( name="Test agent", - tools=[echo], + tools=[echo], # Only one tool model_settings=ModelSettings(tool_choice=None), ) - + # Create a run_config with tool_choice="required" run_config = RunConfig() run_config.model_settings = ModelSettings(tool_choice="required") - + # Execute our code under test processed_response = mock.MagicMock() processed_response.functions = [mock.MagicMock()] # At least one function call @@ -229,47 +221,38 @@ class TestToolChoiceReset: # Execute our code under test if processed_response.functions: - # Reset agent's model_settings - if agent.model_settings.tool_choice == "required" or isinstance(agent.model_settings.tool_choice, str): - agent.model_settings = ModelSettings( - temperature=agent.model_settings.temperature, - top_p=agent.model_settings.top_p, - frequency_penalty=agent.model_settings.frequency_penalty, - presence_penalty=agent.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=agent.model_settings.parallel_tool_calls, - truncation=agent.model_settings.truncation, - max_tokens=agent.model_settings.max_tokens, + # Apply the targeted reset logic + tools = agent.tools + if should_reset_tool_choice(agent.model_settings, tools): + agent.model_settings = dataclasses.replace( + agent.model_settings, + tool_choice="auto" # Reset to auto ) - + # Also reset run_config's model_settings if it exists - if run_config.model_settings and (run_config.model_settings.tool_choice == "required" or - isinstance(run_config.model_settings.tool_choice, str)): - run_config.model_settings = ModelSettings( - temperature=run_config.model_settings.temperature, - top_p=run_config.model_settings.top_p, - frequency_penalty=run_config.model_settings.frequency_penalty, - presence_penalty=run_config.model_settings.presence_penalty, - tool_choice="auto", # Reset to auto - parallel_tool_calls=run_config.model_settings.parallel_tool_calls, - truncation=run_config.model_settings.truncation, - max_tokens=run_config.model_settings.max_tokens, + if ( + run_config.model_settings and + should_reset_tool_choice(run_config.model_settings, tools) + ): + run_config.model_settings = dataclasses.replace( + run_config.model_settings, + tool_choice="auto" # Reset to auto ) - + # Check that run_config's tool_choice was reset to "auto" assert run_config.model_settings.tool_choice == "auto" - + @mock.patch("agents.run.Runner._get_model") async def test_integration_prevents_infinite_loop(self, mock_get_model): """Integration test to verify that tool_choice reset prevents infinite loops""" # Create a counter to track model calls and detect potential infinite loops tool_call_counter = {"count": 0, "potential_infinite_loop": False} - + # Set up our mock model that will always use tools when tool_choice is set mock_model_instance = MockModel(tool_call_counter) # Return our mock model directly mock_get_model.return_value = mock_model_instance - + # Create an agent with tool_choice="required" to force tool usage agent = Agent( name="Test agent", @@ -280,24 +263,24 @@ class TestToolChoiceReset: # This would cause infinite loops without the tool_choice reset tool_use_behavior="run_llm_again", ) - + # Set a timeout to catch potential infinite loops that our fix doesn't address try: # Run the agent with a timeout async def run_with_timeout(): return await Runner.run(agent, input="Test input") - + result = await asyncio.wait_for(run_with_timeout(), timeout=2.0) - + # Verify the agent ran successfully assert result is not None - + # Verify the tool was called at least once but not too many times # (indicating no infinite loop) assert tool_call_counter["count"] >= 1 assert tool_call_counter["count"] < 5 assert not tool_call_counter["potential_infinite_loop"] - - except asyncio.TimeoutError: + + except asyncio.TimeoutError as err: # If we hit a timeout, the test failed - we likely have an infinite loop - assert False, "Timeout occurred, potential infinite loop detected" \ No newline at end of file + raise AssertionError("Timeout occurred, potential infinite loop detected") from err From b6ed16686b09017a2b07914f0342c7370a4b61e8 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sat, 22 Mar 2025 14:22:47 +0800 Subject: [PATCH 19/63] docs: Update tool_choice reset documentation to match implementation --- docs/agents.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/agents.md b/docs/agents.md index c9c39aed..1e04f7e9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -142,6 +142,11 @@ Supplying a list of tools doesn't always mean the LLM will use a tool. You can f !!! note - To prevent infinite loops, the framework automatically resets `tool_choice` to "auto" after a tool call when it's set to "required" or a specific function name. This allows the model to decide whether to make additional tool calls in subsequent turns. + To prevent infinite loops, the framework automatically resets `tool_choice` to "auto" after a tool call in the following scenarios: + + 1. When `tool_choice` is set to a specific function name (any string that's not "auto", "required", or "none") + 2. When `tool_choice` is set to "required" AND there is only one tool available + + This targeted reset mechanism allows the model to decide whether to make additional tool calls in subsequent turns while avoiding infinite loops in these specific cases. If you want the Agent to completely stop after a tool call (rather than continuing with auto mode), you can set [`Agent.tool_use_behavior="stop_on_first_tool"`] which will directly use the tool output as the final response without further LLM processing. From 2a61d474e4b188125b531a042ee86eef1d1482c6 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sun, 23 Mar 2025 17:20:23 +0800 Subject: [PATCH 20/63] fix: prevent modifying the original agent's model_settings This fixes the issue where the original agent's model_settings was being directly modified during the tool choice reset process. The original implementation caused the agent's tool_choice to unintentionally reset to "auto" for subsequent runs, which could be unexpected behavior. The fix creates new copies of the agent and model settings objects using dataclasses.replace() instead of modifying the original objects. This ensures that the tool choice reset is limited to the current run only, maintaining the expected behavior for sequential runs with the same agent. Addresses feedback from @baderalfahad about the agent instance being modified when it should maintain its original state between runs. --- src/agents/_run_impl.py | 10 +- tests/test_tool_choice_reset.py | 405 +++++++++++--------------------- 2 files changed, 148 insertions(+), 267 deletions(-) diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index 1f896d75..1370462c 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -213,19 +213,25 @@ class RunImpl: tools = agent.tools # Only reset in the problematic scenarios where loops are likely unintentional if cls._should_reset_tool_choice(agent.model_settings, tools): - agent.model_settings = dataclasses.replace( + # Create a modified copy instead of modifying the original agent + new_model_settings = dataclasses.replace( agent.model_settings, tool_choice="auto" ) + # Create a new internal agent with updated settings + agent = dataclasses.replace(agent, model_settings=new_model_settings) if ( run_config.model_settings and cls._should_reset_tool_choice(run_config.model_settings, tools) ): - run_config.model_settings = dataclasses.replace( + # Also update the run_config model settings with a copy + new_run_config_settings = dataclasses.replace( run_config.model_settings, tool_choice="auto" ) + # Create a new run_config with the new settings + run_config = dataclasses.replace(run_config, model_settings=new_run_config_settings) # Second, check if there are any handoffs if run_handoffs := processed_response.handoffs: diff --git a/tests/test_tool_choice_reset.py b/tests/test_tool_choice_reset.py index b47c4d97..7dae6f63 100644 --- a/tests/test_tool_choice_reset.py +++ b/tests/test_tool_choice_reset.py @@ -1,286 +1,161 @@ -import asyncio -import dataclasses -import json -from unittest import mock +import pytest -from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall +from agents import Agent, ModelSettings, Runner, Tool +from agents._run_impl import RunImpl -from agents import Agent, ModelSettings, RunConfig, Runner, function_tool -from agents.items import Usage -from agents.models.interface import ModelResponse -from agents.tool import Tool - - -@function_tool -def echo(text: str) -> str: - """Echo the input text""" - return text - - -def should_reset_tool_choice(model_settings: ModelSettings, tools: list[Tool]) -> bool: - if model_settings is None or model_settings.tool_choice is None: - return False - - # for specific tool choices - if ( - isinstance(model_settings.tool_choice, str) and - model_settings.tool_choice not in ["auto", "required", "none"] - ): - return True - - # for one tool and required tool choice - if model_settings.tool_choice == "required": - return len(tools) == 1 - - return False - -# Mock model implementation that always calls tools when tool_choice is set -class MockModel: - def __init__(self, tool_call_counter): - self.tool_call_counter = tool_call_counter - - async def get_response(self, **kwargs): - tools = kwargs.get("tools", []) - model_settings = kwargs.get("model_settings") - - # Increment the counter to track how many times this model is called - self.tool_call_counter["count"] += 1 - - # If we've been called many times, we're likely in an infinite loop - if self.tool_call_counter["count"] > 5: - self.tool_call_counter["potential_infinite_loop"] = True - - # Always create a tool call if tool_choice is required/specific - tool_calls = [] - if model_settings and model_settings.tool_choice: - if model_settings.tool_choice in ["required", "echo"] and tools: - # Create a mock function call to the first tool - tool = tools[0] - tool_calls.append( - ResponseFunctionToolCall( - id="call_1", - name=tool.name, - arguments=json.dumps({"text": "This is a test"}), - call_id="call_1", - type="function_call", - ) - ) - - return ModelResponse( - output=tool_calls, - referenceable_id="123", - usage=Usage(input_tokens=10, output_tokens=10, total_tokens=20), - ) +from .fake_model import FakeModel +from .test_responses import ( + get_function_tool, + get_function_tool_call, + get_text_message, +) class TestToolChoiceReset: - async def test_tool_choice_resets_after_call(self): - """Test that tool_choice is reset to 'auto' after tool call when set to 'required'""" - # Create an agent with tool_choice="required" + + def test_should_reset_tool_choice_direct(self): + """ + Test the _should_reset_tool_choice method directly with various inputs + to ensure it correctly identifies cases where reset is needed. + """ + # Case 1: tool_choice = None should not reset + model_settings = ModelSettings(tool_choice=None) + tools1: list[Tool] = [get_function_tool("tool1")] + # Cast to list[Tool] to fix type checking issues + assert not RunImpl._should_reset_tool_choice(model_settings, tools1) + + # Case 2: tool_choice = "auto" should not reset + model_settings = ModelSettings(tool_choice="auto") + assert not RunImpl._should_reset_tool_choice(model_settings, tools1) + + # Case 3: tool_choice = "none" should not reset + model_settings = ModelSettings(tool_choice="none") + assert not RunImpl._should_reset_tool_choice(model_settings, tools1) + + # Case 4: tool_choice = "required" with one tool should reset + model_settings = ModelSettings(tool_choice="required") + assert RunImpl._should_reset_tool_choice(model_settings, tools1) + + # Case 5: tool_choice = "required" with multiple tools should not reset + model_settings = ModelSettings(tool_choice="required") + tools2: list[Tool] = [get_function_tool("tool1"), get_function_tool("tool2")] + assert not RunImpl._should_reset_tool_choice(model_settings, tools2) + + # Case 6: Specific tool choice should reset + model_settings = ModelSettings(tool_choice="specific_tool") + assert RunImpl._should_reset_tool_choice(model_settings, tools1) + + @pytest.mark.asyncio + async def test_required_tool_choice_with_multiple_runs(self): + """ + Test scenario 1: When multiple runs are executed with tool_choice="required" + Ensure each run works correctly and doesn't get stuck in infinite loop + Also verify that tool_choice remains "required" between runs + """ + # Set up our fake model with responses for two runs + fake_model = FakeModel() + fake_model.add_multiple_turn_outputs([ + [get_text_message("First run response")], + [get_text_message("Second run response")] + ]) + + # Create agent with a custom tool and tool_choice="required" + custom_tool = get_function_tool("custom_tool") agent = Agent( - name="Test agent", - tools=[echo], # Only one tool + name="test_agent", + model=fake_model, + tools=[custom_tool], model_settings=ModelSettings(tool_choice="required"), ) - # Directly modify the model_settings - # Instead of trying to run the full execute_tools_and_side_effects, - # we'll just test the tool_choice reset logic directly - processed_response = mock.MagicMock() - processed_response.functions = [mock.MagicMock()] # At least one function call - processed_response.computer_actions = [] + # First run should work correctly and preserve tool_choice + result1 = await Runner.run(agent, "first run") + assert result1.final_output == "First run response" + assert agent.model_settings.tool_choice == "required", "tool_choice should stay required" - # Create a mock run_config - run_config = mock.MagicMock() - run_config.model_settings = None + # Second run should also work correctly with tool_choice still required + result2 = await Runner.run(agent, "second run") + assert result2.final_output == "Second run response" + assert agent.model_settings.tool_choice == "required", "tool_choice should stay required" - # Execute our code under test - if processed_response.functions: - # Apply the targeted reset logic - tools = agent.tools - if should_reset_tool_choice(agent.model_settings, tools): - agent.model_settings = dataclasses.replace( - agent.model_settings, - tool_choice="auto" # Reset to auto - ) + @pytest.mark.asyncio + async def test_required_with_stop_at_tool_name(self): + """ + Test scenario 2: When using required tool_choice with stop_at_tool_names behavior + Ensure it correctly stops at the specified tool + """ + # Set up fake model to return a tool call for second_tool + fake_model = FakeModel() + fake_model.set_next_output([ + get_function_tool_call("second_tool", "{}") + ]) - # Also reset run_config's model_settings if it exists - if ( - run_config.model_settings and - should_reset_tool_choice(run_config.model_settings, tools) - ): - run_config.model_settings = dataclasses.replace( - run_config.model_settings, - tool_choice="auto" # Reset to auto - ) + # Create agent with two tools and tool_choice="required" and stop_at_tool behavior + first_tool = get_function_tool("first_tool", return_value="first tool result") + second_tool = get_function_tool("second_tool", return_value="second tool result") - # Check that tool_choice was reset to "auto" - assert agent.model_settings.tool_choice == "auto" - - async def test_tool_choice_resets_from_specific_function(self): - """Test tool_choice reset to 'auto' after call when set to specific function name""" - # Create an agent with tool_choice set to a specific function agent = Agent( - name="Test agent", - instructions="You are a test agent", - tools=[echo], - model="gpt-4-0125-preview", - model_settings=ModelSettings(tool_choice="echo"), # Specific function name - ) - - # Execute our code under test - processed_response = mock.MagicMock() - processed_response.functions = [mock.MagicMock()] # At least one function call - processed_response.computer_actions = [] - - # Create a mock run_config - run_config = mock.MagicMock() - run_config.model_settings = None - - # Execute our code under test - if processed_response.functions: - # Apply the targeted reset logic - tools = agent.tools - if should_reset_tool_choice(agent.model_settings, tools): - agent.model_settings = dataclasses.replace( - agent.model_settings, - tool_choice="auto" # Reset to auto - ) - - # Also reset run_config's model_settings if it exists - if ( - run_config.model_settings and - should_reset_tool_choice(run_config.model_settings, tools) - ): - run_config.model_settings = dataclasses.replace( - run_config.model_settings, - tool_choice="auto" # Reset to auto - ) - - # Check that tool_choice was reset to "auto" - assert agent.model_settings.tool_choice == "auto" - - async def test_tool_choice_no_reset_when_auto(self): - """Test that tool_choice is not changed when it's already set to 'auto'""" - # Create an agent with tool_choice="auto" - agent = Agent( - name="Test agent", - tools=[echo], - model_settings=ModelSettings(tool_choice="auto"), - ) - - # Execute our code under test - processed_response = mock.MagicMock() - processed_response.functions = [mock.MagicMock()] # At least one function call - processed_response.computer_actions = [] - - # Create a mock run_config - run_config = mock.MagicMock() - run_config.model_settings = None - - # Execute our code under test - if processed_response.functions: - # Apply the targeted reset logic - tools = agent.tools - if should_reset_tool_choice(agent.model_settings, tools): - agent.model_settings = dataclasses.replace( - agent.model_settings, - tool_choice="auto" # Reset to auto - ) - - # Also reset run_config's model_settings if it exists - if ( - run_config.model_settings and - should_reset_tool_choice(run_config.model_settings, tools) - ): - run_config.model_settings = dataclasses.replace( - run_config.model_settings, - tool_choice="auto" # Reset to auto - ) - - # Check that tool_choice remains "auto" - assert agent.model_settings.tool_choice == "auto" - - async def test_run_config_tool_choice_reset(self): - """Test that run_config.model_settings.tool_choice is reset to 'auto'""" - # Create an agent with default model_settings - agent = Agent( - name="Test agent", - tools=[echo], # Only one tool - model_settings=ModelSettings(tool_choice=None), - ) - - # Create a run_config with tool_choice="required" - run_config = RunConfig() - run_config.model_settings = ModelSettings(tool_choice="required") - - # Execute our code under test - processed_response = mock.MagicMock() - processed_response.functions = [mock.MagicMock()] # At least one function call - processed_response.computer_actions = [] - - # Execute our code under test - if processed_response.functions: - # Apply the targeted reset logic - tools = agent.tools - if should_reset_tool_choice(agent.model_settings, tools): - agent.model_settings = dataclasses.replace( - agent.model_settings, - tool_choice="auto" # Reset to auto - ) - - # Also reset run_config's model_settings if it exists - if ( - run_config.model_settings and - should_reset_tool_choice(run_config.model_settings, tools) - ): - run_config.model_settings = dataclasses.replace( - run_config.model_settings, - tool_choice="auto" # Reset to auto - ) - - # Check that run_config's tool_choice was reset to "auto" - assert run_config.model_settings.tool_choice == "auto" - - @mock.patch("agents.run.Runner._get_model") - async def test_integration_prevents_infinite_loop(self, mock_get_model): - """Integration test to verify that tool_choice reset prevents infinite loops""" - # Create a counter to track model calls and detect potential infinite loops - tool_call_counter = {"count": 0, "potential_infinite_loop": False} - - # Set up our mock model that will always use tools when tool_choice is set - mock_model_instance = MockModel(tool_call_counter) - # Return our mock model directly - mock_get_model.return_value = mock_model_instance - - # Create an agent with tool_choice="required" to force tool usage - agent = Agent( - name="Test agent", - instructions="You are a test agent", - tools=[echo], + name="test_agent", + model=fake_model, + tools=[first_tool, second_tool], model_settings=ModelSettings(tool_choice="required"), - # Use "run_llm_again" to allow LLM to continue after tool calls - # This would cause infinite loops without the tool_choice reset - tool_use_behavior="run_llm_again", + tool_use_behavior={"stop_at_tool_names": ["second_tool"]}, ) - # Set a timeout to catch potential infinite loops that our fix doesn't address - try: - # Run the agent with a timeout - async def run_with_timeout(): - return await Runner.run(agent, input="Test input") + # Run should stop after using second_tool + result = await Runner.run(agent, "run test") + assert result.final_output == "second tool result" - result = await asyncio.wait_for(run_with_timeout(), timeout=2.0) + @pytest.mark.asyncio + async def test_specific_tool_choice(self): + """ + Test scenario 3: When using a specific tool choice name + Ensure it doesn't cause infinite loops + """ + # Set up fake model to return a text message + fake_model = FakeModel() + fake_model.set_next_output([get_text_message("Test message")]) - # Verify the agent ran successfully - assert result is not None + # Create agent with specific tool_choice + tool1 = get_function_tool("tool1") + tool2 = get_function_tool("tool2") + tool3 = get_function_tool("tool3") - # Verify the tool was called at least once but not too many times - # (indicating no infinite loop) - assert tool_call_counter["count"] >= 1 - assert tool_call_counter["count"] < 5 - assert not tool_call_counter["potential_infinite_loop"] + agent = Agent( + name="test_agent", + model=fake_model, + tools=[tool1, tool2, tool3], + model_settings=ModelSettings(tool_choice="tool1"), # Specific tool + ) - except asyncio.TimeoutError as err: - # If we hit a timeout, the test failed - we likely have an infinite loop - raise AssertionError("Timeout occurred, potential infinite loop detected") from err + # Run should complete without infinite loops + result = await Runner.run(agent, "first run") + assert result.final_output == "Test message" + + @pytest.mark.asyncio + async def test_required_with_single_tool(self): + """ + Test scenario 4: When using required tool_choice with only one tool + Ensure it doesn't cause infinite loops + """ + # Set up fake model to return a tool call followed by a text message + fake_model = FakeModel() + fake_model.add_multiple_turn_outputs([ + # First call returns a tool call + [get_function_tool_call("custom_tool", "{}")], + # Second call returns a text message + [get_text_message("Final response")] + ]) + + # Create agent with a single tool and tool_choice="required" + custom_tool = get_function_tool("custom_tool", return_value="tool result") + agent = Agent( + name="test_agent", + model=fake_model, + tools=[custom_tool], + model_settings=ModelSettings(tool_choice="required"), + ) + + # Run should complete without infinite loops + result = await Runner.run(agent, "first run") + assert result.final_output == "Final response" From 02e329526f6b4a2c993b928d3d490f45f6503fd1 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sun, 23 Mar 2025 17:29:48 +0800 Subject: [PATCH 21/63] refactor: streamline tool_choice reset logic This update moves the tool_choice reset logic to a more appropriate location within the RunImpl class, ensuring that the original agent's model_settings remains unmodified during the reset process. The logic now checks for problematic scenarios before creating a modified copy of the agent's settings, maintaining expected behavior across sequential runs. This change enhances clarity and efficiency in handling tool choices. Addresses previous feedback regarding the modification of the agent instance and improves the overall structure of the reset logic. --- src/agents/_run_impl.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index 1370462c..02725200 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -211,15 +211,6 @@ class RunImpl: # Reset tool_choice to "auto" after tool execution to prevent infinite loops if processed_response.functions or processed_response.computer_actions: tools = agent.tools - # Only reset in the problematic scenarios where loops are likely unintentional - if cls._should_reset_tool_choice(agent.model_settings, tools): - # Create a modified copy instead of modifying the original agent - new_model_settings = dataclasses.replace( - agent.model_settings, - tool_choice="auto" - ) - # Create a new internal agent with updated settings - agent = dataclasses.replace(agent, model_settings=new_model_settings) if ( run_config.model_settings and @@ -233,6 +224,16 @@ class RunImpl: # Create a new run_config with the new settings run_config = dataclasses.replace(run_config, model_settings=new_run_config_settings) + # Only reset in the problematic scenarios where loops are likely unintentional + if cls._should_reset_tool_choice(agent.model_settings, tools): + # Create a modified copy instead of modifying the original agent + new_model_settings = dataclasses.replace( + agent.model_settings, + tool_choice="auto" + ) + # Create a new internal agent with updated settings + agent = dataclasses.replace(agent, model_settings=new_model_settings) + # Second, check if there are any handoffs if run_handoffs := processed_response.handoffs: return await cls.execute_handoffs( From 93462b0f76d9d709a77a351383da8bdbe279bffa Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sun, 23 Mar 2025 20:41:18 +0800 Subject: [PATCH 22/63] refactor: improve comments for clarity in tool_choice reset logic --- src/agents/_run_impl.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index 02725200..df3db42f 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -216,22 +216,19 @@ class RunImpl: run_config.model_settings and cls._should_reset_tool_choice(run_config.model_settings, tools) ): - # Also update the run_config model settings with a copy + # update the run_config model settings with a copy new_run_config_settings = dataclasses.replace( run_config.model_settings, tool_choice="auto" ) - # Create a new run_config with the new settings run_config = dataclasses.replace(run_config, model_settings=new_run_config_settings) - # Only reset in the problematic scenarios where loops are likely unintentional if cls._should_reset_tool_choice(agent.model_settings, tools): # Create a modified copy instead of modifying the original agent new_model_settings = dataclasses.replace( agent.model_settings, tool_choice="auto" ) - # Create a new internal agent with updated settings agent = dataclasses.replace(agent, model_settings=new_model_settings) # Second, check if there are any handoffs From 65b906a275e83c507e9c4481a864581975011bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Mon, 24 Mar 2025 09:08:29 +0100 Subject: [PATCH 23/63] feat: add optional dependency for visualization using Graphviz --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 554771ae..ed117233 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,8 @@ dev = [ "playwright==1.50.0", "inline-snapshot>=0.20.7", ] + +[project.optional-dependencies] visualization = [ "graphviz>=0.17", ] From 3e8ecd7bfc11358dc1b35fc5a6fef04d6f70116b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Mon, 24 Mar 2025 09:30:13 +0100 Subject: [PATCH 24/63] feat: enhance visualization functions with optional type hints and improved handling of agents and handoffs --- src/agents/extensions/visualization.py | 39 +++++++++++++++++--------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index 81b63382..a05dcd39 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -1,6 +1,7 @@ import graphviz from agents import Agent +from agents.handoffs import Handoff def get_main_graph(agent: Agent) -> str: @@ -29,7 +30,9 @@ def get_main_graph(agent: Agent) -> str: return "".join(parts) -def get_all_nodes(agent: Agent, parent: Agent = None) -> str: +from typing import Optional + +def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str: """ Recursively generates the nodes for the given agent and its handoffs in DOT format. @@ -47,25 +50,29 @@ def get_all_nodes(agent: Agent, parent: Agent = None) -> str: "fillcolor=lightyellow, width=1.5, height=0.8];" ) - # Smaller tools (ellipse, green) for tool in agent.tools: parts.append( f'"{tool.name}" [label="{tool.name}", shape=ellipse, style=filled, ' f"fillcolor=lightgreen, width=0.5, height=0.3];" ) - # Bigger handoffs (rounded box, yellow) for handoff in agent.handoffs: - parts.append( - f'"{handoff.name}" [label="{handoff.name}", shape=box, style=filled, style=rounded, ' - f"fillcolor=lightyellow, width=1.5, height=0.8];" - ) - parts.append(get_all_nodes(handoff)) + if isinstance(handoff, Handoff): + parts.append( + f'"{handoff.agent_name}" [label="{handoff.agent_name}", shape=box, style=filled, style=rounded, ' + f"fillcolor=lightyellow, width=1.5, height=0.8];" + ) + if isinstance(handoff, Agent): + parts.append( + f'"{handoff.name}" [label="{handoff.name}", shape=box, style=filled, style=rounded, ' + f"fillcolor=lightyellow, width=1.5, height=0.8];" + ) + parts.append(get_all_nodes(handoff)) return "".join(parts) -def get_all_edges(agent: Agent, parent: Agent = None) -> str: +def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str: """ Recursively generates the edges for the given agent and its handoffs in DOT format. @@ -92,14 +99,20 @@ def get_all_edges(agent: Agent, parent: Agent = None) -> str: "{agent.name}" -> "__end__";""") for handoff in agent.handoffs: - parts.append(f""" - "{agent.name}" -> "{handoff.name}";""") - parts.append(get_all_edges(handoff, agent)) + if isinstance(handoff, Handoff): + parts.append(f""" + "{agent.name}" -> "{handoff.agent_name}";""") + if isinstance(handoff, Agent): + parts.append(f""" + "{agent.name}" -> "{handoff.name}";""") + parts.append(get_all_edges(handoff, agent)) return "".join(parts) -def draw_graph(agent: Agent, filename: str = None) -> graphviz.Source: +from typing import Optional + +def draw_graph(agent: Agent, filename: Optional[str] = None) -> graphviz.Source: """ Draws the graph for the given agent and optionally saves it as a PNG file. From 62a50266eb7e4382e06eb417ce14fde0eb6788f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Mon, 24 Mar 2025 09:47:21 +0100 Subject: [PATCH 25/63] refactor: clean up visualization functions by removing unused nodes and improving type hints --- src/agents/extensions/visualization.py | 22 ++++++---------------- tests/test_visualization.py | 14 ++++---------- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index a05dcd39..dd75acfc 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -1,3 +1,5 @@ +from typing import Optional + import graphviz from agents import Agent @@ -20,8 +22,6 @@ def get_main_graph(agent: Agent) -> str: graph [splines=true]; node [fontname="Arial"]; edge [penwidth=1.5]; - "__start__" [shape=ellipse, style=filled, fillcolor=lightblue]; - "__end__" [shape=ellipse, style=filled, fillcolor=lightblue]; """ ] parts.append(get_all_nodes(agent)) @@ -30,8 +30,6 @@ def get_main_graph(agent: Agent) -> str: return "".join(parts) -from typing import Optional - def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str: """ Recursively generates the nodes for the given agent and its handoffs in DOT format. @@ -59,12 +57,14 @@ def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str: for handoff in agent.handoffs: if isinstance(handoff, Handoff): parts.append( - f'"{handoff.agent_name}" [label="{handoff.agent_name}", shape=box, style=filled, style=rounded, ' + f'"{handoff.agent_name}" [label="{handoff.agent_name}", shape=box, ' + f"shape=box, style=filled, style=rounded, " f"fillcolor=lightyellow, width=1.5, height=0.8];" ) if isinstance(handoff, Agent): parts.append( - f'"{handoff.name}" [label="{handoff.name}", shape=box, style=filled, style=rounded, ' + 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)) @@ -85,19 +85,11 @@ def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str: """ parts = [] - if not parent: - parts.append(f""" - "__start__" -> "{agent.name}";""") - for tool in agent.tools: parts.append(f""" "{agent.name}" -> "{tool.name}" [style=dotted, penwidth=1.5]; "{tool.name}" -> "{agent.name}" [style=dotted, penwidth=1.5];""") - if not agent.handoffs: - parts.append(f""" - "{agent.name}" -> "__end__";""") - for handoff in agent.handoffs: if isinstance(handoff, Handoff): parts.append(f""" @@ -110,8 +102,6 @@ def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str: return "".join(parts) -from typing import Optional - def draw_graph(agent: Agent, filename: Optional[str] = None) -> graphviz.Source: """ Draws the graph for the given agent and optionally saves it as a PNG file. diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 12d67fb0..1f83d858 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -10,6 +10,7 @@ from agents.extensions.visualization import ( get_all_nodes, get_main_graph, ) +from agents.handoffs import Handoff @pytest.fixture @@ -19,10 +20,8 @@ def mock_agent(): tool2 = Mock() tool2.name = "Tool2" - handoff1 = Mock() - handoff1.name = "Handoff1" - handoff1.tools = [] - handoff1.handoffs = [] + handoff1 = Mock(spec=Handoff) + handoff1.agent_name = "Handoff1" agent = Mock(spec=Agent) agent.name = "Agent1" @@ -34,12 +33,11 @@ def mock_agent(): def test_get_main_graph(mock_agent): result = get_main_graph(mock_agent) + print(result) assert "digraph G" in result assert "graph [splines=true];" in result assert 'node [fontname="Arial"];' in result assert "edge [penwidth=1.5];" in result - assert '"__start__" [shape=ellipse, style=filled, fillcolor=lightblue];' in result - assert '"__end__" [shape=ellipse, style=filled, fillcolor=lightblue];' in result assert ( '"Agent1" [label="Agent1", shape=box, style=filled, ' "fillcolor=lightyellow, width=1.5, height=0.8];" in result @@ -80,13 +78,11 @@ def test_get_all_nodes(mock_agent): def test_get_all_edges(mock_agent): result = get_all_edges(mock_agent) - assert '"__start__" -> "Agent1";' in result assert '"Agent1" -> "Tool1" [style=dotted, penwidth=1.5];' in result assert '"Tool1" -> "Agent1" [style=dotted, penwidth=1.5];' in result assert '"Agent1" -> "Tool2" [style=dotted, penwidth=1.5];' in result assert '"Tool2" -> "Agent1" [style=dotted, penwidth=1.5];' in result assert '"Agent1" -> "Handoff1";' in result - assert '"Handoff1" -> "__end__";' in result def test_draw_graph(mock_agent): @@ -96,8 +92,6 @@ def test_draw_graph(mock_agent): assert "graph [splines=true];" in graph.source assert 'node [fontname="Arial"];' in graph.source assert "edge [penwidth=1.5];" in graph.source - assert '"__start__" [shape=ellipse, style=filled, fillcolor=lightblue];' in graph.source - assert '"__end__" [shape=ellipse, style=filled, fillcolor=lightblue];' in graph.source assert ( '"Agent1" [label="Agent1", shape=box, style=filled, ' "fillcolor=lightyellow, width=1.5, height=0.8];" in graph.source From 1e3d2c73079a053103364907f002af4a61894249 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Mon, 24 Mar 2025 15:08:02 -0400 Subject: [PATCH 26/63] [1/n] Add MCP types to the SDK ### Summary: 1. Add the MCP dep for python 3.10, since it doesn't support 3.9 and below 2. Create MCPServer, which is the agents SDK representation of an MCP server 3. Create implementations for HTTP-SSE and StdIO servers, directly copying the [MCP SDK example](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py) 4. Add a util to transform MCP tools into Agent SDK tools Note: I added optional caching support to the servers. That way, if you happen to know a server's tools don't change, you can just cache them. ### Test Plan: Checks pass. I added tests at the end of the stack. --- pyproject.toml | 1 + src/agents/mcp/__init__.py | 21 +++ src/agents/mcp/mcp_util.py | 94 +++++++++++++ src/agents/mcp/server.py | 269 +++++++++++++++++++++++++++++++++++++ src/agents/mcp/util.py | 96 +++++++++++++ uv.lock | 93 ++++++++++++- 6 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 src/agents/mcp/__init__.py create mode 100644 src/agents/mcp/mcp_util.py create mode 100644 src/agents/mcp/server.py create mode 100644 src/agents/mcp/util.py diff --git a/pyproject.toml b/pyproject.toml index 667ab355..3678c714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "typing-extensions>=4.12.2, <5", "requests>=2.0, <3", "types-requests>=2.0, <3", + "mcp; python_version >= '3.10'", ] classifiers = [ "Typing :: Typed", diff --git a/src/agents/mcp/__init__.py b/src/agents/mcp/__init__.py new file mode 100644 index 00000000..1a72a89f --- /dev/null +++ b/src/agents/mcp/__init__.py @@ -0,0 +1,21 @@ +try: + from .server import ( + MCPServer, + MCPServerSse, + MCPServerSseParams, + MCPServerStdio, + MCPServerStdioParams, + ) +except ImportError: + pass + +from .util import MCPUtil + +__all__ = [ + "MCPServer", + "MCPServerSse", + "MCPServerSseParams", + "MCPServerStdio", + "MCPServerStdioParams", + "MCPUtil", +] diff --git a/src/agents/mcp/mcp_util.py b/src/agents/mcp/mcp_util.py new file mode 100644 index 00000000..41b4c521 --- /dev/null +++ b/src/agents/mcp/mcp_util.py @@ -0,0 +1,94 @@ +import functools +import json +from typing import Any + +from mcp.types import Tool as MCPTool + +from .. import _debug +from ..exceptions import AgentsException, ModelBehaviorError, UserError +from ..logger import logger +from ..run_context import RunContextWrapper +from ..tool import FunctionTool, Tool +from .server import MCPServer + + +class MCPUtil: + """Set of utilities for interop between MCP and Agents SDK tools.""" + + @classmethod + async def get_all_function_tools(cls, servers: list[MCPServer]) -> list[Tool]: + """Get all function tools from a list of MCP servers.""" + tools = [] + tool_names: set[str] = set() + for server in servers: + server_tools = await cls.get_function_tools(server) + server_tool_names = {tool.name for tool in server_tools} + if len(server_tool_names & tool_names) > 0: + raise UserError( + f"Duplicate tool names found across MCP servers: " + f"{server_tool_names & tool_names}" + ) + tool_names.update(server_tool_names) + tools.extend(server_tools) + + return tools + + @classmethod + async def get_function_tools(cls, server: MCPServer) -> list[Tool]: + """Get all function tools from a single MCP server.""" + tools = await server.list_tools() + return [cls.to_function_tool(tool, server) for tool in tools] + + @classmethod + def to_function_tool(cls, tool: MCPTool, server: MCPServer) -> FunctionTool: + """Convert an MCP tool to an Agents SDK function tool.""" + invoke_func = functools.partial(cls.invoke_mcp_tool, server, tool) + return FunctionTool( + name=tool.name, + description=tool.description or "", + params_json_schema=tool.inputSchema, + on_invoke_tool=invoke_func, + strict_json_schema=False, + ) + + @classmethod + async def invoke_mcp_tool( + cls, server: MCPServer, tool: MCPTool, context: RunContextWrapper[Any], input_json: str + ) -> str: + """Invoke an MCP tool and return the result as a string.""" + try: + json_data: dict[str, Any] = json.loads(input_json) if input_json else {} + except Exception as e: + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invalid JSON input for tool {tool.name}") + else: + logger.debug(f"Invalid JSON input for tool {tool.name}: {input_json}") + raise ModelBehaviorError( + f"Invalid JSON input for tool {tool.name}: {input_json}" + ) from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invoking MCP tool {tool.name}") + else: + logger.debug(f"Invoking MCP tool {tool.name} with input {input_json}") + + try: + result = await server.call_tool(tool.name, json_data) + except Exception as e: + logger.error(f"Error invoking MCP tool {tool.name}: {e}") + raise AgentsException(f"Error invoking MCP tool {tool.name}: {e}") from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"MCP tool {tool.name} completed.") + else: + logger.debug(f"MCP tool {tool.name} returned {result}") + + # The MCP tool result is a list of content items, whereas OpenAI tool outputs are a single + # string. We'll try to convert. + if len(result.content) == 1: + return result.content[0].model_dump_json() + elif len(result.content) > 1: + return json.dumps([item.model_dump() for item in result.content]) + else: + logger.error(f"Errored MCP tool result: {result}") + return "Error running tool." diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py new file mode 100644 index 00000000..e19e686a --- /dev/null +++ b/src/agents/mcp/server.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import abc +import asyncio +from contextlib import AbstractAsyncContextManager, AsyncExitStack +from pathlib import Path +from typing import Any, Literal + +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp import ClientSession, StdioServerParameters, Tool as MCPTool, stdio_client +from mcp.client.sse import sse_client +from mcp.types import CallToolResult, JSONRPCMessage +from typing_extensions import NotRequired, TypedDict + +from ..exceptions import UserError +from ..logger import logger + + +class MCPServer(abc.ABC): + """Base class for Model Context Protocol servers.""" + + @abc.abstractmethod + async def connect(self): + """Connect to the server. For example, this might mean spawning a subprocess or + opening a network connection. The server is expected to remain connected until + `cleanup()` is called. + """ + pass + + @abc.abstractmethod + async def cleanup(self): + """Cleanup the server. For example, this might mean closing a subprocess or + closing a network connection. + """ + pass + + @abc.abstractmethod + async def list_tools(self) -> list[MCPTool]: + """List the tools available on the server.""" + pass + + @abc.abstractmethod + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None) -> CallToolResult: + """Invoke a tool on the server.""" + pass + + +class _MCPServerWithClientSession(MCPServer, abc.ABC): + """Base class for MCP servers that use a `ClientSession` to communicate with the server.""" + + def __init__(self, cache_tools_list: bool): + """ + Args: + cache_tools_list: Whether to cache the tools list. If `True`, the tools list will be + cached and only fetched from the server once. If `False`, the tools list will be + fetched from the server on each call to `list_tools()`. The cache can be invalidated + by calling `invalidate_tools_cache()`. You should set this to `True` if you know the + server will not change its tools list, because it can drastically improve latency + (by avoiding a round-trip to the server every time). + """ + self.session: ClientSession | None = None + self.exit_stack: AsyncExitStack = AsyncExitStack() + self._cleanup_lock: asyncio.Lock = asyncio.Lock() + self.cache_tools_list = cache_tools_list + + # The cache is always dirty at startup, so that we fetch tools at least once + self._cache_dirty = True + self._tools_list: list[MCPTool] | None = None + + @abc.abstractmethod + def create_streams( + self, + ) -> AbstractAsyncContextManager[ + tuple[ + MemoryObjectReceiveStream[JSONRPCMessage | Exception], + MemoryObjectSendStream[JSONRPCMessage], + ] + ]: + """Create the streams for the server.""" + pass + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.cleanup() + + def invalidate_tools_cache(self): + """Invalidate the tools cache.""" + self._cache_dirty = True + + async def connect(self): + """Connect to the server.""" + try: + transport = await self.exit_stack.enter_async_context(self.create_streams()) + read, write = transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session + except Exception as e: + logger.error(f"Error initializing MCP server: {e}") + await self.cleanup() + raise + + async def list_tools(self) -> list[MCPTool]: + """List the tools available on the server.""" + if not self.session: + raise UserError("Server not initialized. Make sure you call `connect()` first.") + + # Return from cache if caching is enabled, we have tools, and the cache is not dirty + if self.cache_tools_list and not self._cache_dirty and self._tools_list: + return self._tools_list + + # Reset the cache dirty to False + self._cache_dirty = False + + # Fetch the tools from the server + self._tools_list = (await self.session.list_tools()).tools + return self._tools_list + + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None) -> CallToolResult: + """Invoke a tool on the server.""" + if not self.session: + raise UserError("Server not initialized. Make sure you call `connect()` first.") + + return await self.session.call_tool(tool_name, arguments) + + async def cleanup(self): + """Cleanup the server.""" + async with self._cleanup_lock: + try: + await self.exit_stack.aclose() + self.session = None + except Exception as e: + logger.error(f"Error cleaning up server: {e}") + + +class MCPServerStdioParams(TypedDict): + """Mirrors `mcp.client.stdio.StdioServerParameters`, but lets you pass params without another + import. + """ + + command: str + """The executable to run to start the server. For example, `python` or `node`.""" + + args: NotRequired[list[str]] + """Command line args to pass to the `command` executable. For example, `['foo.py']` or + `['server.js', '--port', '8080']`.""" + + env: NotRequired[dict[str, str]] + """The environment variables to set for the server. .""" + + cwd: NotRequired[str | Path] + """The working directory to use when spawning the process.""" + + encoding: NotRequired[str] + """The text encoding used when sending/receiving messages to the server. Defaults to `utf-8`.""" + + encoding_error_handler: NotRequired[Literal["strict", "ignore", "replace"]] + """The text encoding error handler. Defaults to `strict`. + + See https://docs.python.org/3/library/codecs.html#codec-base-classes for + explanations of possible values. + """ + + +class MCPServerStdio(_MCPServerWithClientSession): + """MCP server implementation that uses the stdio transport. See the [spec] + (https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#stdio) for + details. + """ + + def __init__(self, params: MCPServerStdioParams, cache_tools_list: bool = False): + """Create a new MCP server based on the stdio transport. + + Args: + params: The params that configure the server. This includes: + - The command (e.g. `python` or `node`) that starts the server. + - The args to pass to the server command (e.g. `foo.py` or `server.js`). + - The environment variables to set for the server. + cache_tools_list: Whether to cache the tools list. If `True`, the tools list will be + cached and only fetched from the server once. If `False`, the tools list will be + fetched from the server on each call to `list_tools()`. The cache can be + invalidated by calling `invalidate_tools_cache()`. You should set this to `True` + if you know the server will not change its tools list, because it can drastically + improve latency (by avoiding a round-trip to the server every time). + """ + super().__init__(cache_tools_list) + + self.params = StdioServerParameters( + command=params["command"], + args=params.get("args", []), + env=params.get("env"), + cwd=params.get("cwd"), + encoding=params.get("encoding", "utf-8"), + encoding_error_handler=params.get("encoding_error_handler", "strict"), + ) + + def create_streams( + self, + ) -> AbstractAsyncContextManager[ + tuple[ + MemoryObjectReceiveStream[JSONRPCMessage | Exception], + MemoryObjectSendStream[JSONRPCMessage], + ] + ]: + """Create the streams for the server.""" + return stdio_client(self.params) + + +class MCPServerSseParams(TypedDict): + """Mirrors the params in`mcp.client.sse.sse_client`.""" + + url: str + """The URL of the server.""" + + headers: NotRequired[dict[str, str]] + """The headers to send to the server.""" + + timeout: NotRequired[float] + """The timeout for the HTTP request. Defaults to 5 seconds.""" + + sse_read_timeout: NotRequired[float] + """The timeout for the SSE connection, in seconds. Defaults to 5 minutes.""" + + +class MCPServerSse(_MCPServerWithClientSession): + """MCP server implementation that uses the HTTP with SSE transport. See the [spec] + (https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#http-with-sse) + for details. + """ + + def __init__(self, params: MCPServerSseParams, cache_tools_list: bool = False): + """Create a new MCP server based on the HTTP with SSE transport. + + Args: + params: The params that configure the server. This includes: + - The URL of the server. + - The headers to send to the server. + - The timeout for the HTTP request. + - The timeout for the SSE connection. + + cache_tools_list: Whether to cache the tools list. If `True`, the tools list will be + cached and only fetched from the server once. If `False`, the tools list will be + fetched from the server on each call to `list_tools()`. The cache can be + invalidated by calling `invalidate_tools_cache()`. You should set this to `True` + if you know the server will not change its tools list, because it can drastically + improve latency (by avoiding a round-trip to the server every time). + """ + super().__init__(cache_tools_list) + + self.params = params + + def create_streams( + self, + ) -> AbstractAsyncContextManager[ + tuple[ + MemoryObjectReceiveStream[JSONRPCMessage | Exception], + MemoryObjectSendStream[JSONRPCMessage], + ] + ]: + """Create the streams for the server.""" + return sse_client( + url=self.params["url"], + headers=self.params.get("headers", None), + timeout=self.params.get("timeout", 5), + sse_read_timeout=self.params.get("sse_read_timeout", 60 * 5), + ) diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py new file mode 100644 index 00000000..038c4fec --- /dev/null +++ b/src/agents/mcp/util.py @@ -0,0 +1,96 @@ +import functools +import json +from typing import TYPE_CHECKING, Any + +from .. import _debug +from ..exceptions import AgentsException, ModelBehaviorError, UserError +from ..logger import logger +from ..run_context import RunContextWrapper +from ..tool import FunctionTool, Tool + +if TYPE_CHECKING: + from mcp.types import Tool as MCPTool + + from .server import MCPServer + + +class MCPUtil: + """Set of utilities for interop between MCP and Agents SDK tools.""" + + @classmethod + async def get_all_function_tools(cls, servers: list["MCPServer"]) -> list[Tool]: + """Get all function tools from a list of MCP servers.""" + tools = [] + tool_names: set[str] = set() + for server in servers: + server_tools = await cls.get_function_tools(server) + server_tool_names = {tool.name for tool in server_tools} + if len(server_tool_names & tool_names) > 0: + raise UserError( + f"Duplicate tool names found across MCP servers: " + f"{server_tool_names & tool_names}" + ) + tool_names.update(server_tool_names) + tools.extend(server_tools) + + return tools + + @classmethod + async def get_function_tools(cls, server: "MCPServer") -> list[Tool]: + """Get all function tools from a single MCP server.""" + tools = await server.list_tools() + return [cls.to_function_tool(tool, server) for tool in tools] + + @classmethod + def to_function_tool(cls, tool: "MCPTool", server: "MCPServer") -> FunctionTool: + """Convert an MCP tool to an Agents SDK function tool.""" + invoke_func = functools.partial(cls.invoke_mcp_tool, server, tool) + return FunctionTool( + name=tool.name, + description=tool.description or "", + params_json_schema=tool.inputSchema, + on_invoke_tool=invoke_func, + strict_json_schema=False, + ) + + @classmethod + async def invoke_mcp_tool( + cls, server: "MCPServer", tool: "MCPTool", context: RunContextWrapper[Any], input_json: str + ) -> str: + """Invoke an MCP tool and return the result as a string.""" + try: + json_data: dict[str, Any] = json.loads(input_json) if input_json else {} + except Exception as e: + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invalid JSON input for tool {tool.name}") + else: + logger.debug(f"Invalid JSON input for tool {tool.name}: {input_json}") + raise ModelBehaviorError( + f"Invalid JSON input for tool {tool.name}: {input_json}" + ) from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invoking MCP tool {tool.name}") + else: + logger.debug(f"Invoking MCP tool {tool.name} with input {input_json}") + + try: + result = await server.call_tool(tool.name, json_data) + except Exception as e: + logger.error(f"Error invoking MCP tool {tool.name}: {e}") + raise AgentsException(f"Error invoking MCP tool {tool.name}: {e}") from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"MCP tool {tool.name} completed.") + else: + logger.debug(f"MCP tool {tool.name} returned {result}") + + # The MCP tool result is a list of content items, whereas OpenAI tool outputs are a single + # string. We'll try to convert. + if len(result.content) == 1: + return result.content[0].model_dump_json() + elif len(result.content) > 1: + return json.dumps([item.model_dump() for item in result.content]) + else: + logger.error(f"Errored MCP tool result: {result}") + return "Error running tool." diff --git a/uv.lock b/uv.lock index a9c79e21..d6eba43f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", @@ -459,6 +458,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + [[package]] name = "idna" version = "3.10" @@ -699,6 +707,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, ] +[[package]] +name = "mcp" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.10'" }, + { name = "httpx", marker = "python_full_version >= '3.10'" }, + { name = "httpx-sse", marker = "python_full_version >= '3.10'" }, + { name = "pydantic", marker = "python_full_version >= '3.10'" }, + { name = "pydantic-settings", marker = "python_full_version >= '3.10'" }, + { name = "sse-starlette", marker = "python_full_version >= '3.10'" }, + { name = "starlette", marker = "python_full_version >= '3.10'" }, + { name = "uvicorn", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c9/c55764824e893fdebe777ac7223200986a275c3191dba9169f8eb6d7c978/mcp-1.5.0.tar.gz", hash = "sha256:5b2766c05e68e01a2034875e250139839498c61792163a7b221fc170c12f5aa9", size = 159128 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d1/3ff566ecf322077d861f1a68a1ff025cad337417bd66ad22a7c6f7dfcfaf/mcp-1.5.0-py3-none-any.whl", hash = "sha256:51c3f35ce93cb702f7513c12406bbea9665ef75a08db909200b07da9db641527", size = 73734 }, +] + [[package]] name = "mdit-py-plugins" version = "0.4.2" @@ -1054,6 +1081,7 @@ version = "0.0.6" source = { editable = "." } dependencies = [ { name = "griffe" }, + { name = "mcp", marker = "python_full_version >= '3.10'" }, { name = "openai" }, { name = "pydantic" }, { name = "requests" }, @@ -1091,6 +1119,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "griffe", specifier = ">=1.5.6,<2" }, + { name = "mcp", marker = "python_full_version >= '3.10'" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, { name = "openai", specifier = ">=1.66.5" }, { name = "pydantic", specifier = ">=2.10,<3" }, @@ -1099,7 +1128,6 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.12.2,<5" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<16" }, ] -provides-extras = ["voice"] [package.metadata.requires-dev] dev = [ @@ -1305,6 +1333,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961 }, ] +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "python_full_version >= '3.10'" }, + { name = "python-dotenv", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + [[package]] name = "pyee" version = "12.1.1" @@ -1496,6 +1537,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + [[package]] name = "python-xlib" version = "0.33" @@ -1660,6 +1710,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/9b/15217b04f3b36d30de55fef542389d722de63f1ad81f9c72d8afc98cb6ab/sounddevice-0.5.1-py3-none-win_amd64.whl", hash = "sha256:4313b63f2076552b23ac3e0abd3bcfc0c1c6a696fc356759a13bd113c9df90f1", size = 363634 }, ] +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.10'" }, + { name = "starlette", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + +[[package]] +name = "starlette" +version = "0.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, +] + [[package]] name = "textual" version = "2.1.2" @@ -1774,6 +1849,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version >= '3.10'" }, + { name = "h11", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] + [[package]] name = "watchdog" version = "6.0.0" From cc247a3fabd06717e737dbd3c5cc225b4d4082ce Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Mon, 24 Mar 2025 15:07:32 -0400 Subject: [PATCH 27/63] [0/n] Only run tests on py3.9, not mypy ### Summary: We don't need to run mypy on 3.9 anyway. Also it causes issues with the rest of this stack. ### Test plan: run checks --- .github/workflows/tests.yml | 3 +++ Makefile | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9a56e88..4f93980a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,6 +8,9 @@ on: branches: - main +env: + UV_FROZEN: "1" + jobs: lint: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index f6b779e3..accf2d91 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,6 @@ snapshots-create: .PHONY: old_version_tests old_version_tests: UV_PROJECT_ENVIRONMENT=.venv_39 uv run --python 3.9 -m pytest - UV_PROJECT_ENVIRONMENT=.venv_39 uv run --python 3.9 -m mypy . .PHONY: build-docs build-docs: From e71d2b6fd781fe0036ad2d13b3836f1c8166fd9d Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Mon, 24 Mar 2025 15:08:02 -0400 Subject: [PATCH 28/63] [2/n] Add MCP support to Runner ### Summary: This enables users to **use** MCP inside the SDK. 1. You add a list of MCP servers to `Agent`, via `mcp_server=[...]` 2. When an agent runs, we look up its MCP tools and add them to the list of tools. 3. When a tool call occurs, we call the relevant MCP server. Notes: 1. There's some refactoring to make sure we send the full list of tools to the Runner/Model etc. 2. Right now, you could have a locally defined tool that conflicts with an MCP defined tool. I didn't add errors for that, will do in a followup. ### Test Plan: See unit tests. Also has an end to end example next PR. --- src/agents/_run_impl.py | 7 +- src/agents/agent.py | 20 +++ src/agents/run.py | 24 +++- tests/mcp/__init__.py | 0 tests/mcp/conftest.py | 11 ++ tests/mcp/helpers.py | 54 ++++++++ tests/mcp/test_caching.py | 57 ++++++++ tests/mcp/test_connect_disconnect.py | 69 ++++++++++ tests/mcp/test_mcp_util.py | 109 +++++++++++++++ tests/mcp/test_runner_calls_mcp.py | 197 +++++++++++++++++++++++++++ tests/mcp/test_server_errors.py | 38 ++++++ tests/test_run_step_execution.py | 1 + tests/test_run_step_processing.py | 100 +++++++++++--- tests/voice/test_openai_stt.py | 10 +- 14 files changed, 662 insertions(+), 35 deletions(-) create mode 100644 tests/mcp/__init__.py create mode 100644 tests/mcp/conftest.py create mode 100644 tests/mcp/helpers.py create mode 100644 tests/mcp/test_caching.py create mode 100644 tests/mcp/test_connect_disconnect.py create mode 100644 tests/mcp/test_mcp_util.py create mode 100644 tests/mcp/test_runner_calls_mcp.py create mode 100644 tests/mcp/test_server_errors.py diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index 2849538d..02e3bf58 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -50,7 +50,7 @@ from .logger import logger from .models.interface import ModelTracing from .run_context import RunContextWrapper, TContext from .stream_events import RunItemStreamEvent, StreamEvent -from .tool import ComputerTool, FunctionTool, FunctionToolResult +from .tool import ComputerTool, FunctionTool, FunctionToolResult, Tool from .tracing import ( SpanError, Trace, @@ -301,6 +301,7 @@ class RunImpl: cls, *, agent: Agent[Any], + all_tools: list[Tool], response: ModelResponse, output_schema: AgentOutputSchema | None, handoffs: list[Handoff], @@ -312,8 +313,8 @@ class RunImpl: computer_actions = [] handoff_map = {handoff.tool_name: handoff for handoff in handoffs} - function_map = {tool.name: tool for tool in agent.tools if isinstance(tool, FunctionTool)} - computer_tool = next((tool for tool in agent.tools if isinstance(tool, ComputerTool)), None) + function_map = {tool.name: tool for tool in all_tools if isinstance(tool, FunctionTool)} + computer_tool = next((tool for tool in all_tools if isinstance(tool, ComputerTool)), None) for output in response.output: if isinstance(output, ResponseOutputMessage): diff --git a/src/agents/agent.py b/src/agents/agent.py index 2723e678..3258e15a 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -12,6 +12,7 @@ from .guardrail import InputGuardrail, OutputGuardrail from .handoffs import Handoff from .items import ItemHelpers from .logger import logger +from .mcp import MCPUtil from .model_settings import ModelSettings from .models.interface import Model from .run_context import RunContextWrapper, TContext @@ -21,6 +22,7 @@ from .util._types import MaybeAwaitable if TYPE_CHECKING: from .lifecycle import AgentHooks + from .mcp import MCPServer from .result import RunResult @@ -107,6 +109,16 @@ class Agent(Generic[TContext]): tools: list[Tool] = field(default_factory=list) """A list of tools that the agent can use.""" + mcp_servers: list[MCPServer] = field(default_factory=list) + """A list of [Model Context Protocol](https://modelcontextprotocol.io/) servers that + the agent can use. Every time the agent runs, it will include tools from these servers in the + list of available tools. + + NOTE: You are expected to manage the lifecycle of these servers. Specifically, you must call + `server.connect()` before passing it to the agent, and `server.cleanup()` when the server is no + longer needed. + """ + input_guardrails: list[InputGuardrail[TContext]] = field(default_factory=list) """A list of checks that run in parallel to the agent's execution, before generating a response. Runs only if the agent is the first agent in the chain. @@ -205,3 +217,11 @@ class Agent(Generic[TContext]): logger.error(f"Instructions must be a string or a function, got {self.instructions}") return None + + async def get_mcp_tools(self) -> list[Tool]: + """Fetches the available tools from the MCP servers.""" + return await MCPUtil.get_all_function_tools(self.mcp_servers) + + async def get_all_tools(self) -> list[Tool]: + """All agent tools, including MCP tools and function tools.""" + return await MCPUtil.get_all_function_tools(self.mcp_servers) + self.tools diff --git a/src/agents/run.py b/src/agents/run.py index 934400fe..b7ac85f9 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -7,6 +7,8 @@ from typing import Any, cast from openai.types.responses import ResponseCompletedEvent +from agents.tool import Tool + from ._run_impl import ( NextStepFinalOutput, NextStepHandoff, @@ -177,7 +179,8 @@ class Runner: # agent changes, or if the agent loop ends. if current_span is None: handoff_names = [h.agent_name for h in cls._get_handoffs(current_agent)] - tool_names = [t.name for t in current_agent.tools] + all_tools = await cls._get_all_tools(current_agent) + tool_names = [t.name for t in all_tools] if output_schema := cls._get_output_schema(current_agent): output_type_name = output_schema.output_type_name() else: @@ -217,6 +220,7 @@ class Runner: ), cls._run_single_turn( agent=current_agent, + all_tools=all_tools, original_input=original_input, generated_items=generated_items, hooks=hooks, @@ -228,6 +232,7 @@ class Runner: else: turn_result = await cls._run_single_turn( agent=current_agent, + all_tools=all_tools, original_input=original_input, generated_items=generated_items, hooks=hooks, @@ -627,7 +632,7 @@ class Runner: system_prompt = await agent.get_system_prompt(context_wrapper) handoffs = cls._get_handoffs(agent) - + all_tools = await cls._get_all_tools(agent) model = cls._get_model(agent, run_config) model_settings = agent.model_settings.resolve(run_config.model_settings) final_response: ModelResponse | None = None @@ -640,7 +645,7 @@ class Runner: system_prompt, input, model_settings, - agent.tools, + all_tools, output_schema, handoffs, get_model_tracing_impl( @@ -677,6 +682,7 @@ class Runner: pre_step_items=streamed_result.new_items, new_response=final_response, output_schema=output_schema, + all_tools=all_tools, handoffs=handoffs, hooks=hooks, context_wrapper=context_wrapper, @@ -691,6 +697,7 @@ class Runner: cls, *, agent: Agent[TContext], + all_tools: list[Tool], original_input: str | list[TResponseInputItem], generated_items: list[RunItem], hooks: RunHooks[TContext], @@ -721,6 +728,7 @@ class Runner: system_prompt, input, output_schema, + all_tools, handoffs, context_wrapper, run_config, @@ -732,6 +740,7 @@ class Runner: pre_step_items=generated_items, new_response=new_response, output_schema=output_schema, + all_tools=all_tools, handoffs=handoffs, hooks=hooks, context_wrapper=context_wrapper, @@ -743,6 +752,7 @@ class Runner: cls, *, agent: Agent[TContext], + all_tools: list[Tool], original_input: str | list[TResponseInputItem], pre_step_items: list[RunItem], new_response: ModelResponse, @@ -754,6 +764,7 @@ class Runner: ) -> SingleStepResult: processed_response = RunImpl.process_model_response( agent=agent, + all_tools=all_tools, response=new_response, output_schema=output_schema, handoffs=handoffs, @@ -853,6 +864,7 @@ class Runner: system_prompt: str | None, input: list[TResponseInputItem], output_schema: AgentOutputSchema | None, + all_tools: list[Tool], handoffs: list[Handoff], context_wrapper: RunContextWrapper[TContext], run_config: RunConfig, @@ -863,7 +875,7 @@ class Runner: system_instructions=system_prompt, input=input, model_settings=model_settings, - tools=agent.tools, + tools=all_tools, output_schema=output_schema, handoffs=handoffs, tracing=get_model_tracing_impl( @@ -892,6 +904,10 @@ class Runner: handoffs.append(handoff(handoff_item)) return handoffs + @classmethod + async def _get_all_tools(cls, agent: Agent[Any]) -> list[Tool]: + return await agent.get_all_tools() + @classmethod def _get_model(cls, agent: Agent[Any], run_config: RunConfig) -> Model: if isinstance(run_config.model, Model): diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mcp/conftest.py b/tests/mcp/conftest.py new file mode 100644 index 00000000..80fd15ec --- /dev/null +++ b/tests/mcp/conftest.py @@ -0,0 +1,11 @@ +import os +import sys + + +# Skip MCP tests on Python 3.9 +def pytest_ignore_collect(collection_path, config): + if sys.version_info[:2] == (3, 9): + this_dir = os.path.dirname(__file__) + + if str(collection_path).startswith(this_dir): + return True diff --git a/tests/mcp/helpers.py b/tests/mcp/helpers.py new file mode 100644 index 00000000..952b3ea7 --- /dev/null +++ b/tests/mcp/helpers.py @@ -0,0 +1,54 @@ +import json +import shutil +from typing import Any + +from mcp import Tool as MCPTool +from mcp.types import CallToolResult, TextContent + +from agents.mcp import MCPServer + +tee = shutil.which("tee") or "" +assert tee, "tee not found" + + +# Added dummy stream classes for patching stdio_client to avoid real I/O during tests +class DummyStream: + async def send(self, msg): + pass + + async def receive(self): + raise Exception("Dummy receive not implemented") + + +class DummyStreamsContextManager: + async def __aenter__(self): + return (DummyStream(), DummyStream()) + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + +class FakeMCPServer(MCPServer): + def __init__(self, tools: list[MCPTool] | None = None): + self.tools: list[MCPTool] = tools or [] + self.tool_calls: list[str] = [] + self.tool_results: list[str] = [] + + def add_tool(self, name: str, input_schema: dict[str, Any]): + self.tools.append(MCPTool(name=name, inputSchema=input_schema)) + + async def connect(self): + pass + + async def cleanup(self): + pass + + async def list_tools(self): + return self.tools + + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None) -> CallToolResult: + self.tool_calls.append(tool_name) + self.tool_results.append(f"result_{tool_name}_{json.dumps(arguments)}") + return CallToolResult( + content=[TextContent(text=self.tool_results[-1], type="text")], + ) diff --git a/tests/mcp/test_caching.py b/tests/mcp/test_caching.py new file mode 100644 index 00000000..cac409e6 --- /dev/null +++ b/tests/mcp/test_caching.py @@ -0,0 +1,57 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from mcp.types import ListToolsResult, Tool as MCPTool + +from agents.mcp import MCPServerStdio + +from .helpers import DummyStreamsContextManager, tee + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_server_caching_works( + mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client +): + """Test that if we turn caching on, the list of tools is cached and not fetched from the server + on each call to `list_tools()`. + """ + server = MCPServerStdio( + params={ + "command": tee, + }, + cache_tools_list=True, + ) + + tools = [ + MCPTool(name="tool1", inputSchema={}), + MCPTool(name="tool2", inputSchema={}), + ] + + mock_list_tools.return_value = ListToolsResult(tools=tools) + + async with server: + # Call list_tools() multiple times + tools = await server.list_tools() + assert tools == tools + + assert mock_list_tools.call_count == 1, "list_tools() should have been called once" + + # Call list_tools() again, should return the cached value + tools = await server.list_tools() + assert tools == tools + + assert mock_list_tools.call_count == 1, "list_tools() should not have been called again" + + # Invalidate the cache and call list_tools() again + server.invalidate_tools_cache() + tools = await server.list_tools() + assert tools == tools + + assert mock_list_tools.call_count == 2, "list_tools() should be called again" + + # Without invalidating the cache, calling list_tools() again should return the cached value + tools = await server.list_tools() + assert tools == tools diff --git a/tests/mcp/test_connect_disconnect.py b/tests/mcp/test_connect_disconnect.py new file mode 100644 index 00000000..b0013039 --- /dev/null +++ b/tests/mcp/test_connect_disconnect.py @@ -0,0 +1,69 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from mcp.types import ListToolsResult, Tool as MCPTool + +from agents.mcp import MCPServerStdio + +from .helpers import DummyStreamsContextManager, tee + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_async_ctx_manager_works( + mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client +): + """Test that the async context manager works.""" + server = MCPServerStdio( + params={ + "command": tee, + }, + cache_tools_list=True, + ) + + tools = [ + MCPTool(name="tool1", inputSchema={}), + MCPTool(name="tool2", inputSchema={}), + ] + + mock_list_tools.return_value = ListToolsResult(tools=tools) + + assert server.session is None, "Server should not be connected" + + async with server: + assert server.session is not None, "Server should be connected" + + assert server.session is None, "Server should be disconnected" + + +@pytest.mark.asyncio +@patch("mcp.client.stdio.stdio_client", return_value=DummyStreamsContextManager()) +@patch("mcp.client.session.ClientSession.initialize", new_callable=AsyncMock, return_value=None) +@patch("mcp.client.session.ClientSession.list_tools") +async def test_manual_connect_disconnect_works( + mock_list_tools: AsyncMock, mock_initialize: AsyncMock, mock_stdio_client +): + """Test that the async context manager works.""" + server = MCPServerStdio( + params={ + "command": tee, + }, + cache_tools_list=True, + ) + + tools = [ + MCPTool(name="tool1", inputSchema={}), + MCPTool(name="tool2", inputSchema={}), + ] + + mock_list_tools.return_value = ListToolsResult(tools=tools) + + assert server.session is None, "Server should not be connected" + + await server.connect() + assert server.session is not None, "Server should be connected" + + await server.cleanup() + assert server.session is None, "Server should be disconnected" diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py new file mode 100644 index 00000000..345df996 --- /dev/null +++ b/tests/mcp/test_mcp_util.py @@ -0,0 +1,109 @@ +import logging +from typing import Any + +import pytest +from mcp.types import Tool as MCPTool +from pydantic import BaseModel + +from agents import FunctionTool, RunContextWrapper +from agents.exceptions import AgentsException, ModelBehaviorError +from agents.mcp import MCPServer, MCPUtil + +from .helpers import FakeMCPServer + + +class Foo(BaseModel): + bar: str + baz: int + + +class Bar(BaseModel): + qux: str + + +@pytest.mark.asyncio +async def test_get_all_function_tools(): + """Test that the get_all_function_tools function returns all function tools from a list of MCP + servers. + """ + names = ["test_tool_1", "test_tool_2", "test_tool_3", "test_tool_4", "test_tool_5"] + schemas = [ + {}, + {}, + {}, + Foo.model_json_schema(), + Bar.model_json_schema(), + ] + + server1 = FakeMCPServer() + server1.add_tool(names[0], schemas[0]) + server1.add_tool(names[1], schemas[1]) + + server2 = FakeMCPServer() + server2.add_tool(names[2], schemas[2]) + server2.add_tool(names[3], schemas[3]) + + server3 = FakeMCPServer() + server3.add_tool(names[4], schemas[4]) + + servers: list[MCPServer] = [server1, server2, server3] + tools = await MCPUtil.get_all_function_tools(servers) + assert len(tools) == 5 + assert all(tool.name in names for tool in tools) + + for idx, tool in enumerate(tools): + assert isinstance(tool, FunctionTool) + assert tool.params_json_schema == schemas[idx] + assert tool.name == names[idx] + + +@pytest.mark.asyncio +async def test_invoke_mcp_tool(): + """Test that the invoke_mcp_tool function invokes an MCP tool and returns the result.""" + server = FakeMCPServer() + server.add_tool("test_tool_1", {}) + + ctx = RunContextWrapper(context=None) + tool = MCPTool(name="test_tool_1", inputSchema={}) + + await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") + # Just making sure it doesn't crash + + +@pytest.mark.asyncio +async def test_mcp_invoke_bad_json_errors(caplog: pytest.LogCaptureFixture): + caplog.set_level(logging.DEBUG) + + """Test that bad JSON input errors are logged and re-raised.""" + server = FakeMCPServer() + server.add_tool("test_tool_1", {}) + + ctx = RunContextWrapper(context=None) + tool = MCPTool(name="test_tool_1", inputSchema={}) + + with pytest.raises(ModelBehaviorError): + await MCPUtil.invoke_mcp_tool(server, tool, ctx, "not_json") + + assert "Invalid JSON input for tool test_tool_1" in caplog.text + + +class CrashingFakeMCPServer(FakeMCPServer): + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None): + raise Exception("Crash!") + + +@pytest.mark.asyncio +async def test_mcp_invocation_crash_causes_error(caplog: pytest.LogCaptureFixture): + caplog.set_level(logging.DEBUG) + + """Test that bad JSON input errors are logged and re-raised.""" + server = CrashingFakeMCPServer() + server.add_tool("test_tool_1", {}) + + ctx = RunContextWrapper(context=None) + tool = MCPTool(name="test_tool_1", inputSchema={}) + + with pytest.raises(AgentsException): + await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") + + assert "Error invoking MCP tool test_tool_1" in caplog.text diff --git a/tests/mcp/test_runner_calls_mcp.py b/tests/mcp/test_runner_calls_mcp.py new file mode 100644 index 00000000..3319c097 --- /dev/null +++ b/tests/mcp/test_runner_calls_mcp.py @@ -0,0 +1,197 @@ +import json + +import pytest +from pydantic import BaseModel + +from agents import Agent, ModelBehaviorError, Runner, UserError + +from ..fake_model import FakeModel +from ..test_responses import get_function_tool_call, get_text_message +from .helpers import FakeMCPServer + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_runner_calls_mcp_tool(streaming: bool): + """Test that the runner calls an MCP tool when the model produces a tool call.""" + server = FakeMCPServer() + server.add_tool("test_tool_1", {}) + server.add_tool("test_tool_2", {}) + server.add_tool("test_tool_3", {}) + model = FakeModel() + agent = Agent( + name="test", + model=model, + mcp_servers=[server], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("test_tool_2", "")], + # Second turn: text message + [get_text_message("done")], + ] + ) + + if streaming: + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, input="user_message") + + assert server.tool_calls == ["test_tool_2"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_runner_asserts_when_mcp_tool_not_found(streaming: bool): + """Test that the runner asserts when an MCP tool is not found.""" + server = FakeMCPServer() + server.add_tool("test_tool_1", {}) + server.add_tool("test_tool_2", {}) + server.add_tool("test_tool_3", {}) + model = FakeModel() + agent = Agent( + name="test", + model=model, + mcp_servers=[server], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("test_tool_doesnt_exist", "")], + # Second turn: text message + [get_text_message("done")], + ] + ) + + with pytest.raises(ModelBehaviorError): + if streaming: + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, input="user_message") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_runner_works_with_multiple_mcp_servers(streaming: bool): + """Test that the runner works with multiple MCP servers.""" + server1 = FakeMCPServer() + server1.add_tool("test_tool_1", {}) + + server2 = FakeMCPServer() + server2.add_tool("test_tool_2", {}) + server2.add_tool("test_tool_3", {}) + + model = FakeModel() + agent = Agent( + name="test", + model=model, + mcp_servers=[server1, server2], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("test_tool_2", "")], + # Second turn: text message + [get_text_message("done")], + ] + ) + + if streaming: + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, input="user_message") + + assert server1.tool_calls == [] + assert server2.tool_calls == ["test_tool_2"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_runner_errors_when_mcp_tools_clash(streaming: bool): + """Test that the runner errors when multiple servers have the same tool name.""" + server1 = FakeMCPServer() + server1.add_tool("test_tool_1", {}) + server1.add_tool("test_tool_2", {}) + + server2 = FakeMCPServer() + server2.add_tool("test_tool_2", {}) + server2.add_tool("test_tool_3", {}) + + model = FakeModel() + agent = Agent( + name="test", + model=model, + mcp_servers=[server1, server2], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("test_tool_3", "")], + # Second turn: text message + [get_text_message("done")], + ] + ) + + with pytest.raises(UserError): + if streaming: + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, input="user_message") + + +class Foo(BaseModel): + bar: str + baz: int + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_runner_calls_mcp_tool_with_args(streaming: bool): + """Test that the runner calls an MCP tool when the model produces a tool call.""" + server = FakeMCPServer() + await server.connect() + server.add_tool("test_tool_1", {}) + server.add_tool("test_tool_2", Foo.model_json_schema()) + server.add_tool("test_tool_3", {}) + model = FakeModel() + agent = Agent( + name="test", + model=model, + mcp_servers=[server], + ) + + json_args = json.dumps(Foo(bar="baz", baz=1).model_dump()) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("test_tool_2", json_args)], + # Second turn: text message + [get_text_message("done")], + ] + ) + + if streaming: + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + else: + await Runner.run(agent, input="user_message") + + assert server.tool_calls == ["test_tool_2"] + assert server.tool_results == [f"result_test_tool_2_{json_args}"] + + await server.cleanup() diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py new file mode 100644 index 00000000..5c6432bc --- /dev/null +++ b/tests/mcp/test_server_errors.py @@ -0,0 +1,38 @@ +import pytest + +from agents.exceptions import UserError +from agents.mcp.server import _MCPServerWithClientSession + + +class CrashingClientSessionServer(_MCPServerWithClientSession): + def __init__(self): + super().__init__(cache_tools_list=False) + self.cleanup_called = False + + def create_streams(self): + raise ValueError("Crash!") + + async def cleanup(self): + self.cleanup_called = True + await super().cleanup() + + +@pytest.mark.asyncio +async def test_server_errors_cause_error_and_cleanup_called(): + server = CrashingClientSessionServer() + + with pytest.raises(ValueError): + await server.connect() + + assert server.cleanup_called + + +@pytest.mark.asyncio +async def test_not_calling_connect_causes_error(): + server = CrashingClientSessionServer() + + with pytest.raises(UserError): + await server.list_tools() + + with pytest.raises(UserError): + await server.call_tool("foo", {}) diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py index 2d581bf6..16c62c84 100644 --- a/tests/test_run_step_execution.py +++ b/tests/test_run_step_execution.py @@ -290,6 +290,7 @@ async def get_execute_result( processed_response = RunImpl.process_model_response( agent=agent, + all_tools=await agent.get_all_tools(), response=response, output_schema=output_schema, handoffs=handoffs, diff --git a/tests/test_run_step_processing.py b/tests/test_run_step_processing.py index 24f9e8e3..2a6634ac 100644 --- a/tests/test_run_step_processing.py +++ b/tests/test_run_step_processing.py @@ -43,7 +43,11 @@ def test_empty_response(): ) result = RunImpl.process_model_response( - agent=agent, response=response, output_schema=None, handoffs=[] + agent=agent, + response=response, + output_schema=None, + handoffs=[], + all_tools=[], ) assert not result.handoffs assert not result.functions @@ -57,13 +61,14 @@ def test_no_tool_calls(): referenceable_id=None, ) result = RunImpl.process_model_response( - agent=agent, response=response, output_schema=None, handoffs=[] + agent=agent, response=response, output_schema=None, handoffs=[], all_tools=[] ) assert not result.handoffs assert not result.functions -def test_single_tool_call(): +@pytest.mark.asyncio +async def test_single_tool_call(): agent = Agent(name="test", tools=[get_function_tool(name="test")]) response = ModelResponse( output=[ @@ -74,7 +79,11 @@ def test_single_tool_call(): referenceable_id=None, ) result = RunImpl.process_model_response( - agent=agent, response=response, output_schema=None, handoffs=[] + agent=agent, + response=response, + output_schema=None, + handoffs=[], + all_tools=await agent.get_all_tools(), ) assert not result.handoffs assert result.functions and len(result.functions) == 1 @@ -84,7 +93,8 @@ def test_single_tool_call(): assert func.tool_call.arguments == "" -def test_missing_tool_call_raises_error(): +@pytest.mark.asyncio +async def test_missing_tool_call_raises_error(): agent = Agent(name="test", tools=[get_function_tool(name="test")]) response = ModelResponse( output=[ @@ -97,11 +107,16 @@ def test_missing_tool_call_raises_error(): with pytest.raises(ModelBehaviorError): RunImpl.process_model_response( - agent=agent, response=response, output_schema=None, handoffs=[] + agent=agent, + response=response, + output_schema=None, + handoffs=[], + all_tools=await agent.get_all_tools(), ) -def test_multiple_tool_calls(): +@pytest.mark.asyncio +async def test_multiple_tool_calls(): agent = Agent( name="test", tools=[ @@ -121,7 +136,11 @@ def test_multiple_tool_calls(): ) result = RunImpl.process_model_response( - agent=agent, response=response, output_schema=None, handoffs=[] + agent=agent, + response=response, + output_schema=None, + handoffs=[], + all_tools=await agent.get_all_tools(), ) assert not result.handoffs assert result.functions and len(result.functions) == 2 @@ -146,7 +165,11 @@ async def test_handoffs_parsed_correctly(): referenceable_id=None, ) result = RunImpl.process_model_response( - agent=agent_3, response=response, output_schema=None, handoffs=[] + agent=agent_3, + response=response, + output_schema=None, + handoffs=[], + all_tools=await agent_3.get_all_tools(), ) assert not result.handoffs, "Shouldn't have a handoff here" @@ -160,6 +183,7 @@ async def test_handoffs_parsed_correctly(): response=response, output_schema=None, handoffs=Runner._get_handoffs(agent_3), + all_tools=await agent_3.get_all_tools(), ) assert len(result.handoffs) == 1, "Should have a handoff here" handoff = result.handoffs[0] @@ -189,10 +213,12 @@ async def test_missing_handoff_fails(): response=response, output_schema=None, handoffs=Runner._get_handoffs(agent_3), + all_tools=await agent_3.get_all_tools(), ) -def test_multiple_handoffs_doesnt_error(): +@pytest.mark.asyncio +async def test_multiple_handoffs_doesnt_error(): agent_1 = Agent(name="test_1") agent_2 = Agent(name="test_2") agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2]) @@ -210,6 +236,7 @@ def test_multiple_handoffs_doesnt_error(): response=response, output_schema=None, handoffs=Runner._get_handoffs(agent_3), + all_tools=await agent_3.get_all_tools(), ) assert len(result.handoffs) == 2, "Should have multiple handoffs here" @@ -218,7 +245,8 @@ class Foo(BaseModel): bar: str -def test_final_output_parsed_correctly(): +@pytest.mark.asyncio +async def test_final_output_parsed_correctly(): agent = Agent(name="test", output_type=Foo) response = ModelResponse( output=[ @@ -234,10 +262,12 @@ def test_final_output_parsed_correctly(): response=response, output_schema=Runner._get_output_schema(agent), handoffs=[], + all_tools=await agent.get_all_tools(), ) -def test_file_search_tool_call_parsed_correctly(): +@pytest.mark.asyncio +async def test_file_search_tool_call_parsed_correctly(): # Ensure that a ResponseFileSearchToolCall output is parsed into a ToolCallItem and that no tool # runs are scheduled. @@ -254,7 +284,11 @@ def test_file_search_tool_call_parsed_correctly(): referenceable_id=None, ) result = RunImpl.process_model_response( - agent=agent, response=response, output_schema=None, handoffs=[] + agent=agent, + response=response, + output_schema=None, + handoffs=[], + all_tools=await agent.get_all_tools(), ) # The final item should be a ToolCallItem for the file search call assert any( @@ -265,7 +299,8 @@ def test_file_search_tool_call_parsed_correctly(): assert not result.handoffs -def test_function_web_search_tool_call_parsed_correctly(): +@pytest.mark.asyncio +async def test_function_web_search_tool_call_parsed_correctly(): agent = Agent(name="test") web_search_call = ResponseFunctionWebSearch(id="w1", status="completed", type="web_search_call") response = ModelResponse( @@ -274,7 +309,11 @@ def test_function_web_search_tool_call_parsed_correctly(): referenceable_id=None, ) result = RunImpl.process_model_response( - agent=agent, response=response, output_schema=None, handoffs=[] + agent=agent, + response=response, + output_schema=None, + handoffs=[], + all_tools=await agent.get_all_tools(), ) assert any( isinstance(item, ToolCallItem) and item.raw_item is web_search_call @@ -284,7 +323,8 @@ def test_function_web_search_tool_call_parsed_correctly(): assert not result.handoffs -def test_reasoning_item_parsed_correctly(): +@pytest.mark.asyncio +async def test_reasoning_item_parsed_correctly(): # Verify that a Reasoning output item is converted into a ReasoningItem. reasoning = ResponseReasoningItem( @@ -296,7 +336,11 @@ def test_reasoning_item_parsed_correctly(): referenceable_id=None, ) result = RunImpl.process_model_response( - agent=Agent(name="test"), response=response, output_schema=None, handoffs=[] + agent=Agent(name="test"), + response=response, + output_schema=None, + handoffs=[], + all_tools=await Agent(name="test").get_all_tools(), ) assert any( isinstance(item, ReasoningItem) and item.raw_item is reasoning for item in result.new_items @@ -342,7 +386,8 @@ class DummyComputer(Computer): return None # pragma: no cover -def test_computer_tool_call_without_computer_tool_raises_error(): +@pytest.mark.asyncio +async def test_computer_tool_call_without_computer_tool_raises_error(): # If the agent has no ComputerTool in its tools, process_model_response should raise a # ModelBehaviorError when encountering a ResponseComputerToolCall. computer_call = ResponseComputerToolCall( @@ -360,11 +405,16 @@ def test_computer_tool_call_without_computer_tool_raises_error(): ) with pytest.raises(ModelBehaviorError): RunImpl.process_model_response( - agent=Agent(name="test"), response=response, output_schema=None, handoffs=[] + agent=Agent(name="test"), + response=response, + output_schema=None, + handoffs=[], + all_tools=await Agent(name="test").get_all_tools(), ) -def test_computer_tool_call_with_computer_tool_parsed_correctly(): +@pytest.mark.asyncio +async def test_computer_tool_call_with_computer_tool_parsed_correctly(): # If the agent contains a ComputerTool, ensure that a ResponseComputerToolCall is parsed into a # ToolCallItem and scheduled to run in computer_actions. dummy_computer = DummyComputer() @@ -383,7 +433,11 @@ def test_computer_tool_call_with_computer_tool_parsed_correctly(): referenceable_id=None, ) result = RunImpl.process_model_response( - agent=agent, response=response, output_schema=None, handoffs=[] + agent=agent, + response=response, + output_schema=None, + handoffs=[], + all_tools=await agent.get_all_tools(), ) assert any( isinstance(item, ToolCallItem) and item.raw_item is computer_call @@ -392,7 +446,8 @@ def test_computer_tool_call_with_computer_tool_parsed_correctly(): assert result.computer_actions and result.computer_actions[0].tool_call == computer_call -def test_tool_and_handoff_parsed_correctly(): +@pytest.mark.asyncio +async def test_tool_and_handoff_parsed_correctly(): agent_1 = Agent(name="test_1") agent_2 = Agent(name="test_2") agent_3 = Agent( @@ -413,6 +468,7 @@ def test_tool_and_handoff_parsed_correctly(): response=response, output_schema=None, handoffs=Runner._get_handoffs(agent_3), + all_tools=await agent_3.get_all_tools(), ) assert result.functions and len(result.functions) == 1 assert len(result.handoffs) == 1, "Should have a handoff here" diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index 75559232..89b5cca7 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -269,7 +269,7 @@ async def test_timeout_waiting_for_created_event(monkeypatch): async for _ in turns: pass - assert "Timeout waiting for transcription_session.created event" in str(exc_info.value) + assert "Timeout waiting for transcription_session.created event" in str(exc_info.value) await session.close() @@ -302,13 +302,11 @@ async def test_session_error_event(): trace_include_sensitive_audio_data=False, ) - with pytest.raises(STTWebsocketConnectionError) as exc_info: + with pytest.raises(STTWebsocketConnectionError): turns = session.transcribe_turns() async for _ in turns: pass - assert "Simulated server error!" in str(exc_info.value) - await session.close() @@ -362,8 +360,8 @@ async def test_inactivity_timeout(): async for turn in session.transcribe_turns(): collected_turns.append(turn) - assert "Timeout waiting for transcription_session" in str(exc_info.value) + assert "Timeout waiting for transcription_session" in str(exc_info.value) - assert len(collected_turns) == 0, "No transcripts expected, but we got something?" + assert len(collected_turns) == 0, "No transcripts expected, but we got something?" await session.close() From fc31f992f556ffff2ff7fa2a04198afb70a05a4a Mon Sep 17 00:00:00 2001 From: apeccaud Date: Tue, 25 Mar 2025 14:52:48 +0000 Subject: [PATCH 29/63] Fix parallel_tool_calls when False --- src/agents/models/openai_responses.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 3eea39ce..b9547123 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -208,7 +208,9 @@ class OpenAIResponsesModel(Model): list_input = ItemHelpers.input_to_new_input_list(input) parallel_tool_calls = ( - True if model_settings.parallel_tool_calls and tools and len(tools) > 0 else NOT_GIVEN + True if model_settings.parallel_tool_calls and tools and len(tools) > 0 + else False if model_settings.parallel_tool_calls is False + else NOT_GIVEN ) tool_choice = Converter.convert_tool_choice(model_settings.tool_choice) From 2c98d79d601b13f1950dc7fbf490cdfd00395b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:59:17 +0100 Subject: [PATCH 30/63] Add visualization optional dependency for graphviz --- pyproject.toml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bea34e5a..876551d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ Repository = "https://github.com/openai/openai-agents-python" [project.optional-dependencies] voice = ["numpy>=2.2.0, <3; python_version>='3.10'", "websockets>=15.0, <16"] +visualization = ["graphviz>=0.17"] [dependency-groups] dev = [ @@ -57,11 +58,6 @@ dev = [ "websockets", ] -[project.optional-dependencies] -visualization = [ - "graphviz>=0.17", -] - [tool.uv.workspace] members = ["agents"] From f28848e850d106c359b86ca99be77a4dca8824ac Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Tue, 25 Mar 2025 12:51:40 -0400 Subject: [PATCH 31/63] [1/n] Add MCP types to the SDK ### Summary: 1. Add the MCP dep for python 3.10, since it doesn't support 3.9 and below 2. Create MCPServer, which is the agents SDK representation of an MCP server 3. Create implementations for HTTP-SSE and StdIO servers, directly copying the [MCP SDK example](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py) 4. Add a util to transform MCP tools into Agent SDK tools Note: I added optional caching support to the servers. That way, if you happen to know a server's tools don't change, you can just cache them. ### Test Plan: Checks pass. I added tests at the end of the stack. --- pyproject.toml | 1 + src/agents/mcp/__init__.py | 21 +++ src/agents/mcp/mcp_util.py | 94 +++++++++++++ src/agents/mcp/server.py | 269 +++++++++++++++++++++++++++++++++++++ src/agents/mcp/util.py | 96 +++++++++++++ uv.lock | 93 ++++++++++++- 6 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 src/agents/mcp/__init__.py create mode 100644 src/agents/mcp/mcp_util.py create mode 100644 src/agents/mcp/server.py create mode 100644 src/agents/mcp/util.py diff --git a/pyproject.toml b/pyproject.toml index 667ab355..3678c714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "typing-extensions>=4.12.2, <5", "requests>=2.0, <3", "types-requests>=2.0, <3", + "mcp; python_version >= '3.10'", ] classifiers = [ "Typing :: Typed", diff --git a/src/agents/mcp/__init__.py b/src/agents/mcp/__init__.py new file mode 100644 index 00000000..1a72a89f --- /dev/null +++ b/src/agents/mcp/__init__.py @@ -0,0 +1,21 @@ +try: + from .server import ( + MCPServer, + MCPServerSse, + MCPServerSseParams, + MCPServerStdio, + MCPServerStdioParams, + ) +except ImportError: + pass + +from .util import MCPUtil + +__all__ = [ + "MCPServer", + "MCPServerSse", + "MCPServerSseParams", + "MCPServerStdio", + "MCPServerStdioParams", + "MCPUtil", +] diff --git a/src/agents/mcp/mcp_util.py b/src/agents/mcp/mcp_util.py new file mode 100644 index 00000000..41b4c521 --- /dev/null +++ b/src/agents/mcp/mcp_util.py @@ -0,0 +1,94 @@ +import functools +import json +from typing import Any + +from mcp.types import Tool as MCPTool + +from .. import _debug +from ..exceptions import AgentsException, ModelBehaviorError, UserError +from ..logger import logger +from ..run_context import RunContextWrapper +from ..tool import FunctionTool, Tool +from .server import MCPServer + + +class MCPUtil: + """Set of utilities for interop between MCP and Agents SDK tools.""" + + @classmethod + async def get_all_function_tools(cls, servers: list[MCPServer]) -> list[Tool]: + """Get all function tools from a list of MCP servers.""" + tools = [] + tool_names: set[str] = set() + for server in servers: + server_tools = await cls.get_function_tools(server) + server_tool_names = {tool.name for tool in server_tools} + if len(server_tool_names & tool_names) > 0: + raise UserError( + f"Duplicate tool names found across MCP servers: " + f"{server_tool_names & tool_names}" + ) + tool_names.update(server_tool_names) + tools.extend(server_tools) + + return tools + + @classmethod + async def get_function_tools(cls, server: MCPServer) -> list[Tool]: + """Get all function tools from a single MCP server.""" + tools = await server.list_tools() + return [cls.to_function_tool(tool, server) for tool in tools] + + @classmethod + def to_function_tool(cls, tool: MCPTool, server: MCPServer) -> FunctionTool: + """Convert an MCP tool to an Agents SDK function tool.""" + invoke_func = functools.partial(cls.invoke_mcp_tool, server, tool) + return FunctionTool( + name=tool.name, + description=tool.description or "", + params_json_schema=tool.inputSchema, + on_invoke_tool=invoke_func, + strict_json_schema=False, + ) + + @classmethod + async def invoke_mcp_tool( + cls, server: MCPServer, tool: MCPTool, context: RunContextWrapper[Any], input_json: str + ) -> str: + """Invoke an MCP tool and return the result as a string.""" + try: + json_data: dict[str, Any] = json.loads(input_json) if input_json else {} + except Exception as e: + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invalid JSON input for tool {tool.name}") + else: + logger.debug(f"Invalid JSON input for tool {tool.name}: {input_json}") + raise ModelBehaviorError( + f"Invalid JSON input for tool {tool.name}: {input_json}" + ) from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invoking MCP tool {tool.name}") + else: + logger.debug(f"Invoking MCP tool {tool.name} with input {input_json}") + + try: + result = await server.call_tool(tool.name, json_data) + except Exception as e: + logger.error(f"Error invoking MCP tool {tool.name}: {e}") + raise AgentsException(f"Error invoking MCP tool {tool.name}: {e}") from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"MCP tool {tool.name} completed.") + else: + logger.debug(f"MCP tool {tool.name} returned {result}") + + # The MCP tool result is a list of content items, whereas OpenAI tool outputs are a single + # string. We'll try to convert. + if len(result.content) == 1: + return result.content[0].model_dump_json() + elif len(result.content) > 1: + return json.dumps([item.model_dump() for item in result.content]) + else: + logger.error(f"Errored MCP tool result: {result}") + return "Error running tool." diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py new file mode 100644 index 00000000..e19e686a --- /dev/null +++ b/src/agents/mcp/server.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import abc +import asyncio +from contextlib import AbstractAsyncContextManager, AsyncExitStack +from pathlib import Path +from typing import Any, Literal + +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream +from mcp import ClientSession, StdioServerParameters, Tool as MCPTool, stdio_client +from mcp.client.sse import sse_client +from mcp.types import CallToolResult, JSONRPCMessage +from typing_extensions import NotRequired, TypedDict + +from ..exceptions import UserError +from ..logger import logger + + +class MCPServer(abc.ABC): + """Base class for Model Context Protocol servers.""" + + @abc.abstractmethod + async def connect(self): + """Connect to the server. For example, this might mean spawning a subprocess or + opening a network connection. The server is expected to remain connected until + `cleanup()` is called. + """ + pass + + @abc.abstractmethod + async def cleanup(self): + """Cleanup the server. For example, this might mean closing a subprocess or + closing a network connection. + """ + pass + + @abc.abstractmethod + async def list_tools(self) -> list[MCPTool]: + """List the tools available on the server.""" + pass + + @abc.abstractmethod + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None) -> CallToolResult: + """Invoke a tool on the server.""" + pass + + +class _MCPServerWithClientSession(MCPServer, abc.ABC): + """Base class for MCP servers that use a `ClientSession` to communicate with the server.""" + + def __init__(self, cache_tools_list: bool): + """ + Args: + cache_tools_list: Whether to cache the tools list. If `True`, the tools list will be + cached and only fetched from the server once. If `False`, the tools list will be + fetched from the server on each call to `list_tools()`. The cache can be invalidated + by calling `invalidate_tools_cache()`. You should set this to `True` if you know the + server will not change its tools list, because it can drastically improve latency + (by avoiding a round-trip to the server every time). + """ + self.session: ClientSession | None = None + self.exit_stack: AsyncExitStack = AsyncExitStack() + self._cleanup_lock: asyncio.Lock = asyncio.Lock() + self.cache_tools_list = cache_tools_list + + # The cache is always dirty at startup, so that we fetch tools at least once + self._cache_dirty = True + self._tools_list: list[MCPTool] | None = None + + @abc.abstractmethod + def create_streams( + self, + ) -> AbstractAsyncContextManager[ + tuple[ + MemoryObjectReceiveStream[JSONRPCMessage | Exception], + MemoryObjectSendStream[JSONRPCMessage], + ] + ]: + """Create the streams for the server.""" + pass + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.cleanup() + + def invalidate_tools_cache(self): + """Invalidate the tools cache.""" + self._cache_dirty = True + + async def connect(self): + """Connect to the server.""" + try: + transport = await self.exit_stack.enter_async_context(self.create_streams()) + read, write = transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session + except Exception as e: + logger.error(f"Error initializing MCP server: {e}") + await self.cleanup() + raise + + async def list_tools(self) -> list[MCPTool]: + """List the tools available on the server.""" + if not self.session: + raise UserError("Server not initialized. Make sure you call `connect()` first.") + + # Return from cache if caching is enabled, we have tools, and the cache is not dirty + if self.cache_tools_list and not self._cache_dirty and self._tools_list: + return self._tools_list + + # Reset the cache dirty to False + self._cache_dirty = False + + # Fetch the tools from the server + self._tools_list = (await self.session.list_tools()).tools + return self._tools_list + + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None) -> CallToolResult: + """Invoke a tool on the server.""" + if not self.session: + raise UserError("Server not initialized. Make sure you call `connect()` first.") + + return await self.session.call_tool(tool_name, arguments) + + async def cleanup(self): + """Cleanup the server.""" + async with self._cleanup_lock: + try: + await self.exit_stack.aclose() + self.session = None + except Exception as e: + logger.error(f"Error cleaning up server: {e}") + + +class MCPServerStdioParams(TypedDict): + """Mirrors `mcp.client.stdio.StdioServerParameters`, but lets you pass params without another + import. + """ + + command: str + """The executable to run to start the server. For example, `python` or `node`.""" + + args: NotRequired[list[str]] + """Command line args to pass to the `command` executable. For example, `['foo.py']` or + `['server.js', '--port', '8080']`.""" + + env: NotRequired[dict[str, str]] + """The environment variables to set for the server. .""" + + cwd: NotRequired[str | Path] + """The working directory to use when spawning the process.""" + + encoding: NotRequired[str] + """The text encoding used when sending/receiving messages to the server. Defaults to `utf-8`.""" + + encoding_error_handler: NotRequired[Literal["strict", "ignore", "replace"]] + """The text encoding error handler. Defaults to `strict`. + + See https://docs.python.org/3/library/codecs.html#codec-base-classes for + explanations of possible values. + """ + + +class MCPServerStdio(_MCPServerWithClientSession): + """MCP server implementation that uses the stdio transport. See the [spec] + (https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#stdio) for + details. + """ + + def __init__(self, params: MCPServerStdioParams, cache_tools_list: bool = False): + """Create a new MCP server based on the stdio transport. + + Args: + params: The params that configure the server. This includes: + - The command (e.g. `python` or `node`) that starts the server. + - The args to pass to the server command (e.g. `foo.py` or `server.js`). + - The environment variables to set for the server. + cache_tools_list: Whether to cache the tools list. If `True`, the tools list will be + cached and only fetched from the server once. If `False`, the tools list will be + fetched from the server on each call to `list_tools()`. The cache can be + invalidated by calling `invalidate_tools_cache()`. You should set this to `True` + if you know the server will not change its tools list, because it can drastically + improve latency (by avoiding a round-trip to the server every time). + """ + super().__init__(cache_tools_list) + + self.params = StdioServerParameters( + command=params["command"], + args=params.get("args", []), + env=params.get("env"), + cwd=params.get("cwd"), + encoding=params.get("encoding", "utf-8"), + encoding_error_handler=params.get("encoding_error_handler", "strict"), + ) + + def create_streams( + self, + ) -> AbstractAsyncContextManager[ + tuple[ + MemoryObjectReceiveStream[JSONRPCMessage | Exception], + MemoryObjectSendStream[JSONRPCMessage], + ] + ]: + """Create the streams for the server.""" + return stdio_client(self.params) + + +class MCPServerSseParams(TypedDict): + """Mirrors the params in`mcp.client.sse.sse_client`.""" + + url: str + """The URL of the server.""" + + headers: NotRequired[dict[str, str]] + """The headers to send to the server.""" + + timeout: NotRequired[float] + """The timeout for the HTTP request. Defaults to 5 seconds.""" + + sse_read_timeout: NotRequired[float] + """The timeout for the SSE connection, in seconds. Defaults to 5 minutes.""" + + +class MCPServerSse(_MCPServerWithClientSession): + """MCP server implementation that uses the HTTP with SSE transport. See the [spec] + (https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/transports/#http-with-sse) + for details. + """ + + def __init__(self, params: MCPServerSseParams, cache_tools_list: bool = False): + """Create a new MCP server based on the HTTP with SSE transport. + + Args: + params: The params that configure the server. This includes: + - The URL of the server. + - The headers to send to the server. + - The timeout for the HTTP request. + - The timeout for the SSE connection. + + cache_tools_list: Whether to cache the tools list. If `True`, the tools list will be + cached and only fetched from the server once. If `False`, the tools list will be + fetched from the server on each call to `list_tools()`. The cache can be + invalidated by calling `invalidate_tools_cache()`. You should set this to `True` + if you know the server will not change its tools list, because it can drastically + improve latency (by avoiding a round-trip to the server every time). + """ + super().__init__(cache_tools_list) + + self.params = params + + def create_streams( + self, + ) -> AbstractAsyncContextManager[ + tuple[ + MemoryObjectReceiveStream[JSONRPCMessage | Exception], + MemoryObjectSendStream[JSONRPCMessage], + ] + ]: + """Create the streams for the server.""" + return sse_client( + url=self.params["url"], + headers=self.params.get("headers", None), + timeout=self.params.get("timeout", 5), + sse_read_timeout=self.params.get("sse_read_timeout", 60 * 5), + ) diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py new file mode 100644 index 00000000..038c4fec --- /dev/null +++ b/src/agents/mcp/util.py @@ -0,0 +1,96 @@ +import functools +import json +from typing import TYPE_CHECKING, Any + +from .. import _debug +from ..exceptions import AgentsException, ModelBehaviorError, UserError +from ..logger import logger +from ..run_context import RunContextWrapper +from ..tool import FunctionTool, Tool + +if TYPE_CHECKING: + from mcp.types import Tool as MCPTool + + from .server import MCPServer + + +class MCPUtil: + """Set of utilities for interop between MCP and Agents SDK tools.""" + + @classmethod + async def get_all_function_tools(cls, servers: list["MCPServer"]) -> list[Tool]: + """Get all function tools from a list of MCP servers.""" + tools = [] + tool_names: set[str] = set() + for server in servers: + server_tools = await cls.get_function_tools(server) + server_tool_names = {tool.name for tool in server_tools} + if len(server_tool_names & tool_names) > 0: + raise UserError( + f"Duplicate tool names found across MCP servers: " + f"{server_tool_names & tool_names}" + ) + tool_names.update(server_tool_names) + tools.extend(server_tools) + + return tools + + @classmethod + async def get_function_tools(cls, server: "MCPServer") -> list[Tool]: + """Get all function tools from a single MCP server.""" + tools = await server.list_tools() + return [cls.to_function_tool(tool, server) for tool in tools] + + @classmethod + def to_function_tool(cls, tool: "MCPTool", server: "MCPServer") -> FunctionTool: + """Convert an MCP tool to an Agents SDK function tool.""" + invoke_func = functools.partial(cls.invoke_mcp_tool, server, tool) + return FunctionTool( + name=tool.name, + description=tool.description or "", + params_json_schema=tool.inputSchema, + on_invoke_tool=invoke_func, + strict_json_schema=False, + ) + + @classmethod + async def invoke_mcp_tool( + cls, server: "MCPServer", tool: "MCPTool", context: RunContextWrapper[Any], input_json: str + ) -> str: + """Invoke an MCP tool and return the result as a string.""" + try: + json_data: dict[str, Any] = json.loads(input_json) if input_json else {} + except Exception as e: + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invalid JSON input for tool {tool.name}") + else: + logger.debug(f"Invalid JSON input for tool {tool.name}: {input_json}") + raise ModelBehaviorError( + f"Invalid JSON input for tool {tool.name}: {input_json}" + ) from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invoking MCP tool {tool.name}") + else: + logger.debug(f"Invoking MCP tool {tool.name} with input {input_json}") + + try: + result = await server.call_tool(tool.name, json_data) + except Exception as e: + logger.error(f"Error invoking MCP tool {tool.name}: {e}") + raise AgentsException(f"Error invoking MCP tool {tool.name}: {e}") from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"MCP tool {tool.name} completed.") + else: + logger.debug(f"MCP tool {tool.name} returned {result}") + + # The MCP tool result is a list of content items, whereas OpenAI tool outputs are a single + # string. We'll try to convert. + if len(result.content) == 1: + return result.content[0].model_dump_json() + elif len(result.content) > 1: + return json.dumps([item.model_dump() for item in result.content]) + else: + logger.error(f"Errored MCP tool result: {result}") + return "Error running tool." diff --git a/uv.lock b/uv.lock index a9c79e21..d6eba43f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", @@ -459,6 +458,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + [[package]] name = "idna" version = "3.10" @@ -699,6 +707,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, ] +[[package]] +name = "mcp" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.10'" }, + { name = "httpx", marker = "python_full_version >= '3.10'" }, + { name = "httpx-sse", marker = "python_full_version >= '3.10'" }, + { name = "pydantic", marker = "python_full_version >= '3.10'" }, + { name = "pydantic-settings", marker = "python_full_version >= '3.10'" }, + { name = "sse-starlette", marker = "python_full_version >= '3.10'" }, + { name = "starlette", marker = "python_full_version >= '3.10'" }, + { name = "uvicorn", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c9/c55764824e893fdebe777ac7223200986a275c3191dba9169f8eb6d7c978/mcp-1.5.0.tar.gz", hash = "sha256:5b2766c05e68e01a2034875e250139839498c61792163a7b221fc170c12f5aa9", size = 159128 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d1/3ff566ecf322077d861f1a68a1ff025cad337417bd66ad22a7c6f7dfcfaf/mcp-1.5.0-py3-none-any.whl", hash = "sha256:51c3f35ce93cb702f7513c12406bbea9665ef75a08db909200b07da9db641527", size = 73734 }, +] + [[package]] name = "mdit-py-plugins" version = "0.4.2" @@ -1054,6 +1081,7 @@ version = "0.0.6" source = { editable = "." } dependencies = [ { name = "griffe" }, + { name = "mcp", marker = "python_full_version >= '3.10'" }, { name = "openai" }, { name = "pydantic" }, { name = "requests" }, @@ -1091,6 +1119,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "griffe", specifier = ">=1.5.6,<2" }, + { name = "mcp", marker = "python_full_version >= '3.10'" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, { name = "openai", specifier = ">=1.66.5" }, { name = "pydantic", specifier = ">=2.10,<3" }, @@ -1099,7 +1128,6 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.12.2,<5" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<16" }, ] -provides-extras = ["voice"] [package.metadata.requires-dev] dev = [ @@ -1305,6 +1333,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961 }, ] +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "python_full_version >= '3.10'" }, + { name = "python-dotenv", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + [[package]] name = "pyee" version = "12.1.1" @@ -1496,6 +1537,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + [[package]] name = "python-xlib" version = "0.33" @@ -1660,6 +1710,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/9b/15217b04f3b36d30de55fef542389d722de63f1ad81f9c72d8afc98cb6ab/sounddevice-0.5.1-py3-none-win_amd64.whl", hash = "sha256:4313b63f2076552b23ac3e0abd3bcfc0c1c6a696fc356759a13bd113c9df90f1", size = 363634 }, ] +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.10'" }, + { name = "starlette", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + +[[package]] +name = "starlette" +version = "0.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, +] + [[package]] name = "textual" version = "2.1.2" @@ -1774,6 +1849,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version >= '3.10'" }, + { name = "h11", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, +] + [[package]] name = "watchdog" version = "6.0.0" From 5a0e6a92154b1e77395664801cfb2462931c0ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:01:45 +0100 Subject: [PATCH 32/63] Rename visualization dependency to viz in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 876551d9..b3910d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ Repository = "https://github.com/openai/openai-agents-python" [project.optional-dependencies] voice = ["numpy>=2.2.0, <3; python_version>='3.10'", "websockets>=15.0, <16"] -visualization = ["graphviz>=0.17"] +viz = ["graphviz>=0.17"] [dependency-groups] dev = [ From c2f912e393e0fc8d4f8475f5f9da9d3147b03813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:16:29 +0100 Subject: [PATCH 33/63] Refactor visualization node label formatting in get_all_nodes function --- src/agents/extensions/visualization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index dd75acfc..f6079857 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -57,7 +57,7 @@ def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str: for handoff in agent.handoffs: if isinstance(handoff, Handoff): parts.append( - f'"{handoff.agent_name}" [label="{handoff.agent_name}", shape=box, ' + f'"{handoff.agent_name}" [label="{handoff.agent_name}", ' f"shape=box, style=filled, style=rounded, " f"fillcolor=lightyellow, width=1.5, height=0.8];" ) From 069e41a08df7875502a4bdfb0d90ada56b1570c2 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Tue, 25 Mar 2025 13:24:18 -0400 Subject: [PATCH 34/63] [3/n] Add an MCP stdio example ### Summary: Spins up a stdio server with some local files, then asks the model questions. ### Test Plan: Run the example, see it work. --- examples/mcp/filesystem_example/README.md | 26 +++++++++ examples/mcp/filesystem_example/main.py | 54 +++++++++++++++++++ .../sample_files/favorite_books.txt | 20 +++++++ .../sample_files/favorite_cities.txt | 4 ++ .../sample_files/favorite_songs.txt | 10 ++++ 5 files changed, 114 insertions(+) create mode 100644 examples/mcp/filesystem_example/README.md create mode 100644 examples/mcp/filesystem_example/main.py create mode 100644 examples/mcp/filesystem_example/sample_files/favorite_books.txt create mode 100644 examples/mcp/filesystem_example/sample_files/favorite_cities.txt create mode 100644 examples/mcp/filesystem_example/sample_files/favorite_songs.txt diff --git a/examples/mcp/filesystem_example/README.md b/examples/mcp/filesystem_example/README.md new file mode 100644 index 00000000..682afc83 --- /dev/null +++ b/examples/mcp/filesystem_example/README.md @@ -0,0 +1,26 @@ +# MCP Filesystem Example + +This example uses the [fileystem MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem), running locally via `npx`. + +Run it via: + +``` +uv run python python examples/mcp/filesystem_example/main.py +``` + +## Details + +The example uses the `MCPServerStdio` class from `agents`, with the command: + +```bash +npx -y "@modelcontextprotocol/server-filesystem" +``` + +It's only given access to the `sample_files` directory adjacent to the example, which contains some sample data. + +Under the hood: + +1. The server is spun up in a subprocess, and exposes a bunch of tools like `list_directory()`, `read_file()`, etc. +2. We add the server instance to the Agent via `mcp_agents`. +3. Each time the agent runs, we call out to the MCP server to fetch the list of tools via `server.list_tools()`. +4. If the LLM chooses to use an MCP tool, we call the MCP server to run the tool via `server.run_tool()`. diff --git a/examples/mcp/filesystem_example/main.py b/examples/mcp/filesystem_example/main.py new file mode 100644 index 00000000..0ba2b675 --- /dev/null +++ b/examples/mcp/filesystem_example/main.py @@ -0,0 +1,54 @@ +import asyncio +import os +import shutil + +from agents import Agent, Runner, trace +from agents.mcp import MCPServer, MCPServerStdio + + +async def run(mcp_server: MCPServer): + agent = Agent( + name="Assistant", + instructions="Use the tools to read the filesystem and answer questions based on those files.", + mcp_servers=[mcp_server], + ) + + # List the files it can read + message = "Read the files and list them." + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Ask about books + message = "What is my #1 favorite book?" + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Ask a question that reads then reasons. + message = "Look at my favorite songs. Suggest one new song that I might like." + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + +async def main(): + current_dir = os.path.dirname(os.path.abspath(__file__)) + samples_dir = os.path.join(current_dir, "sample_files") + + async with MCPServerStdio( + params={ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", samples_dir], + } + ) as server: + with trace(workflow_name="MCP Filesystem Example"): + await run(server) + + +if __name__ == "__main__": + # Let's make sure the user has npx installed + if not shutil.which("npx"): + raise RuntimeError("npx is not installed. Please install it with `npm install -g npx`.") + + asyncio.run(main()) diff --git a/examples/mcp/filesystem_example/sample_files/favorite_books.txt b/examples/mcp/filesystem_example/sample_files/favorite_books.txt new file mode 100644 index 00000000..c55f457e --- /dev/null +++ b/examples/mcp/filesystem_example/sample_files/favorite_books.txt @@ -0,0 +1,20 @@ +1. To Kill a Mockingbird – Harper Lee +2. Pride and Prejudice – Jane Austen +3. 1984 – George Orwell +4. The Hobbit – J.R.R. Tolkien +5. Harry Potter and the Sorcerer’s Stone – J.K. Rowling +6. The Great Gatsby – F. Scott Fitzgerald +7. Charlotte’s Web – E.B. White +8. Anne of Green Gables – Lucy Maud Montgomery +9. The Alchemist – Paulo Coelho +10. Little Women – Louisa May Alcott +11. The Catcher in the Rye – J.D. Salinger +12. Animal Farm – George Orwell +13. The Chronicles of Narnia: The Lion, the Witch, and the Wardrobe – C.S. Lewis +14. The Book Thief – Markus Zusak +15. A Wrinkle in Time – Madeleine L’Engle +16. The Secret Garden – Frances Hodgson Burnett +17. Moby-Dick – Herman Melville +18. Fahrenheit 451 – Ray Bradbury +19. Jane Eyre – Charlotte Brontë +20. The Little Prince – Antoine de Saint-Exupéry \ No newline at end of file diff --git a/examples/mcp/filesystem_example/sample_files/favorite_cities.txt b/examples/mcp/filesystem_example/sample_files/favorite_cities.txt new file mode 100644 index 00000000..1d3354f2 --- /dev/null +++ b/examples/mcp/filesystem_example/sample_files/favorite_cities.txt @@ -0,0 +1,4 @@ +- In the summer, I love visiting London. +- In the winter, Tokyo is great. +- In the spring, San Francisco. +- In the fall, New York is the best. \ No newline at end of file diff --git a/examples/mcp/filesystem_example/sample_files/favorite_songs.txt b/examples/mcp/filesystem_example/sample_files/favorite_songs.txt new file mode 100644 index 00000000..d659bb58 --- /dev/null +++ b/examples/mcp/filesystem_example/sample_files/favorite_songs.txt @@ -0,0 +1,10 @@ +1. "Here Comes the Sun" – The Beatles +2. "Imagine" – John Lennon +3. "Bohemian Rhapsody" – Queen +4. "Shake It Off" – Taylor Swift +5. "Billie Jean" – Michael Jackson +6. "Uptown Funk" – Mark Ronson ft. Bruno Mars +7. "Don’t Stop Believin’" – Journey +8. "Dancing Queen" – ABBA +9. "Happy" – Pharrell Williams +10. "Wonderwall" – Oasis From a93ad02688937c863a82f077f4dc920570e11b9f Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Tue, 25 Mar 2025 13:25:50 -0400 Subject: [PATCH 35/63] [4/n] Add docs for MCP Just adding docs. - --- docs/mcp.md | 51 +++++++++++++++++++++ docs/ref/mcp/server.md | 3 ++ docs/ref/mcp/util.md | 3 ++ mkdocs.yml | 5 ++ src/agents/mcp/mcp_util.py | 94 -------------------------------------- src/agents/mcp/server.py | 16 +++---- 6 files changed, 69 insertions(+), 103 deletions(-) create mode 100644 docs/mcp.md create mode 100644 docs/ref/mcp/server.md create mode 100644 docs/ref/mcp/util.md delete mode 100644 src/agents/mcp/mcp_util.py diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..7ec11c16 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,51 @@ +# Model context protocol + +The [Model context protocol](https://modelcontextprotocol.io/introduction) (aka MCP) is a way to provide tools and context to the LLM. From the MCP docs: + +> MCP is an open protocol that standardizes how applications provide context to LLMs. Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect your devices to various peripherals and accessories, MCP provides a standardized way to connect AI models to different data sources and tools. + +The Agents SDK has support for MCP. This enables you to use a wide range of MCP servers to provide tools to your Agents. + +## MCP servers + +Currently, the MCP spec defines two kinds of servers, based on the transport mechanism they use: + +1. **stdio** servers run as a subprocess of your application. You can think of them as running "locally". +2. **HTTP over SSE** servers run remotely. You connect to them via a URL. + +You can use the [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] and [`MCPServerSse`][agents.mcp.server.MCPServerSse] classes to connect to these servers. + +For example, this is how you'd use the [official MCP filesystem server](https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem). + +```python +async with MCPServerStdio( + params={ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", samples_dir], + } +) as server: + tools = await server.list_tools() +``` + +## Using MCP servers + +MCP servers can be added to Agents. The Agents SDK will call `list_tools()` on the MCP servers each time the Agent is run. This makes the LLM aware of the MCP server's tools. When the LLM calls a tool from an MCP server, the SDK calls `call_tool()` on that server. + +```python + +agent=Agent( + name="Assistant", + instructions="Use the tools to achieve the task", + mcp_servers=[mcp_server_1, mcp_server_2] +) +``` + +## Caching + +Every time an Agent runs, it calls `list_tools()` on the MCP server. This can be a latency hit, especially if the server is a remote server. To automatically cache the list of tools, you can pass `cache_tools_list=True` to both [`MCPServerStdio`][agents.mcp.server.MCPServerStdio] and [`MCPServerSse`][agents.mcp.server.MCPServerSse]. You should only do this if you're certain the tool list will not change. + +If you want to invalidate the cache, you can call `invalidate_tools_cache()` on the servers. + +## End-to-end example + +View complete working examples at [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp). diff --git a/docs/ref/mcp/server.md b/docs/ref/mcp/server.md new file mode 100644 index 00000000..e58efab2 --- /dev/null +++ b/docs/ref/mcp/server.md @@ -0,0 +1,3 @@ +# `MCP Servers` + +::: agents.mcp.server diff --git a/docs/ref/mcp/util.md b/docs/ref/mcp/util.md new file mode 100644 index 00000000..b3f7db25 --- /dev/null +++ b/docs/ref/mcp/util.md @@ -0,0 +1,3 @@ +# `MCP Util` + +::: agents.mcp.util diff --git a/mkdocs.yml b/mkdocs.yml index 941f29ed..454d881a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,6 +28,7 @@ nav: - results.md - streaming.md - tools.md + - mcp.md - handoffs.md - tracing.md - context.md @@ -60,6 +61,8 @@ nav: - ref/models/interface.md - ref/models/openai_chatcompletions.md - ref/models/openai_responses.md + - ref/mcp/server.md + - ref/mcp/util.md - Tracing: - ref/tracing/index.md - ref/tracing/create.md @@ -107,6 +110,8 @@ plugins: show_signature_annotations: true # Makes the font sizes nicer heading_level: 3 + # Show inherited members + inherited_members: true extra: # Remove material generation message in footer diff --git a/src/agents/mcp/mcp_util.py b/src/agents/mcp/mcp_util.py deleted file mode 100644 index 41b4c521..00000000 --- a/src/agents/mcp/mcp_util.py +++ /dev/null @@ -1,94 +0,0 @@ -import functools -import json -from typing import Any - -from mcp.types import Tool as MCPTool - -from .. import _debug -from ..exceptions import AgentsException, ModelBehaviorError, UserError -from ..logger import logger -from ..run_context import RunContextWrapper -from ..tool import FunctionTool, Tool -from .server import MCPServer - - -class MCPUtil: - """Set of utilities for interop between MCP and Agents SDK tools.""" - - @classmethod - async def get_all_function_tools(cls, servers: list[MCPServer]) -> list[Tool]: - """Get all function tools from a list of MCP servers.""" - tools = [] - tool_names: set[str] = set() - for server in servers: - server_tools = await cls.get_function_tools(server) - server_tool_names = {tool.name for tool in server_tools} - if len(server_tool_names & tool_names) > 0: - raise UserError( - f"Duplicate tool names found across MCP servers: " - f"{server_tool_names & tool_names}" - ) - tool_names.update(server_tool_names) - tools.extend(server_tools) - - return tools - - @classmethod - async def get_function_tools(cls, server: MCPServer) -> list[Tool]: - """Get all function tools from a single MCP server.""" - tools = await server.list_tools() - return [cls.to_function_tool(tool, server) for tool in tools] - - @classmethod - def to_function_tool(cls, tool: MCPTool, server: MCPServer) -> FunctionTool: - """Convert an MCP tool to an Agents SDK function tool.""" - invoke_func = functools.partial(cls.invoke_mcp_tool, server, tool) - return FunctionTool( - name=tool.name, - description=tool.description or "", - params_json_schema=tool.inputSchema, - on_invoke_tool=invoke_func, - strict_json_schema=False, - ) - - @classmethod - async def invoke_mcp_tool( - cls, server: MCPServer, tool: MCPTool, context: RunContextWrapper[Any], input_json: str - ) -> str: - """Invoke an MCP tool and return the result as a string.""" - try: - json_data: dict[str, Any] = json.loads(input_json) if input_json else {} - except Exception as e: - if _debug.DONT_LOG_TOOL_DATA: - logger.debug(f"Invalid JSON input for tool {tool.name}") - else: - logger.debug(f"Invalid JSON input for tool {tool.name}: {input_json}") - raise ModelBehaviorError( - f"Invalid JSON input for tool {tool.name}: {input_json}" - ) from e - - if _debug.DONT_LOG_TOOL_DATA: - logger.debug(f"Invoking MCP tool {tool.name}") - else: - logger.debug(f"Invoking MCP tool {tool.name} with input {input_json}") - - try: - result = await server.call_tool(tool.name, json_data) - except Exception as e: - logger.error(f"Error invoking MCP tool {tool.name}: {e}") - raise AgentsException(f"Error invoking MCP tool {tool.name}: {e}") from e - - if _debug.DONT_LOG_TOOL_DATA: - logger.debug(f"MCP tool {tool.name} completed.") - else: - logger.debug(f"MCP tool {tool.name} returned {result}") - - # The MCP tool result is a list of content items, whereas OpenAI tool outputs are a single - # string. We'll try to convert. - if len(result.content) == 1: - return result.content[0].model_dump_json() - elif len(result.content) > 1: - return json.dumps([item.model_dump() for item in result.content]) - else: - logger.error(f"Errored MCP tool result: {result}") - return "Error running tool." diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index e19e686a..91af31db 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -175,10 +175,10 @@ class MCPServerStdio(_MCPServerWithClientSession): """Create a new MCP server based on the stdio transport. Args: - params: The params that configure the server. This includes: - - The command (e.g. `python` or `node`) that starts the server. - - The args to pass to the server command (e.g. `foo.py` or `server.js`). - - The environment variables to set for the server. + params: The params that configure the server. This includes the command to run to + start the server, the args to pass to the command, the environment variables to + set for the server, the working directory to use when spawning the process, and + the text encoding used when sending/receiving messages to the server. cache_tools_list: Whether to cache the tools list. If `True`, the tools list will be cached and only fetched from the server once. If `False`, the tools list will be fetched from the server on each call to `list_tools()`. The cache can be @@ -235,11 +235,9 @@ class MCPServerSse(_MCPServerWithClientSession): """Create a new MCP server based on the HTTP with SSE transport. Args: - params: The params that configure the server. This includes: - - The URL of the server. - - The headers to send to the server. - - The timeout for the HTTP request. - - The timeout for the SSE connection. + params: The params that configure the server. This includes the URL of the server, + the headers to send to the server, the timeout for the HTTP request, and the + timeout for the SSE connection. cache_tools_list: Whether to cache the tools list. If `True`, the tools list will be cached and only fetched from the server once. If `False`, the tools list will be From 944b730da9e905bfd8e8a533ef7f96d4ec207f93 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Tue, 25 Mar 2025 13:29:17 -0400 Subject: [PATCH 36/63] Make the reset behavior on tool use configurable ## Summary: #263 added this behavior. The goal was to prevent infinite loops when tool choice was set. The key change I'm making is: 1. Making it configurable on the agent. 2. Doing bookkeeping in the Runner to track this, to prevent mutating agents. 3. Not resetting the global tool choice in RunConfig. ## Test Plan: Unit tests. . --- docs/agents.md | 9 +- src/agents/_run_impl.py | 73 ++++++------- src/agents/agent.py | 4 + src/agents/models/openai_responses.py | 6 +- src/agents/run.py | 21 ++++ tests/fake_model.py | 10 ++ tests/test_tool_choice_reset.py | 145 +++++++++++++++++--------- 7 files changed, 172 insertions(+), 96 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 1e04f7e9..23b18b64 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -142,11 +142,6 @@ Supplying a list of tools doesn't always mean the LLM will use a tool. You can f !!! note - To prevent infinite loops, the framework automatically resets `tool_choice` to "auto" after a tool call in the following scenarios: - - 1. When `tool_choice` is set to a specific function name (any string that's not "auto", "required", or "none") - 2. When `tool_choice` is set to "required" AND there is only one tool available - - This targeted reset mechanism allows the model to decide whether to make additional tool calls in subsequent turns while avoiding infinite loops in these specific cases. - + To prevent infinite loops, the framework automatically resets `tool_choice` to "auto" after a tool call. This behavior is configurable via [`agent.reset_tool_choice`][agents.agent.Agent.reset_tool_choice]. The infinite loop is because tool results are sent to the LLM, which then generates another tool call because of `tool_choice`, ad infinitum. + If you want the Agent to completely stop after a tool call (rather than continuing with auto mode), you can set [`Agent.tool_use_behavior="stop_on_first_tool"`] which will directly use the tool output as the final response without further LLM processing. diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index 80b9cc6d..61ca4a0f 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -4,7 +4,7 @@ import asyncio import dataclasses import inspect from collections.abc import Awaitable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, cast from openai.types.responses import ( @@ -77,6 +77,23 @@ QUEUE_COMPLETE_SENTINEL = QueueCompleteSentinel() _NOT_FINAL_OUTPUT = ToolsToFinalOutputResult(is_final_output=False, final_output=None) +@dataclass +class AgentToolUseTracker: + agent_to_tools: list[tuple[Agent, list[str]]] = field(default_factory=list) + """Tuple of (agent, list of tools used). Can't use a dict because agents aren't hashable.""" + + def add_tool_use(self, agent: Agent[Any], tool_names: list[str]) -> None: + existing_data = next((item for item in self.agent_to_tools if item[0] == agent), None) + if existing_data: + existing_data[1].extend(tool_names) + else: + self.agent_to_tools.append((agent, tool_names)) + + def has_used_tools(self, agent: Agent[Any]) -> bool: + existing_data = next((item for item in self.agent_to_tools if item[0] == agent), None) + return existing_data is not None and len(existing_data[1]) > 0 + + @dataclass class ToolRunHandoff: handoff: Handoff @@ -101,6 +118,7 @@ class ProcessedResponse: handoffs: list[ToolRunHandoff] functions: list[ToolRunFunction] computer_actions: list[ToolRunComputerAction] + tools_used: list[str] # Names of all tools used, including hosted tools def has_tools_to_run(self) -> bool: # Handoffs, functions and computer actions need local processing @@ -208,29 +226,6 @@ class RunImpl: new_step_items.extend([result.run_item for result in function_results]) new_step_items.extend(computer_results) - # Reset tool_choice to "auto" after tool execution to prevent infinite loops - if processed_response.functions or processed_response.computer_actions: - tools = agent.tools - - if ( - run_config.model_settings and - cls._should_reset_tool_choice(run_config.model_settings, tools) - ): - # update the run_config model settings with a copy - new_run_config_settings = dataclasses.replace( - run_config.model_settings, - tool_choice="auto" - ) - run_config = dataclasses.replace(run_config, model_settings=new_run_config_settings) - - if cls._should_reset_tool_choice(agent.model_settings, tools): - # Create a modified copy instead of modifying the original agent - new_model_settings = dataclasses.replace( - agent.model_settings, - tool_choice="auto" - ) - agent = dataclasses.replace(agent, model_settings=new_model_settings) - # Second, check if there are any handoffs if run_handoffs := processed_response.handoffs: return await cls.execute_handoffs( @@ -322,22 +317,16 @@ class RunImpl: ) @classmethod - def _should_reset_tool_choice(cls, model_settings: ModelSettings, tools: list[Tool]) -> bool: - if model_settings is None or model_settings.tool_choice is None: - return False + def maybe_reset_tool_choice( + cls, agent: Agent[Any], tool_use_tracker: AgentToolUseTracker, model_settings: ModelSettings + ) -> ModelSettings: + """Resets tool choice to None if the agent has used tools and the agent's reset_tool_choice + flag is True.""" - # for specific tool choices - if ( - isinstance(model_settings.tool_choice, str) and - model_settings.tool_choice not in ["auto", "required", "none"] - ): - return True + if agent.reset_tool_choice is True and tool_use_tracker.has_used_tools(agent): + return dataclasses.replace(model_settings, tool_choice=None) - # for one tool and required tool choice - if model_settings.tool_choice == "required": - return len(tools) == 1 - - return False + return model_settings @classmethod def process_model_response( @@ -354,7 +343,7 @@ class RunImpl: run_handoffs = [] functions = [] computer_actions = [] - + tools_used: list[str] = [] handoff_map = {handoff.tool_name: handoff for handoff in handoffs} function_map = {tool.name: tool for tool in all_tools if isinstance(tool, FunctionTool)} computer_tool = next((tool for tool in all_tools if isinstance(tool, ComputerTool)), None) @@ -364,12 +353,15 @@ class RunImpl: items.append(MessageOutputItem(raw_item=output, agent=agent)) elif isinstance(output, ResponseFileSearchToolCall): items.append(ToolCallItem(raw_item=output, agent=agent)) + tools_used.append("file_search") elif isinstance(output, ResponseFunctionWebSearch): items.append(ToolCallItem(raw_item=output, agent=agent)) + tools_used.append("web_search") elif isinstance(output, ResponseReasoningItem): items.append(ReasoningItem(raw_item=output, agent=agent)) elif isinstance(output, ResponseComputerToolCall): items.append(ToolCallItem(raw_item=output, agent=agent)) + tools_used.append("computer_use") if not computer_tool: _error_tracing.attach_error_to_current_span( SpanError( @@ -391,6 +383,8 @@ class RunImpl: if not isinstance(output, ResponseFunctionToolCall): continue + tools_used.append(output.name) + # Handoffs if output.name in handoff_map: items.append(HandoffCallItem(raw_item=output, agent=agent)) @@ -422,6 +416,7 @@ class RunImpl: handoffs=run_handoffs, functions=functions, computer_actions=computer_actions, + tools_used=tools_used, ) @classmethod diff --git a/src/agents/agent.py b/src/agents/agent.py index 3258e15a..b31f00b1 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -155,6 +155,10 @@ class Agent(Generic[TContext]): web search, etc are always processed by the LLM. """ + reset_tool_choice: bool = True + """Whether to reset the tool choice to the default value after a tool has been called. Defaults + to True. This ensures that the agent doesn't enter an infinite loop of tool usage.""" + def clone(self, **kwargs: Any) -> Agent[TContext]: """Make a copy of the agent, with the given arguments changed. For example, you could do: ``` diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index b9547123..c5b591c6 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -208,8 +208,10 @@ class OpenAIResponsesModel(Model): list_input = ItemHelpers.input_to_new_input_list(input) parallel_tool_calls = ( - True if model_settings.parallel_tool_calls and tools and len(tools) > 0 - else False if model_settings.parallel_tool_calls is False + True + if model_settings.parallel_tool_calls and tools and len(tools) > 0 + else False + if model_settings.parallel_tool_calls is False else NOT_GIVEN ) diff --git a/src/agents/run.py b/src/agents/run.py index b7ac85f9..5c21b709 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -10,6 +10,7 @@ from openai.types.responses import ResponseCompletedEvent from agents.tool import Tool from ._run_impl import ( + AgentToolUseTracker, NextStepFinalOutput, NextStepHandoff, NextStepRunAgain, @@ -151,6 +152,8 @@ class Runner: if run_config is None: run_config = RunConfig() + tool_use_tracker = AgentToolUseTracker() + with TraceCtxManager( workflow_name=run_config.workflow_name, trace_id=run_config.trace_id, @@ -227,6 +230,7 @@ class Runner: context_wrapper=context_wrapper, run_config=run_config, should_run_agent_start_hooks=should_run_agent_start_hooks, + tool_use_tracker=tool_use_tracker, ), ) else: @@ -239,6 +243,7 @@ class Runner: context_wrapper=context_wrapper, run_config=run_config, should_run_agent_start_hooks=should_run_agent_start_hooks, + tool_use_tracker=tool_use_tracker, ) should_run_agent_start_hooks = False @@ -486,6 +491,7 @@ class Runner: current_agent = starting_agent current_turn = 0 should_run_agent_start_hooks = True + tool_use_tracker = AgentToolUseTracker() streamed_result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=current_agent)) @@ -546,6 +552,7 @@ class Runner: context_wrapper, run_config, should_run_agent_start_hooks, + tool_use_tracker, ) should_run_agent_start_hooks = False @@ -613,6 +620,7 @@ class Runner: context_wrapper: RunContextWrapper[TContext], run_config: RunConfig, should_run_agent_start_hooks: bool, + tool_use_tracker: AgentToolUseTracker, ) -> SingleStepResult: if should_run_agent_start_hooks: await asyncio.gather( @@ -635,6 +643,8 @@ class Runner: all_tools = await cls._get_all_tools(agent) model = cls._get_model(agent, run_config) model_settings = agent.model_settings.resolve(run_config.model_settings) + model_settings = RunImpl.maybe_reset_tool_choice(agent, tool_use_tracker, model_settings) + final_response: ModelResponse | None = None input = ItemHelpers.input_to_new_input_list(streamed_result.input) @@ -687,6 +697,7 @@ class Runner: hooks=hooks, context_wrapper=context_wrapper, run_config=run_config, + tool_use_tracker=tool_use_tracker, ) RunImpl.stream_step_result_to_queue(single_step_result, streamed_result._event_queue) @@ -704,6 +715,7 @@ class Runner: context_wrapper: RunContextWrapper[TContext], run_config: RunConfig, should_run_agent_start_hooks: bool, + tool_use_tracker: AgentToolUseTracker, ) -> SingleStepResult: # Ensure we run the hooks before anything else if should_run_agent_start_hooks: @@ -732,6 +744,7 @@ class Runner: handoffs, context_wrapper, run_config, + tool_use_tracker, ) return await cls._get_single_step_result_from_response( @@ -745,6 +758,7 @@ class Runner: hooks=hooks, context_wrapper=context_wrapper, run_config=run_config, + tool_use_tracker=tool_use_tracker, ) @classmethod @@ -761,6 +775,7 @@ class Runner: hooks: RunHooks[TContext], context_wrapper: RunContextWrapper[TContext], run_config: RunConfig, + tool_use_tracker: AgentToolUseTracker, ) -> SingleStepResult: processed_response = RunImpl.process_model_response( agent=agent, @@ -769,6 +784,9 @@ class Runner: output_schema=output_schema, handoffs=handoffs, ) + + tool_use_tracker.add_tool_use(agent, processed_response.tools_used) + return await RunImpl.execute_tools_and_side_effects( agent=agent, original_input=original_input, @@ -868,9 +886,12 @@ class Runner: handoffs: list[Handoff], context_wrapper: RunContextWrapper[TContext], run_config: RunConfig, + tool_use_tracker: AgentToolUseTracker, ) -> ModelResponse: model = cls._get_model(agent, run_config) model_settings = agent.model_settings.resolve(run_config.model_settings) + model_settings = RunImpl.maybe_reset_tool_choice(agent, tool_use_tracker, model_settings) + new_response = await model.get_response( system_instructions=system_prompt, input=input, diff --git a/tests/fake_model.py b/tests/fake_model.py index f2ba6229..ecbb7583 100644 --- a/tests/fake_model.py +++ b/tests/fake_model.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import AsyncIterator +from typing import Any from openai.types.responses import Response, ResponseCompletedEvent @@ -31,6 +32,7 @@ class FakeModel(Model): [initial_output] if initial_output else [] ) self.tracing_enabled = tracing_enabled + self.last_turn_args: dict[str, Any] = {} def set_next_output(self, output: list[TResponseOutputItem] | Exception): self.turn_outputs.append(output) @@ -53,6 +55,14 @@ class FakeModel(Model): handoffs: list[Handoff], tracing: ModelTracing, ) -> ModelResponse: + self.last_turn_args = { + "system_instructions": system_instructions, + "input": input, + "model_settings": model_settings, + "tools": tools, + "output_schema": output_schema, + } + with generation_span(disabled=not self.tracing_enabled) as span: output = self.get_next_output() diff --git a/tests/test_tool_choice_reset.py b/tests/test_tool_choice_reset.py index 7dae6f63..f95117fd 100644 --- a/tests/test_tool_choice_reset.py +++ b/tests/test_tool_choice_reset.py @@ -1,63 +1,78 @@ import pytest -from agents import Agent, ModelSettings, Runner, Tool -from agents._run_impl import RunImpl +from agents import Agent, ModelSettings, Runner +from agents._run_impl import AgentToolUseTracker, RunImpl from .fake_model import FakeModel -from .test_responses import ( - get_function_tool, - get_function_tool_call, - get_text_message, -) +from .test_responses import get_function_tool, get_function_tool_call, get_text_message class TestToolChoiceReset: - def test_should_reset_tool_choice_direct(self): """ Test the _should_reset_tool_choice method directly with various inputs to ensure it correctly identifies cases where reset is needed. """ - # Case 1: tool_choice = None should not reset + agent = Agent(name="test_agent") + + # Case 1: Empty tool use tracker should not change the "None" tool choice model_settings = ModelSettings(tool_choice=None) - tools1: list[Tool] = [get_function_tool("tool1")] - # Cast to list[Tool] to fix type checking issues - assert not RunImpl._should_reset_tool_choice(model_settings, tools1) + tracker = AgentToolUseTracker() + new_settings = RunImpl.maybe_reset_tool_choice(agent, tracker, model_settings) + assert new_settings.tool_choice == model_settings.tool_choice - # Case 2: tool_choice = "auto" should not reset + # Case 2: Empty tool use tracker should not change the "auto" tool choice model_settings = ModelSettings(tool_choice="auto") - assert not RunImpl._should_reset_tool_choice(model_settings, tools1) + tracker = AgentToolUseTracker() + new_settings = RunImpl.maybe_reset_tool_choice(agent, tracker, model_settings) + assert model_settings.tool_choice == new_settings.tool_choice - # Case 3: tool_choice = "none" should not reset - model_settings = ModelSettings(tool_choice="none") - assert not RunImpl._should_reset_tool_choice(model_settings, tools1) + # Case 3: Empty tool use tracker should not change the "required" tool choice + model_settings = ModelSettings(tool_choice="required") + tracker = AgentToolUseTracker() + new_settings = RunImpl.maybe_reset_tool_choice(agent, tracker, model_settings) + assert model_settings.tool_choice == new_settings.tool_choice # Case 4: tool_choice = "required" with one tool should reset model_settings = ModelSettings(tool_choice="required") - assert RunImpl._should_reset_tool_choice(model_settings, tools1) + tracker = AgentToolUseTracker() + tracker.add_tool_use(agent, ["tool1"]) + new_settings = RunImpl.maybe_reset_tool_choice(agent, tracker, model_settings) + assert new_settings.tool_choice is None - # Case 5: tool_choice = "required" with multiple tools should not reset + # Case 5: tool_choice = "required" with multiple tools should reset model_settings = ModelSettings(tool_choice="required") - tools2: list[Tool] = [get_function_tool("tool1"), get_function_tool("tool2")] - assert not RunImpl._should_reset_tool_choice(model_settings, tools2) + tracker = AgentToolUseTracker() + tracker.add_tool_use(agent, ["tool1", "tool2"]) + new_settings = RunImpl.maybe_reset_tool_choice(agent, tracker, model_settings) + assert new_settings.tool_choice is None - # Case 6: Specific tool choice should reset - model_settings = ModelSettings(tool_choice="specific_tool") - assert RunImpl._should_reset_tool_choice(model_settings, tools1) + # Case 6: Tool usage on a different agent should not affect the tool choice + model_settings = ModelSettings(tool_choice="foo_bar") + tracker = AgentToolUseTracker() + tracker.add_tool_use(Agent(name="other_agent"), ["foo_bar", "baz"]) + new_settings = RunImpl.maybe_reset_tool_choice(agent, tracker, model_settings) + assert new_settings.tool_choice == model_settings.tool_choice + + # Case 7: tool_choice = "foo_bar" with multiple tools should reset + model_settings = ModelSettings(tool_choice="foo_bar") + tracker = AgentToolUseTracker() + tracker.add_tool_use(agent, ["foo_bar", "baz"]) + new_settings = RunImpl.maybe_reset_tool_choice(agent, tracker, model_settings) + assert new_settings.tool_choice is None @pytest.mark.asyncio async def test_required_tool_choice_with_multiple_runs(self): """ - Test scenario 1: When multiple runs are executed with tool_choice="required" - Ensure each run works correctly and doesn't get stuck in infinite loop - Also verify that tool_choice remains "required" between runs + Test scenario 1: When multiple runs are executed with tool_choice="required", ensure each + run works correctly and doesn't get stuck in an infinite loop. Also verify that tool_choice + remains "required" between runs. """ # Set up our fake model with responses for two runs fake_model = FakeModel() - fake_model.add_multiple_turn_outputs([ - [get_text_message("First run response")], - [get_text_message("Second run response")] - ]) + fake_model.add_multiple_turn_outputs( + [[get_text_message("First run response")], [get_text_message("Second run response")]] + ) # Create agent with a custom tool and tool_choice="required" custom_tool = get_function_tool("custom_tool") @@ -71,24 +86,26 @@ class TestToolChoiceReset: # First run should work correctly and preserve tool_choice result1 = await Runner.run(agent, "first run") assert result1.final_output == "First run response" - assert agent.model_settings.tool_choice == "required", "tool_choice should stay required" + assert fake_model.last_turn_args["model_settings"].tool_choice == "required", ( + "tool_choice should stay required" + ) # Second run should also work correctly with tool_choice still required result2 = await Runner.run(agent, "second run") assert result2.final_output == "Second run response" - assert agent.model_settings.tool_choice == "required", "tool_choice should stay required" + assert fake_model.last_turn_args["model_settings"].tool_choice == "required", ( + "tool_choice should stay required" + ) @pytest.mark.asyncio async def test_required_with_stop_at_tool_name(self): """ - Test scenario 2: When using required tool_choice with stop_at_tool_names behavior - Ensure it correctly stops at the specified tool + Test scenario 2: When using required tool_choice with stop_at_tool_names behavior, ensure + it correctly stops at the specified tool """ # Set up fake model to return a tool call for second_tool fake_model = FakeModel() - fake_model.set_next_output([ - get_function_tool_call("second_tool", "{}") - ]) + fake_model.set_next_output([get_function_tool_call("second_tool", "{}")]) # Create agent with two tools and tool_choice="required" and stop_at_tool behavior first_tool = get_function_tool("first_tool", return_value="first tool result") @@ -109,8 +126,8 @@ class TestToolChoiceReset: @pytest.mark.asyncio async def test_specific_tool_choice(self): """ - Test scenario 3: When using a specific tool choice name - Ensure it doesn't cause infinite loops + Test scenario 3: When using a specific tool choice name, ensure it doesn't cause infinite + loops. """ # Set up fake model to return a text message fake_model = FakeModel() @@ -135,17 +152,19 @@ class TestToolChoiceReset: @pytest.mark.asyncio async def test_required_with_single_tool(self): """ - Test scenario 4: When using required tool_choice with only one tool - Ensure it doesn't cause infinite loops + Test scenario 4: When using required tool_choice with only one tool, ensure it doesn't cause + infinite loops. """ # Set up fake model to return a tool call followed by a text message fake_model = FakeModel() - fake_model.add_multiple_turn_outputs([ - # First call returns a tool call - [get_function_tool_call("custom_tool", "{}")], - # Second call returns a text message - [get_text_message("Final response")] - ]) + fake_model.add_multiple_turn_outputs( + [ + # First call returns a tool call + [get_function_tool_call("custom_tool", "{}")], + # Second call returns a text message + [get_text_message("Final response")], + ] + ) # Create agent with a single tool and tool_choice="required" custom_tool = get_function_tool("custom_tool", return_value="tool result") @@ -159,3 +178,33 @@ class TestToolChoiceReset: # Run should complete without infinite loops result = await Runner.run(agent, "first run") assert result.final_output == "Final response" + + @pytest.mark.asyncio + async def test_dont_reset_tool_choice_if_not_required(self): + """ + Test scenario 5: When agent.reset_tool_choice is False, ensure tool_choice is not reset. + """ + # Set up fake model to return a tool call followed by a text message + fake_model = FakeModel() + fake_model.add_multiple_turn_outputs( + [ + # First call returns a tool call + [get_function_tool_call("custom_tool", "{}")], + # Second call returns a text message + [get_text_message("Final response")], + ] + ) + + # Create agent with a single tool and tool_choice="required" and reset_tool_choice=False + custom_tool = get_function_tool("custom_tool", return_value="tool result") + agent = Agent( + name="test_agent", + model=fake_model, + tools=[custom_tool], + model_settings=ModelSettings(tool_choice="required"), + reset_tool_choice=False, + ) + + await Runner.run(agent, "test") + + assert fake_model.last_turn_args["model_settings"].tool_choice == "required" From 8ba7b0932f7c97eb39692b0f0c016d75dc39b31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:29:55 +0100 Subject: [PATCH 37/63] Update installation instructions for visualization dependency to use 'viz' --- docs/visualization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/visualization.md b/docs/visualization.md index 6bb5ee5d..2e3b188b 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -7,7 +7,7 @@ Agent visualization allows you to generate a structured graphical representation The visualization functionality relies on the **Graphviz** package. To use it, ensure you have Graphviz installed and add it as a dependency in `pyproject.toml`. Alternatively, install it directly via pip: ```bash -pip install openai-agents[visualization] +pip install openai-agents[viz] ``` ## Generating a Graph From db13026c0e8911ee4a19de2737ae769aa1118ab3 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Tue, 25 Mar 2025 13:34:45 -0400 Subject: [PATCH 38/63] Update visualization.md --- docs/visualization.md | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/docs/visualization.md b/docs/visualization.md index 2e3b188b..00f3126d 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -4,10 +4,10 @@ Agent visualization allows you to generate a structured graphical representation ## Installation -The visualization functionality relies on the **Graphviz** package. To use it, ensure you have Graphviz installed and add it as a dependency in `pyproject.toml`. Alternatively, install it directly via pip: +Install the optional `viz` dependency group: ```bash -pip install openai-agents[viz] +pip install "openai-agents[viz]" ``` ## Generating a Graph @@ -83,24 +83,4 @@ draw_graph(triage_agent, filename="agent_graph.png") This will generate `agent_graph.png` in the working directory. -## Testing the Visualization - -The visualization functionality includes test coverage to ensure correctness. Tests are located in `tests/test_visualizations.py` and verify: - -- Node and edge correctness in `get_main_graph()`. -- Proper agent and tool representation in `get_all_nodes()`. -- Accurate relationship mapping in `get_all_edges()`. -- Graph rendering functionality in `draw_graph()`. - -Run tests using: - -```bash -pytest tests/test_visualizations.py -``` - -## Conclusion - -Agent visualization provides a powerful way to **understand, debug, and communicate** how agents interact within an application. By leveraging **Graphviz**, you can generate intuitive visual representations of complex agent structures effortlessly. - -For further details on agent functionality, see the [Agents documentation](agents.md). From bde89698926fe481b14cd2bcca6998b4ce9d5b60 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Tue, 25 Mar 2025 13:37:01 -0400 Subject: [PATCH 39/63] Update pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d2b56046..eb0bae39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dev = [ "pynput", "textual", "websockets", + "graphviz", ] [tool.uv.workspace] From c988fd4aaf96508bd6307ce343fe37c525771496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:46:23 +0100 Subject: [PATCH 40/63] Add graphviz as a dependency and update import statements --- src/agents/extensions/visualization.py | 2 +- tests/test_visualization.py | 2 +- uv.lock | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index f6079857..236bec9b 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -1,6 +1,6 @@ from typing import Optional -import graphviz +import graphviz # type: ignore from agents import Agent from agents.handoffs import Handoff diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 1f83d858..046cdd6e 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -1,6 +1,6 @@ from unittest.mock import Mock -import graphviz +import graphviz # type: ignore import pytest from agents import Agent diff --git a/uv.lock b/uv.lock index d6eba43f..3ee7f047 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 1 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", @@ -348,6 +349,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, ] +[[package]] +name = "graphviz" +version = "0.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126 }, +] + [[package]] name = "greenlet" version = "3.1.1" @@ -1090,6 +1100,9 @@ dependencies = [ ] [package.optional-dependencies] +viz = [ + { name = "graphviz" }, +] voice = [ { name = "numpy", version = "2.2.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "websockets" }, @@ -1098,6 +1111,7 @@ voice = [ [package.dev-dependencies] dev = [ { name = "coverage" }, + { name = "graphviz" }, { name = "inline-snapshot" }, { name = "mkdocs" }, { name = "mkdocs-material" }, @@ -1118,6 +1132,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "graphviz", marker = "extra == 'viz'", specifier = ">=0.17" }, { name = "griffe", specifier = ">=1.5.6,<2" }, { name = "mcp", marker = "python_full_version >= '3.10'" }, { name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" }, @@ -1128,10 +1143,12 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.12.2,<5" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<16" }, ] +provides-extras = ["voice", "viz"] [package.metadata.requires-dev] dev = [ { name = "coverage", specifier = ">=7.6.12" }, + { name = "graphviz" }, { name = "inline-snapshot", specifier = ">=0.20.7" }, { name = "mkdocs", specifier = ">=1.6.0" }, { name = "mkdocs-material", specifier = ">=9.6.0" }, From 650d151314fe854d0c0fe66b134f3f559c4dbe7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:56:01 +0100 Subject: [PATCH 41/63] Add visualization.md to navigation in mkdocs.yml --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 941f29ed..a27a6369 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,6 +35,7 @@ nav: - multi_agent.md - models.md - config.md + - visualization.md - Voice agents: - voice/quickstart.md - voice/pipeline.md From d9a4f96ecb14c71d83ca4b78c660282eee1373cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 19:02:21 +0100 Subject: [PATCH 42/63] Refactor get_all_edges function to remove unused parent parameter --- src/agents/extensions/visualization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index 236bec9b..e2de019d 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -72,7 +72,7 @@ def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str: return "".join(parts) -def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str: +def get_all_edges(agent: Agent) -> str: """ Recursively generates the edges for the given agent and its handoffs in DOT format. @@ -97,7 +97,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)) return "".join(parts) From cf993012f50332c828f86a5b2d2367d9931ac3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 19:03:39 +0100 Subject: [PATCH 43/63] Add Jupyter Notebook files to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 2ab5a819..7ad013cb 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ cython_debug/ # PyPI configuration file .pypirc + +# Jupyter Notebook +*.ipynb From b5f6544f6f63c57e74d6899ae534a803dd54c347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 19:11:43 +0100 Subject: [PATCH 44/63] Add start and end nodes to graph visualization and update edge generation --- src/agents/extensions/visualization.py | 23 ++++++++++++++++++++-- tests/test_visualization.py | 27 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index e2de019d..08b185e1 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -4,6 +4,7 @@ import graphviz # type: ignore from agents import Agent from agents.handoffs import Handoff +from agents.tool import Tool def get_main_graph(agent: Agent) -> str: @@ -41,6 +42,14 @@ def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str: str: The DOT format string representing the nodes. """ parts = [] + + # Start and end the graph + parts.append( + f'"__start__" [label="__start__", shape=ellipse, style=filled, ' + f"fillcolor=lightblue, width=0.5, height=0.3];" + f'"__end__" [label="__end__", shape=ellipse, style=filled, ' + f"fillcolor=lightblue, width=0.5, height=0.3];" + ) # Ensure parent agent node is colored if not parent: parts.append( @@ -72,7 +81,7 @@ def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str: return "".join(parts) -def get_all_edges(agent: Agent) -> str: +def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str: """ Recursively generates the edges for the given agent and its handoffs in DOT format. @@ -85,6 +94,11 @@ def get_all_edges(agent: Agent) -> str: """ parts = [] + if not parent: + parts.append( + f'"__start__" -> "{agent.name}";' + ) + for tool in agent.tools: parts.append(f""" "{agent.name}" -> "{tool.name}" [style=dotted, penwidth=1.5]; @@ -97,7 +111,12 @@ def get_all_edges(agent: Agent) -> str: if isinstance(handoff, Agent): parts.append(f""" "{agent.name}" -> "{handoff.name}";""") - parts.append(get_all_edges(handoff)) + parts.append(get_all_edges(handoff, agent)) + + if not agent.handoffs and not isinstance(agent, Tool): + parts.append( + f'"{agent.name}" -> "__end__";' + ) return "".join(parts) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 046cdd6e..b530f50c 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -38,6 +38,14 @@ def test_get_main_graph(mock_agent): assert "graph [splines=true];" in result assert 'node [fontname="Arial"];' in result assert "edge [penwidth=1.5];" in result + assert ( + '"__start__" [label="__start__", shape=ellipse, style=filled, ' + "fillcolor=lightblue, width=0.5, height=0.3];" in result + ) + assert ( + '"__end__" [label="__end__", shape=ellipse, style=filled, ' + "fillcolor=lightblue, width=0.5, height=0.3];" in result + ) assert ( '"Agent1" [label="Agent1", shape=box, style=filled, ' "fillcolor=lightyellow, width=1.5, height=0.8];" in result @@ -58,6 +66,14 @@ def test_get_main_graph(mock_agent): def test_get_all_nodes(mock_agent): result = get_all_nodes(mock_agent) + assert ( + '"__start__" [label="__start__", shape=ellipse, style=filled, ' + "fillcolor=lightblue, width=0.5, height=0.3];" in result + ) + assert ( + '"__end__" [label="__end__", shape=ellipse, style=filled, ' + "fillcolor=lightblue, width=0.5, height=0.3];" in result + ) assert ( '"Agent1" [label="Agent1", shape=box, style=filled, ' "fillcolor=lightyellow, width=1.5, height=0.8];" in result @@ -78,6 +94,8 @@ def test_get_all_nodes(mock_agent): def test_get_all_edges(mock_agent): result = get_all_edges(mock_agent) + assert '"__start__" -> "Agent1";' in result + assert '"Agent1" -> "__end__";' assert '"Agent1" -> "Tool1" [style=dotted, penwidth=1.5];' in result assert '"Tool1" -> "Agent1" [style=dotted, penwidth=1.5];' in result assert '"Agent1" -> "Tool2" [style=dotted, penwidth=1.5];' in result @@ -85,6 +103,7 @@ def test_get_all_edges(mock_agent): assert '"Agent1" -> "Handoff1";' in result + def test_draw_graph(mock_agent): graph = draw_graph(mock_agent) assert isinstance(graph, graphviz.Source) @@ -92,6 +111,14 @@ def test_draw_graph(mock_agent): assert "graph [splines=true];" in graph.source assert 'node [fontname="Arial"];' in graph.source assert "edge [penwidth=1.5];" in graph.source + assert ( + '"__start__" [label="__start__", shape=ellipse, style=filled, ' + "fillcolor=lightblue, width=0.5, height=0.3];" in graph.source + ) + assert ( + '"__end__" [label="__end__", shape=ellipse, style=filled, ' + "fillcolor=lightblue, width=0.5, height=0.3];" in graph.source + ) assert ( '"Agent1" [label="Agent1", shape=box, style=filled, ' "fillcolor=lightyellow, width=1.5, height=0.8];" in graph.source From dc3093bab373ee5ace6059eb2c48b75a54410cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 19:12:40 +0100 Subject: [PATCH 45/63] Refactor visualization functions to improve formatting and streamline edge generation --- src/agents/extensions/visualization.py | 16 ++++++---------- tests/test_visualization.py | 1 - 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index 08b185e1..013a21eb 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -45,10 +45,10 @@ def get_all_nodes(agent: Agent, parent: Optional[Agent] = None) -> str: # Start and end the graph parts.append( - f'"__start__" [label="__start__", shape=ellipse, style=filled, ' - f"fillcolor=lightblue, width=0.5, height=0.3];" - f'"__end__" [label="__end__", shape=ellipse, style=filled, ' - f"fillcolor=lightblue, width=0.5, height=0.3];" + '"__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: @@ -95,9 +95,7 @@ def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str: parts = [] if not parent: - parts.append( - f'"__start__" -> "{agent.name}";' - ) + parts.append(f'"__start__" -> "{agent.name}";') for tool in agent.tools: parts.append(f""" @@ -114,9 +112,7 @@ def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str: parts.append(get_all_edges(handoff, agent)) if not agent.handoffs and not isinstance(agent, Tool): - parts.append( - f'"{agent.name}" -> "__end__";' - ) + parts.append(f'"{agent.name}" -> "__end__";') return "".join(parts) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index b530f50c..6aa86774 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -103,7 +103,6 @@ def test_get_all_edges(mock_agent): assert '"Agent1" -> "Handoff1";' in result - def test_draw_graph(mock_agent): graph = draw_graph(mock_agent) assert isinstance(graph, graphviz.Source) From c6e8f4c0ff1ca9b86c9852ccb8a41859f5b10e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 19:17:35 +0100 Subject: [PATCH 46/63] Fix type ignore comment for agent check in get_all_edges function --- src/agents/extensions/visualization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index 013a21eb..9d0d9f63 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -111,7 +111,7 @@ def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str: "{agent.name}" -> "{handoff.name}";""") parts.append(get_all_edges(handoff, agent)) - if not agent.handoffs and not isinstance(agent, Tool): + if not agent.handoffs and not isinstance(agent, Tool): # type: ignore parts.append(f'"{agent.name}" -> "__end__";') return "".join(parts) From 3f439484fd8e750ebd0149151f43984a6a39c1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 19:18:57 +0100 Subject: [PATCH 47/63] linting --- src/agents/extensions/visualization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/extensions/visualization.py b/src/agents/extensions/visualization.py index 9d0d9f63..5fb35062 100644 --- a/src/agents/extensions/visualization.py +++ b/src/agents/extensions/visualization.py @@ -111,7 +111,7 @@ def get_all_edges(agent: Agent, parent: Optional[Agent] = None) -> str: "{agent.name}" -> "{handoff.name}";""") parts.append(get_all_edges(handoff, agent)) - if not agent.handoffs and not isinstance(agent, Tool): # type: ignore + if not agent.handoffs and not isinstance(agent, Tool): # type: ignore parts.append(f'"{agent.name}" -> "__end__";') return "".join(parts) From 58967dcf1496e6688d2ea7626eb66ec51bf55591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Bravo?= <123977407+MartinEBravo@users.noreply.github.com> Date: Tue, 25 Mar 2025 19:31:32 +0100 Subject: [PATCH 48/63] Remove Jupyter Notebook files from .gitignore --- .gitignore | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 7ad013cb..7dd22b88 100644 --- a/.gitignore +++ b/.gitignore @@ -141,7 +141,4 @@ cython_debug/ .ruff_cache/ # PyPI configuration file -.pypirc - -# Jupyter Notebook -*.ipynb +.pypirc \ No newline at end of file From d639ebdf91f9207bd58c72c468d1b87ce04285e2 Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Tue, 25 Mar 2025 12:11:33 -0700 Subject: [PATCH 49/63] fix(examples): make sure audio playback finishes --- examples/voice/static/main.py | 5 +++++ examples/voice/static/util.py | 1 + 2 files changed, 6 insertions(+) diff --git a/examples/voice/static/main.py b/examples/voice/static/main.py index 4e3840fc..1b9e2024 100644 --- a/examples/voice/static/main.py +++ b/examples/voice/static/main.py @@ -1,6 +1,8 @@ import asyncio import random +import numpy as np + from agents import Agent, function_tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions from agents.voice import ( @@ -78,6 +80,9 @@ async def main(): elif event.type == "voice_stream_event_lifecycle": print(f"Received lifecycle event: {event.event}") + # Add 1 second of silence to the end of the stream to avoid cutting off the last audio. + player.add_audio(np.zeros(24000 * 1, dtype=np.int16)) + if __name__ == "__main__": asyncio.run(main()) diff --git a/examples/voice/static/util.py b/examples/voice/static/util.py index 455bafee..a5806f41 100644 --- a/examples/voice/static/util.py +++ b/examples/voice/static/util.py @@ -62,6 +62,7 @@ class AudioPlayer: return self def __exit__(self, exc_type, exc_value, traceback): + self.stream.stop() # wait for the stream to finish self.stream.close() def add_audio(self, audio_data: npt.NDArray[np.int16]): From 6cb8b1ceaa013f30a41f528796cf90c10bc0026c Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Tue, 25 Mar 2025 18:01:00 -0400 Subject: [PATCH 50/63] [5/n] MCP tracing ## Summary: Adds tracing and tests for tracing. - Tools are added to the agents - Theres a span for the mcp tools lookup - Functions have MCP data ## Test Plan: Unit tests . --- examples/mcp/filesystem_example/main.py | 9 +- src/agents/__init__.py | 4 + src/agents/agent.py | 3 +- src/agents/mcp/server.py | 38 ++++- src/agents/mcp/util.py | 27 +++- src/agents/run.py | 17 +- src/agents/tracing/__init__.py | 4 + src/agents/tracing/create.py | 29 ++++ src/agents/tracing/span_data.py | 34 +++- src/agents/voice/models/openai_stt.py | 3 +- tests/mcp/helpers.py | 4 + tests/mcp/test_mcp_tracing.py | 198 ++++++++++++++++++++++++ tests/mcp/test_server_errors.py | 4 + 13 files changed, 352 insertions(+), 22 deletions(-) create mode 100644 tests/mcp/test_mcp_tracing.py diff --git a/examples/mcp/filesystem_example/main.py b/examples/mcp/filesystem_example/main.py index 0ba2b675..ae6fadd2 100644 --- a/examples/mcp/filesystem_example/main.py +++ b/examples/mcp/filesystem_example/main.py @@ -2,7 +2,7 @@ import asyncio import os import shutil -from agents import Agent, Runner, trace +from agents import Agent, Runner, gen_trace_id, trace from agents.mcp import MCPServer, MCPServerStdio @@ -37,12 +37,15 @@ async def main(): samples_dir = os.path.join(current_dir, "sample_files") async with MCPServerStdio( + name="Filesystem Server, via npx", params={ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", samples_dir], - } + }, ) as server: - with trace(workflow_name="MCP Filesystem Example"): + trace_id = gen_trace_id() + with trace(workflow_name="MCP Filesystem Example", trace_id=trace_id): + print(f"View trace: https://platform.openai.com/traces/{trace_id}\n") await run(server) diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 47bb2649..242f5649 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -70,6 +70,7 @@ from .tracing import ( GenerationSpanData, GuardrailSpanData, HandoffSpanData, + MCPListToolsSpanData, Span, SpanData, SpanError, @@ -89,6 +90,7 @@ from .tracing import ( get_current_trace, guardrail_span, handoff_span, + mcp_tools_span, set_trace_processors, set_tracing_disabled, set_tracing_export_api_key, @@ -220,6 +222,7 @@ __all__ = [ "speech_group_span", "transcription_span", "speech_span", + "mcp_tools_span", "trace", "Trace", "TracingProcessor", @@ -234,6 +237,7 @@ __all__ = [ "HandoffSpanData", "SpeechGroupSpanData", "SpeechSpanData", + "MCPListToolsSpanData", "TranscriptionSpanData", "set_default_openai_key", "set_default_openai_client", diff --git a/src/agents/agent.py b/src/agents/agent.py index b31f00b1..13bb464e 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -228,4 +228,5 @@ class Agent(Generic[TContext]): async def get_all_tools(self) -> list[Tool]: """All agent tools, including MCP tools and function tools.""" - return await MCPUtil.get_all_function_tools(self.mcp_servers) + self.tools + mcp_tools = await self.get_mcp_tools() + return mcp_tools + self.tools diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 91af31db..e70d7ce6 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -27,6 +27,12 @@ class MCPServer(abc.ABC): """ pass + @property + @abc.abstractmethod + def name(self) -> str: + """A readable name for the server.""" + pass + @abc.abstractmethod async def cleanup(self): """Cleanup the server. For example, this might mean closing a subprocess or @@ -171,7 +177,12 @@ class MCPServerStdio(_MCPServerWithClientSession): details. """ - def __init__(self, params: MCPServerStdioParams, cache_tools_list: bool = False): + def __init__( + self, + params: MCPServerStdioParams, + cache_tools_list: bool = False, + name: str | None = None, + ): """Create a new MCP server based on the stdio transport. Args: @@ -185,6 +196,8 @@ class MCPServerStdio(_MCPServerWithClientSession): invalidated by calling `invalidate_tools_cache()`. You should set this to `True` if you know the server will not change its tools list, because it can drastically improve latency (by avoiding a round-trip to the server every time). + name: A readable name for the server. If not provided, we'll create one from the + command. """ super().__init__(cache_tools_list) @@ -197,6 +210,8 @@ class MCPServerStdio(_MCPServerWithClientSession): encoding_error_handler=params.get("encoding_error_handler", "strict"), ) + self._name = name or f"stdio: {self.params.command}" + def create_streams( self, ) -> AbstractAsyncContextManager[ @@ -208,6 +223,11 @@ class MCPServerStdio(_MCPServerWithClientSession): """Create the streams for the server.""" return stdio_client(self.params) + @property + def name(self) -> str: + """A readable name for the server.""" + return self._name + class MCPServerSseParams(TypedDict): """Mirrors the params in`mcp.client.sse.sse_client`.""" @@ -231,7 +251,12 @@ class MCPServerSse(_MCPServerWithClientSession): for details. """ - def __init__(self, params: MCPServerSseParams, cache_tools_list: bool = False): + def __init__( + self, + params: MCPServerSseParams, + cache_tools_list: bool = False, + name: str | None = None, + ): """Create a new MCP server based on the HTTP with SSE transport. Args: @@ -245,10 +270,14 @@ class MCPServerSse(_MCPServerWithClientSession): invalidated by calling `invalidate_tools_cache()`. You should set this to `True` if you know the server will not change its tools list, because it can drastically improve latency (by avoiding a round-trip to the server every time). + + name: A readable name for the server. If not provided, we'll create one from the + URL. """ super().__init__(cache_tools_list) self.params = params + self._name = name or f"sse: {self.params['url']}" def create_streams( self, @@ -265,3 +294,8 @@ class MCPServerSse(_MCPServerWithClientSession): timeout=self.params.get("timeout", 5), sse_read_timeout=self.params.get("sse_read_timeout", 60 * 5), ) + + @property + def name(self) -> str: + """A readable name for the server.""" + return self._name diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 038c4fec..36c18bea 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -7,6 +7,7 @@ from ..exceptions import AgentsException, ModelBehaviorError, UserError from ..logger import logger from ..run_context import RunContextWrapper from ..tool import FunctionTool, Tool +from ..tracing import FunctionSpanData, get_current_span, mcp_tools_span if TYPE_CHECKING: from mcp.types import Tool as MCPTool @@ -38,7 +39,11 @@ class MCPUtil: @classmethod async def get_function_tools(cls, server: "MCPServer") -> list[Tool]: """Get all function tools from a single MCP server.""" - tools = await server.list_tools() + + with mcp_tools_span(server=server.name) as span: + tools = await server.list_tools() + span.span_data.result = [tool.name for tool in tools] + return [cls.to_function_tool(tool, server) for tool in tools] @classmethod @@ -88,9 +93,23 @@ class MCPUtil: # The MCP tool result is a list of content items, whereas OpenAI tool outputs are a single # string. We'll try to convert. if len(result.content) == 1: - return result.content[0].model_dump_json() + tool_output = result.content[0].model_dump_json() elif len(result.content) > 1: - return json.dumps([item.model_dump() for item in result.content]) + tool_output = json.dumps([item.model_dump() for item in result.content]) else: logger.error(f"Errored MCP tool result: {result}") - return "Error running tool." + tool_output = "Error running tool." + + current_span = get_current_span() + if current_span: + if isinstance(current_span.span_data, FunctionSpanData): + current_span.span_data.output = tool_output + current_span.span_data.mcp_data = { + "server": server.name, + } + else: + logger.warning( + f"Current span is not a FunctionSpanData, skipping tool output: {current_span}" + ) + + return tool_output diff --git a/src/agents/run.py b/src/agents/run.py index 5c21b709..0159822a 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -7,8 +7,6 @@ from typing import Any, cast from openai.types.responses import ResponseCompletedEvent -from agents.tool import Tool - from ._run_impl import ( AgentToolUseTracker, NextStepFinalOutput, @@ -40,6 +38,7 @@ from .models.openai_provider import OpenAIProvider from .result import RunResult, RunResultStreaming from .run_context import RunContextWrapper, TContext from .stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent +from .tool import Tool from .tracing import Span, SpanError, agent_span, get_current_trace, trace from .tracing.span_data import AgentSpanData from .usage import Usage @@ -182,8 +181,6 @@ class Runner: # agent changes, or if the agent loop ends. if current_span is None: handoff_names = [h.agent_name for h in cls._get_handoffs(current_agent)] - all_tools = await cls._get_all_tools(current_agent) - tool_names = [t.name for t in all_tools] if output_schema := cls._get_output_schema(current_agent): output_type_name = output_schema.output_type_name() else: @@ -192,11 +189,13 @@ class Runner: current_span = agent_span( name=current_agent.name, handoffs=handoff_names, - tools=tool_names, output_type=output_type_name, ) current_span.start(mark_as_current=True) + all_tools = await cls._get_all_tools(current_agent) + current_span.span_data.tools = [t.name for t in all_tools] + current_turn += 1 if current_turn > max_turns: _error_tracing.attach_error_to_span( @@ -504,7 +503,6 @@ class Runner: # agent changes, or if the agent loop ends. if current_span is None: handoff_names = [h.agent_name for h in cls._get_handoffs(current_agent)] - tool_names = [t.name for t in current_agent.tools] if output_schema := cls._get_output_schema(current_agent): output_type_name = output_schema.output_type_name() else: @@ -513,11 +511,13 @@ class Runner: current_span = agent_span( name=current_agent.name, handoffs=handoff_names, - tools=tool_names, output_type=output_type_name, ) current_span.start(mark_as_current=True) + all_tools = await cls._get_all_tools(current_agent) + tool_names = [t.name for t in all_tools] + current_span.span_data.tools = tool_names current_turn += 1 streamed_result.current_turn = current_turn @@ -553,6 +553,7 @@ class Runner: run_config, should_run_agent_start_hooks, tool_use_tracker, + all_tools, ) should_run_agent_start_hooks = False @@ -621,6 +622,7 @@ class Runner: run_config: RunConfig, should_run_agent_start_hooks: bool, tool_use_tracker: AgentToolUseTracker, + all_tools: list[Tool], ) -> SingleStepResult: if should_run_agent_start_hooks: await asyncio.gather( @@ -640,7 +642,6 @@ class Runner: system_prompt = await agent.get_system_prompt(context_wrapper) handoffs = cls._get_handoffs(agent) - all_tools = await cls._get_all_tools(agent) model = cls._get_model(agent, run_config) model_settings = agent.model_settings.resolve(run_config.model_settings) model_settings = RunImpl.maybe_reset_tool_choice(agent, tool_use_tracker, model_settings) diff --git a/src/agents/tracing/__init__.py b/src/agents/tracing/__init__.py index dc7c7cfd..9df94426 100644 --- a/src/agents/tracing/__init__.py +++ b/src/agents/tracing/__init__.py @@ -9,6 +9,7 @@ from .create import ( get_current_trace, guardrail_span, handoff_span, + mcp_tools_span, response_span, speech_group_span, speech_span, @@ -25,6 +26,7 @@ from .span_data import ( GenerationSpanData, GuardrailSpanData, HandoffSpanData, + MCPListToolsSpanData, ResponseSpanData, SpanData, SpeechGroupSpanData, @@ -59,6 +61,7 @@ __all__ = [ "GenerationSpanData", "GuardrailSpanData", "HandoffSpanData", + "MCPListToolsSpanData", "ResponseSpanData", "SpeechGroupSpanData", "SpeechSpanData", @@ -69,6 +72,7 @@ __all__ = [ "speech_group_span", "speech_span", "transcription_span", + "mcp_tools_span", ] diff --git a/src/agents/tracing/create.py b/src/agents/tracing/create.py index af2f156f..b6fe4610 100644 --- a/src/agents/tracing/create.py +++ b/src/agents/tracing/create.py @@ -12,6 +12,7 @@ from .span_data import ( GenerationSpanData, GuardrailSpanData, HandoffSpanData, + MCPListToolsSpanData, ResponseSpanData, SpeechGroupSpanData, SpeechSpanData, @@ -424,3 +425,31 @@ def speech_group_span( parent=parent, disabled=disabled, ) + + +def mcp_tools_span( + server: str | None = None, + result: list[str] | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[MCPListToolsSpanData]: + """Create a new MCP list tools span. The span will not be started automatically, you should + either do `with mcp_tools_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + server: The name of the MCP server. + result: The result of the MCP list tools call. + span_id: The ID of the span. Optional. If not provided, we will generate an ID. We + recommend using `util.gen_span_id()` to generate a span ID, to guarantee that IDs are + correctly formatted. + parent: The parent span or trace. If not provided, we will automatically use the current + trace/span as the parent. + disabled: If True, we will return a Span but the Span will not be recorded. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=MCPListToolsSpanData(server=server, result=result), + span_id=span_id, + parent=parent, + disabled=disabled, + ) diff --git a/src/agents/tracing/span_data.py b/src/agents/tracing/span_data.py index 95e7fe0f..ed2a3f2d 100644 --- a/src/agents/tracing/span_data.py +++ b/src/agents/tracing/span_data.py @@ -49,12 +49,19 @@ class AgentSpanData(SpanData): class FunctionSpanData(SpanData): - __slots__ = ("name", "input", "output") + __slots__ = ("name", "input", "output", "mcp_data") - def __init__(self, name: str, input: str | None, output: Any | None): + def __init__( + self, + name: str, + input: str | None, + output: Any | None, + mcp_data: dict[str, Any] | None = None, + ): self.name = name self.input = input self.output = output + self.mcp_data = mcp_data @property def type(self) -> str: @@ -66,6 +73,7 @@ class FunctionSpanData(SpanData): "name": self.name, "input": self.input, "output": str(self.output) if self.output else None, + "mcp_data": self.mcp_data, } @@ -282,3 +290,25 @@ class SpeechGroupSpanData(SpanData): "type": self.type, "input": self.input, } + + +class MCPListToolsSpanData(SpanData): + __slots__ = ( + "server", + "result", + ) + + def __init__(self, server: str | None = None, result: list[str] | None = None): + self.server = server + self.result = result + + @property + def type(self) -> str: + return "mcp_tools" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "server": self.server, + "result": self.result, + } diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py index a5cf8acd..1ae4ea14 100644 --- a/src/agents/voice/models/openai_stt.py +++ b/src/agents/voice/models/openai_stt.py @@ -10,9 +10,8 @@ from typing import Any, cast from openai import AsyncOpenAI -from agents.exceptions import AgentsException - from ... import _debug +from ...exceptions import AgentsException from ...logger import logger from ...tracing import Span, SpanError, TranscriptionSpanData, transcription_span from ..exceptions import STTWebsocketConnectionError diff --git a/tests/mcp/helpers.py b/tests/mcp/helpers.py index 952b3ea7..8ff153c1 100644 --- a/tests/mcp/helpers.py +++ b/tests/mcp/helpers.py @@ -52,3 +52,7 @@ class FakeMCPServer(MCPServer): return CallToolResult( content=[TextContent(text=self.tool_results[-1], type="text")], ) + + @property + def name(self) -> str: + return "fake_mcp_server" diff --git a/tests/mcp/test_mcp_tracing.py b/tests/mcp/test_mcp_tracing.py new file mode 100644 index 00000000..b71954b5 --- /dev/null +++ b/tests/mcp/test_mcp_tracing.py @@ -0,0 +1,198 @@ +import pytest +from inline_snapshot import snapshot + +from agents import Agent, Runner + +from ..fake_model import FakeModel +from ..test_responses import get_function_tool, get_function_tool_call, get_text_message +from ..testing_processor import SPAN_PROCESSOR_TESTING, fetch_normalized_spans +from .helpers import FakeMCPServer + + +@pytest.mark.asyncio +async def test_mcp_tracing(): + model = FakeModel() + server = FakeMCPServer() + server.add_tool("test_tool_1", {}) + agent = Agent( + name="test", + model=model, + mcp_servers=[server], + tools=[get_function_tool("non_mcp_tool", "tool_result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("test_tool_1", "")], + # Second turn: text message + [get_text_message("done")], + ] + ) + + # First run: should list MCP tools before first and second steps + x = Runner.run_streamed(agent, input="first_test") + async for _ in x.stream_events(): + pass + + assert x.final_output == "done" + spans = fetch_normalized_spans() + + # Should have a single tool listing, and the function span should have MCP data + assert spans == snapshot( + [ + { + "workflow_name": "Agent workflow", + "children": [ + { + "type": "agent", + "data": { + "name": "test", + "handoffs": [], + "tools": ["test_tool_1", "non_mcp_tool"], + "output_type": "str", + }, + "children": [ + { + "type": "mcp_tools", + "data": {"server": "fake_mcp_server", "result": ["test_tool_1"]}, + }, + { + "type": "function", + "data": { + "name": "test_tool_1", + "input": "", + "output": '{"type":"text","text":"result_test_tool_1_{}","annotations":null}', # noqa: E501 + "mcp_data": {"server": "fake_mcp_server"}, + }, + }, + ], + } + ], + } + ] + ) + + server.add_tool("test_tool_2", {}) + + SPAN_PROCESSOR_TESTING.clear() + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [ + get_text_message("a_message"), + get_function_tool_call("non_mcp_tool", ""), + get_function_tool_call("test_tool_2", ""), + ], + # Second turn: text message + [get_text_message("done")], + ] + ) + + await Runner.run(agent, input="second_test") + spans = fetch_normalized_spans() + + # Should have a single tool listing, and the function span should have MCP data, and the non-mcp + # tool function span should not have MCP data + assert spans == snapshot( + [ + { + "workflow_name": "Agent workflow", + "children": [ + { + "type": "agent", + "data": { + "name": "test", + "handoffs": [], + "tools": ["test_tool_1", "test_tool_2", "non_mcp_tool"], + "output_type": "str", + }, + "children": [ + { + "type": "mcp_tools", + "data": { + "server": "fake_mcp_server", + "result": ["test_tool_1", "test_tool_2"], + }, + }, + { + "type": "function", + "data": { + "name": "non_mcp_tool", + "input": "", + "output": "tool_result", + }, + }, + { + "type": "function", + "data": { + "name": "test_tool_2", + "input": "", + "output": '{"type":"text","text":"result_test_tool_2_{}","annotations":null}', # noqa: E501 + "mcp_data": {"server": "fake_mcp_server"}, + }, + }, + ], + } + ], + } + ] + ) + + SPAN_PROCESSOR_TESTING.clear() + + # Add more tools to the server + server.add_tool("test_tool_3", {}) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("test_tool_3", "")], + # Second turn: text message + [get_text_message("done")], + ] + ) + + await Runner.run(agent, input="third_test") + + spans = fetch_normalized_spans() + + # Should have a single tool listing, and the function span should have MCP data, and the non-mcp + # tool function span should not have MCP data + assert spans == snapshot( + [ + { + "workflow_name": "Agent workflow", + "children": [ + { + "type": "agent", + "data": { + "name": "test", + "handoffs": [], + "tools": ["test_tool_1", "test_tool_2", "test_tool_3", "non_mcp_tool"], + "output_type": "str", + }, + "children": [ + { + "type": "mcp_tools", + "data": { + "server": "fake_mcp_server", + "result": ["test_tool_1", "test_tool_2", "test_tool_3"], + }, + }, + { + "type": "function", + "data": { + "name": "test_tool_3", + "input": "", + "output": '{"type":"text","text":"result_test_tool_3_{}","annotations":null}', # noqa: E501 + "mcp_data": {"server": "fake_mcp_server"}, + }, + }, + ], + } + ], + } + ] + ) diff --git a/tests/mcp/test_server_errors.py b/tests/mcp/test_server_errors.py index 5c6432bc..bdca7ce6 100644 --- a/tests/mcp/test_server_errors.py +++ b/tests/mcp/test_server_errors.py @@ -16,6 +16,10 @@ class CrashingClientSessionServer(_MCPServerWithClientSession): self.cleanup_called = True await super().cleanup() + @property + def name(self) -> str: + return "crashing_client_session_server" + @pytest.mark.asyncio async def test_server_errors_cause_error_and_cleanup_called(): From 788b4faba60675147a769569e60e71d0a14f06ee Mon Sep 17 00:00:00 2001 From: Dmitry Pimenov Date: Tue, 25 Mar 2025 16:30:50 -0700 Subject: [PATCH 51/63] adding Git MCP server example --- examples/mcp/git_example/main.py | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 examples/mcp/git_example/main.py diff --git a/examples/mcp/git_example/main.py b/examples/mcp/git_example/main.py new file mode 100644 index 00000000..5ce747a7 --- /dev/null +++ b/examples/mcp/git_example/main.py @@ -0,0 +1,48 @@ +import asyncio +import os +import shutil + +from agents import Agent, Runner, trace +from agents.mcp import MCPServer, MCPServerStdio + +async def run(mcp_server: MCPServer, directory_path: str): + agent = Agent( + name="Assistant", + instructions=f"Answer questions about the git repository at {directory_path}, use that for repo_path", + mcp_servers=[mcp_server], + ) + + message = f"Who's the most frequent contributor?" + print("\n" + "-" * 40) + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + message = f"Summarize the last change in the repository." + print("\n" + "-" * 40) + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + +async def main(): + # Ask the user for the directory path + directory_path = input("Please enter the path to the git repository: ") + + async with MCPServerStdio( + params={ + "command": "uvx", + "args": [ + "mcp-server-git" + ] + } + ) as server: + with trace(workflow_name="MCP Git Example"): + await run(server, directory_path) + + +if __name__ == "__main__": + if not shutil.which("uvx"): + raise RuntimeError("uvx is not installed. Please install it with `pip install uvx`.") + + asyncio.run(main()) \ No newline at end of file From 3587502c69b4d3ba74ac7d0e2f8bab5d684c5d83 Mon Sep 17 00:00:00 2001 From: Dmitry Pimenov Date: Tue, 25 Mar 2025 16:34:00 -0700 Subject: [PATCH 52/63] fixing lint issues --- examples/mcp/git_example/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/mcp/git_example/main.py b/examples/mcp/git_example/main.py index 5ce747a7..cfc15108 100644 --- a/examples/mcp/git_example/main.py +++ b/examples/mcp/git_example/main.py @@ -1,10 +1,10 @@ import asyncio -import os import shutil from agents import Agent, Runner, trace from agents.mcp import MCPServer, MCPServerStdio + async def run(mcp_server: MCPServer, directory_path: str): agent = Agent( name="Assistant", @@ -12,13 +12,13 @@ async def run(mcp_server: MCPServer, directory_path: str): mcp_servers=[mcp_server], ) - message = f"Who's the most frequent contributor?" + message = "Who's the most frequent contributor?" print("\n" + "-" * 40) print(f"Running: {message}") result = await Runner.run(starting_agent=agent, input=message) print(result.final_output) - message = f"Summarize the last change in the repository." + message = "Summarize the last change in the repository." print("\n" + "-" * 40) print(f"Running: {message}") result = await Runner.run(starting_agent=agent, input=message) @@ -45,4 +45,4 @@ if __name__ == "__main__": if not shutil.which("uvx"): raise RuntimeError("uvx is not installed. Please install it with `pip install uvx`.") - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) From d53411ba6a2fdccaf75552a4f47cab5fbfaadae6 Mon Sep 17 00:00:00 2001 From: Dmitry Pimenov Date: Tue, 25 Mar 2025 16:52:52 -0700 Subject: [PATCH 53/63] added readme, fixed typo --- examples/mcp/filesystem_example/README.md | 2 +- examples/mcp/git_example/README.md | 25 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 examples/mcp/git_example/README.md diff --git a/examples/mcp/filesystem_example/README.md b/examples/mcp/filesystem_example/README.md index 682afc83..1b6b1837 100644 --- a/examples/mcp/filesystem_example/README.md +++ b/examples/mcp/filesystem_example/README.md @@ -1,6 +1,6 @@ # MCP Filesystem Example -This example uses the [fileystem MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem), running locally via `npx`. +This example uses the [filesystem MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem), running locally via `npx`. Run it via: diff --git a/examples/mcp/git_example/README.md b/examples/mcp/git_example/README.md new file mode 100644 index 00000000..35ff7cde --- /dev/null +++ b/examples/mcp/git_example/README.md @@ -0,0 +1,25 @@ +# MCP Git Example + +This example uses the [git MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/git), running locally via `uvx`. + +Run it via: + +``` +uv run python examples/mcp/git_example/main.py +``` + +## Details + +The example uses the `MCPServerStdio` class from `agents`, with the command: + +```bash +uvx mcp-server-git +``` +Prior to running the agent, the user is prompted to provide a local directory path to their git repo. Using that, the Agent can invoke Git MCP tools like `git_log` to inspect the git commit log. + +Under the hood: + +1. The server is spun up in a subprocess, and exposes a bunch of tools like `git_log()` +2. We add the server instance to the Agent via `mcp_agents`. +3. Each time the agent runs, we call out to the MCP server to fetch the list of tools via `server.list_tools()`. The result is cached. +4. If the LLM chooses to use an MCP tool, we call the MCP server to run the tool via `server.run_tool()`. From 0fa584983937f933940baceb1de2588e6cdedb60 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 26 Mar 2025 07:44:59 -0700 Subject: [PATCH 54/63] Mark handoff span as errored when multiple handoffs are requested (#344) Also includes the set of requested agents in the error data. image --- src/agents/_run_impl.py | 13 ++++++++++++- tests/test_tracing_errors.py | 27 +++++++++++++++++++++++++-- tests/test_tracing_errors_streamed.py | 13 ++++++++++++- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index 61ca4a0f..94c181b7 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -529,7 +529,8 @@ class RunImpl: run_config: RunConfig, ) -> SingleStepResult: # If there is more than one handoff, add tool responses that reject those handoffs - if len(run_handoffs) > 1: + multiple_handoffs = len(run_handoffs) > 1 + if multiple_handoffs: output_message = "Multiple handoffs detected, ignoring this one." new_step_items.extend( [ @@ -551,6 +552,16 @@ class RunImpl: context_wrapper, actual_handoff.tool_call.arguments ) span_handoff.span_data.to_agent = new_agent.name + if multiple_handoffs: + requested_agents = [handoff.handoff.agent_name for handoff in run_handoffs] + span_handoff.set_error( + SpanError( + message="Multiple handoffs requested", + data={ + "requested_agents": requested_agents, + }, + ) + ) # Append a tool output item for the handoff new_step_items.append( diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py index baa77681..6d698bcc 100644 --- a/tests/test_tracing_errors.py +++ b/tests/test_tracing_errors.py @@ -244,7 +244,18 @@ async def test_multiple_handoff_doesnt_error(): }, }, {"type": "generation"}, - {"type": "handoff", "data": {"from_agent": "test", "to_agent": "test"}}, + {"type": "handoff", + "data": {"from_agent": "test", "to_agent": "test"}, + "error": { + "data": { + "requested_agents": [ + "test", + "test", + ], + }, + "message": "Multiple handoffs requested", + }, + }, ], }, { @@ -372,7 +383,19 @@ async def test_handoffs_lead_to_correct_agent_spans(): {"type": "generation"}, { "type": "handoff", - "data": {"from_agent": "test_agent_3", "to_agent": "test_agent_1"}, + "data": { + "from_agent": "test_agent_3", + "to_agent": "test_agent_1" + }, + "error": { + "data": { + "requested_agents": [ + "test_agent_1", + "test_agent_2", + ], + }, + "message": "Multiple handoffs requested", + }, }, ], }, diff --git a/tests/test_tracing_errors_streamed.py b/tests/test_tracing_errors_streamed.py index 7e65ff12..416793e7 100644 --- a/tests/test_tracing_errors_streamed.py +++ b/tests/test_tracing_errors_streamed.py @@ -262,7 +262,14 @@ async def test_multiple_handoff_doesnt_error(): }, }, {"type": "generation"}, - {"type": "handoff", "data": {"from_agent": "test", "to_agent": "test"}}, + { + "type": "handoff", + "data": {"from_agent": "test", "to_agent": "test"}, + "error": { + "data": {"requested_agents": ["test", "test"]}, + "message": "Multiple handoffs requested", + }, + }, ], }, { @@ -396,6 +403,10 @@ async def test_handoffs_lead_to_correct_agent_spans(): {"type": "generation"}, { "type": "handoff", + "error": { + "message": "Multiple handoffs requested", + "data": {"requested_agents": ["test_agent_1", "test_agent_2"]}, + }, "data": {"from_agent": "test_agent_3", "to_agent": "test_agent_1"}, }, ], From fe42548cce932e2b331ebe5bb0ef60dc4ee625e0 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Wed, 26 Mar 2025 12:06:24 -0400 Subject: [PATCH 55/63] v0.0.7 (#353) Upgrade version to prepare for a new release. --- pyproject.toml | 2 +- uv.lock | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eb0bae39..ab1be5ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.0.6" +version = "0.0.7" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.9" diff --git a/uv.lock b/uv.lock index 3ee7f047..a9e8f1e7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", @@ -1087,7 +1086,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.0.6" +version = "0.0.7" source = { editable = "." } dependencies = [ { name = "griffe" }, @@ -1143,7 +1142,6 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.12.2,<5" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<16" }, ] -provides-extras = ["voice", "viz"] [package.metadata.requires-dev] dev = [ From aeda40c10f75de44ecec12ad10f9c5d71d0cba2e Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Wed, 26 Mar 2025 12:56:35 -0400 Subject: [PATCH 56/63] MCP example for SSE (#354) Simple example with a local SSE server. --- examples/mcp/filesystem_example/README.md | 2 +- examples/mcp/git_example/README.md | 5 +- examples/mcp/git_example/main.py | 8 +-- examples/mcp/sse_example/README.md | 13 ++++ examples/mcp/sse_example/main.py | 83 +++++++++++++++++++++++ examples/mcp/sse_example/server.py | 33 +++++++++ 6 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 examples/mcp/sse_example/README.md create mode 100644 examples/mcp/sse_example/main.py create mode 100644 examples/mcp/sse_example/server.py diff --git a/examples/mcp/filesystem_example/README.md b/examples/mcp/filesystem_example/README.md index 1b6b1837..83183fb4 100644 --- a/examples/mcp/filesystem_example/README.md +++ b/examples/mcp/filesystem_example/README.md @@ -10,7 +10,7 @@ uv run python python examples/mcp/filesystem_example/main.py ## Details -The example uses the `MCPServerStdio` class from `agents`, with the command: +The example uses the `MCPServerStdio` class from `agents.mcp`, with the command: ```bash npx -y "@modelcontextprotocol/server-filesystem" diff --git a/examples/mcp/git_example/README.md b/examples/mcp/git_example/README.md index 35ff7cde..6a809afa 100644 --- a/examples/mcp/git_example/README.md +++ b/examples/mcp/git_example/README.md @@ -10,16 +10,17 @@ uv run python examples/mcp/git_example/main.py ## Details -The example uses the `MCPServerStdio` class from `agents`, with the command: +The example uses the `MCPServerStdio` class from `agents.mcp`, with the command: ```bash uvx mcp-server-git ``` + Prior to running the agent, the user is prompted to provide a local directory path to their git repo. Using that, the Agent can invoke Git MCP tools like `git_log` to inspect the git commit log. Under the hood: 1. The server is spun up in a subprocess, and exposes a bunch of tools like `git_log()` 2. We add the server instance to the Agent via `mcp_agents`. -3. Each time the agent runs, we call out to the MCP server to fetch the list of tools via `server.list_tools()`. The result is cached. +3. Each time the agent runs, we call out to the MCP server to fetch the list of tools via `server.list_tools()`. The result is cached. 4. If the LLM chooses to use an MCP tool, we call the MCP server to run the tool via `server.run_tool()`. diff --git a/examples/mcp/git_example/main.py b/examples/mcp/git_example/main.py index cfc15108..ab229e85 100644 --- a/examples/mcp/git_example/main.py +++ b/examples/mcp/git_example/main.py @@ -30,12 +30,8 @@ async def main(): directory_path = input("Please enter the path to the git repository: ") async with MCPServerStdio( - params={ - "command": "uvx", - "args": [ - "mcp-server-git" - ] - } + cache_tools_list=True, # Cache the tools list, for demonstration + params={"command": "uvx", "args": ["mcp-server-git"]}, ) as server: with trace(workflow_name="MCP Git Example"): await run(server, directory_path) diff --git a/examples/mcp/sse_example/README.md b/examples/mcp/sse_example/README.md new file mode 100644 index 00000000..605c2cc9 --- /dev/null +++ b/examples/mcp/sse_example/README.md @@ -0,0 +1,13 @@ +# MCP SSE Example + +This example uses a local SSE server in [server.py](server.py). + +Run the example via: + +``` +uv run python python examples/mcp/sse_example/main.py +``` + +## Details + +The example uses the `MCPServerSse` class from `agents.mcp`. The server runs in a sub-process at `https://localhost:8000/sse`. diff --git a/examples/mcp/sse_example/main.py b/examples/mcp/sse_example/main.py new file mode 100644 index 00000000..024b087e --- /dev/null +++ b/examples/mcp/sse_example/main.py @@ -0,0 +1,83 @@ +import asyncio +import os +import shutil +import subprocess +import time +from typing import Any + +from agents import Agent, Runner, gen_trace_id, trace +from agents.mcp import MCPServer, MCPServerSse +from agents.model_settings import ModelSettings + + +async def run(mcp_server: MCPServer): + agent = Agent( + name="Assistant", + instructions="Use the tools to answer the questions.", + mcp_servers=[mcp_server], + model_settings=ModelSettings(tool_choice="required"), + ) + + # Use the `add` tool to add two numbers + message = "Add these numbers: 7 and 22." + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Run the `get_weather` tool + message = "What's the weather in Tokyo?" + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Run the `get_secret_word` tool + message = "What's the secret word?" + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + +async def main(): + async with MCPServerSse( + name="SSE Python Server", + params={ + "url": "http://localhost:8000/sse", + }, + ) as server: + trace_id = gen_trace_id() + with trace(workflow_name="SSE Example", trace_id=trace_id): + print(f"View trace: https://platform.openai.com/traces/{trace_id}\n") + await run(server) + + +if __name__ == "__main__": + # Let's make sure the user has uv installed + if not shutil.which("uv"): + raise RuntimeError( + "uv is not installed. Please install it: https://docs.astral.sh/uv/getting-started/installation/" + ) + + # We'll run the SSE server in a subprocess. Usually this would be a remote server, but for this + # demo, we'll run it locally at http://localhost:8000/sse + process: subprocess.Popen[Any] | None = None + try: + this_dir = os.path.dirname(os.path.abspath(__file__)) + server_file = os.path.join(this_dir, "server.py") + + print("Starting SSE server at http://localhost:8000/sse ...") + + # Run `uv run server.py` to start the SSE server + process = subprocess.Popen(["uv", "run", server_file]) + # Give it 3 seconds to start + time.sleep(3) + + print("SSE server started. Running example...\n\n") + except Exception as e: + print(f"Error starting SSE server: {e}") + exit(1) + + try: + asyncio.run(main()) + finally: + if process: + process.terminate() diff --git a/examples/mcp/sse_example/server.py b/examples/mcp/sse_example/server.py new file mode 100644 index 00000000..df364aa3 --- /dev/null +++ b/examples/mcp/sse_example/server.py @@ -0,0 +1,33 @@ +import random + +import requests +from mcp.server.fastmcp import FastMCP + +# Create server +mcp = FastMCP("Echo Server") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + print(f"[debug-server] add({a}, {b})") + return a + b + + +@mcp.tool() +def get_secret_word() -> str: + print("[debug-server] get_secret_word()") + return random.choice(["apple", "banana", "cherry"]) + + +@mcp.tool() +def get_current_weather(city: str) -> str: + print(f"[debug-server] get_current_weather({city})") + + endpoint = "https://wttr.in" + response = requests.get(f"{endpoint}/{city}") + return response.text + + +if __name__ == "__main__": + mcp.run(transport="sse") From 1c8e9a55db7acdcbef216b42d18c845b39e35434 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Wed, 26 Mar 2025 13:56:34 -0400 Subject: [PATCH 57/63] Tracing screenshot for MCP docs (#355) --- docs/assets/images/mcp-tracing.jpg | Bin 0 -> 407851 bytes docs/mcp.md | 13 +++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 docs/assets/images/mcp-tracing.jpg diff --git a/docs/assets/images/mcp-tracing.jpg b/docs/assets/images/mcp-tracing.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cefeb66b91891a569c44a49db65f425204d2e386 GIT binary patch literal 407851 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 z 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°I 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

uP-PX~jh$Kgv4=W(`f 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 Date: Wed, 26 Mar 2025 15:52:19 -0400 Subject: [PATCH 58/63] Raise error on more invalid function schemas (#356) Towards #345 ## Summary: Using a `dict` or `Mapping` isn't strict-mode compliant. But we were checking for the literal `True` whereas the value can also be an array, for example. Fix that. ## Test Plan: Unit tests --- src/agents/strict_schema.py | 2 +- tests/test_function_schema.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py index 910ad85f..3f37660a 100644 --- a/src/agents/strict_schema.py +++ b/src/agents/strict_schema.py @@ -54,7 +54,7 @@ def _ensure_strict_json_schema( elif ( typ == "object" and "additionalProperties" in json_schema - and json_schema["additionalProperties"] is True + and json_schema["additionalProperties"] ): raise UserError( "additionalProperties should not be set for object types. This could be because " diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 2407ab03..ef1e9c22 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from enum import Enum from typing import Any, Literal @@ -421,10 +422,20 @@ def test_var_keyword_dict_annotation(): def func(**kwargs: dict[str, int]): return kwargs - fs = function_schema(func, use_docstring_info=False) + fs = function_schema(func, use_docstring_info=False, strict_json_schema=False) properties = fs.params_json_schema.get("properties", {}) # The name of the field is "kwargs", and it's a JSON object i.e. a dict. assert properties.get("kwargs").get("type") == "object" # The values in the dict are integers. assert properties.get("kwargs").get("additionalProperties").get("type") == "integer" + + +def test_schema_with_mapping_raises_strict_mode_error(): + """A mapping type is not allowed in strict mode. Same for dicts. Ensure we raise a UserError.""" + + def func_with_mapping(test_one: Mapping[str, int]) -> str: + return "foo" + + with pytest.raises(UserError): + function_schema(func_with_mapping) From d1e94324e884ddffd78a21908fd998cbaac8d28e Mon Sep 17 00:00:00 2001 From: Steven Heidel Date: Wed, 26 Mar 2025 16:01:28 -0700 Subject: [PATCH 59/63] Expose the "store" parameter through ModelSettings (#357) Closes https://github.com/openai/openai-agents-python/issues/173 This will also set stored completions to True by default, encouraging a best practice. --- src/agents/model_settings.py | 28 ++++++++++--------- src/agents/models/openai_chatcompletions.py | 4 +++ src/agents/models/openai_responses.py | 1 + tests/test_agent_runner.py | 30 +++++++++++++++++++++ tests/test_openai_chatcompletions.py | 2 ++ 5 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/agents/model_settings.py b/src/agents/model_settings.py index cc4b6cb6..2b0885ab 100644 --- a/src/agents/model_settings.py +++ b/src/agents/model_settings.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, fields, replace from typing import Literal @@ -30,8 +30,9 @@ class ModelSettings: tool_choice: Literal["auto", "required", "none"] | str | None = None """The tool choice to use when calling the model.""" - parallel_tool_calls: bool | None = False - """Whether to use parallel tool calls when calling the model.""" + parallel_tool_calls: bool | None = None + """Whether to use parallel tool calls when calling the model. + Defaults to False if not provided.""" truncation: Literal["auto", "disabled"] | None = None """The truncation strategy to use when calling the model.""" @@ -39,18 +40,19 @@ class ModelSettings: max_tokens: int | None = None """The maximum number of output tokens to generate.""" + store: bool | None = None + """Whether to store the generated model response for later retrieval. + Defaults to True if not provided.""" + def resolve(self, override: ModelSettings | None) -> ModelSettings: """Produce a new ModelSettings by overlaying any non-None values from the override on top of this instance.""" if override is None: return self - return ModelSettings( - temperature=override.temperature or self.temperature, - top_p=override.top_p or self.top_p, - frequency_penalty=override.frequency_penalty or self.frequency_penalty, - presence_penalty=override.presence_penalty or self.presence_penalty, - tool_choice=override.tool_choice or self.tool_choice, - parallel_tool_calls=override.parallel_tool_calls or self.parallel_tool_calls, - truncation=override.truncation or self.truncation, - max_tokens=override.max_tokens or self.max_tokens, - ) + + changes = { + field.name: getattr(override, field.name) + for field in fields(self) + if getattr(override, field.name) is not None + } + return replace(self, **changes) diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 8c649813..de7b1ae4 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -518,6 +518,9 @@ class OpenAIChatCompletionsModel(Model): f"Response format: {response_format}\n" ) + # Match the behavior of Responses where store is True when not given + store = model_settings.store if model_settings.store is not None else True + ret = await self._get_client().chat.completions.create( model=self.model, messages=converted_messages, @@ -532,6 +535,7 @@ class OpenAIChatCompletionsModel(Model): parallel_tool_calls=parallel_tool_calls, stream=stream, stream_options={"include_usage": True} if stream else NOT_GIVEN, + store=store, extra_headers=_HEADERS, ) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index c5b591c6..17803fda 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -246,6 +246,7 @@ class OpenAIResponsesModel(Model): stream=stream, extra_headers=_HEADERS, text=response_format, + store=self._non_null_or_not_given(model_settings.store), ) def _get_client(self) -> AsyncOpenAI: diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index e8e060fd..ce0c5804 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -14,8 +14,10 @@ from agents import ( InputGuardrail, InputGuardrailTripwireTriggered, ModelBehaviorError, + ModelSettings, OutputGuardrail, OutputGuardrailTripwireTriggered, + RunConfig, RunContextWrapper, Runner, UserError, @@ -634,3 +636,31 @@ async def test_tool_use_behavior_custom_function(): assert len(result.raw_responses) == 2, "should have two model responses" assert result.final_output == "the_final_output", "should have used the custom function" + + +@pytest.mark.asyncio +async def test_model_settings_override(): + model = FakeModel() + agent = Agent( + name="test", + model=model, + model_settings=ModelSettings(temperature=1.0, max_tokens=1000) + ) + + model.add_multiple_turn_outputs( + [ + [ + get_text_message("a_message"), + ], + ] + ) + + await Runner.run( + agent, + input="user_message", + run_config=RunConfig(model_settings=ModelSettings(0.5)), + ) + + # temperature is overridden by Runner.run, but max_tokens is not + assert model.last_turn_args["model_settings"].temperature == 0.5 + assert model.last_turn_args["model_settings"].max_tokens == 1000 diff --git a/tests/test_openai_chatcompletions.py b/tests/test_openai_chatcompletions.py index 95216476..9a53e2b7 100644 --- a/tests/test_openai_chatcompletions.py +++ b/tests/test_openai_chatcompletions.py @@ -226,6 +226,7 @@ async def test_fetch_response_non_stream(monkeypatch) -> None: # Ensure expected args were passed through to OpenAI client. kwargs = completions.kwargs assert kwargs["stream"] is False + assert kwargs["store"] is True assert kwargs["model"] == "gpt-4" assert kwargs["messages"][0]["role"] == "system" assert kwargs["messages"][0]["content"] == "sys" @@ -279,6 +280,7 @@ async def test_fetch_response_stream(monkeypatch) -> None: ) # Check OpenAI client was called for streaming assert completions.kwargs["stream"] is True + assert completions.kwargs["store"] is True assert completions.kwargs["stream_options"] == {"include_usage": True} # Response is a proper openai Response assert isinstance(response, Response) From 1683606b6fea54378fe936224413613e329e56eb Mon Sep 17 00:00:00 2001 From: amri369 <39493555+amri369@users.noreply.github.com> Date: Thu, 27 Mar 2025 21:05:30 +0400 Subject: [PATCH 60/63] Hotfix mcp example (#365) Small typo in uv command fixed --- examples/mcp/filesystem_example/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/mcp/filesystem_example/README.md b/examples/mcp/filesystem_example/README.md index 83183fb4..4ed6ac46 100644 --- a/examples/mcp/filesystem_example/README.md +++ b/examples/mcp/filesystem_example/README.md @@ -5,7 +5,7 @@ This example uses the [filesystem MCP server](https://github.com/modelcontextpro Run it via: ``` -uv run python python examples/mcp/filesystem_example/main.py +uv run python examples/mcp/filesystem_example/main.py ``` ## Details From a7a814d30d5625d7d7c915abfd74fc9d76c55d25 Mon Sep 17 00:00:00 2001 From: Martin Jagusch <_@mj.io> Date: Thu, 27 Mar 2025 18:40:06 +0100 Subject: [PATCH 61/63] =?UTF-8?q?Remove=20duplicate=20dependency=20definit?= =?UTF-8?q?ion=20of=C2=A0pynput=20(#367)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pynput` is in the pyproject.toml file twice, as you can see on line 54. The uv.lock file doesn't change. --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ab1be5ed..8518301f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,6 @@ dev = [ "pynput", "types-pynput", "sounddevice", - "pynput", "textual", "websockets", "graphviz", From 4c40fec70f7a9665773bee4a2a7c71881053ee87 Mon Sep 17 00:00:00 2001 From: Ali Waleed Date: Fri, 28 Mar 2025 03:33:49 +0200 Subject: [PATCH 62/63] Add Langtrace to `tracing.md` (#371) --- docs/tracing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tracing.md b/docs/tracing.md index 8c68c208..b5412de0 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -111,3 +111,4 @@ To customize this default setup, to send traces to alternative or additional bac - [Maxim AI](https://www.getmaxim.ai/docs/observe/integrations/openai-agents-sdk) - [Comet Opik](https://www.comet.com/docs/opik/tracing/integrations/openai_agents) - [Langfuse](https://langfuse.com/docs/integrations/openaiagentssdk/openai-agents) +- [Langtrace](https://docs.langtrace.ai/supported-integrations/llm-frameworks/openai-agents-sdk) From e891dfc314c56ff88e70e33f8b92987b81ba0fe8 Mon Sep 17 00:00:00 2001 From: Eric Peter Date: Sun, 30 Mar 2025 20:53:09 -0700 Subject: [PATCH 63/63] Databricks MLflow tracing integration (#401) --- docs/tracing.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/tracing.md b/docs/tracing.md index b5412de0..b5dd54a6 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -101,7 +101,8 @@ To customize this default setup, to send traces to alternative or additional bac - [Weights & Biases](https://weave-docs.wandb.ai/guides/integrations/openai_agents) - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) -- [MLflow](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow (self-hosted/OSS](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) +- [MLflow (Databricks hosted](https://docs.databricks.com/aws/en/mlflow/mlflow-tracing#-automatic-tracing) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) - [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) - [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk)