diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index edd7681a..73586eaa 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -10,7 +10,7 @@ assignees: '' ### Please read this first - **Have you read the docs?**[Agents SDK docs](https://openai.github.io/openai-agents-python/) -- **Have you searched for related issues?** Others may have had similar requesrs +- **Have you searched for related issues?** Others may have had similar requests ### Describe the feature What is the feature you're requesting? How would it work? Please provide examples and details if possible. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index cb4a05dc..6c639d72 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -10,7 +10,7 @@ assignees: '' ### Please read this first - **Have you read the docs?**[Agents SDK docs](https://openai.github.io/openai-agents-python/) -- **Have you searched for related issues?** Others may have had similar requesrs +- **Have you searched for related issues?** Others may have had similar requests ### Question Describe your question. Provide details if available. diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9b388533 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/docs/assets/images/mcp-tracing.jpg b/docs/assets/images/mcp-tracing.jpg new file mode 100644 index 00000000..cefeb66b Binary files /dev/null and b/docs/assets/images/mcp-tracing.jpg differ diff --git a/docs/mcp.md b/docs/mcp.md index 7ec11c16..e279a25e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,4 +1,4 @@ -# Model context protocol +# Model context protocol (MCP) 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: @@ -46,6 +46,15 @@ Every time an Agent runs, it calls `list_tools()` on the MCP server. This can be If you want to invalidate the cache, you can call `invalidate_tools_cache()` on the servers. -## End-to-end example +## End-to-end examples View complete working examples at [examples/mcp](https://github.com/openai/openai-agents-python/tree/main/examples/mcp). + +## Tracing + +[Tracing](./tracing.md) automatically captures MCP operations, including: + +1. Calls to the MCP server to list tools +2. MCP-related info on function calls + +![MCP Tracing Screenshot](./assets/images/mcp-tracing.jpg) diff --git a/docs/running_agents.md b/docs/running_agents.md index 32abf9d5..f631cf46 100644 --- a/docs/running_agents.md +++ b/docs/running_agents.md @@ -53,7 +53,7 @@ The `run_config` parameter lets you configure some global settings for the agent - [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: A global input filter to apply to all handoffs, if the handoff doesn't already have one. The input filter allows you to edit the inputs that are sent to the new agent. See the documentation in [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] for more details. - [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: Allows you to disable [tracing](tracing.md) for the entire run. - [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: Configures whether traces will include potentially sensitive data, such as LLM and tool call inputs/outputs. -- [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: Sets the tracing workflow name, trace ID and trace group ID for the run. We recommend at least setting `workflow_name`. The session ID is an optional field that lets you link traces across multiple runs. +- [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: Sets the tracing workflow name, trace ID and trace group ID for the run. We recommend at least setting `workflow_name`. The group ID is an optional field that lets you link traces across multiple runs. - [`trace_metadata`][agents.run.RunConfig.trace_metadata]: Metadata to include on all traces. ## Conversations/chat threads diff --git a/docs/tracing.md b/docs/tracing.md index 8c68c208..ea48a2e2 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) @@ -111,3 +112,5 @@ 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) +- [Okahu-Monocle](https://github.com/monocle2ai/monocle) diff --git a/examples/financial_research_agent/manager.py b/examples/financial_research_agent/manager.py index a996296d..58ec11bf 100644 --- a/examples/financial_research_agent/manager.py +++ b/examples/financial_research_agent/manager.py @@ -38,7 +38,7 @@ async def run(self, query: str) -> None: with trace("Financial research trace", trace_id=trace_id): self.printer.update_item( "trace_id", - f"View trace: https://platform.openai.com/traces/{trace_id}", + f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}", is_done=True, hide_checkmark=True, ) diff --git a/examples/mcp/filesystem_example/README.md b/examples/mcp/filesystem_example/README.md index 1b6b1837..4ed6ac46 100644 --- a/examples/mcp/filesystem_example/README.md +++ b/examples/mcp/filesystem_example/README.md @@ -5,12 +5,12 @@ 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 -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/filesystem_example/main.py b/examples/mcp/filesystem_example/main.py index ae6fadd2..92c2b2db 100644 --- a/examples/mcp/filesystem_example/main.py +++ b/examples/mcp/filesystem_example/main.py @@ -45,7 +45,7 @@ async def main(): ) as server: 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") + print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") await run(server) 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..9a667d31 --- /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 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..7c1137d2 --- /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?trace_id={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") diff --git a/examples/research_bot/manager.py b/examples/research_bot/manager.py index 47306f14..dab68569 100644 --- a/examples/research_bot/manager.py +++ b/examples/research_bot/manager.py @@ -23,7 +23,7 @@ async def run(self, query: str) -> None: with trace("Research trace", trace_id=trace_id): self.printer.update_item( "trace_id", - f"View trace: https://platform.openai.com/traces/{trace_id}", + f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}", is_done=True, hide_checkmark=True, ) diff --git a/examples/research_bot/sample_outputs/product_recs.txt b/examples/research_bot/sample_outputs/product_recs.txt index 78865f23..fd14d533 100644 --- a/examples/research_bot/sample_outputs/product_recs.txt +++ b/examples/research_bot/sample_outputs/product_recs.txt @@ -3,7 +3,7 @@ $ uv run python -m examples.research_bot.main What would you like to research? Best surfboards for beginners. I can catch my own waves, but previously used an 11ft board. What should I look for, what are my options? Various budget ranges. -View trace: https://platform.openai.com/traces/trace_... +View trace: https://platform.openai.com/traces/trace?trace_id=trace_... Starting research... ✅ Will perform 15 searches ✅ Searching... 15/15 completed diff --git a/examples/research_bot/sample_outputs/vacation.txt b/examples/research_bot/sample_outputs/vacation.txt index b2649981..491c0005 100644 --- a/examples/research_bot/sample_outputs/vacation.txt +++ b/examples/research_bot/sample_outputs/vacation.txt @@ -2,7 +2,7 @@ $ uv run python -m examples.research_bot.main What would you like to research? Caribbean vacation spots in April, optimizing for surfing, hiking and water sports -View trace: https://platform.openai.com/traces/trace_.... +View trace: https://platform.openai.com/traces/trace?trace_id=trace_.... Starting research... ✅ Will perform 15 searches ✅ Searching... 15/15 completed diff --git a/pyproject.toml b/pyproject.toml index ab1be5ed..3ade2c52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.0.7" +version = "0.0.8" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.9" @@ -13,7 +13,7 @@ dependencies = [ "typing-extensions>=4.12.2, <5", "requests>=2.0, <3", "types-requests>=2.0, <3", - "mcp; python_version >= '3.10'", + "mcp>=1.6.0, <2; python_version >= '3.10'", ] classifiers = [ "Typing :: Typed", @@ -54,7 +54,6 @@ dev = [ "pynput", "types-pynput", "sounddevice", - "pynput", "textual", "websockets", "graphviz", diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 242f5649..db7d312e 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -100,6 +100,7 @@ transcription_span, ) from .usage import Usage +from .version import __version__ def set_default_openai_key(key: str, use_for_tracing: bool = True) -> None: @@ -247,4 +248,5 @@ def enable_verbose_stdout_logging(): "gen_trace_id", "gen_span_id", "default_tool_error_function", + "__version__", ] diff --git a/src/agents/agent.py b/src/agents/agent.py index 13bb464e..4c6de245 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, cast -from typing_extensions import TypeAlias, TypedDict +from typing_extensions import NotRequired, TypeAlias, TypedDict from .guardrail import InputGuardrail, OutputGuardrail from .handoffs import Handoff @@ -44,7 +44,7 @@ class ToolsToFinalOutputResult: MaybeAwaitable[ToolsToFinalOutputResult], ] """A function that takes a run context and a list of tool results, and returns a -`ToolToFinalOutputResult`. +`ToolsToFinalOutputResult`. """ @@ -53,6 +53,15 @@ class StopAtTools(TypedDict): """A list of tool names, any of which will stop the agent from running further.""" +class MCPConfig(TypedDict): + """Configuration for MCP servers.""" + + convert_schemas_to_strict: NotRequired[bool] + """If True, we will attempt to convert the MCP schemas to strict-mode schemas. This is a + best-effort conversion, so some schemas may not be convertible. Defaults to False. + """ + + @dataclass class Agent(Generic[TContext]): """An agent is an AI model configured with instructions, tools, guardrails, handoffs and more. @@ -119,6 +128,9 @@ class Agent(Generic[TContext]): longer needed. """ + mcp_config: MCPConfig = field(default_factory=lambda: MCPConfig()) + """Configuration for MCP servers.""" + 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. @@ -224,7 +236,8 @@ async def get_system_prompt(self, run_context: RunContextWrapper[TContext]) -> s 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) + convert_schemas_to_strict = self.mcp_config.get("convert_schemas_to_strict", False) + return await MCPUtil.get_all_function_tools(self.mcp_servers, convert_schemas_to_strict) async def get_all_tools(self) -> list[Tool]: """All agent tools, including MCP tools and function tools.""" diff --git a/src/agents/mcp/util.py b/src/agents/mcp/util.py index 36c18bea..770ae8dd 100644 --- a/src/agents/mcp/util.py +++ b/src/agents/mcp/util.py @@ -2,6 +2,8 @@ import json from typing import TYPE_CHECKING, Any +from agents.strict_schema import ensure_strict_json_schema + from .. import _debug from ..exceptions import AgentsException, ModelBehaviorError, UserError from ..logger import logger @@ -19,12 +21,14 @@ 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]: + async def get_all_function_tools( + cls, servers: list["MCPServer"], convert_schemas_to_strict: bool + ) -> 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_tools = await cls.get_function_tools(server, convert_schemas_to_strict) server_tool_names = {tool.name for tool in server_tools} if len(server_tool_names & tool_names) > 0: raise UserError( @@ -37,25 +41,37 @@ async def get_all_function_tools(cls, servers: list["MCPServer"]) -> list[Tool]: return tools @classmethod - async def get_function_tools(cls, server: "MCPServer") -> list[Tool]: + async def get_function_tools( + cls, server: "MCPServer", convert_schemas_to_strict: bool + ) -> list[Tool]: """Get all function tools from a single MCP server.""" 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] + return [cls.to_function_tool(tool, server, convert_schemas_to_strict) for tool in tools] @classmethod - def to_function_tool(cls, tool: "MCPTool", server: "MCPServer") -> FunctionTool: + def to_function_tool( + cls, tool: "MCPTool", server: "MCPServer", convert_schemas_to_strict: bool + ) -> FunctionTool: """Convert an MCP tool to an Agents SDK function tool.""" invoke_func = functools.partial(cls.invoke_mcp_tool, server, tool) + schema, is_strict = tool.inputSchema, False + if convert_schemas_to_strict: + try: + schema = ensure_strict_json_schema(schema) + is_strict = True + except Exception as e: + logger.info(f"Error converting MCP schema to strict mode: {e}") + return FunctionTool( name=tool.name, description=tool.description or "", - params_json_schema=tool.inputSchema, + params_json_schema=schema, on_invoke_tool=invoke_func, - strict_json_schema=False, + strict_json_schema=is_strict, ) @classmethod diff --git a/src/agents/model_settings.py b/src/agents/model_settings.py index cc4b6cb6..bac71f58 100644 --- a/src/agents/model_settings.py +++ b/src/agents/model_settings.py @@ -1,8 +1,10 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, fields, replace from typing import Literal +from openai.types.shared import Reasoning + @dataclass class ModelSettings: @@ -30,8 +32,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 +42,27 @@ class ModelSettings: max_tokens: int | None = None """The maximum number of output tokens to generate.""" + reasoning: Reasoning | None = None + """Configuration options for + [reasoning models](https://platform.openai.com/docs/guides/reasoning). + """ + + metadata: dict[str, str] | None = None + """Metadata to include with the model response call.""" + + 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..cbc48c50 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -518,6 +518,11 @@ async def _fetch_response( 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 + + reasoning_effort = model_settings.reasoning.effort if model_settings.reasoning else None + ret = await self._get_client().chat.completions.create( model=self.model, messages=converted_messages, @@ -532,7 +537,10 @@ async def _fetch_response( parallel_tool_calls=parallel_tool_calls, stream=stream, stream_options={"include_usage": True} if stream else NOT_GIVEN, + store=store, + reasoning_effort=self._non_null_or_not_given(reasoning_effort), extra_headers=_HEADERS, + metadata=model_settings.metadata, ) if isinstance(ret, ChatCompletion): @@ -551,6 +559,7 @@ async def _fetch_response( temperature=model_settings.temperature, tools=[], parallel_tool_calls=parallel_tool_calls or False, + reasoning=model_settings.reasoning, ) return response, ret @@ -919,12 +928,13 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: elif func_call := cls.maybe_function_tool_call(item): asst = ensure_assistant_message() tool_calls = list(asst.get("tool_calls", [])) + arguments = func_call["arguments"] if func_call["arguments"] else "{}" new_tool_call = ChatCompletionMessageToolCallParam( id=func_call["call_id"], type="function", function={ "name": func_call["name"], - "arguments": func_call["arguments"], + "arguments": arguments, }, ) tool_calls.append(new_tool_call) @@ -967,7 +977,7 @@ def to_openai(cls, tool: Tool) -> ChatCompletionToolParam: } raise UserError( - f"Hosted tools are not supported with the ChatCompletions API. FGot tool type: " + f"Hosted tools are not supported with the ChatCompletions API. Got tool type: " f"{type(tool)}, tool: {tool}" ) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index c5b591c6..6fda4bb3 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -246,6 +246,9 @@ async def _fetch_response( stream=stream, extra_headers=_HEADERS, text=response_format, + store=self._non_null_or_not_given(model_settings.store), + reasoning=self._non_null_or_not_given(model_settings.reasoning), + metadata=model_settings.metadata, ) def _get_client(self) -> AsyncOpenAI: 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/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 5d8f4d8a..f929d05d 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -182,7 +182,6 @@ def __init__( # Track when we next *must* perform a scheduled export self._next_export_time = time.time() + self._schedule_delay - self._shutdown_event = threading.Event() self._worker_thread = threading.Thread(target=self._run, daemon=True) self._worker_thread.start() diff --git a/src/agents/tracing/span_data.py b/src/agents/tracing/span_data.py index ed2a3f2d..fe2e0618 100644 --- a/src/agents/tracing/span_data.py +++ b/src/agents/tracing/span_data.py @@ -236,7 +236,7 @@ def export(self) -> dict[str, Any]: class SpeechSpanData(SpanData): - __slots__ = ("input", "output", "model", "model_config", "first_byte_at") + __slots__ = ("input", "output", "model", "model_config", "first_content_at") def __init__( self, diff --git a/src/agents/version.py b/src/agents/version.py index a0b7e9be..9b22499e 100644 --- a/src/agents/version.py +++ b/src/agents/version.py @@ -1,7 +1,7 @@ import importlib.metadata try: - __version__ = importlib.metadata.version("agents") + __version__ = importlib.metadata.version("openai-agents") except importlib.metadata.PackageNotFoundError: # Fallback if running from source without being installed __version__ = "0.0.0" diff --git a/tests/mcp/test_mcp_util.py b/tests/mcp/test_mcp_util.py index 345df996..64378b59 100644 --- a/tests/mcp/test_mcp_util.py +++ b/tests/mcp/test_mcp_util.py @@ -2,10 +2,11 @@ from typing import Any import pytest +from inline_snapshot import snapshot from mcp.types import Tool as MCPTool -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter -from agents import FunctionTool, RunContextWrapper +from agents import Agent, FunctionTool, RunContextWrapper from agents.exceptions import AgentsException, ModelBehaviorError from agents.mcp import MCPServer, MCPUtil @@ -18,7 +19,16 @@ class Foo(BaseModel): class Bar(BaseModel): - qux: str + qux: dict[str, str] + + +Baz = TypeAdapter(dict[str, str]) + + +def _convertible_schema() -> dict[str, Any]: + schema = Foo.model_json_schema() + schema["additionalProperties"] = False + return schema @pytest.mark.asyncio @@ -47,7 +57,7 @@ async def test_get_all_function_tools(): server3.add_tool(names[4], schemas[4]) servers: list[MCPServer] = [server1, server2, server3] - tools = await MCPUtil.get_all_function_tools(servers) + tools = await MCPUtil.get_all_function_tools(servers, convert_schemas_to_strict=False) assert len(tools) == 5 assert all(tool.name in names for tool in tools) @@ -56,6 +66,11 @@ async def test_get_all_function_tools(): assert tool.params_json_schema == schemas[idx] assert tool.name == names[idx] + # Also make sure it works with strict schemas + tools = await MCPUtil.get_all_function_tools(servers, convert_schemas_to_strict=True) + assert len(tools) == 5 + assert all(tool.name in names for tool in tools) + @pytest.mark.asyncio async def test_invoke_mcp_tool(): @@ -107,3 +122,141 @@ async def test_mcp_invocation_crash_causes_error(caplog: pytest.LogCaptureFixtur await MCPUtil.invoke_mcp_tool(server, tool, ctx, "") assert "Error invoking MCP tool test_tool_1" in caplog.text + + +@pytest.mark.asyncio +async def test_agent_convert_schemas_true(): + """Test that setting convert_schemas_to_strict to True converts non-strict schemas to strict. + - 'foo' tool is already strict and remains strict. + - 'bar' tool is non-strict and becomes strict (additionalProperties set to False, etc). + """ + strict_schema = Foo.model_json_schema() + non_strict_schema = Baz.json_schema() + possible_to_convert_schema = _convertible_schema() + + server = FakeMCPServer() + server.add_tool("foo", strict_schema) + server.add_tool("bar", non_strict_schema) + server.add_tool("baz", possible_to_convert_schema) + agent = Agent( + name="test_agent", mcp_servers=[server], mcp_config={"convert_schemas_to_strict": True} + ) + tools = await agent.get_mcp_tools() + + foo_tool = next(tool for tool in tools if tool.name == "foo") + assert isinstance(foo_tool, FunctionTool) + bar_tool = next(tool for tool in tools if tool.name == "bar") + assert isinstance(bar_tool, FunctionTool) + baz_tool = next(tool for tool in tools if tool.name == "baz") + assert isinstance(baz_tool, FunctionTool) + + # Checks that additionalProperties is set to False + assert foo_tool.params_json_schema == snapshot( + { + "properties": { + "bar": {"title": "Bar", "type": "string"}, + "baz": {"title": "Baz", "type": "integer"}, + }, + "required": ["bar", "baz"], + "title": "Foo", + "type": "object", + "additionalProperties": False, + } + ) + assert foo_tool.strict_json_schema is True, "foo_tool should be strict" + + # Checks that additionalProperties is set to False + assert bar_tool.params_json_schema == snapshot( + { + "type": "object", + "additionalProperties": {"type": "string"}, + } + ) + assert bar_tool.strict_json_schema is False, "bar_tool should not be strict" + + # Checks that additionalProperties is set to False + assert baz_tool.params_json_schema == snapshot( + { + "properties": { + "bar": {"title": "Bar", "type": "string"}, + "baz": {"title": "Baz", "type": "integer"}, + }, + "required": ["bar", "baz"], + "title": "Foo", + "type": "object", + "additionalProperties": False, + } + ) + assert baz_tool.strict_json_schema is True, "baz_tool should be strict" + + +@pytest.mark.asyncio +async def test_agent_convert_schemas_false(): + """Test that setting convert_schemas_to_strict to False leaves tool schemas as non-strict. + - 'foo' tool remains strict. + - 'bar' tool remains non-strict (additionalProperties remains True). + """ + strict_schema = Foo.model_json_schema() + non_strict_schema = Baz.json_schema() + possible_to_convert_schema = _convertible_schema() + + server = FakeMCPServer() + server.add_tool("foo", strict_schema) + server.add_tool("bar", non_strict_schema) + server.add_tool("baz", possible_to_convert_schema) + + agent = Agent( + name="test_agent", mcp_servers=[server], mcp_config={"convert_schemas_to_strict": False} + ) + tools = await agent.get_mcp_tools() + + foo_tool = next(tool for tool in tools if tool.name == "foo") + assert isinstance(foo_tool, FunctionTool) + bar_tool = next(tool for tool in tools if tool.name == "bar") + assert isinstance(bar_tool, FunctionTool) + baz_tool = next(tool for tool in tools if tool.name == "baz") + assert isinstance(baz_tool, FunctionTool) + + assert foo_tool.params_json_schema == strict_schema + assert foo_tool.strict_json_schema is False, "Shouldn't be converted unless specified" + + assert bar_tool.params_json_schema == non_strict_schema + assert bar_tool.strict_json_schema is False + + assert baz_tool.params_json_schema == possible_to_convert_schema + assert baz_tool.strict_json_schema is False, "Shouldn't be converted unless specified" + + +@pytest.mark.asyncio +async def test_agent_convert_schemas_unset(): + """Test that leaving convert_schemas_to_strict unset (defaulting to False) leaves tool schemas + as non-strict. + - 'foo' tool remains strict. + - 'bar' tool remains non-strict. + """ + strict_schema = Foo.model_json_schema() + non_strict_schema = Baz.json_schema() + possible_to_convert_schema = _convertible_schema() + + server = FakeMCPServer() + server.add_tool("foo", strict_schema) + server.add_tool("bar", non_strict_schema) + server.add_tool("baz", possible_to_convert_schema) + agent = Agent(name="test_agent", mcp_servers=[server]) + tools = await agent.get_mcp_tools() + + foo_tool = next(tool for tool in tools if tool.name == "foo") + assert isinstance(foo_tool, FunctionTool) + bar_tool = next(tool for tool in tools if tool.name == "bar") + assert isinstance(bar_tool, FunctionTool) + baz_tool = next(tool for tool in tools if tool.name == "baz") + assert isinstance(baz_tool, FunctionTool) + + assert foo_tool.params_json_schema == strict_schema + assert foo_tool.strict_json_schema is False, "Shouldn't be converted unless specified" + + assert bar_tool.params_json_schema == non_strict_schema + assert bar_tool.strict_json_schema is False + + assert baz_tool.params_json_schema == possible_to_convert_schema + assert baz_tool.strict_json_schema is False, "Shouldn't be converted unless specified" diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index e8e060fd..4f277656 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -14,8 +14,10 @@ InputGuardrail, InputGuardrailTripwireTriggered, ModelBehaviorError, + ModelSettings, OutputGuardrail, OutputGuardrailTripwireTriggered, + RunConfig, RunContextWrapper, Runner, UserError, @@ -634,3 +636,29 @@ 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_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) diff --git a/tests/test_function_tool_decorator.py b/tests/test_function_tool_decorator.py index 903dd123..3b52788f 100644 --- a/tests/test_function_tool_decorator.py +++ b/tests/test_function_tool_decorator.py @@ -3,6 +3,7 @@ from typing import Any, Optional import pytest +from inline_snapshot import snapshot from agents import function_tool from agents.run_context import RunContextWrapper @@ -198,3 +199,37 @@ async def test_all_optional_params_function(): input_data = {"x": 10, "y": "world", "z": 99} output = await tool.on_invoke_tool(ctx_wrapper(), json.dumps(input_data)) assert output == "10_world_99" + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather for a given city. + + Args: + city: The city to get the weather for. + """ + return f"The weather in {city} is sunny." + + +@pytest.mark.asyncio +async def test_extract_descriptions_from_docstring(): + """Ensure that we extract function and param descriptions from docstrings.""" + + tool = get_weather + assert tool.description == "Get the weather for a given city." + params_json_schema = tool.params_json_schema + assert params_json_schema == snapshot( + { + "type": "object", + "properties": { + "city": { + "description": "The city to get the weather for.", + "title": "City", + "type": "string", + } + }, + "title": "get_weather_args", + "required": ["city"], + "additionalProperties": False, + } + ) 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 @@ def __init__(self, completions: DummyCompletions) -> 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 @@ def __init__(self, completions: DummyCompletions) -> 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) diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py index 6d698bcc..72bd39ed 100644 --- a/tests/test_tracing_errors.py +++ b/tests/test_tracing_errors.py @@ -244,9 +244,10 @@ async def test_multiple_handoff_doesnt_error(): }, }, {"type": "generation"}, - {"type": "handoff", - "data": {"from_agent": "test", "to_agent": "test"}, - "error": { + { + "type": "handoff", + "data": {"from_agent": "test", "to_agent": "test"}, + "error": { "data": { "requested_agents": [ "test", @@ -255,7 +256,7 @@ async def test_multiple_handoff_doesnt_error(): }, "message": "Multiple handoffs requested", }, - }, + }, ], }, { @@ -383,10 +384,7 @@ 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": [ diff --git a/uv.lock b/uv.lock index a9e8f1e7..e443c009 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'", @@ -718,7 +719,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "python_full_version >= '3.10'" }, @@ -730,9 +731,9 @@ dependencies = [ { 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 } +sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d1/3ff566ecf322077d861f1a68a1ff025cad337417bd66ad22a7c6f7dfcfaf/mcp-1.5.0-py3-none-any.whl", hash = "sha256:51c3f35ce93cb702f7513c12406bbea9665ef75a08db909200b07da9db641527", size = 73734 }, + { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, ] [[package]] @@ -1133,7 +1134,7 @@ dev = [ 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 = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.6.0,<2" }, { 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" }, @@ -1142,6 +1143,7 @@ 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 = [