From b38153719dc1a0c0bb7fb07439dc71024c5d96f0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 12 Mar 2025 20:45:50 +1100 Subject: [PATCH 01/65] chore(docs): added comet opik to tracing on README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 90fea50..4c906a3 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ The Agents SDK is designed to be highly flexible, allowing you to model a wide r ## Tracing -The Agents SDK automatically traces your agent runs, making it easy to track and debug the behavior of your agents. Tracing is extensible by design, supporting custom spans and a wide variety of external destinations, including [Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents), [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk), and [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk). For more details about how to customize or disable tracing, see [Tracing](http://openai.github.io/openai-agents-python/tracing). +The Agents SDK automatically traces your agent runs, making it easy to track and debug the behavior of your agents. Tracing is extensible by design, supporting custom spans and a wide variety of external destinations, including [Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents), [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk), [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) and [Opik](https://www.comet.com/docs/opik/tracing/integrations/openai). For more details about how to customize or disable tracing, see [Tracing](http://openai.github.io/openai-agents-python/tracing). ## Development (only needed if you need to edit the SDK/examples) From 87f397d37b8fc137670e980297730e3064fb7610 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 12 Mar 2025 20:46:54 +1100 Subject: [PATCH 02/65] chore(docs): Update tracing.md --- docs/tracing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tracing.md b/docs/tracing.md index da0d536..e098547 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -90,6 +90,7 @@ To customize this default setup, to send traces to alternative or additional bac External trace processors include: +- [Opik](https://www.comet.com/docs/opik/tracing/integrations/openai) - [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) From d76486ef909425ca43587df2e55964a38cc18339 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 12 Mar 2025 23:26:42 +1100 Subject: [PATCH 03/65] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c906a3..c8ffb32 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ The Agents SDK is designed to be highly flexible, allowing you to model a wide r ## Tracing -The Agents SDK automatically traces your agent runs, making it easy to track and debug the behavior of your agents. Tracing is extensible by design, supporting custom spans and a wide variety of external destinations, including [Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents), [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk), [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) and [Opik](https://www.comet.com/docs/opik/tracing/integrations/openai). For more details about how to customize or disable tracing, see [Tracing](http://openai.github.io/openai-agents-python/tracing). +The Agents SDK automatically traces your agent runs, making it easy to track and debug the behavior of your agents. Tracing is extensible by design, supporting custom spans and a wide variety of external destinations, including [Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents), [AgentOps](https://docs.agentops.ai/v1/integrations/agentssdk), [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) and [Opik](https://www.comet.com/docs/opik/tracing/integrations/openai_agents). For more details about how to customize or disable tracing, see [Tracing](http://openai.github.io/openai-agents-python/tracing). ## Development (only needed if you need to edit the SDK/examples) From 40dcad3f94adc7449cdf935a7488677765d0f202 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 12 Mar 2025 23:26:57 +1100 Subject: [PATCH 04/65] Update tracing.md --- docs/tracing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tracing.md b/docs/tracing.md index e098547..06e3a88 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -90,7 +90,7 @@ To customize this default setup, to send traces to alternative or additional bac External trace processors include: -- [Opik](https://www.comet.com/docs/opik/tracing/integrations/openai) +- [Opik](https://www.comet.com/docs/opik/tracing/integrations/openai_agents) - [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) From 7e85c035334d0a02be2d12342f188cfe91776641 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 13 Mar 2025 07:56:27 +1100 Subject: [PATCH 05/65] Update tracing.md --- docs/tracing.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tracing.md b/docs/tracing.md index 113222b..19a7e00 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -90,8 +90,8 @@ To customize this default setup, to send traces to alternative or additional bac External trace processors include: -- [Opik](https://www.comet.com/docs/opik/tracing/integrations/openai_agents) -- [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) +- [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) +- [Comet Opik](https://www.comet.com/docs/opik/tracing/integrations/openai_agents) - [Keywords AI](https://docs.keywordsai.co/integration/development-frameworks/openai-agent) +- [Pydantic Logfire](https://logfire.pydantic.dev/docs/integrations/llms/openai/#openai-agents) From 47aed7d3625be3c840024deb8ce8e067ed32d854 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Mon, 17 Mar 2025 15:06:57 -0400 Subject: [PATCH 06/65] Update tests and docs for strict mode decorator --- src/agents/tool.py | 7 +++++-- tests/test_function_tool_decorator.py | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/agents/tool.py b/src/agents/tool.py index 0baf2c0..3c30921 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -190,8 +190,11 @@ def function_tool( failure_error_function: If provided, use this function to generate an error message when the tool call fails. The error message is sent to the LLM. If you pass None, then no error message will be sent and instead an Exception will be raised. - strict_mode: If False, parameters with default values become optional in the - function schema. + strict_mode: Whether to enable strict mode for the tool's JSON schema. We *strongly* + recommend setting this to True, as it increases the likelihood of correct JSON input. + If False, it allows non-strict JSON schemas. For example, if a parameter has a default + value, it will be optional, additional properties are allowed, etc. See here for more: + https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas """ def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: diff --git a/tests/test_function_tool_decorator.py b/tests/test_function_tool_decorator.py index f146ec7..903dd12 100644 --- a/tests/test_function_tool_decorator.py +++ b/tests/test_function_tool_decorator.py @@ -152,9 +152,13 @@ def optional_param_function(a: int, b: Optional[int] = None) -> str: @pytest.mark.asyncio -async def test_optional_param_function(): +async def test_non_strict_mode_function(): tool = optional_param_function + assert tool.strict_json_schema is False, "strict_json_schema should be False" + + assert tool.params_json_schema.get("required") == ["a"], "required should only be a" + input_data = {"a": 5} output = await tool.on_invoke_tool(ctx_wrapper(), json.dumps(input_data)) assert output == "5_no_b" @@ -165,7 +169,7 @@ async def test_optional_param_function(): @function_tool(strict_mode=False) -def multiple_optional_params_function( +def all_optional_params_function( x: int = 42, y: str = "hello", z: Optional[int] = None, @@ -176,8 +180,12 @@ def multiple_optional_params_function( @pytest.mark.asyncio -async def test_multiple_optional_params_function(): - tool = multiple_optional_params_function +async def test_all_optional_params_function(): + tool = all_optional_params_function + + assert tool.strict_json_schema is False, "strict_json_schema should be False" + + assert tool.params_json_schema.get("required") is None, "required should be empty" input_data: dict[str, Any] = {} output = await tool.on_invoke_tool(ctx_wrapper(), json.dumps(input_data)) From e5befd0cba4f508ecb771958267f53527ada8cff Mon Sep 17 00:00:00 2001 From: Akshay Deo Date: Tue, 18 Mar 2025 07:49:33 +0530 Subject: [PATCH 07/65] chore: adds Maxim AI to tracing processors --- docs/tracing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tracing.md b/docs/tracing.md index 7b1ab7a..051d114 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -99,3 +99,4 @@ To customize this default setup, to send traces to alternative or additional bac - [Scorecard](https://docs.scorecard.io/docs/documentation/features/tracing#openai-agents-sdk-integration) - [Keywords AI](https://docs.keywordsai.co/integration/development-frameworks/openai-agent) - [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/trace_with_openai_agents_sdk) +- [Maxim AI](https://www.getmaxim.ai/docs/observe/integrations/openai-agents-sdk) From e0eb4be14508e73adc9976951ea42cd0c53dd102 Mon Sep 17 00:00:00 2001 From: B-Step62 Date: Fri, 14 Mar 2025 13:01:15 +0900 Subject: [PATCH 08/65] [doc] add mlflow tracing integration Signed-off-by: B-Step62 --- docs/tracing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tracing.md b/docs/tracing.md index 7b1ab7a..6a48c18 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -93,6 +93,7 @@ To customize this default setup, to send traces to alternative or additional bac ## External tracing processors list - [Arize-Phoenix](https://docs.arize.com/phoenix/tracing/integrations-tracing/openai-agents-sdk) +- [MLflow](https://mlflow.org/docs/latest/tracing/integrations/openai-agent) - [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) From 74b197bc2c4cfe9ac0f1b1a20227c47a42659225 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Tue, 18 Mar 2025 16:46:32 -0400 Subject: [PATCH 09/65] Add min 95% code coverage --- .github/workflows/tests.yml | 4 ++-- Makefile | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6dce5c8..c9a56e8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,8 +50,8 @@ jobs: enable-cache: true - name: Install dependencies run: make sync - - name: Run tests - run: make tests + - name: Run tests with coverage + run: make coverage build-docs: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 39899d8..16ed5fe 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,13 @@ mypy: tests: uv run pytest +.PHONY: coverage +coverage: + + uv run coverage run -m pytest + uv run coverage xml -o coverage.xml + uv run coverage report -m --fail-under=95 + .PHONY: snapshots-fix snapshots-fix: uv run pytest --inline-snapshot=fix @@ -42,4 +49,6 @@ serve-docs: .PHONY: deploy-docs deploy-docs: uv run mkdocs gh-deploy --force --verbose + + From 10aa5555af49dfe0cb7f326243cdc8274be307bb Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Tue, 18 Mar 2025 21:43:02 -0400 Subject: [PATCH 10/65] Introduce tool_use_behavior on agents --- docs/agents.md | 13 ++ examples/agent_patterns/forcing_tool_use.py | 99 ++++++++++ examples/basic/tools.py | 34 ++++ src/agents/__init__.py | 6 +- src/agents/_run_impl.py | 99 +++++++++- src/agents/agent.py | 52 +++++- src/agents/items.py | 6 +- src/agents/tool.py | 27 ++- src/agents/tracing/span_data.py | 4 +- tests/test_agent_runner.py | 82 +++++++++ tests/test_function_tool.py | 4 +- tests/test_tool_use_behavior.py | 194 ++++++++++++++++++++ 12 files changed, 594 insertions(+), 26 deletions(-) create mode 100644 examples/agent_patterns/forcing_tool_use.py create mode 100644 examples/basic/tools.py create mode 100644 tests/test_tool_use_behavior.py diff --git a/docs/agents.md b/docs/agents.md index 17589b3..1c31473 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -130,3 +130,16 @@ robot_agent = pirate_agent.clone( instructions="Write like a robot", ) ``` + +## Forcing tool use + +Supplying a list of tools doesn't always mean the LLM will use a tool. You can force tool use by setting [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]. Valid values are: + +1. `auto`, which allows the LLM to decide whether or not to use a tool. +2. `required`, which requires the LLM to use a tool (but it can intelligently decide which tool). +3. `none`, which requires the LLM to _not_ use a tool. +4. Setting a specific string e.g. `my_tool`, which requires the LLM to use that specific tool. + +!!! 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. diff --git a/examples/agent_patterns/forcing_tool_use.py b/examples/agent_patterns/forcing_tool_use.py new file mode 100644 index 0000000..3f4e35a --- /dev/null +++ b/examples/agent_patterns/forcing_tool_use.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Literal + +from pydantic import BaseModel + +from agents import ( + Agent, + FunctionToolResult, + ModelSettings, + RunContextWrapper, + Runner, + ToolsToFinalOutputFunction, + ToolsToFinalOutputResult, + function_tool, +) + +""" +This example shows how to force the agent to use a tool. It uses `ModelSettings(tool_choice="required")` +to force the agent to use any tool. + +You can run it with 3 options: +1. `default`: The default behavior, which is to send the tool output to the LLM. In this case, + `tool_choice` is not set, because otherwise it would result in an infinite loop - the LLM would + call the tool, the tool would run and send the results to the LLM, and that would repeat + (because the model is forced to use a tool every time.) +2. `first_tool_result`: The first tool result is used as the final output. +3. `custom`: A custom tool use behavior function is used. The custom function receives all the tool + results, and chooses to use the first tool result to generate the final output. + +Usage: +python examples/agent_patterns/forcing_tool_use.py -t default +python examples/agent_patterns/forcing_tool_use.py -t first_tool +python examples/agent_patterns/forcing_tool_use.py -t custom +""" + + +class Weather(BaseModel): + city: str + temperature_range: str + conditions: str + + +@function_tool +def get_weather(city: str) -> Weather: + print("[debug] get_weather called") + return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind") + + +async def custom_tool_use_behavior( + context: RunContextWrapper[Any], results: list[FunctionToolResult] +) -> ToolsToFinalOutputResult: + weather: Weather = results[0].output + return ToolsToFinalOutputResult( + is_final_output=True, final_output=f"{weather.city} is {weather.conditions}." + ) + + +async def main(tool_use_behavior: Literal["default", "first_tool", "custom"] = "default"): + if tool_use_behavior == "default": + behavior: Literal["run_llm_again", "stop_on_first_tool"] | ToolsToFinalOutputFunction = ( + "run_llm_again" + ) + elif tool_use_behavior == "first_tool": + behavior = "stop_on_first_tool" + elif tool_use_behavior == "custom": + behavior = custom_tool_use_behavior + + agent = Agent( + name="Weather agent", + instructions="You are a helpful agent.", + tools=[get_weather], + tool_use_behavior=behavior, + model_settings=ModelSettings( + tool_choice="required" if tool_use_behavior != "default" else None + ), + ) + + result = await Runner.run(agent, input="What's the weather in Tokyo?") + print(result.final_output) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument( + "-t", + "--tool-use-behavior", + type=str, + required=True, + choices=["default", "first_tool", "custom"], + help="The behavior to use for tool use. Default will cause tool outputs to be sent to the model. " + "first_tool_result will cause the first tool result to be used as the final output. " + "custom will use a custom tool use behavior function.", + ) + args = parser.parse_args() + asyncio.run(main(args.tool_use_behavior)) diff --git a/examples/basic/tools.py b/examples/basic/tools.py new file mode 100644 index 0000000..8936065 --- /dev/null +++ b/examples/basic/tools.py @@ -0,0 +1,34 @@ +import asyncio + +from pydantic import BaseModel + +from agents import Agent, Runner, function_tool + + +class Weather(BaseModel): + city: str + temperature_range: str + conditions: str + + +@function_tool +def get_weather(city: str) -> Weather: + print("[debug] get_weather called") + return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") + + +agent = Agent( + name="Hello world", + instructions="You are a helpful agent.", + tools=[get_weather], +) + + +async def main(): + result = await Runner.run(agent, input="What's the weather in Tokyo?") + print(result.final_output) + # The weather in Tokyo is sunny. + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 21a2f2a..a7a1272 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -5,7 +5,7 @@ from typing import Literal from openai import AsyncOpenAI from . import _config -from .agent import Agent +from .agent import Agent, ToolsToFinalOutputFunction, ToolsToFinalOutputResult from .agent_output import AgentOutputSchema from .computer import AsyncComputer, Button, Computer, Environment from .exceptions import ( @@ -57,6 +57,7 @@ from .tool import ( ComputerTool, FileSearchTool, FunctionTool, + FunctionToolResult, Tool, WebSearchTool, default_tool_error_function, @@ -137,6 +138,8 @@ def enable_verbose_stdout_logging(): __all__ = [ "Agent", + "ToolsToFinalOutputFunction", + "ToolsToFinalOutputResult", "Runner", "Model", "ModelProvider", @@ -190,6 +193,7 @@ __all__ = [ "AgentUpdatedStreamEvent", "StreamEvent", "FunctionTool", + "FunctionToolResult", "ComputerTool", "FileSearchTool", "Tool", diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index c0c0ebd..2849538 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio +import inspect +from collections.abc import Awaitable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from openai.types.responses import ( ResponseComputerToolCall, @@ -25,7 +27,7 @@ from openai.types.responses.response_computer_tool_call import ( from openai.types.responses.response_input_param import ComputerCallOutput from openai.types.responses.response_reasoning_item import ResponseReasoningItem -from .agent import Agent +from .agent import Agent, ToolsToFinalOutputResult from .agent_output import AgentOutputSchema from .computer import AsyncComputer, Computer from .exceptions import AgentsException, ModelBehaviorError, UserError @@ -48,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 +from .tool import ComputerTool, FunctionTool, FunctionToolResult from .tracing import ( SpanError, Trace, @@ -70,6 +72,8 @@ class QueueCompleteSentinel: QUEUE_COMPLETE_SENTINEL = QueueCompleteSentinel() +_NOT_FINAL_OUTPUT = ToolsToFinalOutputResult(is_final_output=False, final_output=None) + @dataclass class ToolRunHandoff: @@ -199,7 +203,7 @@ class RunImpl: config=run_config, ), ) - new_step_items.extend(function_results) + new_step_items.extend([result.run_item for result in function_results]) new_step_items.extend(computer_results) # Second, check if there are any handoffs @@ -216,6 +220,36 @@ class RunImpl: run_config=run_config, ) + # Third, we'll check if the tool use should result in a final output + check_tool_use = await cls._check_for_final_output_from_tools( + agent=agent, + tool_results=function_results, + context_wrapper=context_wrapper, + config=run_config, + ) + + if check_tool_use.is_final_output: + # If the output type is str, then let's just stringify it + if not agent.output_type or agent.output_type is str: + check_tool_use.final_output = str(check_tool_use.final_output) + + if check_tool_use.final_output is None: + logger.error( + "Model returned a final output of None. Not raising an error because we assume" + "you know what you're doing." + ) + + return await cls.execute_final_output( + agent=agent, + original_input=original_input, + new_response=new_response, + pre_step_items=pre_step_items, + new_step_items=new_step_items, + final_output=check_tool_use.final_output, + hooks=hooks, + context_wrapper=context_wrapper, + ) + # Now we can check if the model also produced a final output message_items = [item for item in new_step_items if isinstance(item, MessageOutputItem)] @@ -355,10 +389,10 @@ class RunImpl: hooks: RunHooks[TContext], context_wrapper: RunContextWrapper[TContext], config: RunConfig, - ) -> list[RunItem]: + ) -> list[FunctionToolResult]: async def run_single_tool( func_tool: FunctionTool, tool_call: ResponseFunctionToolCall - ) -> str: + ) -> Any: with function_span(func_tool.name) as span_fn: if config.trace_include_sensitive_data: span_fn.span_data.input = tool_call.arguments @@ -404,10 +438,14 @@ class RunImpl: results = await asyncio.gather(*tasks) return [ - ToolCallOutputItem( - output=str(result), - raw_item=ItemHelpers.tool_call_output_item(tool_run.tool_call, str(result)), - agent=agent, + FunctionToolResult( + tool=tool_run.function_tool, + output=result, + run_item=ToolCallOutputItem( + output=result, + raw_item=ItemHelpers.tool_call_output_item(tool_run.tool_call, str(result)), + agent=agent, + ), ) for tool_run, result in zip(tool_runs, results) ] @@ -646,6 +684,47 @@ class RunImpl: if event: queue.put_nowait(event) + @classmethod + async def _check_for_final_output_from_tools( + cls, + *, + agent: Agent[TContext], + tool_results: list[FunctionToolResult], + context_wrapper: RunContextWrapper[TContext], + config: RunConfig, + ) -> ToolsToFinalOutputResult: + """Returns (i, final_output).""" + if not tool_results: + return _NOT_FINAL_OUTPUT + + if agent.tool_use_behavior == "run_llm_again": + return _NOT_FINAL_OUTPUT + elif agent.tool_use_behavior == "stop_on_first_tool": + return ToolsToFinalOutputResult( + is_final_output=True, final_output=tool_results[0].output + ) + elif isinstance(agent.tool_use_behavior, dict): + names = agent.tool_use_behavior.get("stop_at_tool_names", []) + for tool_result in tool_results: + if tool_result.tool.name in names: + return ToolsToFinalOutputResult( + is_final_output=True, final_output=tool_result.output + ) + return ToolsToFinalOutputResult(is_final_output=False, final_output=None) + elif callable(agent.tool_use_behavior): + if inspect.iscoroutinefunction(agent.tool_use_behavior): + return await cast( + Awaitable[ToolsToFinalOutputResult], + agent.tool_use_behavior(context_wrapper, tool_results), + ) + else: + return cast( + ToolsToFinalOutputResult, agent.tool_use_behavior(context_wrapper, tool_results) + ) + + logger.error(f"Invalid tool_use_behavior: {agent.tool_use_behavior}") + raise UserError(f"Invalid tool_use_behavior: {agent.tool_use_behavior}") + class TraceCtxManager: """Creates a trace only if there is no current trace, and manages the trace lifecycle.""" diff --git a/src/agents/agent.py b/src/agents/agent.py index 3c4588e..2723e67 100644 --- a/src/agents/agent.py +++ b/src/agents/agent.py @@ -4,7 +4,9 @@ import dataclasses import inspect from collections.abc import Awaitable from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Callable, Generic, cast +from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, cast + +from typing_extensions import TypeAlias, TypedDict from .guardrail import InputGuardrail, OutputGuardrail from .handoffs import Handoff @@ -13,7 +15,7 @@ from .logger import logger from .model_settings import ModelSettings from .models.interface import Model from .run_context import RunContextWrapper, TContext -from .tool import Tool, function_tool +from .tool import FunctionToolResult, Tool, function_tool from .util import _transforms from .util._types import MaybeAwaitable @@ -22,6 +24,33 @@ if TYPE_CHECKING: from .result import RunResult +@dataclass +class ToolsToFinalOutputResult: + is_final_output: bool + """Whether this is the final output. If False, the LLM will run again and receive the tool call + output. + """ + + final_output: Any | None = None + """The final output. Can be None if `is_final_output` is False, otherwise must match the + `output_type` of the agent. + """ + + +ToolsToFinalOutputFunction: TypeAlias = Callable[ + [RunContextWrapper[TContext], list[FunctionToolResult]], + MaybeAwaitable[ToolsToFinalOutputResult], +] +"""A function that takes a run context and a list of tool results, and returns a +`ToolToFinalOutputResult`. +""" + + +class StopAtTools(TypedDict): + stop_at_tool_names: list[str] + """A list of tool names, any of which will stop the agent from running further.""" + + @dataclass class Agent(Generic[TContext]): """An agent is an AI model configured with instructions, tools, guardrails, handoffs and more. @@ -95,6 +124,25 @@ class Agent(Generic[TContext]): """A class that receives callbacks on various lifecycle events for this agent. """ + tool_use_behavior: ( + Literal["run_llm_again", "stop_on_first_tool"] | StopAtTools | ToolsToFinalOutputFunction + ) = "run_llm_again" + """This lets you configure how tool use is handled. + - "run_llm_again": The default behavior. Tools are run, and then the LLM receives the results + and gets to respond. + - "stop_on_first_tool": The output of the first tool call is used as the final output. This + means that the LLM does not process the result of the tool call. + - A list of tool names: The agent will stop running if any of the tools in the list are called. + The final output will be the output of the first matching tool call. The LLM does not + process the result of the tool call. + - A function: If you pass a function, it will be called with the run context and the list of + tool results. It must return a `ToolToFinalOutputResult`, which determines whether the tool + calls result in a final output. + + NOTE: This configuration is specific to FunctionTools. Hosted tools, such as file search, + web search, etc are always processed by the LLM. + """ + 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/items.py b/src/agents/items.py index ffbeba0..c2af0df 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -129,8 +129,10 @@ class ToolCallOutputItem(RunItemBase[Union[FunctionCallOutput, ComputerCallOutpu raw_item: FunctionCallOutput | ComputerCallOutput """The raw item from the model.""" - output: str - """The output of the tool call.""" + output: Any + """The output of the tool call. This is whatever the tool call returned; the `raw_item` + contains a string representation of the output. + """ type: Literal["tool_call_output_item"] = "tool_call_output_item" diff --git a/src/agents/tool.py b/src/agents/tool.py index 3c30921..c1c1624 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -15,6 +15,7 @@ from . import _debug from .computer import AsyncComputer, Computer from .exceptions import ModelBehaviorError from .function_schema import DocstringStyle, function_schema +from .items import RunItem from .logger import logger from .run_context import RunContextWrapper from .tracing import SpanError @@ -29,6 +30,18 @@ ToolFunctionWithContext = Callable[Concatenate[RunContextWrapper[Any], ToolParam ToolFunction = Union[ToolFunctionWithoutContext[ToolParams], ToolFunctionWithContext[ToolParams]] +@dataclass +class FunctionToolResult: + tool: FunctionTool + """The tool that was run.""" + + output: Any + """The output of the tool.""" + + run_item: RunItem + """The run item that was produced as a result of the tool call.""" + + @dataclass class FunctionTool: """A tool that wraps a function. In most cases, you should use the `function_tool` helpers to @@ -44,15 +57,15 @@ class FunctionTool: params_json_schema: dict[str, Any] """The JSON schema for the tool's parameters.""" - on_invoke_tool: Callable[[RunContextWrapper[Any], str], Awaitable[str]] + on_invoke_tool: Callable[[RunContextWrapper[Any], str], Awaitable[Any]] """A function that invokes the tool with the given context and parameters. The params passed are: 1. The tool run context. 2. The arguments from the LLM, as a JSON string. - You must return a string representation of the tool output. In case of errors, you can either - raise an Exception (which will cause the run to fail) or return a string error message (which - will be sent back to the LLM). + You must return a string representation of the tool output, or something we can call `str()` on. + In case of errors, you can either raise an Exception (which will cause the run to fail) or + return a string error message (which will be sent back to the LLM). """ strict_json_schema: bool = True @@ -207,7 +220,7 @@ def function_tool( strict_json_schema=strict_mode, ) - async def _on_invoke_tool_impl(ctx: RunContextWrapper[Any], input: str) -> str: + async def _on_invoke_tool_impl(ctx: RunContextWrapper[Any], input: str) -> Any: try: json_data: dict[str, Any] = json.loads(input) if input else {} except Exception as e: @@ -254,9 +267,9 @@ def function_tool( else: logger.debug(f"Tool {schema.name} returned {result}") - return str(result) + return result - async def _on_invoke_tool(ctx: RunContextWrapper[Any], input: str) -> str: + async def _on_invoke_tool(ctx: RunContextWrapper[Any], input: str) -> Any: try: return await _on_invoke_tool_impl(ctx, input) except Exception as e: diff --git a/src/agents/tracing/span_data.py b/src/agents/tracing/span_data.py index 5e5d38c..1a49d8e 100644 --- a/src/agents/tracing/span_data.py +++ b/src/agents/tracing/span_data.py @@ -51,7 +51,7 @@ class AgentSpanData(SpanData): class FunctionSpanData(SpanData): __slots__ = ("name", "input", "output") - def __init__(self, name: str, input: str | None, output: str | None): + def __init__(self, name: str, input: str | None, output: Any | None): self.name = name self.input = input self.output = output @@ -65,7 +65,7 @@ class FunctionSpanData(SpanData): "type": self.type, "name": self.name, "input": self.input, - "output": self.output, + "output": str(self.output) if self.output else None, } diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index c124915..e8e060f 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -21,6 +21,8 @@ from agents import ( UserError, handoff, ) +from agents.agent import ToolsToFinalOutputResult +from agents.tool import FunctionToolResult, function_tool from .fake_model import FakeModel from .test_responses import ( @@ -552,3 +554,83 @@ async def test_output_guardrail_tripwire_triggered_causes_exception(): with pytest.raises(OutputGuardrailTripwireTriggered): await Runner.run(agent, input="user_message") + + +@function_tool +def test_tool_one(): + return Foo(bar="tool_one_result") + + +@function_tool +def test_tool_two(): + return "tool_two_result" + + +@pytest.mark.asyncio +async def test_tool_use_behavior_first_output(): + model = FakeModel() + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("foo", "tool_result"), test_tool_one, test_tool_two], + tool_use_behavior="stop_on_first_tool", + output_type=Foo, + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [ + get_text_message("a_message"), + get_function_tool_call("test_tool_one", None), + get_function_tool_call("test_tool_two", None), + ], + ] + ) + + result = await Runner.run(agent, input="user_message") + + assert result.final_output == Foo(bar="tool_one_result"), ( + "should have used the first tool result" + ) + + +def custom_tool_use_behavior( + context: RunContextWrapper[Any], results: list[FunctionToolResult] +) -> ToolsToFinalOutputResult: + if "test_tool_one" in [result.tool.name for result in results]: + return ToolsToFinalOutputResult(is_final_output=True, final_output="the_final_output") + else: + return ToolsToFinalOutputResult(is_final_output=False, final_output=None) + + +@pytest.mark.asyncio +async def test_tool_use_behavior_custom_function(): + model = FakeModel() + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("foo", "tool_result"), test_tool_one, test_tool_two], + tool_use_behavior=custom_tool_use_behavior, + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [ + get_text_message("a_message"), + get_function_tool_call("test_tool_two", None), + ], + # Second turn: a message and tool call + [ + get_text_message("a_message"), + get_function_tool_call("test_tool_one", None), + get_function_tool_call("test_tool_two", None), + ], + ] + ) + + result = await Runner.run(agent, input="user_message") + + assert len(result.raw_responses) == 2, "should have two model responses" + assert result.final_output == "the_final_output", "should have used the custom function" diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py index 6a78309..0a57aea 100644 --- a/tests/test_function_tool.py +++ b/tests/test_function_tool.py @@ -49,10 +49,10 @@ async def test_simple_function(): assert tool.name == "simple_function" result = await tool.on_invoke_tool(RunContextWrapper(None), '{"a": 1}') - assert result == "6" + assert result == 6 result = await tool.on_invoke_tool(RunContextWrapper(None), '{"a": 1, "b": 2}') - assert result == "3" + assert result == 3 # Missing required argument should raise an error with pytest.raises(ModelBehaviorError): diff --git a/tests/test_tool_use_behavior.py b/tests/test_tool_use_behavior.py new file mode 100644 index 0000000..6a673b7 --- /dev/null +++ b/tests/test_tool_use_behavior.py @@ -0,0 +1,194 @@ +# Copyright + +from __future__ import annotations + +from typing import cast + +import pytest +from openai.types.responses.response_input_item_param import FunctionCallOutput + +from agents import ( + Agent, + FunctionToolResult, + RunConfig, + RunContextWrapper, + ToolCallOutputItem, + ToolsToFinalOutputResult, + UserError, +) +from agents._run_impl import RunImpl + +from .test_responses import get_function_tool + + +def _make_function_tool_result( + agent: Agent, output: str, tool_name: str | None = None +) -> FunctionToolResult: + # Construct a FunctionToolResult with the given output using a simple function tool. + tool = get_function_tool(tool_name or "dummy", return_value=output) + raw_item: FunctionCallOutput = cast( + FunctionCallOutput, + { + "call_id": "1", + "output": output, + "type": "function_call_output", + }, + ) + # For this test we don't care about the specific RunItem subclass, only the output field + run_item = ToolCallOutputItem(agent=agent, raw_item=raw_item, output=output) + return FunctionToolResult(tool=tool, output=output, run_item=run_item) + + +@pytest.mark.asyncio +async def test_no_tool_results_returns_not_final_output() -> None: + # If there are no tool results at all, tool_use_behavior should not produce a final output. + agent = Agent(name="test") + result = await RunImpl._check_for_final_output_from_tools( + agent=agent, + tool_results=[], + context_wrapper=RunContextWrapper(context=None), + config=RunConfig(), + ) + assert result.is_final_output is False + assert result.final_output is None + + +@pytest.mark.asyncio +async def test_run_llm_again_behavior() -> None: + # With the default run_llm_again behavior, even with tools we still expect to keep running. + agent = Agent(name="test", tool_use_behavior="run_llm_again") + tool_results = [_make_function_tool_result(agent, "ignored")] + result = await RunImpl._check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + config=RunConfig(), + ) + assert result.is_final_output is False + assert result.final_output is None + + +@pytest.mark.asyncio +async def test_stop_on_first_tool_behavior() -> None: + # When tool_use_behavior is stop_on_first_tool, we should surface first tool output as final. + agent = Agent(name="test", tool_use_behavior="stop_on_first_tool") + tool_results = [ + _make_function_tool_result(agent, "first_tool_output"), + _make_function_tool_result(agent, "ignored"), + ] + result = await RunImpl._check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + config=RunConfig(), + ) + assert result.is_final_output is True + assert result.final_output == "first_tool_output" + + +@pytest.mark.asyncio +async def test_custom_tool_use_behavior_sync() -> None: + """If tool_use_behavior is a sync function, we should call it and propagate its return.""" + + def behavior( + context: RunContextWrapper, results: list[FunctionToolResult] + ) -> ToolsToFinalOutputResult: + assert len(results) == 3 + return ToolsToFinalOutputResult(is_final_output=True, final_output="custom") + + agent = Agent(name="test", tool_use_behavior=behavior) + tool_results = [ + _make_function_tool_result(agent, "ignored1"), + _make_function_tool_result(agent, "ignored2"), + _make_function_tool_result(agent, "ignored3"), + ] + result = await RunImpl._check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + config=RunConfig(), + ) + assert result.is_final_output is True + assert result.final_output == "custom" + + +@pytest.mark.asyncio +async def test_custom_tool_use_behavior_async() -> None: + """If tool_use_behavior is an async function, we should await it and propagate its return.""" + + async def behavior( + context: RunContextWrapper, results: list[FunctionToolResult] + ) -> ToolsToFinalOutputResult: + assert len(results) == 3 + return ToolsToFinalOutputResult(is_final_output=True, final_output="async_custom") + + agent = Agent(name="test", tool_use_behavior=behavior) + tool_results = [ + _make_function_tool_result(agent, "ignored1"), + _make_function_tool_result(agent, "ignored2"), + _make_function_tool_result(agent, "ignored3"), + ] + result = await RunImpl._check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + config=RunConfig(), + ) + assert result.is_final_output is True + assert result.final_output == "async_custom" + + +@pytest.mark.asyncio +async def test_invalid_tool_use_behavior_raises() -> None: + """If tool_use_behavior is invalid, we should raise a UserError.""" + agent = Agent(name="test") + # Force an invalid value; mypy will complain, so ignore the type here. + agent.tool_use_behavior = "bad_value" # type: ignore[assignment] + tool_results = [_make_function_tool_result(agent, "ignored")] + with pytest.raises(UserError): + await RunImpl._check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + config=RunConfig(), + ) + + +@pytest.mark.asyncio +async def test_tool_names_to_stop_at_behavior() -> None: + agent = Agent( + name="test", + tools=[ + get_function_tool("tool1", return_value="tool1_output"), + get_function_tool("tool2", return_value="tool2_output"), + get_function_tool("tool3", return_value="tool3_output"), + ], + tool_use_behavior={"stop_at_tool_names": ["tool1"]}, + ) + + tool_results = [ + _make_function_tool_result(agent, "ignored1", "tool2"), + _make_function_tool_result(agent, "ignored3", "tool3"), + ] + result = await RunImpl._check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + config=RunConfig(), + ) + assert result.is_final_output is False, "We should not have stopped at tool1" + + # Now test with a tool that matches the list + tool_results = [ + _make_function_tool_result(agent, "output1", "tool1"), + _make_function_tool_result(agent, "ignored2", "tool2"), + _make_function_tool_result(agent, "ignored3", "tool3"), + ] + result = await RunImpl._check_for_final_output_from_tools( + agent=agent, + tool_results=tool_results, + context_wrapper=RunContextWrapper(context=None), + config=RunConfig(), + ) + assert result.is_final_output is True, "We should have stopped at tool1" + assert result.final_output == "output1" From bc8369c76a68e4508e46cc946de4a21716a056ea Mon Sep 17 00:00:00 2001 From: CCM Date: Wed, 19 Mar 2025 11:10:58 +0800 Subject: [PATCH 11/65] fix reasoning order in guardrails.md --- docs/guardrails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guardrails.md b/docs/guardrails.md index caf3277..70d9649 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -111,8 +111,8 @@ class MessageOutput(BaseModel): # (1)! response: str class MathOutput(BaseModel): # (2)! - is_math: bool reasoning: str + is_math: bool guardrail_agent = Agent( name="Guardrail check", From 8c9974bc90b71ac6f4c5f427fc53fcfb5a9c8a2e Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Wed, 19 Mar 2025 12:29:27 -0400 Subject: [PATCH 12/65] Fix breaking changes from openai 1.66.2 --- pyproject.toml | 2 +- src/agents/models/openai_chatcompletions.py | 7 +- uv.lock | 161 ++++++++++---------- 3 files changed, 87 insertions(+), 83 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3ad1d37..1418f8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ authors = [ { name = "OpenAI", email = "support@openai.com" }, ] dependencies = [ - "openai>=1.66.2", + "openai>=1.66.5", "pydantic>=2.10, <3", "griffe>=1.5.6, <2", "typing-extensions>=4.12.2, <5", diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 3543225..7fe981e 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -54,7 +54,7 @@ from openai.types.responses import ( ResponseUsage, ) from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message -from openai.types.responses.response_usage import OutputTokensDetails +from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails from .. import _debug from ..agent_output import AgentOutputSchema @@ -420,6 +420,11 @@ class OpenAIChatCompletionsModel(Model): and usage.completion_tokens_details.reasoning_tokens else 0 ), + input_tokens_details=InputTokensDetails( + cached_tokens=usage.prompt_tokens_details.cached_tokens + if usage.prompt_tokens_details and usage.prompt_tokens_details.cached_tokens + else 0 + ), ) if usage else None diff --git a/uv.lock b/uv.lock index 2c2e05b..b301991 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,4 @@ version = 1 -revision = 1 requires-python = ">=3.9" [[package]] @@ -13,7 +12,7 @@ wheels = [ [[package]] name = "anyio" -version = "4.8.0" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -21,9 +20,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, ] [[package]] @@ -163,72 +162,72 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.12" +version = "7.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941 } +sdist = { url = "https://files.pythonhosted.org/packages/02/36/465f5492443265e1278f9a82ffe6aeed3f1db779da0d6e7d4611a5cfb6af/coverage-7.7.0.tar.gz", hash = "sha256:cd879d4646055a573775a1cec863d00c9ff8c55860f8b17f6d8eee9140c06166", size = 809969 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/67/81dc41ec8f548c365d04a29f1afd492d3176b372c33e47fa2a45a01dc13a/coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8", size = 208345 }, - { url = "https://files.pythonhosted.org/packages/33/43/17f71676016c8829bde69e24c852fef6bd9ed39f774a245d9ec98f689fa0/coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879", size = 208775 }, - { url = "https://files.pythonhosted.org/packages/86/25/c6ff0775f8960e8c0840845b723eed978d22a3cd9babd2b996e4a7c502c6/coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe", size = 237925 }, - { url = "https://files.pythonhosted.org/packages/b0/3d/5f5bd37046243cb9d15fff2c69e498c2f4fe4f9b42a96018d4579ed3506f/coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674", size = 235835 }, - { url = "https://files.pythonhosted.org/packages/b5/f1/9e6b75531fe33490b910d251b0bf709142e73a40e4e38a3899e6986fe088/coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb", size = 236966 }, - { url = "https://files.pythonhosted.org/packages/4f/bc/aef5a98f9133851bd1aacf130e754063719345d2fb776a117d5a8d516971/coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c", size = 236080 }, - { url = "https://files.pythonhosted.org/packages/eb/d0/56b4ab77f9b12aea4d4c11dc11cdcaa7c29130b837eb610639cf3400c9c3/coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c", size = 234393 }, - { url = "https://files.pythonhosted.org/packages/0d/77/28ef95c5d23fe3dd191a0b7d89c82fea2c2d904aef9315daf7c890e96557/coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e", size = 235536 }, - { url = "https://files.pythonhosted.org/packages/29/62/18791d3632ee3ff3f95bc8599115707d05229c72db9539f208bb878a3d88/coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425", size = 211063 }, - { url = "https://files.pythonhosted.org/packages/fc/57/b3878006cedfd573c963e5c751b8587154eb10a61cc0f47a84f85c88a355/coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa", size = 211955 }, - { url = "https://files.pythonhosted.org/packages/64/2d/da78abbfff98468c91fd63a73cccdfa0e99051676ded8dd36123e3a2d4d5/coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015", size = 208464 }, - { url = "https://files.pythonhosted.org/packages/31/f2/c269f46c470bdabe83a69e860c80a82e5e76840e9f4bbd7f38f8cebbee2f/coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45", size = 208893 }, - { url = "https://files.pythonhosted.org/packages/47/63/5682bf14d2ce20819998a49c0deadb81e608a59eed64d6bc2191bc8046b9/coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702", size = 241545 }, - { url = "https://files.pythonhosted.org/packages/6a/b6/6b6631f1172d437e11067e1c2edfdb7238b65dff965a12bce3b6d1bf2be2/coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0", size = 239230 }, - { url = "https://files.pythonhosted.org/packages/c7/01/9cd06cbb1be53e837e16f1b4309f6357e2dfcbdab0dd7cd3b1a50589e4e1/coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f", size = 241013 }, - { url = "https://files.pythonhosted.org/packages/4b/26/56afefc03c30871326e3d99709a70d327ac1f33da383cba108c79bd71563/coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f", size = 239750 }, - { url = "https://files.pythonhosted.org/packages/dd/ea/88a1ff951ed288f56aa561558ebe380107cf9132facd0b50bced63ba7238/coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d", size = 238462 }, - { url = "https://files.pythonhosted.org/packages/6e/d4/1d9404566f553728889409eff82151d515fbb46dc92cbd13b5337fa0de8c/coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba", size = 239307 }, - { url = "https://files.pythonhosted.org/packages/12/c1/e453d3b794cde1e232ee8ac1d194fde8e2ba329c18bbf1b93f6f5eef606b/coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f", size = 211117 }, - { url = "https://files.pythonhosted.org/packages/d5/db/829185120c1686fa297294f8fcd23e0422f71070bf85ef1cc1a72ecb2930/coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558", size = 212019 }, - { url = "https://files.pythonhosted.org/packages/e2/7f/4af2ed1d06ce6bee7eafc03b2ef748b14132b0bdae04388e451e4b2c529b/coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad", size = 208645 }, - { url = "https://files.pythonhosted.org/packages/dc/60/d19df912989117caa95123524d26fc973f56dc14aecdec5ccd7d0084e131/coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3", size = 208898 }, - { url = "https://files.pythonhosted.org/packages/bd/10/fecabcf438ba676f706bf90186ccf6ff9f6158cc494286965c76e58742fa/coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574", size = 242987 }, - { url = "https://files.pythonhosted.org/packages/4c/53/4e208440389e8ea936f5f2b0762dcd4cb03281a7722def8e2bf9dc9c3d68/coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985", size = 239881 }, - { url = "https://files.pythonhosted.org/packages/c4/47/2ba744af8d2f0caa1f17e7746147e34dfc5f811fb65fc153153722d58835/coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750", size = 242142 }, - { url = "https://files.pythonhosted.org/packages/e9/90/df726af8ee74d92ee7e3bf113bf101ea4315d71508952bd21abc3fae471e/coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea", size = 241437 }, - { url = "https://files.pythonhosted.org/packages/f6/af/995263fd04ae5f9cf12521150295bf03b6ba940d0aea97953bb4a6db3e2b/coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3", size = 239724 }, - { url = "https://files.pythonhosted.org/packages/1c/8e/5bb04f0318805e190984c6ce106b4c3968a9562a400180e549855d8211bd/coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a", size = 241329 }, - { url = "https://files.pythonhosted.org/packages/9e/9d/fa04d9e6c3f6459f4e0b231925277cfc33d72dfab7fa19c312c03e59da99/coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95", size = 211289 }, - { url = "https://files.pythonhosted.org/packages/53/40/53c7ffe3c0c3fff4d708bc99e65f3d78c129110d6629736faf2dbd60ad57/coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288", size = 212079 }, - { url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673 }, - { url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945 }, - { url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484 }, - { url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525 }, - { url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545 }, - { url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179 }, - { url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288 }, - { url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032 }, - { url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315 }, - { url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099 }, - { url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511 }, - { url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729 }, - { url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988 }, - { url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697 }, - { url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033 }, - { url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535 }, - { url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192 }, - { url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627 }, - { url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033 }, - { url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240 }, - { url = "https://files.pythonhosted.org/packages/6c/eb/cf062b1c3dbdcafd64a2a154beea2e4aa8e9886c34e41f53fa04925c8b35/coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d", size = 208343 }, - { url = "https://files.pythonhosted.org/packages/95/42/4ebad0ab065228e29869a060644712ab1b0821d8c29bfefa20c2118c9e19/coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929", size = 208769 }, - { url = "https://files.pythonhosted.org/packages/44/9f/421e84f7f9455eca85ff85546f26cbc144034bb2587e08bfc214dd6e9c8f/coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87", size = 237553 }, - { url = "https://files.pythonhosted.org/packages/c9/c4/a2c4f274bcb711ed5db2ccc1b851ca1c45f35ed6077aec9d6c61845d80e3/coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c", size = 235473 }, - { url = "https://files.pythonhosted.org/packages/e0/10/a3d317e38e5627b06debe861d6c511b1611dd9dc0e2a47afbe6257ffd341/coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2", size = 236575 }, - { url = "https://files.pythonhosted.org/packages/4d/49/51cd991b56257d2e07e3d5cb053411e9de5b0f4e98047167ec05e4e19b55/coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd", size = 235690 }, - { url = "https://files.pythonhosted.org/packages/f7/87/631e5883fe0a80683a1f20dadbd0f99b79e17a9d8ea9aff3a9b4cfe50b93/coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73", size = 234040 }, - { url = "https://files.pythonhosted.org/packages/7c/34/edd03f6933f766ec97dddd178a7295855f8207bb708dbac03777107ace5b/coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86", size = 235048 }, - { url = "https://files.pythonhosted.org/packages/ee/1e/d45045b7d3012fe518c617a57b9f9396cdaebe6455f1b404858b32c38cdd/coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31", size = 211085 }, - { url = "https://files.pythonhosted.org/packages/df/ea/086cb06af14a84fe773b86aa140892006a906c5ec947e609ceb6a93f6257/coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57", size = 211965 }, - { url = "https://files.pythonhosted.org/packages/7a/7f/05818c62c7afe75df11e0233bd670948d68b36cdbf2a339a095bc02624a8/coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf", size = 200558 }, - { url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552 }, + { url = "https://files.pythonhosted.org/packages/10/f5/2b801fe88f199707cf9ec66dcee036e7073b5a208a4a161b64371b3f1e35/coverage-7.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a538a23119d1e2e2ce077e902d02ea3d8e0641786ef6e0faf11ce82324743944", size = 210608 }, + { url = "https://files.pythonhosted.org/packages/07/44/bcc030cf977d1069a28742c0a67284c6e831ef172f914055b3d45da83f89/coverage-7.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1586ad158523f4133499a4f322b230e2cfef9cc724820dbd58595a5a236186f4", size = 211042 }, + { url = "https://files.pythonhosted.org/packages/2c/3f/b427f17e1bcf3e1f5ac42fc0b6cb623616f2aedcfc7fde17a058afb62568/coverage-7.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b6c96d69928a3a6767fab8dc1ce8a02cf0156836ccb1e820c7f45a423570d98", size = 240168 }, + { url = "https://files.pythonhosted.org/packages/58/92/6e8d71c5e651f152ffc518ec4cd7add87035533e88af29e233635c0f0dfb/coverage-7.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f18d47641282664276977c604b5a261e51fefc2980f5271d547d706b06a837f", size = 238079 }, + { url = "https://files.pythonhosted.org/packages/40/33/1c25ae35c16972dc379c24cd7dde20359d076dee00687825c92a53e43b02/coverage-7.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a1e18a85bd066c7c556d85277a7adf4651f259b2579113844835ba1a74aafd", size = 239216 }, + { url = "https://files.pythonhosted.org/packages/4d/3d/adf40bdd07a49e1880632c1bc6b31f42d32cf0bfe6b4d294a8f706d70078/coverage-7.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:70f0925c4e2bfc965369f417e7cc72538fd1ba91639cf1e4ef4b1a6b50439b3b", size = 239126 }, + { url = "https://files.pythonhosted.org/packages/72/a5/51e39811cd0ec0569a25fe8e6bac0a00efa222a3e49d51d64f5ba0dce24a/coverage-7.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b0fac2088ec4aaeb5468b814bd3ff5e5978364bfbce5e567c44c9e2854469f6c", size = 237842 }, + { url = "https://files.pythonhosted.org/packages/ab/b7/c5796906cd9eed6d258138f1fddc8d6af01b6d07b3c183bac4a9731ac383/coverage-7.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3e212a894d8ae07fde2ca8b43d666a6d49bbbddb10da0f6a74ca7bd31f20054", size = 238136 }, + { url = "https://files.pythonhosted.org/packages/d7/8a/bd34ea3c602b3ef323a001d375f9b1d663e901079bb26b5f9b8f96fae32b/coverage-7.7.0-cp310-cp310-win32.whl", hash = "sha256:f32b165bf6dfea0846a9c9c38b7e1d68f313956d60a15cde5d1709fddcaf3bee", size = 213320 }, + { url = "https://files.pythonhosted.org/packages/94/60/6e7efe849e305a233623a80aaeba7ebb02809fa63ab8a1e49c4323b8083b/coverage-7.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:a2454b12a3f12cc4698f3508912e6225ec63682e2ca5a96f80a2b93cef9e63f3", size = 214219 }, + { url = "https://files.pythonhosted.org/packages/e8/ec/9e0c9358a3bd56b1ddbf266b889ea9d51ee29e58fb72712d5600663fa806/coverage-7.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a0a207c87a9f743c8072d059b4711f8d13c456eb42dac778a7d2e5d4f3c253a7", size = 210722 }, + { url = "https://files.pythonhosted.org/packages/be/bd/7b47a4302423a13960ee30682900d7ca20cee15c978b1d9ea9594d59d352/coverage-7.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d673e3add00048215c2cc507f1228a7523fd8bf34f279ac98334c9b07bd2656", size = 211154 }, + { url = "https://files.pythonhosted.org/packages/c6/7c/ae54d9022440196bf9f3fad535388678a3db186980ff58a4956ddeb849a2/coverage-7.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f81fe93dc1b8e5673f33443c0786c14b77e36f1025973b85e07c70353e46882b", size = 243787 }, + { url = "https://files.pythonhosted.org/packages/2d/21/913a2a2d89a2221f4410fbea4ff84e64ddf4367a4b9eb2c328bd01a1a401/coverage-7.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8c7524779003d59948c51b4fcbf1ca4e27c26a7d75984f63488f3625c328b9b", size = 241473 }, + { url = "https://files.pythonhosted.org/packages/40/f1/5ae36fffd542fb86ab3b2d5e012af0840265f3dd001ad0ffabe9e4dbdcf6/coverage-7.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c124025430249118d018dcedc8b7426f39373527c845093132196f2a483b6dd", size = 243259 }, + { url = "https://files.pythonhosted.org/packages/47/1b/abc87bad7f606a4df321bd8300413fe13700099a163e7d63453c7c70c1b2/coverage-7.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e7f559c36d5cdc448ee13e7e56ed7b6b5d44a40a511d584d388a0f5d940977ba", size = 242904 }, + { url = "https://files.pythonhosted.org/packages/e0/b3/ff0cf15f5709996727dda2fa00af6f4da92ea3e16168400346f2f742341a/coverage-7.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:37cbc7b0d93dfd133e33c7ec01123fbb90401dce174c3b6661d8d36fb1e30608", size = 241079 }, + { url = "https://files.pythonhosted.org/packages/05/c9/fcad82aad05b1eb8040e6c25ae7a1303716cc05718d4dd326e0fab31aa14/coverage-7.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7d2a65876274acf544703e943c010b60bd79404e3623a1e5d52b64a6e2728de5", size = 241617 }, + { url = "https://files.pythonhosted.org/packages/59/9f/d1efe149afa5c3a459c08bf04f7e6917ef4ee8e3440df5c3e87d6b972870/coverage-7.7.0-cp311-cp311-win32.whl", hash = "sha256:f5a2f71d6a91238e7628f23538c26aa464d390cbdedf12ee2a7a0fb92a24482a", size = 213372 }, + { url = "https://files.pythonhosted.org/packages/88/d2/4b58f03e399185b01fb3168d4b870882de9c7a10e273f99c8f25ec690302/coverage-7.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae8006772c6b0fa53c33747913473e064985dac4d65f77fd2fdc6474e7cd54e4", size = 214285 }, + { url = "https://files.pythonhosted.org/packages/b7/47/f7b870caa26082ff8033be074ac61dc175a6b0c965adf7b910f92a6d7cfe/coverage-7.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:056d3017ed67e7ddf266e6f57378ece543755a4c9231e997789ab3bd11392c94", size = 210907 }, + { url = "https://files.pythonhosted.org/packages/ea/eb/40b39bdc6c1da403257f0fcb2c1b2fd81ff9f66c13abbe3862f42780e1c1/coverage-7.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33c1394d8407e2771547583b66a85d07ed441ff8fae5a4adb4237ad39ece60db", size = 211162 }, + { url = "https://files.pythonhosted.org/packages/53/08/42a2db41b4646d6261122773e222dd7105e2306526f2d7846de6fee808ec/coverage-7.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fbb7a0c3c21908520149d7751cf5b74eb9b38b54d62997b1e9b3ac19a8ee2fe", size = 245223 }, + { url = "https://files.pythonhosted.org/packages/78/2a/0ceb328a7e67e8639d5c7800b8161d4b5f489073ac8d5ac33b11eadee218/coverage-7.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb356e7ae7c2da13f404bf8f75be90f743c6df8d4607022e759f5d7d89fe83f8", size = 242114 }, + { url = "https://files.pythonhosted.org/packages/ba/68/42b13b849d40af1581830ff06c60f4ec84649764f4a58d5c6e20ae11cbd4/coverage-7.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce730d484038e97f27ea2dbe5d392ec5c2261f28c319a3bb266f6b213650135", size = 244371 }, + { url = "https://files.pythonhosted.org/packages/68/66/ab7c3b9fdbeb8bdd322f5b67b1886463834dba2014a534caba60fb0075ea/coverage-7.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa4dff57fc21a575672176d5ab0ef15a927199e775c5e8a3d75162ab2b0c7705", size = 244134 }, + { url = "https://files.pythonhosted.org/packages/01/74/b833d299a479681957d6b238e16a0725586e1d56ec1e43658f3184550bb0/coverage-7.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b667b91f4f714b17af2a18e220015c941d1cf8b07c17f2160033dbe1e64149f0", size = 242353 }, + { url = "https://files.pythonhosted.org/packages/f9/c5/0ed656d65da39bbab8e8fc367dc3d465a7501fea0f2b1caccfb4f6361c9f/coverage-7.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:693d921621a0c8043bfdc61f7d4df5ea6d22165fe8b807cac21eb80dd94e4bbd", size = 243543 }, + { url = "https://files.pythonhosted.org/packages/87/b5/142bcff3828e4cce5d4c9ddc9222de1664464263acca09638e4eb0dbda7c/coverage-7.7.0-cp312-cp312-win32.whl", hash = "sha256:52fc89602cde411a4196c8c6894afb384f2125f34c031774f82a4f2608c59d7d", size = 213543 }, + { url = "https://files.pythonhosted.org/packages/29/74/99d226985def03284bad6a9aff27a1079a8881ec7523b5980b00a5260527/coverage-7.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ce8cf59e09d31a4915ff4c3b94c6514af4c84b22c4cc8ad7c3c546a86150a92", size = 214344 }, + { url = "https://files.pythonhosted.org/packages/45/2f/df6235ec963b9eb6b6b2f3c24f70448f1ffa13b9a481c155a6caff176395/coverage-7.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4545485fef7a8a2d8f30e6f79ce719eb154aab7e44217eb444c1d38239af2072", size = 210934 }, + { url = "https://files.pythonhosted.org/packages/f3/85/ff19510bf642e334845318ddb73a550d2b17082831fa9ae053ce72288be7/coverage-7.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1393e5aa9441dafb0162c36c8506c648b89aea9565b31f6bfa351e66c11bcd82", size = 211212 }, + { url = "https://files.pythonhosted.org/packages/2d/6a/af6582a419550d35eacc3e1bf9f4a936dda0ae559632a0bc4e3aef694ac8/coverage-7.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:316f29cc3392fa3912493ee4c83afa4a0e2db04ff69600711f8c03997c39baaa", size = 244727 }, + { url = "https://files.pythonhosted.org/packages/55/62/7c49526111c91f3d7d27e111c22c8d08722f5b661c3f031b625b4d7bc4d9/coverage-7.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1ffde1d6bc2a92f9c9207d1ad808550873748ac2d4d923c815b866baa343b3f", size = 241768 }, + { url = "https://files.pythonhosted.org/packages/62/4b/2dc27700782be9795cbbbe98394dd19ef74815d78d5027ed894972cd1b4a/coverage-7.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:416e2a8845eaff288f97eaf76ab40367deafb9073ffc47bf2a583f26b05e5265", size = 243790 }, + { url = "https://files.pythonhosted.org/packages/d3/11/9cc1ae56d3015edca69437f3121c2b44de309f6828980b29e4cc9b13246d/coverage-7.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5efdeff5f353ed3352c04e6b318ab05c6ce9249c25ed3c2090c6e9cadda1e3b2", size = 243861 }, + { url = "https://files.pythonhosted.org/packages/db/e4/2398ed93edcf42ff43002d91c37be11514d825cec382606654fd44f4b8fa/coverage-7.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:57f3bd0d29bf2bd9325c0ff9cc532a175110c4bf8f412c05b2405fd35745266d", size = 241942 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/b6bd35b17a2b8d26bdb21d5ea4351a837ec01edf552655e833629af05b90/coverage-7.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ab7090f04b12dc6469882ce81244572779d3a4b67eea1c96fb9ecc8c607ef39", size = 243228 }, + { url = "https://files.pythonhosted.org/packages/6d/06/d8701bae1e5d865edeb00a6c2a71bd7659ca6af349789271c6fd16a57909/coverage-7.7.0-cp313-cp313-win32.whl", hash = "sha256:180e3fc68ee4dc5af8b33b6ca4e3bb8aa1abe25eedcb958ba5cff7123071af68", size = 213572 }, + { url = "https://files.pythonhosted.org/packages/d7/c1/7e67780bfcaed6bed20100c9e1b2645e3414577b4bdad169578325249045/coverage-7.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:55143aa13c49491f5606f05b49ed88663446dce3a4d3c5d77baa4e36a16d3573", size = 214372 }, + { url = "https://files.pythonhosted.org/packages/ed/25/50b0447442a415ad3da33093c589d9ef945dd6933225f1ce0ac97476397e/coverage-7.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc41374d2f27d81d6558f8a24e5c114580ffefc197fd43eabd7058182f743322", size = 211774 }, + { url = "https://files.pythonhosted.org/packages/13/cc/3daddc707e934d3c0aafaa4a9b217f53fcf4133d4e40cc6ae63aa51243b8/coverage-7.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:89078312f06237417adda7c021c33f80f7a6d2db8572a5f6c330d89b080061ce", size = 211995 }, + { url = "https://files.pythonhosted.org/packages/98/99/c92f43355d3d67f6bf8c946a350f2174e18f9ea7c8a1e36c9eb84ab7d20b/coverage-7.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b2f144444879363ea8834cd7b6869d79ac796cb8f864b0cfdde50296cd95816", size = 256226 }, + { url = "https://files.pythonhosted.org/packages/25/62/65f0f33c08e0a1632f1e487b9c2d252e8bad6a77a942836043972b0ba6d2/coverage-7.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60e6347d1ed882b1159ffea172cb8466ee46c665af4ca397edbf10ff53e9ffaf", size = 251937 }, + { url = "https://files.pythonhosted.org/packages/b2/10/99a9565aaeb159aade178c6509c8324a9c9e825b01f02242a37c2a8869f8/coverage-7.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb203c0afffaf1a8f5b9659a013f8f16a1b2cad3a80a8733ceedc968c0cf4c57", size = 254276 }, + { url = "https://files.pythonhosted.org/packages/a7/12/206196edbf0b82250b11bf5c252fe25ebaa2b7c8d66edb0c194e7b3403fe/coverage-7.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ad0edaa97cb983d9f2ff48cadddc3e1fb09f24aa558abeb4dc9a0dbacd12cbb4", size = 255366 }, + { url = "https://files.pythonhosted.org/packages/a5/82/a2abb8d4cdd99c6a443ab6682c0eee5797490a2113a45ffaa8b6b31c5dcc/coverage-7.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c5f8a5364fc37b2f172c26a038bc7ec4885f429de4a05fc10fdcb53fb5834c5c", size = 253536 }, + { url = "https://files.pythonhosted.org/packages/4d/7d/3747e000e60ad5dd8157bd978f99979967d56cb35c55235980c85305db86/coverage-7.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4e09534037933bf6eb31d804e72c52ec23219b32c1730f9152feabbd7499463", size = 254344 }, + { url = "https://files.pythonhosted.org/packages/45/56/7c33f8a6de1b3b079374d2ae490ccf76fb7c094a23f72d10f071989fc3ef/coverage-7.7.0-cp313-cp313t-win32.whl", hash = "sha256:1b336d06af14f8da5b1f391e8dec03634daf54dfcb4d1c4fb6d04c09d83cef90", size = 214284 }, + { url = "https://files.pythonhosted.org/packages/95/ab/657bfa6171800a67bd1c005402f06d6b78610820ef1364ea4f85b04bbb5b/coverage-7.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b54a1ee4c6f1905a436cbaa04b26626d27925a41cbc3a337e2d3ff7038187f07", size = 215445 }, + { url = "https://files.pythonhosted.org/packages/d1/42/0e77be6f2fafe7f3de88ddf9f8d9a0d8e9a75f9517081d261d31439908c7/coverage-7.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1c8fbce80b2b8bf135d105aa8f5b36eae0c57d702a1cc3ebdea2a6f03f6cdde5", size = 210604 }, + { url = "https://files.pythonhosted.org/packages/0e/62/a82adc7818545fca3987367c6b20f239645678438f7da5827a4960bcbe7f/coverage-7.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d9710521f07f526de30ccdead67e6b236fe996d214e1a7fba8b36e2ba2cd8261", size = 211031 }, + { url = "https://files.pythonhosted.org/packages/a6/50/a98b418fcaf531b2829b2a06f47f8c5cbc0dcce4a9aa63c5f30bf47d1a92/coverage-7.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7789e700f33f2b133adae582c9f437523cd5db8de845774988a58c360fc88253", size = 239791 }, + { url = "https://files.pythonhosted.org/packages/58/f7/0a8f891fce6f389b1062a520aff130fa6974433efeb549dd19cbdccc76b3/coverage-7.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c36093aca722db73633cf2359026ed7782a239eb1c6db2abcff876012dc4cf", size = 237718 }, + { url = "https://files.pythonhosted.org/packages/a9/8f/362c91661e6c43ff86b65b15bbb60ad1ad4924e9d1e35a0d5f08eb3337c4/coverage-7.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c075d167a6ec99b798c1fdf6e391a1d5a2d054caffe9593ba0f97e3df2c04f0e", size = 238820 }, + { url = "https://files.pythonhosted.org/packages/dd/4b/56520dba6f38ad59e96cdeb8c7eafa47781576d2baabdfa10f8c1813b37b/coverage-7.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d013c07061751ae81861cae6ec3a4fe04e84781b11fd4b6b4201590234b25c7b", size = 238595 }, + { url = "https://files.pythonhosted.org/packages/4d/e6/acfae468bd1f9b691b29d42f93bfd7080c05021103f03580934c066a3844/coverage-7.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:104bf640f408f4e115b85110047c7f27377e1a8b7ba86f7db4fa47aa49dc9a8e", size = 236820 }, + { url = "https://files.pythonhosted.org/packages/22/4f/9b65332326b0c5b7de197a52e766e2bd547beec6948e1d5c4063289e3281/coverage-7.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:39abcacd1ed54e2c33c54bdc488b310e8ef6705833f7148b6eb9a547199d375d", size = 237800 }, + { url = "https://files.pythonhosted.org/packages/bb/99/1c2214678731517d91774b75ed5c0f72feefee3270c232c286b314518d7d/coverage-7.7.0-cp39-cp39-win32.whl", hash = "sha256:8e336b56301774ace6be0017ff85c3566c556d938359b61b840796a0202f805c", size = 213341 }, + { url = "https://files.pythonhosted.org/packages/21/30/4d9ae5544f839da30e42e03850d1dfe4ab184d6307ed971e70178760a68d/coverage-7.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:8c938c6ae59be67ac19a7204e079efc94b38222cd7d0269f96e45e18cddeaa59", size = 214227 }, + { url = "https://files.pythonhosted.org/packages/cb/69/6a5eac32d2e8721274ef75df1b9fd6a8f7e8231e41ff7bc5501f19835f25/coverage-7.7.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:3b0e6e54591ae0d7427def8a4d40fca99df6b899d10354bab73cd5609807261c", size = 202813 }, + { url = "https://files.pythonhosted.org/packages/2a/ac/60f409a448e5b0e9b8539716f683568aa5848c1be903cdbbc805a552cdf8/coverage-7.7.0-py3-none-any.whl", hash = "sha256:708f0a1105ef2b11c79ed54ed31f17e6325ac936501fc373f24be3e6a578146a", size = 202803 }, ] [[package]] @@ -333,14 +332,14 @@ wheels = [ [[package]] name = "griffe" -version = "1.6.0" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/1a/d467b93f5e0ea4edf3c1caef44cfdd53a4a498cb3a6bb722df4dd0fdd66a/griffe-1.6.0.tar.gz", hash = "sha256:eb5758088b9c73ad61c7ac014f3cdfb4c57b5c2fcbfca69996584b702aefa354", size = 391819 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/ba/1ebe51a22c491a3fc94b44ef9c46a5b5472540e24a5c3f251cebbab7214b/griffe-1.6.1.tar.gz", hash = "sha256:ff0acf706b2680f8c721412623091c891e752b2c61b7037618f7b77d06732cf5", size = 393112 } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/02/5a22bc98d0aebb68c15ba70d2da1c84a5ef56048d79634e5f96cd2ba96e9/griffe-1.6.0-py3-none-any.whl", hash = "sha256:9f1dfe035d4715a244ed2050dfbceb05b1f470809ed4f6bb10ece5a7302f8dd1", size = 128470 }, + { url = "https://files.pythonhosted.org/packages/1f/d3/a760d1062e44587230aa65573c70edaad4ee8a0e60e193a3172b304d24d8/griffe-1.6.1-py3-none-any.whl", hash = "sha256:b0131670db16834f82383bcf4f788778853c9bf4dc7a1a2b708bb0808ca56a98", size = 128615 }, ] [[package]] @@ -674,7 +673,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.6.7" +version = "9.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -689,9 +688,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/d7/93e19c9587e5f4ed25647890555d58cf484a4d412be7037dc17b9c9179d9/mkdocs_material-9.6.7.tar.gz", hash = "sha256:3e2c1fceb9410056c2d91f334a00cdea3215c28750e00c691c1e46b2a33309b4", size = 3947458 } +sdist = { url = "https://files.pythonhosted.org/packages/11/cb/6dd3b6a7925429c0229738098ee874dbf7fa02db55558adb2c5bf86077b2/mkdocs_material-9.6.9.tar.gz", hash = "sha256:a4872139715a1f27b2aa3f3dc31a9794b7bbf36333c0ba4607cf04786c94f89c", size = 3948083 } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/d3/12f22de41bdd9e576ddc459b38c651d68edfb840b32acaa1f46ae36845e3/mkdocs_material-9.6.7-py3-none-any.whl", hash = "sha256:8a159e45e80fcaadd9fbeef62cbf928569b93df954d4dc5ba76d46820caf7b47", size = 8696755 }, + { url = "https://files.pythonhosted.org/packages/db/7c/ea5a671b2ff5d0e3f3108a7f7d75b541d683e4969aaead2a8f3e59e0fc27/mkdocs_material-9.6.9-py3-none-any.whl", hash = "sha256:6e61b7fb623ce2aa4622056592b155a9eea56ff3487d0835075360be45a4c8d1", size = 8697935 }, ] [[package]] @@ -729,7 +728,7 @@ python = [ [[package]] name = "mkdocstrings-python" -version = "1.16.5" +version = "1.16.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe" }, @@ -737,9 +736,9 @@ dependencies = [ { name = "mkdocstrings" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/81/3575e451682e0ed3c39e9b57d1fd30590cd28a965131ead14bf2efe34a1b/mkdocstrings_python-1.16.5.tar.gz", hash = "sha256:706b28dd0f59249a7c22cc5d517c9521e06c030b57e2a5478e1928a58f900abb", size = 426979 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/e7/0691e34e807a8f5c28f0988fcfeeb584f0b569ce433bf341944f14bdb3ff/mkdocstrings_python-1.16.6.tar.gz", hash = "sha256:cefe0f0e17ab4a4611f01b0a2af75e4298664e0ff54feb83c91a485bfed82dc9", size = 201565 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/27/42f8a520111a4dde9722f08ca75d761b68722158b2232b63def061de12a8/mkdocstrings_python-1.16.5-py3-none-any.whl", hash = "sha256:0899a12e356eab8e83720c63e15d0ff51cd96603216c837618de346e086b39ba", size = 451550 }, + { url = "https://files.pythonhosted.org/packages/6a/42/ed682687ef5f248e104f82806d5d9893f6dd81d8cb4561692e190ba1a252/mkdocstrings_python-1.16.6-py3-none-any.whl", hash = "sha256:de877dd71f69878c973c4897a39683b7b6961bee7b058879095b69681488453f", size = 123207 }, ] [[package]] @@ -797,7 +796,7 @@ wheels = [ [[package]] name = "openai" -version = "1.66.2" +version = "1.66.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -809,9 +808,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/e1/b3e1fda1aa32d4f40d4de744e91de4de65c854c3e53c63342e4b5f9c5995/openai-1.66.2.tar.gz", hash = "sha256:9b3a843c25f81ee09b6469d483d9fba779d5c6ea41861180772f043481b0598d", size = 397041 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/10/b19dc682c806e6735a8387f2003afe2abada9f9e5227318de642c6949524/openai-1.66.5.tar.gz", hash = "sha256:f61b8fac29490ca8fdc6d996aa6926c18dbe5639536f8c40219c40db05511b11", size = 398595 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/6f/3315b3583ffe3e31c55b446cb22d2a7c235e65ca191674fffae62deb3c11/openai-1.66.2-py3-none-any.whl", hash = "sha256:75194057ee6bb8b732526387b6041327a05656d976fc21c064e21c8ac6b07999", size = 567268 }, + { url = "https://files.pythonhosted.org/packages/c7/3b/1ba418920ecd1eae7cc4d4ac8a01711ee0879b1a57dd81d10551e5b9a2ea/openai-1.66.5-py3-none-any.whl", hash = "sha256:74be528175f8389f67675830c51a15bd51e874425c86d3de6153bf70ed6c2884", size = 571144 }, ] [[package]] @@ -846,7 +845,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "griffe", specifier = ">=1.5.6,<2" }, - { name = "openai", specifier = ">=1.66.2" }, + { name = "openai", specifier = ">=1.66.5" }, { name = "pydantic", specifier = ">=2.10,<3" }, { name = "requests", specifier = ">=2.0,<3" }, { name = "types-requests", specifier = ">=2.0,<3" }, From 1ed181c641c581058b9552b48d57db8c477dedf0 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Wed, 19 Mar 2025 12:32:26 -0400 Subject: [PATCH 13/65] v0.0.5 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1418f8e..8aecf1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.0.4" +version = "0.0.5" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.9" diff --git a/uv.lock b/uv.lock index b301991..698c557 100644 --- a/uv.lock +++ b/uv.lock @@ -815,7 +815,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.0.4" +version = "0.0.5" source = { editable = "." } dependencies = [ { name = "griffe" }, From 4dd3e210acf3bac0cdb247cc435273f6bd076af1 Mon Sep 17 00:00:00 2001 From: jhills20 Date: Wed, 19 Mar 2025 11:43:09 -0700 Subject: [PATCH 14/65] add examples section to docs --- docs/examples.md | 33 +++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 34 insertions(+) create mode 100644 docs/examples.md diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..358137d --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,33 @@ +# Examples + +Check out a variety of sample implementations of the SDK in the examples section of the [repo](https://github.com/openai/openai-agents-python/tree/main/examples). The examples are organized into several categories that demonstrate different patterns and capabilities. + + +## Categories + +- **agent_patterns** + Examples in this category illustrate common agent design patterns, such as: + - Deterministic workflows + - Agents as tools + - Parallel agent execution + +- **basic** + These examples showcase foundational capabilities of the SDK, such as: + - Dynamic system prompts + - Streaming outputs + - Lifecycle events + +- **tool examples** + Learn how to implement OAI hosted tools such as web search and file search, + and integrate them into your agents. + +- **model providers** + Explore how to use non-OpenAI models with the SDK. + +- **handoffs** + See practical examples of agent handoffs. + +- **customer_service & research_bot** + Two more built-out examples that illustrate real-world applications: + - **customer_service**: Example customer service system for an airline. + - **research_bot**: Simple deep research clone. diff --git a/mkdocs.yml b/mkdocs.yml index 398fb74..e566b61 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,7 @@ theme: nav: - Intro: index.md - Quickstart: quickstart.md + - Examples: examples.md - Documentation: - agents.md - running_agents.md From d295a53e533797e04459299d00250ada12e5550e Mon Sep 17 00:00:00 2001 From: James Hills <70035505+jhills20@users.noreply.github.com> Date: Wed, 19 Mar 2025 14:23:28 -0700 Subject: [PATCH 15/65] formatting updates to examples doc --- docs/examples.md | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 358137d..1d3ebde 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -5,29 +5,32 @@ Check out a variety of sample implementations of the SDK in the examples section ## Categories -- **agent_patterns** - Examples in this category illustrate common agent design patterns, such as: - - Deterministic workflows - - Agents as tools - - Parallel agent execution +- **agent_patterns:** + Examples in this category illustrate common agent design patterns, such as + + - Deterministic workflows + - Agents as tools + - Parallel agent execution -- **basic** - These examples showcase foundational capabilities of the SDK, such as: - - Dynamic system prompts - - Streaming outputs - - Lifecycle events +- **basic:** + These examples showcase foundational capabilities of the SDK, such as + + - Dynamic system prompts + - Streaming outputs + - Lifecycle events -- **tool examples** +- **tool examples:** Learn how to implement OAI hosted tools such as web search and file search, and integrate them into your agents. -- **model providers** +- **model providers:** Explore how to use non-OpenAI models with the SDK. -- **handoffs** +- **handoffs:** See practical examples of agent handoffs. -- **customer_service & research_bot** - Two more built-out examples that illustrate real-world applications: - - **customer_service**: Example customer service system for an airline. - - **research_bot**: Simple deep research clone. +- **customer_service** and **research_bot:** + Two more built-out examples that illustrate real-world applications + + - **customer_service**: Example customer service system for an airline. + - **research_bot**: Simple deep research clone. From 0dec5712dbe34d655bc2fb0200babaf67d4acd9c Mon Sep 17 00:00:00 2001 From: Shyamal H Anadkat Date: Wed, 19 Mar 2025 22:27:40 -0700 Subject: [PATCH 16/65] Adds example for financial agent --- examples/financial_research_agent/README.md | 38 +++++ examples/financial_research_agent/__init__.py | 0 .../agents/__init__.py | 0 .../agents/financials_agent.py | 23 +++ .../agents/planner_agent.py | 35 +++++ .../agents/risk_agent.py | 22 +++ .../agents/search_agent.py | 18 +++ .../agents/verifier_agent.py | 27 ++++ .../agents/writer_agent.py | 34 +++++ examples/financial_research_agent/main.py | 17 +++ examples/financial_research_agent/manager.py | 140 ++++++++++++++++++ examples/financial_research_agent/printer.py | 45 ++++++ 12 files changed, 399 insertions(+) create mode 100644 examples/financial_research_agent/README.md create mode 100644 examples/financial_research_agent/__init__.py create mode 100644 examples/financial_research_agent/agents/__init__.py create mode 100644 examples/financial_research_agent/agents/financials_agent.py create mode 100644 examples/financial_research_agent/agents/planner_agent.py create mode 100644 examples/financial_research_agent/agents/risk_agent.py create mode 100644 examples/financial_research_agent/agents/search_agent.py create mode 100644 examples/financial_research_agent/agents/verifier_agent.py create mode 100644 examples/financial_research_agent/agents/writer_agent.py create mode 100644 examples/financial_research_agent/main.py create mode 100644 examples/financial_research_agent/manager.py create mode 100644 examples/financial_research_agent/printer.py diff --git a/examples/financial_research_agent/README.md b/examples/financial_research_agent/README.md new file mode 100644 index 0000000..756ade6 --- /dev/null +++ b/examples/financial_research_agent/README.md @@ -0,0 +1,38 @@ +# Financial Research Agent Example + +This example shows how you might compose a richer financial research agent using the Agents SDK. The pattern is similar to the `research_bot` example, but with more specialized sub‑agents and a verification step. + +The flow is: + +1. **Planning**: A planner agent turns the end user’s request into a list of search terms relevant to financial analysis – recent news, earnings calls, corporate filings, industry commentary, etc. +2. **Search**: A search agent uses the built‑in `WebSearchTool` to retrieve terse summaries for each search term. (You could also add `FileSearchTool` if you have indexed PDFs or 10‑Ks.) +3. **Sub‑analysts**: Additional agents (e.g. a fundamentals analyst and a risk analyst) are exposed as tools so the writer can call them inline and incorporate their outputs. +4. **Writing**: A senior writer agent brings together the search snippets and any sub‑analyst summaries into a long‑form markdown report plus a short executive summary. +5. **Verification**: A final verifier agent audits the report for obvious inconsistencies or missing sourcing. + +You can run the example with: + +```bash +python -m examples.financial_research_agent.main +``` + +and enter a query like: + +``` +Write up an analysis of Apple Inc.'s most recent quarter. +``` + +### Starter prompt + +The writer agent is seeded with instructions similar to: + +``` +You are a senior financial analyst. You will be provided with the original query +and a set of raw search summaries. Your job is to synthesize these into a +long‑form markdown report (at least several paragraphs) with a short executive +summary. You also have access to tools like `fundamentals_analysis` and +`risk_analysis` to get short specialist write‑ups if you want to incorporate them. +Add a few follow‑up questions for further research. +``` + +You can tweak these prompts and sub‑agents to suit your own data sources and preferred report structure. diff --git a/examples/financial_research_agent/__init__.py b/examples/financial_research_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/financial_research_agent/agents/__init__.py b/examples/financial_research_agent/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/financial_research_agent/agents/financials_agent.py b/examples/financial_research_agent/agents/financials_agent.py new file mode 100644 index 0000000..953531f --- /dev/null +++ b/examples/financial_research_agent/agents/financials_agent.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel + +from agents import Agent + +# A sub‑agent focused on analyzing a company's fundamentals. +FINANCIALS_PROMPT = ( + "You are a financial analyst focused on company fundamentals such as revenue, " + "profit, margins and growth trajectory. Given a collection of web (and optional file) " + "search results about a company, write a concise analysis of its recent financial " + "performance. Pull out key metrics or quotes. Keep it under 2 paragraphs." +) + + +class AnalysisSummary(BaseModel): + summary: str + """Short text summary for this aspect of the analysis.""" + + +financials_agent = Agent( + name="FundamentalsAnalystAgent", + instructions=FINANCIALS_PROMPT, + output_type=AnalysisSummary, +) diff --git a/examples/financial_research_agent/agents/planner_agent.py b/examples/financial_research_agent/agents/planner_agent.py new file mode 100644 index 0000000..14aaa0b --- /dev/null +++ b/examples/financial_research_agent/agents/planner_agent.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel + +from agents import Agent + +# Generate a plan of searches to ground the financial analysis. +# For a given financial question or company, we want to search for +# recent news, official filings, analyst commentary, and other +# relevant background. +PROMPT = ( + "You are a financial research planner. Given a request for financial analysis, " + "produce a set of web searches to gather the context needed. Aim for recent " + "headlines, earnings calls or 10‑K snippets, analyst commentary, and industry background. " + "Output between 5 and 15 search terms to query for." +) + + +class FinancialSearchItem(BaseModel): + reason: str + """Your reasoning for why this search is relevant.""" + + query: str + """The search term to feed into a web (or file) search.""" + + +class FinancialSearchPlan(BaseModel): + searches: list[FinancialSearchItem] + """A list of searches to perform.""" + + +planner_agent = Agent( + name="FinancialPlannerAgent", + instructions=PROMPT, + model="o3-mini", + output_type=FinancialSearchPlan, +) diff --git a/examples/financial_research_agent/agents/risk_agent.py b/examples/financial_research_agent/agents/risk_agent.py new file mode 100644 index 0000000..e24deb4 --- /dev/null +++ b/examples/financial_research_agent/agents/risk_agent.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + +from agents import Agent + +# A sub‑agent specializing in identifying risk factors or concerns. +RISK_PROMPT = ( + "You are a risk analyst looking for potential red flags in a company's outlook. " + "Given background research, produce a short analysis of risks such as competitive threats, " + "regulatory issues, supply chain problems, or slowing growth. Keep it under 2 paragraphs." +) + + +class AnalysisSummary(BaseModel): + summary: str + """Short text summary for this aspect of the analysis.""" + + +risk_agent = Agent( + name="RiskAnalystAgent", + instructions=RISK_PROMPT, + output_type=AnalysisSummary, +) diff --git a/examples/financial_research_agent/agents/search_agent.py b/examples/financial_research_agent/agents/search_agent.py new file mode 100644 index 0000000..4ef2522 --- /dev/null +++ b/examples/financial_research_agent/agents/search_agent.py @@ -0,0 +1,18 @@ +from agents import Agent, WebSearchTool +from agents.model_settings import ModelSettings + +# Given a search term, use web search to pull back a brief summary. +# Summaries should be concise but capture the main financial points. +INSTRUCTIONS = ( + "You are a research assistant specializing in financial topics. " + "Given a search term, use web search to retrieve up‑to‑date context and " + "produce a short summary of at most 300 words. Focus on key numbers, events, " + "or quotes that will be useful to a financial analyst." +) + +search_agent = Agent( + name="FinancialSearchAgent", + instructions=INSTRUCTIONS, + tools=[WebSearchTool()], + model_settings=ModelSettings(tool_choice="required"), +) diff --git a/examples/financial_research_agent/agents/verifier_agent.py b/examples/financial_research_agent/agents/verifier_agent.py new file mode 100644 index 0000000..9ae660e --- /dev/null +++ b/examples/financial_research_agent/agents/verifier_agent.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel + +from agents import Agent + +# Agent to sanity‑check a synthesized report for consistency and recall. +# This can be used to flag potential gaps or obvious mistakes. +VERIFIER_PROMPT = ( + "You are a meticulous auditor. You have been handed a financial analysis report. " + "Your job is to verify the report is internally consistent, clearly sourced, and makes " + "no unsupported claims. Point out any issues or uncertainties." +) + + +class VerificationResult(BaseModel): + verified: bool + """Whether the report seems coherent and plausible.""" + + issues: str + """If not verified, describe the main issues or concerns.""" + + +verifier_agent = Agent( + name="VerificationAgent", + instructions=VERIFIER_PROMPT, + model="gpt-4o", + output_type=VerificationResult, +) diff --git a/examples/financial_research_agent/agents/writer_agent.py b/examples/financial_research_agent/agents/writer_agent.py new file mode 100644 index 0000000..0f56100 --- /dev/null +++ b/examples/financial_research_agent/agents/writer_agent.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel + +from agents import Agent + +# Writer agent brings together the raw search results and optionally calls out +# to sub‑analyst tools for specialized commentary, then returns a cohesive markdown report. +WRITER_PROMPT = ( + "You are a senior financial analyst. You will be provided with the original query and " + "a set of raw search summaries. Your task is to synthesize these into a long‑form markdown " + "report (at least several paragraphs) including a short executive summary and follow‑up " + "questions. If needed, you can call the available analysis tools (e.g. fundamentals_analysis, " + "risk_analysis) to get short specialist write‑ups to incorporate." +) + + +class FinancialReportData(BaseModel): + short_summary: str + """A short 2‑3 sentence executive summary.""" + + markdown_report: str + """The full markdown report.""" + + follow_up_questions: list[str] + """Suggested follow‑up questions for further research.""" + + +# Note: We will attach handoffs to specialist analyst agents at runtime in the manager. +# This shows how an agent can use handoffs to delegate to specialized subagents. +writer_agent = Agent( + name="FinancialWriterAgent", + instructions=WRITER_PROMPT, + model="gpt-4.5-preview-2025-02-27", + output_type=FinancialReportData, +) diff --git a/examples/financial_research_agent/main.py b/examples/financial_research_agent/main.py new file mode 100644 index 0000000..3fa8a7e --- /dev/null +++ b/examples/financial_research_agent/main.py @@ -0,0 +1,17 @@ +import asyncio + +from .manager import FinancialResearchManager + + +# Entrypoint for the financial bot example. +# Run this as `python -m examples.financial_bot.main` and enter a +# financial research query, for example: +# "Write up an analysis of Apple Inc.'s most recent quarter." +async def main() -> None: + query = input("Enter a financial research query: ") + mgr = FinancialResearchManager() + await mgr.run(query) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/financial_research_agent/manager.py b/examples/financial_research_agent/manager.py new file mode 100644 index 0000000..9a7722a --- /dev/null +++ b/examples/financial_research_agent/manager.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import asyncio +import time +from collections.abc import Sequence + +from rich.console import Console + +from agents import Runner, RunResult, custom_span, gen_trace_id, trace + +from .agents.financials_agent import financials_agent +from .agents.planner_agent import FinancialSearchItem, FinancialSearchPlan, planner_agent +from .agents.risk_agent import risk_agent +from .agents.search_agent import search_agent +from .agents.verifier_agent import VerificationResult, verifier_agent +from .agents.writer_agent import FinancialReportData, writer_agent +from .printer import Printer + + +async def _summary_extractor(run_result: RunResult) -> str: + """Custom output extractor for sub‑agents that return an AnalysisSummary.""" + # The financial/risk analyst agents emit an AnalysisSummary with a `summary` field. + # We want the tool call to return just that summary text so the writer can drop it inline. + return str(run_result.final_output.summary) + + +class FinancialResearchManager: + """ + Orchestrates the full flow: planning, searching, sub‑analysis, writing, and verification. + """ + + def __init__(self) -> None: + self.console = Console() + self.printer = Printer(self.console) + + async def run(self, query: str) -> None: + trace_id = gen_trace_id() + 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}", + is_done=True, + hide_checkmark=True, + ) + self.printer.update_item( + "start", "Starting financial research...", is_done=True) + search_plan = await self._plan_searches(query) + search_results = await self._perform_searches(search_plan) + report = await self._write_report(query, search_results) + verification = await self._verify_report(report) + + final_report = f"Report summary\n\n{report.short_summary}" + self.printer.update_item( + "final_report", final_report, is_done=True) + + self.printer.end() + + # Print to stdout + print("\n\n=====REPORT=====\n\n") + print(f"Report:\n{report.markdown_report}") + print("\n\n=====FOLLOW UP QUESTIONS=====\n\n") + print("\n".join(report.follow_up_questions)) + print("\n\n=====VERIFICATION=====\n\n") + print(verification) + + async def _plan_searches(self, query: str) -> FinancialSearchPlan: + self.printer.update_item("planning", "Planning searches...") + result = await Runner.run(planner_agent, f"Query: {query}") + self.printer.update_item( + "planning", + f"Will perform {len(result.final_output.searches)} searches", + is_done=True, + ) + return result.final_output_as(FinancialSearchPlan) + + async def _perform_searches(self, search_plan: FinancialSearchPlan) -> Sequence[str]: + with custom_span("Search the web"): + self.printer.update_item("searching", "Searching...") + tasks = [asyncio.create_task(self._search(item)) + for item in search_plan.searches] + results: list[str] = [] + num_completed = 0 + for task in asyncio.as_completed(tasks): + result = await task + if result is not None: + results.append(result) + num_completed += 1 + self.printer.update_item( + "searching", f"Searching... {num_completed}/{len(tasks)} completed" + ) + self.printer.mark_item_done("searching") + return results + + async def _search(self, item: FinancialSearchItem) -> str | None: + input_data = f"Search term: {item.query}\nReason: {item.reason}" + try: + result = await Runner.run(search_agent, input_data) + return str(result.final_output) + except Exception: + return None + + async def _write_report(self, query: str, search_results: Sequence[str]) -> FinancialReportData: + # Expose the specialist analysts as tools so the writer can invoke them inline + # and still produce the final FinancialReportData output. + fundamentals_tool = financials_agent.as_tool( + tool_name="fundamentals_analysis", + tool_description="Use to get a short write‑up of key financial metrics", + custom_output_extractor=_summary_extractor, + ) + risk_tool = risk_agent.as_tool( + tool_name="risk_analysis", + tool_description="Use to get a short write‑up of potential red flags", + custom_output_extractor=_summary_extractor, + ) + writer_with_tools = writer_agent.clone( + tools=[fundamentals_tool, risk_tool]) + self.printer.update_item("writing", "Thinking about report...") + input_data = f"Original query: {query}\nSummarized search results: {search_results}" + result = Runner.run_streamed(writer_with_tools, input_data) + update_messages = [ + "Planning report structure...", + "Writing sections...", + "Finalizing report...", + ] + last_update = time.time() + next_message = 0 + async for _ in result.stream_events(): + if time.time() - last_update > 5 and next_message < len(update_messages): + self.printer.update_item( + "writing", update_messages[next_message]) + next_message += 1 + last_update = time.time() + self.printer.mark_item_done("writing") + return result.final_output_as(FinancialReportData) + + async def _verify_report(self, report: FinancialReportData) -> VerificationResult: + self.printer.update_item("verifying", "Verifying report...") + result = await Runner.run(verifier_agent, report.markdown_report) + self.printer.mark_item_done("verifying") + return result.final_output_as(VerificationResult) diff --git a/examples/financial_research_agent/printer.py b/examples/financial_research_agent/printer.py new file mode 100644 index 0000000..16e04d2 --- /dev/null +++ b/examples/financial_research_agent/printer.py @@ -0,0 +1,45 @@ +from typing import Any + +from rich.console import Console, Group +from rich.live import Live +from rich.spinner import Spinner + + +class Printer: + """ + Simple wrapper to stream status updates. Used by the financial bot + manager as it orchestrates planning, search and writing. + """ + def __init__(self, console: Console) -> None: + self.live = Live(console=console) + self.items: dict[str, tuple[str, bool]] = {} + self.hide_done_ids: set[str] = set() + self.live.start() + + def end(self) -> None: + self.live.stop() + + def hide_done_checkmark(self, item_id: str) -> None: + self.hide_done_ids.add(item_id) + + def update_item( + self, item_id: str, content: str, is_done: bool = False, hide_checkmark: bool = False + ) -> None: + self.items[item_id] = (content, is_done) + if hide_checkmark: + self.hide_done_ids.add(item_id) + self.flush() + + def mark_item_done(self, item_id: str) -> None: + self.items[item_id] = (self.items[item_id][0], True) + self.flush() + + def flush(self) -> None: + renderables: list[Any] = [] + for item_id, (content, is_done) in self.items.items(): + if is_done: + prefix = "✅ " if item_id not in self.hide_done_ids else "" + renderables.append(prefix + content) + else: + renderables.append(Spinner("dots", text=content)) + self.live.update(Group(*renderables)) From 65264b6b8a1e742f1b84245992ee366d1ea32b9b Mon Sep 17 00:00:00 2001 From: Raduan77 Date: Thu, 20 Mar 2025 11:23:57 +0100 Subject: [PATCH 17/65] fix typos in /examples --- examples/basic/lifecycle_example.py | 2 +- examples/research_bot/agents/search_agent.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/basic/lifecycle_example.py b/examples/basic/lifecycle_example.py index 9b36510..285bfec 100644 --- a/examples/basic/lifecycle_example.py +++ b/examples/basic/lifecycle_example.py @@ -79,7 +79,7 @@ multiply_agent = Agent( start_agent = Agent( name="Start Agent", - instructions="Generate a random number. If it's even, stop. If it's odd, hand off to the multipler agent.", + instructions="Generate a random number. If it's even, stop. If it's odd, hand off to the multiplier agent.", tools=[random_number], output_type=FinalResult, handoffs=[multiply_agent], diff --git a/examples/research_bot/agents/search_agent.py b/examples/research_bot/agents/search_agent.py index 72cbc8e..f69cfda 100644 --- a/examples/research_bot/agents/search_agent.py +++ b/examples/research_bot/agents/search_agent.py @@ -4,7 +4,7 @@ from agents.model_settings import ModelSettings INSTRUCTIONS = ( "You are a research assistant. Given a search term, you search the web for that term and" "produce a concise summary of the results. The summary must 2-3 paragraphs and less than 300" - "words. Capture the main points. Write succintly, no need to have complete sentences or good" + "words. Capture the main points. Write succinctly, no need to have complete sentences or good" "grammar. This will be consumed by someone synthesizing a report, so its vital you capture the" "essence and ignore any fluff. Do not include any additional commentary other than the summary" "itself." From e9f6d08260925fb5f78ebc43641bfe9cfd9d89ed Mon Sep 17 00:00:00 2001 From: Raduan77 Date: Thu, 20 Mar 2025 11:24:08 +0100 Subject: [PATCH 18/65] fix typos in src/ --- src/agents/_run_impl.py | 2 +- src/agents/models/openai_chatcompletions.py | 2 +- src/agents/models/openai_responses.py | 2 +- src/agents/stream_events.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index 2849538..ad722dc 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -670,7 +670,7 @@ class RunImpl: elif isinstance(item, HandoffCallItem): event = RunItemStreamEvent(item=item, name="handoff_requested") elif isinstance(item, HandoffOutputItem): - event = RunItemStreamEvent(item=item, name="handoff_occured") + event = RunItemStreamEvent(item=item, name="handoff_occurred") elif isinstance(item, ToolCallItem): event = RunItemStreamEvent(item=item, name="tool_called") elif isinstance(item, ToolCallOutputItem): diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py index 7fe981e..8c64981 100644 --- a/src/agents/models/openai_chatcompletions.py +++ b/src/agents/models/openai_chatcompletions.py @@ -757,7 +757,7 @@ class _Converter: elif isinstance(c, dict) and c.get("type") == "input_file": raise UserError(f"File uploads are not supported for chat completions {c}") else: - raise UserError(f"Unknonw content: {c}") + raise UserError(f"Unknown content: {c}") return out @classmethod diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 78765ec..3eea39c 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -83,7 +83,7 @@ class OpenAIResponsesModel(Model): ) if _debug.DONT_LOG_MODEL_DATA: - logger.debug("LLM responsed") + logger.debug("LLM responded") else: logger.debug( "LLM resp:\n" diff --git a/src/agents/stream_events.py b/src/agents/stream_events.py index bd37d11..eff345b 100644 --- a/src/agents/stream_events.py +++ b/src/agents/stream_events.py @@ -31,7 +31,7 @@ class RunItemStreamEvent: name: Literal[ "message_output_created", "handoff_requested", - "handoff_occured", + "handoff_occurred", "tool_called", "tool_output", "reasoning_item_created", From 96d1e8af8e467e3de8c5e7e74ec30015b01035a0 Mon Sep 17 00:00:00 2001 From: Raduan77 Date: Thu, 20 Mar 2025 11:24:15 +0100 Subject: [PATCH 19/65] fix typos in tests --- tests/test_agent_runner_streamed.py | 2 +- tests/test_global_hooks.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py index 4c7c7ef..87a76a7 100644 --- a/tests/test_agent_runner_streamed.py +++ b/tests/test_agent_runner_streamed.py @@ -674,7 +674,7 @@ async def test_streaming_events(): total_expected_item_count = sum(expected_item_type_map.values()) assert event_counts["run_item_stream_event"] == total_expected_item_count, ( - f"Expectd {total_expected_item_count} events, got {event_counts['run_item_stream_event']}" + f"Expected {total_expected_item_count} events, got {event_counts['run_item_stream_event']}" f"Expected events were: {expected_item_type_map}, got {event_counts}" ) diff --git a/tests/test_global_hooks.py b/tests/test_global_hooks.py index 6ac35b9..4585441 100644 --- a/tests/test_global_hooks.py +++ b/tests/test_global_hooks.py @@ -223,7 +223,7 @@ class Foo(TypedDict): @pytest.mark.asyncio -async def test_structed_output_non_streamed_agent_hooks(): +async def test_structured_output_non_streamed_agent_hooks(): hooks = RunHooksForTests() model = FakeModel() agent_1 = Agent(name="test_1", model=model) @@ -296,7 +296,7 @@ async def test_structed_output_non_streamed_agent_hooks(): @pytest.mark.asyncio -async def test_structed_output_streamed_agent_hooks(): +async def test_structured_output_streamed_agent_hooks(): hooks = RunHooksForTests() model = FakeModel() agent_1 = Agent(name="test_1", model=model) From 7031d4ab87f9f76790da1bd8dfb822b09f4eea6a Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 20 Mar 2025 13:49:38 +0200 Subject: [PATCH 20/65] Remove redundant weaker tracing assertions --- tests/test_agent_tracing.py | 39 ------ tests/test_responses_tracing.py | 13 -- tests/test_tracing_errors.py | 88 +------------ tests/test_tracing_errors_streamed.py | 172 +------------------------- 4 files changed, 2 insertions(+), 310 deletions(-) diff --git a/tests/test_agent_tracing.py b/tests/test_agent_tracing.py index 3d7196a..5c7173f 100644 --- a/tests/test_agent_tracing.py +++ b/tests/test_agent_tracing.py @@ -23,9 +23,6 @@ async def test_single_run_is_single_trace(): await Runner.run(agent, input="first_test") - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -45,12 +42,6 @@ async def test_single_run_is_single_trace(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 1, ( - f"Got {len(spans)}, but expected 1: the agent span. data:" - f"{[span.span_data for span in spans]}" - ) - @pytest.mark.asyncio async def test_multiple_runs_are_multiple_traces(): @@ -69,9 +60,6 @@ async def test_multiple_runs_are_multiple_traces(): await Runner.run(agent, input="first_test") await Runner.run(agent, input="second_test") - traces = fetch_traces() - assert len(traces) == 2, f"Expected 2 traces, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -105,9 +93,6 @@ async def test_multiple_runs_are_multiple_traces(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 2, f"Got {len(spans)}, but expected 2: agent span per run" - @pytest.mark.asyncio async def test_wrapped_trace_is_single_trace(): @@ -129,9 +114,6 @@ async def test_wrapped_trace_is_single_trace(): await Runner.run(agent, input="second_test") await Runner.run(agent, input="third_test") - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -169,9 +151,6 @@ async def test_wrapped_trace_is_single_trace(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 3, f"Got {len(spans)}, but expected 3: the agent span per run" - @pytest.mark.asyncio async def test_parent_disabled_trace_disabled_agent_trace(): @@ -185,15 +164,8 @@ async def test_parent_disabled_trace_disabled_agent_trace(): await Runner.run(agent, input="first_test") - traces = fetch_traces() - assert len(traces) == 0, f"Expected 0 traces, got {len(traces)}" assert fetch_normalized_spans() == snapshot([]) - spans = fetch_ordered_spans() - assert len(spans) == 0, ( - f"Expected no spans, got {len(spans)}, with {[x.span_data for x in spans]}" - ) - @pytest.mark.asyncio async def test_manual_disabling_works(): @@ -206,13 +178,8 @@ async def test_manual_disabling_works(): await Runner.run(agent, input="first_test", run_config=RunConfig(tracing_disabled=True)) - traces = fetch_traces() - assert len(traces) == 0, f"Expected 0 traces, got {len(traces)}" assert fetch_normalized_spans() == snapshot([]) - spans = fetch_ordered_spans() - assert len(spans) == 0, f"Got {len(spans)}, but expected no spans" - @pytest.mark.asyncio async def test_trace_config_works(): @@ -255,9 +222,6 @@ async def test_not_starting_streaming_creates_trace(): break await asyncio.sleep(0.1) - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -277,9 +241,6 @@ async def test_not_starting_streaming_creates_trace(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 1, f"Got {len(spans)}, but expected 1: the agent span" - # Await the stream to avoid warnings about it not being awaited async for _ in result.stream_events(): pass diff --git a/tests/test_responses_tracing.py b/tests/test_responses_tracing.py index 41b87eb..eda65cf 100644 --- a/tests/test_responses_tracing.py +++ b/tests/test_responses_tracing.py @@ -64,13 +64,6 @@ async def test_get_response_creates_trace(monkeypatch): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 1 - - assert isinstance(spans[0].span_data, ResponseSpanData) - assert spans[0].span_data.response is not None - assert spans[0].span_data.response.id == "dummy-id" - @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @@ -164,12 +157,6 @@ async def test_stream_response_creates_trace(monkeypatch): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 1 - assert isinstance(spans[0].span_data, ResponseSpanData) - assert spans[0].span_data.response is not None - assert spans[0].span_data.response.id == "dummy-id-123" - @pytest.mark.allow_call_model_methods @pytest.mark.asyncio diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py index 5dbd7c1..baa7768 100644 --- a/tests/test_tracing_errors.py +++ b/tests/test_tracing_errors.py @@ -18,7 +18,6 @@ from agents import ( Runner, TResponseInputItem, ) -from agents.tracing import AgentSpanData, FunctionSpanData, GenerationSpanData from .fake_model import FakeModel from .test_responses import ( @@ -28,7 +27,7 @@ from .test_responses import ( get_handoff_tool_call, get_text_message, ) -from .testing_processor import fetch_normalized_spans, fetch_ordered_spans, fetch_traces +from .testing_processor import fetch_normalized_spans @pytest.mark.asyncio @@ -43,9 +42,6 @@ async def test_single_turn_model_error(): with pytest.raises(ValueError): await Runner.run(agent, input="first_test") - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -74,13 +70,6 @@ async def test_single_turn_model_error(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 2, f"should have agent and generation spans, got {len(spans)}" - - generation_span = spans[1] - assert isinstance(generation_span.span_data, GenerationSpanData) - assert generation_span.error, "should have error" - @pytest.mark.asyncio async def test_multi_turn_no_handoffs(): @@ -106,9 +95,6 @@ async def test_multi_turn_no_handoffs(): with pytest.raises(ValueError): await Runner.run(agent, input="first_test") - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -146,15 +132,6 @@ async def test_multi_turn_no_handoffs(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 4, ( - f"should have agent, generation, tool, generation, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - - last_generation_span = [x for x in spans if isinstance(x.span_data, GenerationSpanData)][-1] - assert last_generation_span.error, "should have error" - @pytest.mark.asyncio async def test_tool_call_error(): @@ -173,9 +150,6 @@ async def test_tool_call_error(): with pytest.raises(ModelBehaviorError): await Runner.run(agent, input="first_test") - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -209,15 +183,6 @@ async def test_tool_call_error(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 3, ( - f"should have agent, generation, tool spans, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - - function_span = [x for x in spans if isinstance(x.span_data, FunctionSpanData)][0] - assert function_span.error, "should have error" - @pytest.mark.asyncio async def test_multiple_handoff_doesnt_error(): @@ -255,9 +220,6 @@ async def test_multiple_handoff_doesnt_error(): result = await Runner.run(agent_3, input="user_message") assert result.last_agent == agent_1, "should have picked first handoff" - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -295,12 +257,6 @@ async def test_multiple_handoff_doesnt_error(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 7, ( - f"should have 2 agent, 1 function, 3 generation, 1 handoff, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - class Foo(TypedDict): bar: str @@ -326,9 +282,6 @@ async def test_multiple_final_output_doesnt_error(): result = await Runner.run(agent_1, input="user_message") assert result.final_output == Foo(bar="abc") - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -344,12 +297,6 @@ async def test_multiple_final_output_doesnt_error(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 2, ( - f"should have 1 agent, 1 generation, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - @pytest.mark.asyncio async def test_handoffs_lead_to_correct_agent_spans(): @@ -399,9 +346,6 @@ async def test_handoffs_lead_to_correct_agent_spans(): f"should have ended on the third agent, got {result.last_agent.name}" ) - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -472,12 +416,6 @@ async def test_handoffs_lead_to_correct_agent_spans(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 12, ( - f"should have 3 agents, 2 function, 5 generation, 2 handoff, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - @pytest.mark.asyncio async def test_max_turns_exceeded(): @@ -503,9 +441,6 @@ async def test_max_turns_exceeded(): with pytest.raises(MaxTurnsExceeded): await Runner.run(agent, input="user_message", max_turns=2) - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -538,15 +473,6 @@ async def test_max_turns_exceeded(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 5, ( - f"should have 1 agent span, 2 generations, 2 function calls, got " - f"{len(spans)} with data: {[x.span_data for x in spans]}" - ) - - agent_span = [x for x in spans if isinstance(x.span_data, AgentSpanData)][-1] - assert agent_span.error, "last agent should have error" - def guardrail_function( context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] @@ -568,9 +494,6 @@ async def test_guardrail_error(): with pytest.raises(InputGuardrailTripwireTriggered): await Runner.run(agent, input="user_message") - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -594,12 +517,3 @@ async def test_guardrail_error(): } ] ) - - spans = fetch_ordered_spans() - assert len(spans) == 2, ( - f"should have 1 agent, 1 guardrail, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - - agent_span = [x for x in spans if isinstance(x.span_data, AgentSpanData)][-1] - assert agent_span.error, "last agent should have error" diff --git a/tests/test_tracing_errors_streamed.py b/tests/test_tracing_errors_streamed.py index 74cda2d..7e65ff1 100644 --- a/tests/test_tracing_errors_streamed.py +++ b/tests/test_tracing_errors_streamed.py @@ -10,9 +10,6 @@ from typing_extensions import TypedDict from agents import ( Agent, - AgentSpanData, - FunctionSpanData, - GenerationSpanData, GuardrailFunctionOutput, InputGuardrail, InputGuardrailTripwireTriggered, @@ -33,7 +30,7 @@ from .test_responses import ( get_handoff_tool_call, get_text_message, ) -from .testing_processor import fetch_normalized_spans, fetch_ordered_spans, fetch_traces +from .testing_processor import fetch_normalized_spans @pytest.mark.asyncio @@ -50,9 +47,6 @@ async def test_single_turn_model_error(): async for _ in result.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -82,13 +76,6 @@ async def test_single_turn_model_error(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 2, f"should have agent and generation spans, got {len(spans)}" - - generation_span = spans[1] - assert isinstance(generation_span.span_data, GenerationSpanData) - assert generation_span.error, "should have error" - @pytest.mark.asyncio async def test_multi_turn_no_handoffs(): @@ -116,9 +103,6 @@ async def test_multi_turn_no_handoffs(): async for _ in result.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -157,15 +141,6 @@ async def test_multi_turn_no_handoffs(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 4, ( - f"should have agent, generation, tool, generation, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - - last_generation_span = [x for x in spans if isinstance(x.span_data, GenerationSpanData)][-1] - assert last_generation_span.error, "should have error" - @pytest.mark.asyncio async def test_tool_call_error(): @@ -186,9 +161,6 @@ async def test_tool_call_error(): async for _ in result.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -226,15 +198,6 @@ async def test_tool_call_error(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 3, ( - f"should have agent, generation, tool spans, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - - function_span = [x for x in spans if isinstance(x.span_data, FunctionSpanData)][0] - assert function_span.error, "should have error" - @pytest.mark.asyncio async def test_multiple_handoff_doesnt_error(): @@ -275,9 +238,6 @@ async def test_multiple_handoff_doesnt_error(): assert result.last_agent == agent_1, "should have picked first handoff" - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -315,12 +275,6 @@ async def test_multiple_handoff_doesnt_error(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 7, ( - f"should have 2 agent, 1 function, 3 generation, 1 handoff, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - class Foo(TypedDict): bar: str @@ -350,9 +304,6 @@ async def test_multiple_final_output_no_error(): assert isinstance(result.final_output, dict) assert result.final_output["bar"] == "abc" - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -368,12 +319,6 @@ async def test_multiple_final_output_no_error(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 2, ( - f"should have 1 agent, 1 generation, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - @pytest.mark.asyncio async def test_handoffs_lead_to_correct_agent_spans(): @@ -425,85 +370,6 @@ async def test_handoffs_lead_to_correct_agent_spans(): f"should have ended on the third agent, got {result.last_agent.name}" ) - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - - assert fetch_normalized_spans() == snapshot( - [ - { - "workflow_name": "Agent workflow", - "children": [ - { - "type": "agent", - "data": { - "name": "test_agent_3", - "handoffs": ["test_agent_1", "test_agent_2"], - "tools": ["some_function"], - "output_type": "str", - }, - "children": [ - {"type": "generation"}, - { - "type": "function", - "data": { - "name": "some_function", - "input": '{"a": "b"}', - "output": "result", - }, - }, - {"type": "generation"}, - { - "type": "handoff", - "data": {"from_agent": "test_agent_3", "to_agent": "test_agent_1"}, - }, - ], - }, - { - "type": "agent", - "data": { - "name": "test_agent_1", - "handoffs": ["test_agent_3"], - "tools": ["some_function"], - "output_type": "str", - }, - "children": [ - {"type": "generation"}, - { - "type": "function", - "data": { - "name": "some_function", - "input": '{"a": "b"}', - "output": "result", - }, - }, - {"type": "generation"}, - { - "type": "handoff", - "data": {"from_agent": "test_agent_1", "to_agent": "test_agent_3"}, - }, - ], - }, - { - "type": "agent", - "data": { - "name": "test_agent_3", - "handoffs": ["test_agent_1", "test_agent_2"], - "tools": ["some_function"], - "output_type": "str", - }, - "children": [{"type": "generation"}], - }, - ], - } - ] - ) - - spans = fetch_ordered_spans() - assert len(spans) == 12, ( - f"should have 3 agents, 2 function, 5 generation, 2 handoff, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - assert fetch_normalized_spans() == snapshot( [ { @@ -601,9 +467,6 @@ async def test_max_turns_exceeded(): async for _ in result.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -636,15 +499,6 @@ async def test_max_turns_exceeded(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 5, ( - f"should have 1 agent, 2 generations, 2 function calls, got " - f"{len(spans)} with data: {[x.span_data for x in spans]}" - ) - - agent_span = [x for x in spans if isinstance(x.span_data, AgentSpanData)][-1] - assert agent_span.error, "last agent should have error" - def input_guardrail_function( context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] @@ -673,9 +527,6 @@ async def test_input_guardrail_error(): await asyncio.sleep(1) - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -703,15 +554,6 @@ async def test_input_guardrail_error(): ] ) - spans = fetch_ordered_spans() - assert len(spans) == 2, ( - f"should have 1 agent, 1 guardrail, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - - agent_span = [x for x in spans if isinstance(x.span_data, AgentSpanData)][-1] - assert agent_span.error, "last agent should have error" - def output_guardrail_function( context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any @@ -740,9 +582,6 @@ async def test_output_guardrail_error(): await asyncio.sleep(1) - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - assert fetch_normalized_spans() == snapshot( [ { @@ -766,12 +605,3 @@ async def test_output_guardrail_error(): } ] ) - - spans = fetch_ordered_spans() - assert len(spans) == 2, ( - f"should have 1 agent, 1 guardrail, got {len(spans)} with data: " - f"{[x.span_data for x in spans]}" - ) - - agent_span = [x for x in spans if isinstance(x.span_data, AgentSpanData)][-1] - assert agent_span.error, "last agent should have error" From ea3e8ce230b3807cc74ca3e319590751e3a9bdd1 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 20 Mar 2025 13:56:11 +0200 Subject: [PATCH 21/65] lint --- Makefile | 1 + tests/test_agent_tracing.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 16ed5fe..f6b779e 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ sync: .PHONY: format format: uv run ruff format + uv run ruff check --fix .PHONY: lint lint: diff --git a/tests/test_agent_tracing.py b/tests/test_agent_tracing.py index 5c7173f..8318b60 100644 --- a/tests/test_agent_tracing.py +++ b/tests/test_agent_tracing.py @@ -9,7 +9,7 @@ from agents import Agent, RunConfig, Runner, trace from .fake_model import FakeModel from .test_responses import get_text_message -from .testing_processor import fetch_normalized_spans, fetch_ordered_spans, fetch_traces +from .testing_processor import fetch_normalized_spans, fetch_traces @pytest.mark.asyncio From fb77c74fa17dfb6518d92277c651ff0d7cd2f1ce Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Thu, 20 Mar 2025 21:22:27 +0800 Subject: [PATCH 22/65] 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 2849538..a60ae1d 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 0000000..e01a5f0 --- /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 cde67f2b7194b974fef9e4f3fd9e0e4898f14ca4 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Thu, 20 Mar 2025 21:26:59 +0800 Subject: [PATCH 23/65] 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 e01a5f0..c60a951 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 9384a0fb3fd13151c010d3f45c89bfcb05172784 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Thu, 20 Mar 2025 21:28:24 +0800 Subject: [PATCH 24/65] 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 c60a951..e01a5f0 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 d169d792885d07ba674d8f2c1bcc7be7f08d556b Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Thu, 20 Mar 2025 21:49:38 +0800 Subject: [PATCH 25/65] 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 1c31473..c9c39ae 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 5c77298a473a21e8dc845248266f60e797b8193a Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Thu, 20 Mar 2025 11:18:08 -0400 Subject: [PATCH 26/65] Indentation for mkdocs.yml --- mkdocs.yml | 214 ++++++++++++++++++++++++++--------------------------- 1 file changed, 107 insertions(+), 107 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index e566b61..6b81531 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,122 +1,122 @@ site_name: OpenAI Agents SDK theme: - name: material - features: - # Allows copying code blocks - - content.code.copy - # Allows selecting code blocks - - content.code.select - # Shows the current path in the sidebar - - navigation.path - # Shows sections in the sidebar - - navigation.sections - # Shows sections expanded by default - - navigation.expand - # Enables annotations in code blocks - - content.code.annotate - palette: - primary: black - logo: assets/logo.svg - favicon: images/favicon-platform.svg + name: material + features: + # Allows copying code blocks + - content.code.copy + # Allows selecting code blocks + - content.code.select + # Shows the current path in the sidebar + - navigation.path + # Shows sections in the sidebar + - navigation.sections + # Shows sections expanded by default + - navigation.expand + # Enables annotations in code blocks + - content.code.annotate + palette: + primary: black + logo: assets/logo.svg + favicon: images/favicon-platform.svg nav: - - Intro: index.md - - Quickstart: quickstart.md - - Examples: examples.md - - Documentation: - - agents.md - - running_agents.md - - results.md - - streaming.md - - tools.md - - handoffs.md - - tracing.md - - context.md - - guardrails.md - - multi_agent.md - - models.md - - config.md - - API Reference: - - Agents: - - ref/index.md - - ref/agent.md - - ref/run.md - - ref/tool.md - - ref/result.md - - ref/stream_events.md - - ref/handoffs.md - - ref/lifecycle.md - - ref/items.md - - ref/run_context.md - - ref/usage.md - - ref/exceptions.md - - ref/guardrail.md - - ref/model_settings.md - - ref/agent_output.md - - ref/function_schema.md - - ref/models/interface.md - - ref/models/openai_chatcompletions.md - - ref/models/openai_responses.md - - Tracing: - - ref/tracing/index.md - - ref/tracing/create.md - - ref/tracing/traces.md - - ref/tracing/spans.md - - ref/tracing/processor_interface.md - - ref/tracing/processors.md - - ref/tracing/scope.md - - ref/tracing/setup.md - - ref/tracing/span_data.md - - ref/tracing/util.md - - Extensions: - - ref/extensions/handoff_filters.md - - ref/extensions/handoff_prompt.md + - Intro: index.md + - Quickstart: quickstart.md + - Examples: examples.md + - Documentation: + - agents.md + - running_agents.md + - results.md + - streaming.md + - tools.md + - handoffs.md + - tracing.md + - context.md + - guardrails.md + - multi_agent.md + - models.md + - config.md + - API Reference: + - Agents: + - ref/index.md + - ref/agent.md + - ref/run.md + - ref/tool.md + - ref/result.md + - ref/stream_events.md + - ref/handoffs.md + - ref/lifecycle.md + - ref/items.md + - ref/run_context.md + - ref/usage.md + - ref/exceptions.md + - ref/guardrail.md + - ref/model_settings.md + - ref/agent_output.md + - ref/function_schema.md + - ref/models/interface.md + - ref/models/openai_chatcompletions.md + - ref/models/openai_responses.md + - Tracing: + - ref/tracing/index.md + - ref/tracing/create.md + - ref/tracing/traces.md + - ref/tracing/spans.md + - ref/tracing/processor_interface.md + - ref/tracing/processors.md + - ref/tracing/scope.md + - ref/tracing/setup.md + - ref/tracing/span_data.md + - ref/tracing/util.md + - Extensions: + - ref/extensions/handoff_filters.md + - ref/extensions/handoff_prompt.md plugins: - - search - - mkdocstrings: - handlers: - python: - paths: ["src/agents"] - selection: - docstring_style: google - options: - # Shows links to other members in signatures - signature_crossrefs: true - # Orders members by source order, rather than alphabetical - members_order: source - # Puts the signature on a separate line from the member name - separate_signature: true - # Shows type annotations in signatures - show_signature_annotations: true - # Makes the font sizes nicer - heading_level: 3 + - search + - mkdocstrings: + handlers: + python: + paths: ["src/agents"] + selection: + docstring_style: google + options: + # Shows links to other members in signatures + signature_crossrefs: true + # Orders members by source order, rather than alphabetical + members_order: source + # Puts the signature on a separate line from the member name + separate_signature: true + # Shows type annotations in signatures + show_signature_annotations: true + # Makes the font sizes nicer + heading_level: 3 extra: - # Remove material generation message in footer - generator: false + # Remove material generation message in footer + generator: false markdown_extensions: - - admonition - - pymdownx.details - - pymdownx.superfences - - attr_list - - md_in_html - - pymdownx.highlight: - anchor_linenums: true - line_spans: __span - pygments_lang_class: true - - pymdownx.inlinehilite - - pymdownx.snippets - - pymdownx.superfences + - admonition + - pymdownx.details + - pymdownx.superfences + - attr_list + - md_in_html + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences validation: - omitted_files: warn - absolute_links: warn - unrecognized_links: warn - anchors: warn + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + anchors: warn extra_css: - - stylesheets/extra.css + - stylesheets/extra.css watch: - - "src/agents" + - "src/agents" From c7ce154637d335a09cc6b47d427bb7882c6640d5 Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Thu, 20 Mar 2025 09:32:02 -0700 Subject: [PATCH 27/65] feat: add voice pipeline support > Co-authored-by: rm@openai.com --- docs/ref/voice/events.md | 3 + docs/ref/voice/exceptions.md | 3 + docs/ref/voice/input.md | 3 + docs/ref/voice/model.md | 3 + docs/ref/voice/models/openai_provider.md | 3 + docs/ref/voice/models/openai_stt.md | 3 + docs/ref/voice/models/openai_tts.md | 3 + docs/ref/voice/pipeline.md | 3 + docs/ref/voice/pipeline_config.md | 3 + docs/ref/voice/result.md | 3 + docs/ref/voice/utils.md | 3 + docs/ref/voice/workflow.md | 3 + docs/voice/pipeline.md | 75 +++ docs/voice/quickstart.md | 189 ++++++ docs/voice/tracing.md | 14 + examples/voice/__init__.py | 0 examples/voice/static/README.md | 26 + examples/voice/static/__init__.py | 0 examples/voice/static/main.py | 84 +++ examples/voice/static/util.py | 68 +++ examples/voice/streamed/README.md | 25 + examples/voice/streamed/__init__.py | 0 examples/voice/streamed/agents.py | 87 +++ examples/voice/streamed/main.py | 222 +++++++ mkdocs.yml | 23 +- pyproject.toml | 31 +- src/agents/__init__.py | 60 ++ src/agents/models/openai_provider.py | 13 + src/agents/tracing/__init__.py | 12 + src/agents/tracing/create.py | 122 +++- src/agents/tracing/span_data.py | 96 ++++ src/agents/tracing/util.py | 5 + src/agents/voice/__init__.py | 51 ++ src/agents/voice/events.py | 47 ++ src/agents/voice/exceptions.py | 8 + src/agents/voice/imports.py | 11 + src/agents/voice/input.py | 88 +++ src/agents/voice/model.py | 193 +++++++ src/agents/voice/models/__init__.py | 0 .../voice/models/openai_model_provider.py | 97 ++++ src/agents/voice/models/openai_stt.py | 457 +++++++++++++++ src/agents/voice/models/openai_tts.py | 54 ++ src/agents/voice/pipeline.py | 151 +++++ src/agents/voice/pipeline_config.py | 46 ++ src/agents/voice/result.py | 287 ++++++++++ src/agents/voice/utils.py | 37 ++ src/agents/voice/workflow.py | 93 +++ tests/voice/__init__.py | 0 tests/voice/fake_models.py | 112 ++++ tests/voice/helpers.py | 18 + tests/voice/test_input.py | 124 ++++ tests/voice/test_openai_stt.py | 365 ++++++++++++ tests/voice/test_openai_tts.py | 91 +++ tests/voice/test_pipeline.py | 176 ++++++ tests/voice/test_workflow.py | 184 ++++++ uv.lock | 541 +++++++++++++++++- 56 files changed, 4386 insertions(+), 33 deletions(-) create mode 100644 docs/ref/voice/events.md create mode 100644 docs/ref/voice/exceptions.md create mode 100644 docs/ref/voice/input.md create mode 100644 docs/ref/voice/model.md create mode 100644 docs/ref/voice/models/openai_provider.md create mode 100644 docs/ref/voice/models/openai_stt.md create mode 100644 docs/ref/voice/models/openai_tts.md create mode 100644 docs/ref/voice/pipeline.md create mode 100644 docs/ref/voice/pipeline_config.md create mode 100644 docs/ref/voice/result.md create mode 100644 docs/ref/voice/utils.md create mode 100644 docs/ref/voice/workflow.md create mode 100644 docs/voice/pipeline.md create mode 100644 docs/voice/quickstart.md create mode 100644 docs/voice/tracing.md create mode 100644 examples/voice/__init__.py create mode 100644 examples/voice/static/README.md create mode 100644 examples/voice/static/__init__.py create mode 100644 examples/voice/static/main.py create mode 100644 examples/voice/static/util.py create mode 100644 examples/voice/streamed/README.md create mode 100644 examples/voice/streamed/__init__.py create mode 100644 examples/voice/streamed/agents.py create mode 100644 examples/voice/streamed/main.py create mode 100644 src/agents/voice/__init__.py create mode 100644 src/agents/voice/events.py create mode 100644 src/agents/voice/exceptions.py create mode 100644 src/agents/voice/imports.py create mode 100644 src/agents/voice/input.py create mode 100644 src/agents/voice/model.py create mode 100644 src/agents/voice/models/__init__.py create mode 100644 src/agents/voice/models/openai_model_provider.py create mode 100644 src/agents/voice/models/openai_stt.py create mode 100644 src/agents/voice/models/openai_tts.py create mode 100644 src/agents/voice/pipeline.py create mode 100644 src/agents/voice/pipeline_config.py create mode 100644 src/agents/voice/result.py create mode 100644 src/agents/voice/utils.py create mode 100644 src/agents/voice/workflow.py create mode 100644 tests/voice/__init__.py create mode 100644 tests/voice/fake_models.py create mode 100644 tests/voice/helpers.py create mode 100644 tests/voice/test_input.py create mode 100644 tests/voice/test_openai_stt.py create mode 100644 tests/voice/test_openai_tts.py create mode 100644 tests/voice/test_pipeline.py create mode 100644 tests/voice/test_workflow.py diff --git a/docs/ref/voice/events.md b/docs/ref/voice/events.md new file mode 100644 index 0000000..71e88e3 --- /dev/null +++ b/docs/ref/voice/events.md @@ -0,0 +1,3 @@ +# `Events` + +::: agents.voice.events diff --git a/docs/ref/voice/exceptions.md b/docs/ref/voice/exceptions.md new file mode 100644 index 0000000..61f6ca8 --- /dev/null +++ b/docs/ref/voice/exceptions.md @@ -0,0 +1,3 @@ +# `Exceptions` + +::: agents.voice.exceptions diff --git a/docs/ref/voice/input.md b/docs/ref/voice/input.md new file mode 100644 index 0000000..b61d2f5 --- /dev/null +++ b/docs/ref/voice/input.md @@ -0,0 +1,3 @@ +# `Input` + +::: agents.voice.input diff --git a/docs/ref/voice/model.md b/docs/ref/voice/model.md new file mode 100644 index 0000000..212d3de --- /dev/null +++ b/docs/ref/voice/model.md @@ -0,0 +1,3 @@ +# `Model` + +::: agents.voice.model diff --git a/docs/ref/voice/models/openai_provider.md b/docs/ref/voice/models/openai_provider.md new file mode 100644 index 0000000..f8a4088 --- /dev/null +++ b/docs/ref/voice/models/openai_provider.md @@ -0,0 +1,3 @@ +# `OpenAIVoiceModelProvider` + +::: agents.voice.models.openai_model_provider diff --git a/docs/ref/voice/models/openai_stt.md b/docs/ref/voice/models/openai_stt.md new file mode 100644 index 0000000..eeeb641 --- /dev/null +++ b/docs/ref/voice/models/openai_stt.md @@ -0,0 +1,3 @@ +# `OpenAI STT` + +::: agents.voice.models.openai_stt diff --git a/docs/ref/voice/models/openai_tts.md b/docs/ref/voice/models/openai_tts.md new file mode 100644 index 0000000..920c324 --- /dev/null +++ b/docs/ref/voice/models/openai_tts.md @@ -0,0 +1,3 @@ +# `OpenAI TTS` + +::: agents.voice.models.openai_tts diff --git a/docs/ref/voice/pipeline.md b/docs/ref/voice/pipeline.md new file mode 100644 index 0000000..7a1ec69 --- /dev/null +++ b/docs/ref/voice/pipeline.md @@ -0,0 +1,3 @@ +# `Pipeline` + +::: agents.voice.pipeline diff --git a/docs/ref/voice/pipeline_config.md b/docs/ref/voice/pipeline_config.md new file mode 100644 index 0000000..0bc0467 --- /dev/null +++ b/docs/ref/voice/pipeline_config.md @@ -0,0 +1,3 @@ +# `Pipeline Config` + +::: agents.voice.pipeline_config diff --git a/docs/ref/voice/result.md b/docs/ref/voice/result.md new file mode 100644 index 0000000..60d985a --- /dev/null +++ b/docs/ref/voice/result.md @@ -0,0 +1,3 @@ +# `Result` + +::: agents.voice.result diff --git a/docs/ref/voice/utils.md b/docs/ref/voice/utils.md new file mode 100644 index 0000000..c13efc6 --- /dev/null +++ b/docs/ref/voice/utils.md @@ -0,0 +1,3 @@ +# `Utils` + +::: agents.voice.utils diff --git a/docs/ref/voice/workflow.md b/docs/ref/voice/workflow.md new file mode 100644 index 0000000..a5ae128 --- /dev/null +++ b/docs/ref/voice/workflow.md @@ -0,0 +1,3 @@ +# `Workflow` + +::: agents.voice.workflow diff --git a/docs/voice/pipeline.md b/docs/voice/pipeline.md new file mode 100644 index 0000000..8cf5daf --- /dev/null +++ b/docs/voice/pipeline.md @@ -0,0 +1,75 @@ +# Pipelines and workflows + +[`VoicePipeline`][agents.voice.pipeline.VoicePipeline] is a class that makes it easy to turn your agentic workflows into a voice app. You pass in a workflow to run, and the pipeline takes care of transcribing input audio, detecting when the audio ends, calling your workflow at the right time, and turning the workflow output back into audio. + +```mermaid +graph LR + %% Input + A["🎤 Audio Input"] + + %% Voice Pipeline + subgraph Voice_Pipeline [Voice Pipeline] + direction TB + B["Transcribe (speech-to-text)"] + C["Your Code"]:::highlight + D["Text-to-speech"] + B --> C --> D + end + + %% Output + E["🎧 Audio Output"] + + %% Flow + A --> Voice_Pipeline + Voice_Pipeline --> E + + %% Custom styling + classDef highlight fill:#ffcc66,stroke:#333,stroke-width:1px,font-weight:700; + +``` + +## Configuring a pipeline + +When you create a pipeline, you can set a few things: + +1. The [`workflow`][agents.voice.workflow.VoiceWorkflowBase], which is the code that runs each time new audio is transcribed. +2. The [`speech-to-text`][agents.voice.model.STTModel] and [`text-to-speech`][agents.voice.model.TTSModel] models used +3. The [`config`][agents.voice.pipeline_config.VoicePipelineConfig], which lets you configure things like: + - A model provider, which can map model names to models + - Tracing, including whether to disable tracing, whether audio files are uploaded, the workflow name, trace IDs etc. + - Settings on the TTS and STT models, like the prompt, language and data types used. + +## Running a pipeline + +You can run a pipeline via the [`run()`][agents.voice.pipeline.VoicePipeline.run] method, which lets you pass in audio input in two forms: + +1. [`AudioInput`][agents.voice.input.AudioInput] is used when you have a full audio transcript, and just want to produce a result for it. This is useful in cases where you don't need to detect when a speaker is done speaking; for example, when you have pre-recorded audio or in push-to-talk apps where it's clear when the user is done speaking. +2. [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput] is used when you might need to detect when a user is done speaking. It allows you to push audio chunks as they are detected, and the voice pipeline will automatically run the agent workflow at the right time, via a process called "activity detection". + +## Results + +The result of a voice pipeline run is a [`StreamedAudioResult`][agents.voice.result.StreamedAudioResult]. This is an object that lets you stream events as they occur. There are a few kinds of [`VoiceStreamEvent`][agents.voice.events.VoiceStreamEvent], including: + +1. [`VoiceStreamEventAudio`][agents.voice.events.VoiceStreamEventAudio], which contains a chunk of audio. +2. [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle], which informs you of lifecycle events like a turn starting or ending. +3. [`VoiceStreamEventError`][agents.voice.events.VoiceStreamEventError], is an error event. + +```python + +result = await pipeline.run(input) + +async for event in result.stream(): + if event.type == "voice_stream_event_audio": + # play audio + elif event.type == "voice_stream_event_lifecycle": + # lifecycle + elif event.type == "voice_stream_event_error" + # error + ... +``` + +## Best practices + +### Interruptions + +The Agents SDK currently does not support any built-in interruptions support for [`StreamedAudioInput`][agents.voice.input.StreamedAudioInput]. Instead for every detected turn it will trigger a separate run of your workflow. If you want to handle interruptions inside your application you can listen to the [`VoiceStreamEventLifecycle`][agents.voice.events.VoiceStreamEventLifecycle] events. `turn_started` will indicate that a new turn was transcribed and processing is beginning. `turn_ended` will trigger after all the audio was dispatched for a respective turn. You could use these events to mute the microphone of the speaker when the model starts a turn and unmute it after you flushed all the related audio for a turn. diff --git a/docs/voice/quickstart.md b/docs/voice/quickstart.md new file mode 100644 index 0000000..144b748 --- /dev/null +++ b/docs/voice/quickstart.md @@ -0,0 +1,189 @@ +# Quickstart + +## Prerequisites + +Make sure you've followed the base [quickstart instructions](../quickstart.md) for the Agents SDK, and set up a virtual environment. Then, install the optional voice dependencies from the SDK: + +```bash +pip install openai-agents[voice] +``` + +## Concepts + +The main concept to know about is a [`VoicePipeline`][agents.voice.pipeline.VoicePipeline], which is a 3 step process: + +1. Run a speech-to-text model to turn audio into text. +2. Run your code, which is usually an agentic workflow, to produce a result. +3. Run a text-to-speech model to turn the result text back into audio. + +```mermaid +graph LR + %% Input + A["🎤 Audio Input"] + + %% Voice Pipeline + subgraph Voice_Pipeline [Voice Pipeline] + direction TB + B["Transcribe (speech-to-text)"] + C["Your Code"]:::highlight + D["Text-to-speech"] + B --> C --> D + end + + %% Output + E["🎧 Audio Output"] + + %% Flow + A --> Voice_Pipeline + Voice_Pipeline --> E + + %% Custom styling + classDef highlight fill:#ffcc66,stroke:#333,stroke-width:1px,font-weight:700; + +``` + +## Agents + +First, let's set up some Agents. This should feel familiar to you if you've built any agents with this SDK. We'll have a couple of Agents, a handoff, and a tool. + +```python +import asyncio +import random + +from agents import ( + Agent, + function_tool, +) +from agents.extensions.handoff_prompt import prompt_with_handoff_instructions + + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather for a given city.""" + print(f"[debug] get_weather called with city: {city}") + choices = ["sunny", "cloudy", "rainy", "snowy"] + return f"The weather in {city} is {random.choice(choices)}." + + +spanish_agent = Agent( + name="Spanish", + handoff_description="A spanish speaking agent.", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. Speak in Spanish.", + ), + model="gpt-4o-mini", +) + +agent = Agent( + name="Assistant", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. If the user speaks in Spanish, handoff to the spanish agent.", + ), + model="gpt-4o-mini", + handoffs=[spanish_agent], + tools=[get_weather], +) +``` + +## Voice pipeline + +We'll set up a simple voice pipeline, using [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] as the workflow. + +```python +from agents import SingleAgentVoiceWorkflow, VoicePipeline, +pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) +``` + +## Run the pipeline + +```python +import numpy as np +import sounddevice as sd + +# For simplicity, we'll just create 3 seconds of silence +# In reality, you'd get microphone data +audio = np.zeros(24000 * 3, dtype=np.int16) +result = await pipeline.run(audio_input) + +# Create an audio player using `sounddevice` +player = sd.OutputStream(samplerate=24000, channels=1, dtype=np.int16) +player.start() + +# Play the audio stream as it comes in +async for event in result.stream(): + if event.type == "voice_stream_event_audio": + player.write(event.data) + +``` + +## Put it all together + +```python +import asyncio +import random + +import numpy as np +import sounddevice as sd + +from agents import ( + Agent, + AudioInput, + SingleAgentVoiceWorkflow, + VoicePipeline, + function_tool, + set_tracing_disabled, +) +from agents.extensions.handoff_prompt import prompt_with_handoff_instructions + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather for a given city.""" + print(f"[debug] get_weather called with city: {city}") + choices = ["sunny", "cloudy", "rainy", "snowy"] + return f"The weather in {city} is {random.choice(choices)}." + + +spanish_agent = Agent( + name="Spanish", + handoff_description="A spanish speaking agent.", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. Speak in Spanish.", + ), + model="gpt-4o-mini", +) + +agent = Agent( + name="Assistant", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. If the user speaks in Spanish, handoff to the spanish agent.", + ), + model="gpt-4o-mini", + handoffs=[spanish_agent], + tools=[get_weather], +) + + +async def main(): + pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) + buffer = np.zeros(24000 * 3, dtype=np.int16) + audio_input = AudioInput(buffer=buffer) + + result = await pipeline.run(audio_input) + + # Create an audio player using `sounddevice` + player = sd.OutputStream(samplerate=24000, channels=1, dtype=np.int16) + player.start() + + # Play the audio stream as it comes in + async for event in result.stream(): + if event.type == "voice_stream_event_audio": + player.write(event.data) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +If you run this example, the agent will speak to you! Check out the example in [examples/voice/static](https://github.com/openai/openai-agents-python/tree/main/examples/voice/static) to see a demo where you can speak to the agent yourself. diff --git a/docs/voice/tracing.md b/docs/voice/tracing.md new file mode 100644 index 0000000..311a9ba --- /dev/null +++ b/docs/voice/tracing.md @@ -0,0 +1,14 @@ +# Tracing + +Just like the way [agents are traced](../tracing.md), voice pipelines are also automatically traced. + +You can read the tracing doc above for basic tracing information, but you can additionally configure tracing of a pipeline via [`VoicePipelineConfig`][agents.voice.pipeline_config.VoicePipelineConfig]. + +Key tracing related fields are: + +- [`tracing_disabled`][agents.voice.pipeline_config.VoicePipelineConfig.tracing_disabled]: controls whether tracing is disabled. By default, tracing is enabled. +- [`trace_include_sensitive_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_data]: controls whether traces include potentially sensitive data, like audio transcripts. This is specifically for the voice pipeline, and not for anything that goes on inside your Workflow. +- [`trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]: controls whether traces include audio data. +- [`workflow_name`][agents.voice.pipeline_config.VoicePipelineConfig.workflow_name]: The name of the trace workflow. +- [`group_id`][agents.voice.pipeline_config.VoicePipelineConfig.group_id]: The `group_id` of the trace, which lets you link multiple traces. +- [`trace_metadata`][agents.voice.pipeline_config.VoicePipelineConfig.tracing_disabled]: Additional metadata to include with the trace. diff --git a/examples/voice/__init__.py b/examples/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/voice/static/README.md b/examples/voice/static/README.md new file mode 100644 index 0000000..74dc114 --- /dev/null +++ b/examples/voice/static/README.md @@ -0,0 +1,26 @@ +# Static voice demo + +This demo operates by capturing a recording, then running a voice pipeline on it. + +Run via: + +``` +python -m examples.voice.static.main +``` + +## How it works + +1. We create a `VoicePipeline`, setup with a custom workflow. The workflow runs an Agent, but it also has some custom responses if you say the secret word. +2. When you speak, audio is forwarded to the voice pipeline. When you stop speaking, the agent runs. +3. The pipeline is run with the audio, which causes it to: + 1. Transcribe the audio + 2. Feed the transcription to the workflow, which runs the agent. + 3. Stream the output of the agent to a text-to-speech model. +4. Play the audio. + +Some suggested examples to try: + +- Tell me a joke (_the assistant tells you a joke_) +- What's the weather in Tokyo? (_will call the `get_weather` tool and then speak_) +- Hola, como estas? (_will handoff to the spanish agent_) +- Tell me about dogs. (_will respond with the hardcoded "you guessed the secret word" message_) diff --git a/examples/voice/static/__init__.py b/examples/voice/static/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/voice/static/main.py b/examples/voice/static/main.py new file mode 100644 index 0000000..5f512db --- /dev/null +++ b/examples/voice/static/main.py @@ -0,0 +1,84 @@ +import asyncio +import random + +from agents import ( + Agent, + AudioInput, + SingleAgentVoiceWorkflow, + SingleAgentWorkflowCallbacks, + VoicePipeline, + function_tool, +) +from agents.extensions.handoff_prompt import prompt_with_handoff_instructions + +from .util import AudioPlayer, record_audio + +""" +This is a simple example that uses a recorded audio buffer. Run it via: +`python -m examples.voice.static.main` + +1. You can record an audio clip in the terminal. +2. The pipeline automatically transcribes the audio. +3. The agent workflow is a simple one that starts at the Assistant agent. +4. The output of the agent is streamed to the audio player. + +Try examples like: +- Tell me a joke (will respond with a joke) +- What's the weather in Tokyo? (will call the `get_weather` tool and then speak) +- Hola, como estas? (will handoff to the spanish agent) +""" + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather for a given city.""" + print(f"[debug] get_weather called with city: {city}") + choices = ["sunny", "cloudy", "rainy", "snowy"] + return f"The weather in {city} is {random.choice(choices)}." + + +spanish_agent = Agent( + name="Spanish", + handoff_description="A spanish speaking agent.", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. Speak in Spanish.", + ), + model="gpt-4o-mini", +) + +agent = Agent( + name="Assistant", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. If the user speaks in Spanish, handoff to the spanish agent.", + ), + model="gpt-4o-mini", + handoffs=[spanish_agent], + tools=[get_weather], +) + + +class WorkflowCallbacks(SingleAgentWorkflowCallbacks): + def on_run(self, workflow: SingleAgentVoiceWorkflow, transcription: str) -> None: + print(f"[debug] on_run called with transcription: {transcription}") + + +async def main(): + pipeline = VoicePipeline( + workflow=SingleAgentVoiceWorkflow(agent, callbacks=WorkflowCallbacks()) + ) + + audio_input = AudioInput(buffer=record_audio()) + + result = await pipeline.run(audio_input) + + with AudioPlayer() as player: + async for event in result.stream(): + if event.type == "voice_stream_event_audio": + player.add_audio(event.data) + print("Received audio") + elif event.type == "voice_stream_event_lifecycle": + print(f"Received lifecycle event: {event.event}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/voice/static/util.py b/examples/voice/static/util.py new file mode 100644 index 0000000..455bafe --- /dev/null +++ b/examples/voice/static/util.py @@ -0,0 +1,68 @@ +import curses +import time + +import numpy as np +import numpy.typing as npt +import sounddevice as sd + + +def _record_audio(screen: curses.window) -> npt.NDArray[np.float32]: + screen.nodelay(True) # Non-blocking input + screen.clear() + screen.addstr( + "Press to start recording. Press again to stop recording.\n" + ) + screen.refresh() + + recording = False + audio_buffer: list[npt.NDArray[np.float32]] = [] + + def _audio_callback(indata, frames, time_info, status): + if status: + screen.addstr(f"Status: {status}\n") + screen.refresh() + if recording: + audio_buffer.append(indata.copy()) + + # Open the audio stream with the callback. + with sd.InputStream(samplerate=24000, channels=1, dtype=np.float32, callback=_audio_callback): + while True: + key = screen.getch() + if key == ord(" "): + recording = not recording + if recording: + screen.addstr("Recording started...\n") + else: + screen.addstr("Recording stopped.\n") + break + screen.refresh() + time.sleep(0.01) + + # Combine recorded audio chunks. + if audio_buffer: + audio_data = np.concatenate(audio_buffer, axis=0) + else: + audio_data = np.empty((0,), dtype=np.float32) + + return audio_data + + +def record_audio(): + # Using curses to record audio in a way that: + # - doesn't require accessibility permissions on macos + # - doesn't block the terminal + audio_data = curses.wrapper(_record_audio) + return audio_data + + +class AudioPlayer: + def __enter__(self): + self.stream = sd.OutputStream(samplerate=24000, channels=1, dtype=np.int16) + self.stream.start() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.stream.close() + + def add_audio(self, audio_data: npt.NDArray[np.int16]): + self.stream.write(audio_data) diff --git a/examples/voice/streamed/README.md b/examples/voice/streamed/README.md new file mode 100644 index 0000000..ab0ffed --- /dev/null +++ b/examples/voice/streamed/README.md @@ -0,0 +1,25 @@ +# Streamed voice demo + +This is an interactive demo, where you can talk to an Agent conversationally. It uses the voice pipeline's built in turn detection feature, so if you stop speaking the Agent responds. + +Run via: + +``` +python -m examples.voice.streamed.main +``` + +## How it works + +1. We create a `VoicePipeline`, setup with a `SingleAgentVoiceWorkflow`. This is a workflow that starts at an Assistant agent, has tools and handoffs. +2. Audio input is captured from the terminal. +3. The pipeline is run with the recorded audio, which causes it to: + 1. Transcribe the audio + 2. Feed the transcription to the workflow, which runs the agent. + 3. Stream the output of the agent to a text-to-speech model. +4. Play the audio. + +Some suggested examples to try: + +- Tell me a joke (_the assistant tells you a joke_) +- What's the weather in Tokyo? (_will call the `get_weather` tool and then speak_) +- Hola, como estas? (_will handoff to the spanish agent_) diff --git a/examples/voice/streamed/__init__.py b/examples/voice/streamed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/voice/streamed/agents.py b/examples/voice/streamed/agents.py new file mode 100644 index 0000000..dcf312d --- /dev/null +++ b/examples/voice/streamed/agents.py @@ -0,0 +1,87 @@ +import random +from collections.abc import AsyncIterator +from typing import Callable + +from agents import ( + Agent, + Runner, + TResponseInputItem, + VoiceWorkflowBase, + VoiceWorkflowHelper, + function_tool, +) +from agents.extensions.handoff_prompt import prompt_with_handoff_instructions + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather for a given city.""" + print(f"[debug] get_weather called with city: {city}") + choices = ["sunny", "cloudy", "rainy", "snowy"] + return f"The weather in {city} is {random.choice(choices)}." + + +spanish_agent = Agent( + name="Spanish", + handoff_description="A spanish speaking agent.", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. Speak in Spanish.", + ), + model="gpt-4o-mini", +) + +agent = Agent( + name="Assistant", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. If the user speaks in Spanish, handoff to the spanish agent.", + ), + model="gpt-4o-mini", + handoffs=[spanish_agent], + tools=[get_weather], +) + + +class MyWorkflow(VoiceWorkflowBase): + def __init__(self, secret_word: str, on_start: Callable[[str], None]): + """ + Args: + secret_word: The secret word to guess. + on_start: A callback that is called when the workflow starts. The transcription + is passed in as an argument. + """ + self._input_history: list[TResponseInputItem] = [] + self._current_agent = agent + self._secret_word = secret_word.lower() + self._on_start = on_start + + async def run(self, transcription: str) -> AsyncIterator[str]: + self._on_start(transcription) + + # Add the transcription to the input history + self._input_history.append( + { + "role": "user", + "content": transcription, + } + ) + + # If the user guessed the secret word, do alternate logic + if self._secret_word in transcription.lower(): + yield "You guessed the secret word!" + self._input_history.append( + { + "role": "assistant", + "content": "You guessed the secret word!", + } + ) + return + + # Otherwise, run the agent + result = Runner.run_streamed(self._current_agent, self._input_history) + + async for chunk in VoiceWorkflowHelper.stream_text_from(result): + yield chunk + + # Update the input history and current agent + self._input_history = result.to_input_list() + self._current_agent = result.last_agent diff --git a/examples/voice/streamed/main.py b/examples/voice/streamed/main.py new file mode 100644 index 0000000..3689433 --- /dev/null +++ b/examples/voice/streamed/main.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import asyncio + +import numpy as np +import sounddevice as sd +from textual import events +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.reactive import reactive +from textual.widgets import Button, RichLog, Static +from typing_extensions import override + +from agents import VoicePipeline +from agents.voice.input import StreamedAudioInput + +from .agents import MyWorkflow + +CHUNK_LENGTH_S = 0.05 # 100ms +SAMPLE_RATE = 24000 +FORMAT = np.int16 +CHANNELS = 1 + + +class Header(Static): + """A header widget.""" + + session_id = reactive("") + + @override + def render(self) -> str: + return "Speak to the agent. When you stop speaking, it will respond." + + +class AudioStatusIndicator(Static): + """A widget that shows the current audio recording status.""" + + is_recording = reactive(False) + + @override + def render(self) -> str: + status = ( + "🔴 Recording... (Press K to stop)" + if self.is_recording + else "⚪ Press K to start recording (Q to quit)" + ) + return status + + +class RealtimeApp(App[None]): + CSS = """ + Screen { + background: #1a1b26; /* Dark blue-grey background */ + } + + Container { + border: double rgb(91, 164, 91); + } + + Horizontal { + width: 100%; + } + + #input-container { + height: 5; /* Explicit height for input container */ + margin: 1 1; + padding: 1 2; + } + + Input { + width: 80%; + height: 3; /* Explicit height for input */ + } + + Button { + width: 20%; + height: 3; /* Explicit height for button */ + } + + #bottom-pane { + width: 100%; + height: 82%; /* Reduced to make room for session display */ + border: round rgb(205, 133, 63); + content-align: center middle; + } + + #status-indicator { + height: 3; + content-align: center middle; + background: #2a2b36; + border: solid rgb(91, 164, 91); + margin: 1 1; + } + + #session-display { + height: 3; + content-align: center middle; + background: #2a2b36; + border: solid rgb(91, 164, 91); + margin: 1 1; + } + + Static { + color: white; + } + """ + + should_send_audio: asyncio.Event + audio_player: sd.OutputStream + last_audio_item_id: str | None + connected: asyncio.Event + + def __init__(self) -> None: + super().__init__() + self.last_audio_item_id = None + self.should_send_audio = asyncio.Event() + self.connected = asyncio.Event() + self.pipeline = VoicePipeline( + workflow=MyWorkflow(secret_word="dog", on_start=self._on_transcription) + ) + self._audio_input = StreamedAudioInput() + self.audio_player = sd.OutputStream( + samplerate=SAMPLE_RATE, + channels=CHANNELS, + dtype=FORMAT, + ) + + def _on_transcription(self, transcription: str) -> None: + try: + self.query_one("#bottom-pane", RichLog).write(f"Transcription: {transcription}") + except Exception: + pass + + @override + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + with Container(): + yield Header(id="session-display") + yield AudioStatusIndicator(id="status-indicator") + yield RichLog(id="bottom-pane", wrap=True, highlight=True, markup=True) + + async def on_mount(self) -> None: + self.run_worker(self.start_voice_pipeline()) + self.run_worker(self.send_mic_audio()) + + async def start_voice_pipeline(self) -> None: + try: + self.audio_player.start() + self.result = await self.pipeline.run(self._audio_input) + + async for event in self.result.stream(): + bottom_pane = self.query_one("#bottom-pane", RichLog) + if event.type == "voice_stream_event_audio": + self.audio_player.write(event.data) + bottom_pane.write( + f"Received audio: {len(event.data) if event.data is not None else '0'} bytes" + ) + elif event.type == "voice_stream_event_lifecycle": + bottom_pane.write(f"Lifecycle event: {event.event}") + except Exception as e: + bottom_pane = self.query_one("#bottom-pane", RichLog) + bottom_pane.write(f"Error: {e}") + finally: + self.audio_player.close() + + async def send_mic_audio(self) -> None: + device_info = sd.query_devices() + print(device_info) + + read_size = int(SAMPLE_RATE * 0.02) + + stream = sd.InputStream( + channels=CHANNELS, + samplerate=SAMPLE_RATE, + dtype="int16", + ) + stream.start() + + status_indicator = self.query_one(AudioStatusIndicator) + + try: + while True: + if stream.read_available < read_size: + await asyncio.sleep(0) + continue + + await self.should_send_audio.wait() + status_indicator.is_recording = True + + data, _ = stream.read(read_size) + + await self._audio_input.add_audio(data) + await asyncio.sleep(0) + except KeyboardInterrupt: + pass + finally: + stream.stop() + stream.close() + + async def on_key(self, event: events.Key) -> None: + """Handle key press events.""" + if event.key == "enter": + self.query_one(Button).press() + return + + if event.key == "q": + self.exit() + return + + if event.key == "k": + status_indicator = self.query_one(AudioStatusIndicator) + if status_indicator.is_recording: + self.should_send_audio.clear() + status_indicator.is_recording = False + else: + self.should_send_audio.set() + status_indicator.is_recording = True + + +if __name__ == "__main__": + app = RealtimeApp() + app.run() diff --git a/mkdocs.yml b/mkdocs.yml index 6b81531..941f29e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,6 +35,10 @@ nav: - multi_agent.md - models.md - config.md + - Voice agents: + - voice/quickstart.md + - voice/pipeline.md + - voice/tracing.md - API Reference: - Agents: - ref/index.md @@ -67,6 +71,19 @@ nav: - ref/tracing/setup.md - ref/tracing/span_data.md - ref/tracing/util.md + - Voice: + - ref/voice/pipeline.md + - ref/voice/workflow.md + - ref/voice/input.md + - ref/voice/result.md + - ref/voice/pipeline_config.md + - ref/voice/events.md + - ref/voice/exceptions.md + - ref/voice/model.md + - ref/voice/utils.md + - ref/voice/models/openai_provider.md + - ref/voice/models/openai_stt.md + - ref/voice/models/openai_tts.md - Extensions: - ref/extensions/handoff_filters.md - ref/extensions/handoff_prompt.md @@ -96,9 +113,13 @@ extra: generator: false markdown_extensions: + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - admonition - pymdownx.details - - pymdownx.superfences - attr_list - md_in_html - pymdownx.highlight: diff --git a/pyproject.toml b/pyproject.toml index 8aecf1b..9d0d8c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,7 @@ description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.9" license = "MIT" -authors = [ - { name = "OpenAI", email = "support@openai.com" }, -] +authors = [{ name = "OpenAI", email = "support@openai.com" }] dependencies = [ "openai>=1.66.5", "pydantic>=2.10, <3", @@ -27,13 +25,16 @@ classifiers = [ "Intended Audience :: Developers", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Python Modules", - "License :: OSI Approved :: MIT License" + "License :: OSI Approved :: MIT License", ] [project.urls] Homepage = "https://github.com/openai/openai-agents-python" 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"] + [dependency-groups] dev = [ "mypy", @@ -48,6 +49,11 @@ dev = [ "coverage>=7.6.12", "playwright==1.50.0", "inline-snapshot>=0.20.7", + "pynput", + "types-pynput", + "sounddevice", + "pynput", + "textual", ] [tool.uv.workspace] members = ["agents"] @@ -74,8 +80,8 @@ select = [ "F", # pyflakes "I", # isort "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade + "C4", # flake8-comprehensions + "UP", # pyupgrade ] isort = { combine-as-imports = true, known-first-party = ["agents"] } @@ -91,11 +97,12 @@ disallow_incomplete_defs = false disallow_untyped_defs = false disallow_untyped_calls = false +[[tool.mypy.overrides]] +module = "sounddevice.*" +ignore_missing_imports = true + [tool.coverage.run] -source = [ - "tests", - "src/agents", -] +source = ["tests", "src/agents"] [tool.coverage.report] show_missing = true @@ -109,7 +116,7 @@ exclude_also = [ ] [tool.pytest.ini_options] -asyncio_mode = "auto" +asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" filterwarnings = [ # This is a warning that is expected to happen: we have an async filter that raises an exception @@ -120,4 +127,4 @@ markers = [ ] [tool.inline-snapshot] -format-command="ruff format --stdin-filename {filename}" \ No newline at end of file +format-command = "ruff format --stdin-filename {filename}" diff --git a/src/agents/__init__.py b/src/agents/__init__.py index a7a1272..c0b47dc 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -73,8 +73,11 @@ from .tracing import ( Span, SpanData, SpanError, + SpeechGroupSpanData, + SpeechSpanData, Trace, TracingProcessor, + TranscriptionSpanData, add_trace_processor, agent_span, custom_span, @@ -89,9 +92,37 @@ from .tracing import ( set_trace_processors, set_tracing_disabled, set_tracing_export_api_key, + speech_group_span, + speech_span, trace, + transcription_span, ) from .usage import Usage +from .voice import ( + AudioInput, + OpenAISTTModel, + OpenAISTTTranscriptionSession, + OpenAITTSModel, + OpenAIVoiceModelProvider, + SingleAgentVoiceWorkflow, + SingleAgentWorkflowCallbacks, + StreamedAudioInput, + StreamedAudioResult, + StreamedTranscriptionSession, + STTModel, + STTModelSettings, + TTSModel, + TTSModelSettings, + VoiceModelProvider, + VoicePipeline, + VoicePipelineConfig, + VoiceStreamEvent, + VoiceStreamEventAudio, + VoiceStreamEventLifecycle, + VoiceWorkflowBase, + VoiceWorkflowHelper, + get_sentence_based_splitter, +) def set_default_openai_key(key: str, use_for_tracing: bool = True) -> None: @@ -211,6 +242,9 @@ __all__ = [ "handoff_span", "set_trace_processors", "set_tracing_disabled", + "speech_group_span", + "transcription_span", + "speech_span", "trace", "Trace", "TracingProcessor", @@ -223,6 +257,9 @@ __all__ = [ "GenerationSpanData", "GuardrailSpanData", "HandoffSpanData", + "SpeechGroupSpanData", + "SpeechSpanData", + "TranscriptionSpanData", "set_default_openai_key", "set_default_openai_client", "set_default_openai_api", @@ -231,4 +268,27 @@ __all__ = [ "gen_trace_id", "gen_span_id", "default_tool_error_function", + "AudioInput", + "StreamedAudioInput", + "STTModel", + "STTModelSettings", + "TTSModel", + "TTSModelSettings", + "VoiceModelProvider", + "StreamedAudioResult", + "SingleAgentVoiceWorkflow", + "OpenAIVoiceModelProvider", + "OpenAISTTModel", + "OpenAITTSModel", + "VoiceStreamEventAudio", + "VoiceStreamEventLifecycle", + "VoiceStreamEvent", + "VoicePipeline", + "VoicePipelineConfig", + "get_sentence_based_splitter", + "VoiceWorkflowHelper", + "VoiceWorkflowBase", + "StreamedTranscriptionSession", + "OpenAISTTTranscriptionSession", + "SingleAgentWorkflowCallbacks", ] diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py index e6a859f..e7e922a 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -34,6 +34,19 @@ class OpenAIProvider(ModelProvider): project: str | None = None, use_responses: bool | None = None, ) -> None: + """Create a new OpenAI provider. + + Args: + api_key: The API key to use for the OpenAI client. If not provided, we will use the + default API key. + base_url: The base URL to use for the OpenAI client. If not provided, we will use the + default base URL. + openai_client: An optional OpenAI client to use. If not provided, we will create a new + OpenAI client using the api_key and base_url. + organization: The organization to use for the OpenAI client. + project: The project to use for the OpenAI client. + use_responses: Whether to use the OpenAI responses API. + """ if openai_client is not None: assert api_key is None and base_url is None, ( "Don't provide api_key or base_url if you provide openai_client" diff --git a/src/agents/tracing/__init__.py b/src/agents/tracing/__init__.py index 8e80201..dc7c7cf 100644 --- a/src/agents/tracing/__init__.py +++ b/src/agents/tracing/__init__.py @@ -10,7 +10,10 @@ from .create import ( guardrail_span, handoff_span, response_span, + speech_group_span, + speech_span, trace, + transcription_span, ) from .processor_interface import TracingProcessor from .processors import default_exporter, default_processor @@ -24,6 +27,9 @@ from .span_data import ( HandoffSpanData, ResponseSpanData, SpanData, + SpeechGroupSpanData, + SpeechSpanData, + TranscriptionSpanData, ) from .spans import Span, SpanError from .traces import Trace @@ -54,9 +60,15 @@ __all__ = [ "GuardrailSpanData", "HandoffSpanData", "ResponseSpanData", + "SpeechGroupSpanData", + "SpeechSpanData", + "TranscriptionSpanData", "TracingProcessor", "gen_trace_id", "gen_span_id", + "speech_group_span", + "speech_span", + "transcription_span", ] diff --git a/src/agents/tracing/create.py b/src/agents/tracing/create.py index 78a064b..af2f156 100644 --- a/src/agents/tracing/create.py +++ b/src/agents/tracing/create.py @@ -13,6 +13,9 @@ from .span_data import ( GuardrailSpanData, HandoffSpanData, ResponseSpanData, + SpeechGroupSpanData, + SpeechSpanData, + TranscriptionSpanData, ) from .spans import Span from .traces import Trace @@ -181,7 +184,11 @@ def generation_span( """ return GLOBAL_TRACE_PROVIDER.create_span( span_data=GenerationSpanData( - input=input, output=output, model=model, model_config=model_config, usage=usage + input=input, + output=output, + model=model, + model_config=model_config, + usage=usage, ), span_id=span_id, parent=parent, @@ -304,3 +311,116 @@ def guardrail_span( parent=parent, disabled=disabled, ) + + +def transcription_span( + model: str | None = None, + input: str | None = None, + input_format: str | None = "pcm", + output: str | None = None, + model_config: Mapping[str, Any] | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[TranscriptionSpanData]: + """Create a new transcription span. The span will not be started automatically, you should + either do `with transcription_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + model: The name of the model used for the speech-to-text. + input: The audio input of the speech-to-text transcription, as a base64 encoded string of + audio bytes. + input_format: The format of the audio input (defaults to "pcm"). + output: The output of the speech-to-text transcription. + model_config: The model configuration (hyperparameters) used. + 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. + + Returns: + The newly created speech-to-text span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=TranscriptionSpanData( + input=input, + input_format=input_format, + output=output, + model=model, + model_config=model_config, + ), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def speech_span( + model: str | None = None, + input: str | None = None, + output: str | None = None, + output_format: str | None = "pcm", + model_config: Mapping[str, Any] | None = None, + first_content_at: str | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[SpeechSpanData]: + """Create a new speech span. The span will not be started automatically, you should either do + `with speech_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + model: The name of the model used for the text-to-speech. + input: The text input of the text-to-speech. + output: The audio output of the text-to-speech as base64 encoded string of PCM audio bytes. + output_format: The format of the audio output (defaults to "pcm"). + model_config: The model configuration (hyperparameters) used. + first_content_at: The time of the first byte of the audio output. + 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=SpeechSpanData( + model=model, + input=input, + output=output, + output_format=output_format, + model_config=model_config, + first_content_at=first_content_at, + ), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def speech_group_span( + input: str | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[SpeechGroupSpanData]: + """Create a new speech group span. The span will not be started automatically, you should + either do `with speech_group_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + input: The input text used for the speech request. + 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=SpeechGroupSpanData(input=input), + 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 1a49d8e..95e7fe0 100644 --- a/src/agents/tracing/span_data.py +++ b/src/agents/tracing/span_data.py @@ -186,3 +186,99 @@ class GuardrailSpanData(SpanData): "name": self.name, "triggered": self.triggered, } + + +class TranscriptionSpanData(SpanData): + __slots__ = ( + "input", + "output", + "model", + "model_config", + ) + + def __init__( + self, + input: str | None = None, + input_format: str | None = "pcm", + output: str | None = None, + model: str | None = None, + model_config: Mapping[str, Any] | None = None, + ): + self.input = input + self.input_format = input_format + self.output = output + self.model = model + self.model_config = model_config + + @property + def type(self) -> str: + return "transcription" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "input": { + "data": self.input or "", + "format": self.input_format, + }, + "output": self.output, + "model": self.model, + "model_config": self.model_config, + } + + +class SpeechSpanData(SpanData): + __slots__ = ("input", "output", "model", "model_config", "first_byte_at") + + def __init__( + self, + input: str | None = None, + output: str | None = None, + output_format: str | None = "pcm", + model: str | None = None, + model_config: Mapping[str, Any] | None = None, + first_content_at: str | None = None, + ): + self.input = input + self.output = output + self.output_format = output_format + self.model = model + self.model_config = model_config + self.first_content_at = first_content_at + + @property + def type(self) -> str: + return "speech" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "input": self.input, + "output": { + "data": self.output or "", + "format": self.output_format, + }, + "model": self.model, + "model_config": self.model_config, + "first_content_at": self.first_content_at, + } + + +class SpeechGroupSpanData(SpanData): + __slots__ = "input" + + def __init__( + self, + input: str | None = None, + ): + self.input = input + + @property + def type(self) -> str: + return "speech-group" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "input": self.input, + } diff --git a/src/agents/tracing/util.py b/src/agents/tracing/util.py index 3e5cad9..f546b4e 100644 --- a/src/agents/tracing/util.py +++ b/src/agents/tracing/util.py @@ -15,3 +15,8 @@ def gen_trace_id() -> str: def gen_span_id() -> str: """Generates a new span ID.""" return f"span_{uuid.uuid4().hex[:24]}" + + +def gen_group_id() -> str: + """Generates a new group ID.""" + return f"group_{uuid.uuid4().hex[:24]}" diff --git a/src/agents/voice/__init__.py b/src/agents/voice/__init__.py new file mode 100644 index 0000000..499c064 --- /dev/null +++ b/src/agents/voice/__init__.py @@ -0,0 +1,51 @@ +from .events import VoiceStreamEvent, VoiceStreamEventAudio, VoiceStreamEventLifecycle +from .exceptions import STTWebsocketConnectionError +from .input import AudioInput, StreamedAudioInput +from .model import ( + StreamedTranscriptionSession, + STTModel, + STTModelSettings, + TTSModel, + TTSModelSettings, + VoiceModelProvider, +) +from .models.openai_model_provider import OpenAIVoiceModelProvider +from .models.openai_stt import OpenAISTTModel, OpenAISTTTranscriptionSession +from .models.openai_tts import OpenAITTSModel +from .pipeline import VoicePipeline +from .pipeline_config import VoicePipelineConfig +from .result import StreamedAudioResult +from .utils import get_sentence_based_splitter +from .workflow import ( + SingleAgentVoiceWorkflow, + SingleAgentWorkflowCallbacks, + VoiceWorkflowBase, + VoiceWorkflowHelper, +) + +__all__ = [ + "AudioInput", + "StreamedAudioInput", + "STTModel", + "STTModelSettings", + "TTSModel", + "TTSModelSettings", + "VoiceModelProvider", + "StreamedAudioResult", + "SingleAgentVoiceWorkflow", + "OpenAIVoiceModelProvider", + "OpenAISTTModel", + "OpenAITTSModel", + "VoiceStreamEventAudio", + "VoiceStreamEventLifecycle", + "VoiceStreamEvent", + "VoicePipeline", + "VoicePipelineConfig", + "get_sentence_based_splitter", + "VoiceWorkflowHelper", + "VoiceWorkflowBase", + "SingleAgentWorkflowCallbacks", + "StreamedTranscriptionSession", + "OpenAISTTTranscriptionSession", + "STTWebsocketConnectionError", +] diff --git a/src/agents/voice/events.py b/src/agents/voice/events.py new file mode 100644 index 0000000..bdcd081 --- /dev/null +++ b/src/agents/voice/events.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Union + +from typing_extensions import TypeAlias + +from .imports import np, npt + + +@dataclass +class VoiceStreamEventAudio: + """Streaming event from the VoicePipeline""" + + data: npt.NDArray[np.int16 | np.float32] | None + """The audio data.""" + + type: Literal["voice_stream_event_audio"] = "voice_stream_event_audio" + """The type of event.""" + + +@dataclass +class VoiceStreamEventLifecycle: + """Streaming event from the VoicePipeline""" + + event: Literal["turn_started", "turn_ended", "session_ended"] + """The event that occurred.""" + + type: Literal["voice_stream_event_lifecycle"] = "voice_stream_event_lifecycle" + """The type of event.""" + + +@dataclass +class VoiceStreamEventError: + """Streaming event from the VoicePipeline""" + + error: Exception + """The error that occurred.""" + + type: Literal["voice_stream_event_error"] = "voice_stream_event_error" + """The type of event.""" + + +VoiceStreamEvent: TypeAlias = Union[ + VoiceStreamEventAudio, VoiceStreamEventLifecycle, VoiceStreamEventError +] +"""An event from the `VoicePipeline`, streamed via `StreamedAudioResult.stream()`.""" diff --git a/src/agents/voice/exceptions.py b/src/agents/voice/exceptions.py new file mode 100644 index 0000000..97dccac --- /dev/null +++ b/src/agents/voice/exceptions.py @@ -0,0 +1,8 @@ +from ..exceptions import AgentsException + + +class STTWebsocketConnectionError(AgentsException): + """Exception raised when the STT websocket connection fails.""" + + def __init__(self, message: str): + self.message = message diff --git a/src/agents/voice/imports.py b/src/agents/voice/imports.py new file mode 100644 index 0000000..37062da --- /dev/null +++ b/src/agents/voice/imports.py @@ -0,0 +1,11 @@ +try: + import numpy as np + import numpy.typing as npt + import websockets +except ImportError as _e: + raise ImportError( + "`numpy` + `websockets` are required to use voice. You can install them via the optional " + "dependency group: `pip install openai-agents[voice]`." + ) from _e + +__all__ = ["np", "npt", "websockets"] diff --git a/src/agents/voice/input.py b/src/agents/voice/input.py new file mode 100644 index 0000000..8613d27 --- /dev/null +++ b/src/agents/voice/input.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import asyncio +import base64 +import io +import wave +from dataclasses import dataclass + +from ..exceptions import UserError +from .imports import np, npt + +DEFAULT_SAMPLE_RATE = 24000 + + +def _buffer_to_audio_file( + buffer: npt.NDArray[np.int16 | np.float32], + frame_rate: int = DEFAULT_SAMPLE_RATE, + sample_width: int = 2, + channels: int = 1, +) -> tuple[str, io.BytesIO, str]: + if buffer.dtype == np.float32: + # convert to int16 + buffer = np.clip(buffer, -1.0, 1.0) + buffer = (buffer * 32767).astype(np.int16) + elif buffer.dtype != np.int16: + raise UserError("Buffer must be a numpy array of int16 or float32") + + audio_file = io.BytesIO() + with wave.open(audio_file, "w") as wav_file: + wav_file.setnchannels(channels) + wav_file.setsampwidth(sample_width) + wav_file.setframerate(frame_rate) + wav_file.writeframes(buffer.tobytes()) + audio_file.seek(0) + + # (filename, bytes, content_type) + return ("audio.wav", audio_file, "audio/wav") + + +@dataclass +class AudioInput: + """Static audio to be used as input for the VoicePipeline.""" + + buffer: npt.NDArray[np.int16 | np.float32] + """ + A buffer containing the audio data for the agent. Must be a numpy array of int16 or float32. + """ + + frame_rate: int = DEFAULT_SAMPLE_RATE + """The sample rate of the audio data. Defaults to 24000.""" + + sample_width: int = 2 + """The sample width of the audio data. Defaults to 2.""" + + channels: int = 1 + """The number of channels in the audio data. Defaults to 1.""" + + def to_audio_file(self) -> tuple[str, io.BytesIO, str]: + """Returns a tuple of (filename, bytes, content_type)""" + return _buffer_to_audio_file(self.buffer, self.frame_rate, self.sample_width, self.channels) + + def to_base64(self) -> str: + """Returns the audio data as a base64 encoded string.""" + if self.buffer.dtype == np.float32: + # convert to int16 + self.buffer = np.clip(self.buffer, -1.0, 1.0) + self.buffer = (self.buffer * 32767).astype(np.int16) + elif self.buffer.dtype != np.int16: + raise UserError("Buffer must be a numpy array of int16 or float32") + + return base64.b64encode(self.buffer.tobytes()).decode("utf-8") + + +class StreamedAudioInput: + """Audio input represented as a stream of audio data. You can pass this to the `VoicePipeline` + and then push audio data into the queue using the `add_audio` method. + """ + + def __init__(self): + self.queue: asyncio.Queue[npt.NDArray[np.int16 | np.float32]] = asyncio.Queue() + + async def add_audio(self, audio: npt.NDArray[np.int16 | np.float32]): + """Adds more audio data to the stream. + + Args: + audio: The audio data to add. Must be a numpy array of int16 or float32. + """ + await self.queue.put(audio) diff --git a/src/agents/voice/model.py b/src/agents/voice/model.py new file mode 100644 index 0000000..220d4b4 --- /dev/null +++ b/src/agents/voice/model.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import abc +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any, Callable, Literal + +from .imports import np, npt +from .input import AudioInput, StreamedAudioInput +from .utils import get_sentence_based_splitter + +DEFAULT_TTS_INSTRUCTIONS = ( + "You will receive partial sentences. Do not complete the sentence, just read out the text." +) +DEFAULT_TTS_BUFFER_SIZE = 120 + + +@dataclass +class TTSModelSettings: + """Settings for a TTS model.""" + + voice: ( + Literal["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer"] | None + ) = None + """ + The voice to use for the TTS model. If not provided, the default voice for the respective model + will be used. + """ + + buffer_size: int = 120 + """The minimal size of the chunks of audio data that are being streamed out.""" + + dtype: npt.DTypeLike = np.int16 + """The data type for the audio data to be returned in.""" + + transform_data: ( + Callable[[npt.NDArray[np.int16 | np.float32]], npt.NDArray[np.int16 | np.float32]] | None + ) = None + """ + A function to transform the data from the TTS model. This is useful if you want the resulting + audio stream to have the data in a specific shape already. + """ + + instructions: str = ( + "You will receive partial sentences. Do not complete the sentence just read out the text." + ) + """ + The instructions to use for the TTS model. This is useful if you want to control the tone of the + audio output. + """ + + text_splitter: Callable[[str], tuple[str, str]] = get_sentence_based_splitter() + """ + A function to split the text into chunks. This is useful if you want to split the text into + chunks before sending it to the TTS model rather than waiting for the whole text to be + processed. + """ + + speed: float | None = None + """The speed with which the TTS model will read the text. Between 0.25 and 4.0.""" + + +class TTSModel(abc.ABC): + """A text-to-speech model that can convert text into audio output.""" + + @property + @abc.abstractmethod + def model_name(self) -> str: + """The name of the TTS model.""" + pass + + @abc.abstractmethod + def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: + """Given a text string, produces a stream of audio bytes, in PCM format. + + Args: + text: The text to convert to audio. + + Returns: + An async iterator of audio bytes, in PCM format. + """ + pass + + +class StreamedTranscriptionSession(abc.ABC): + """A streamed transcription of audio input.""" + + @abc.abstractmethod + def transcribe_turns(self) -> AsyncIterator[str]: + """Yields a stream of text transcriptions. Each transcription is a turn in the conversation. + + This method is expected to return only after `close()` is called. + """ + pass + + @abc.abstractmethod + async def close(self) -> None: + """Closes the session.""" + pass + + +@dataclass +class STTModelSettings: + """Settings for a speech-to-text model.""" + + prompt: str | None = None + """Instructions for the model to follow.""" + + language: str | None = None + """The language of the audio input.""" + + temperature: float | None = None + """The temperature of the model.""" + + turn_detection: dict[str, Any] | None = None + """The turn detection settings for the model when using streamed audio input.""" + + +class STTModel(abc.ABC): + """A speech-to-text model that can convert audio input into text.""" + + @property + @abc.abstractmethod + def model_name(self) -> str: + """The name of the STT model.""" + pass + + @abc.abstractmethod + async def transcribe( + self, + input: AudioInput, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ) -> str: + """Given an audio input, produces a text transcription. + + Args: + input: The audio input to transcribe. + settings: The settings to use for the transcription. + trace_include_sensitive_data: Whether to include sensitive data in traces. + trace_include_sensitive_audio_data: Whether to include sensitive audio data in traces. + + Returns: + The text transcription of the audio input. + """ + pass + + @abc.abstractmethod + async def create_session( + self, + input: StreamedAudioInput, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ) -> StreamedTranscriptionSession: + """Creates a new transcription session, which you can push audio to, and receive a stream + of text transcriptions. + + Args: + input: The audio input to transcribe. + settings: The settings to use for the transcription. + trace_include_sensitive_data: Whether to include sensitive data in traces. + trace_include_sensitive_audio_data: Whether to include sensitive audio data in traces. + + Returns: + A new transcription session. + """ + pass + + +class VoiceModelProvider(abc.ABC): + """The base interface for a voice model provider. + + A model provider is responsible for creating speech-to-text and text-to-speech models, given a + name. + """ + + @abc.abstractmethod + def get_stt_model(self, model_name: str | None) -> STTModel: + """Get a speech-to-text model by name. + + Args: + model_name: The name of the model to get. + + Returns: + The speech-to-text model. + """ + pass + + @abc.abstractmethod + def get_tts_model(self, model_name: str | None) -> TTSModel: + """Get a text-to-speech model by name.""" diff --git a/src/agents/voice/models/__init__.py b/src/agents/voice/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/voice/models/openai_model_provider.py b/src/agents/voice/models/openai_model_provider.py new file mode 100644 index 0000000..094df4c --- /dev/null +++ b/src/agents/voice/models/openai_model_provider.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import httpx +from openai import AsyncOpenAI, DefaultAsyncHttpxClient + +from ...models import _openai_shared +from ..model import STTModel, TTSModel, VoiceModelProvider +from .openai_stt import OpenAISTTModel +from .openai_tts import OpenAITTSModel + +_http_client: httpx.AsyncClient | None = None + + +# If we create a new httpx client for each request, that would mean no sharing of connection pools, +# which would mean worse latency and resource usage. So, we share the client across requests. +def shared_http_client() -> httpx.AsyncClient: + global _http_client + if _http_client is None: + _http_client = DefaultAsyncHttpxClient() + return _http_client + + +DEFAULT_STT_MODEL = "gpt-4o-transcribe" +DEFAULT_TTS_MODEL = "gpt-4o-mini-tts" + + +class OpenAIVoiceModelProvider(VoiceModelProvider): + """A voice model provider that uses OpenAI models.""" + + def __init__( + self, + *, + api_key: str | None = None, + base_url: str | None = None, + openai_client: AsyncOpenAI | None = None, + organization: str | None = None, + project: str | None = None, + ) -> None: + """Create a new OpenAI voice model provider. + + Args: + api_key: The API key to use for the OpenAI client. If not provided, we will use the + default API key. + base_url: The base URL to use for the OpenAI client. If not provided, we will use the + default base URL. + openai_client: An optional OpenAI client to use. If not provided, we will create a new + OpenAI client using the api_key and base_url. + organization: The organization to use for the OpenAI client. + project: The project to use for the OpenAI client. + """ + if openai_client is not None: + assert api_key is None and base_url is None, ( + "Don't provide api_key or base_url if you provide openai_client" + ) + self._client: AsyncOpenAI | None = openai_client + else: + self._client = None + self._stored_api_key = api_key + self._stored_base_url = base_url + self._stored_organization = organization + self._stored_project = project + + # We lazy load the client in case you never actually use OpenAIProvider(). Otherwise + # AsyncOpenAI() raises an error if you don't have an API key set. + def _get_client(self) -> AsyncOpenAI: + if self._client is None: + self._client = _openai_shared.get_default_openai_client() or AsyncOpenAI( + api_key=self._stored_api_key or _openai_shared.get_default_openai_key(), + base_url=self._stored_base_url, + organization=self._stored_organization, + project=self._stored_project, + http_client=shared_http_client(), + ) + + return self._client + + def get_stt_model(self, model_name: str | None) -> STTModel: + """Get a speech-to-text model by name. + + Args: + model_name: The name of the model to get. + + Returns: + The speech-to-text model. + """ + return OpenAISTTModel(model_name or DEFAULT_STT_MODEL, self._get_client()) + + def get_tts_model(self, model_name: str | None) -> TTSModel: + """Get a text-to-speech model by name. + + Args: + model_name: The name of the model to get. + + Returns: + The text-to-speech model. + """ + return OpenAITTSModel(model_name or DEFAULT_TTS_MODEL, self._get_client()) diff --git a/src/agents/voice/models/openai_stt.py b/src/agents/voice/models/openai_stt.py new file mode 100644 index 0000000..a5cf8ac --- /dev/null +++ b/src/agents/voice/models/openai_stt.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import time +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any, cast + +from openai import AsyncOpenAI + +from agents.exceptions import AgentsException + +from ... import _debug +from ...logger import logger +from ...tracing import Span, SpanError, TranscriptionSpanData, transcription_span +from ..exceptions import STTWebsocketConnectionError +from ..imports import np, npt, websockets +from ..input import AudioInput, StreamedAudioInput +from ..model import StreamedTranscriptionSession, STTModel, STTModelSettings + +EVENT_INACTIVITY_TIMEOUT = 1000 # Timeout for inactivity in event processing +SESSION_CREATION_TIMEOUT = 10 # Timeout waiting for session.created event +SESSION_UPDATE_TIMEOUT = 10 # Timeout waiting for session.updated event + +DEFAULT_TURN_DETECTION = {"type": "semantic_vad"} + + +@dataclass +class ErrorSentinel: + error: Exception + + +class SessionCompleteSentinel: + pass + + +class WebsocketDoneSentinel: + pass + + +def _audio_to_base64(audio_data: list[npt.NDArray[np.int16 | np.float32]]) -> str: + concatenated_audio = np.concatenate(audio_data) + if concatenated_audio.dtype == np.float32: + # convert to int16 + concatenated_audio = np.clip(concatenated_audio, -1.0, 1.0) + concatenated_audio = (concatenated_audio * 32767).astype(np.int16) + audio_bytes = concatenated_audio.tobytes() + return base64.b64encode(audio_bytes).decode("utf-8") + + +async def _wait_for_event( + event_queue: asyncio.Queue[dict[str, Any]], expected_types: list[str], timeout: float +): + """ + Wait for an event from event_queue whose type is in expected_types within the specified timeout. + """ + start_time = time.time() + while True: + remaining = timeout - (time.time() - start_time) + if remaining <= 0: + raise TimeoutError(f"Timeout waiting for event(s): {expected_types}") + evt = await asyncio.wait_for(event_queue.get(), timeout=remaining) + evt_type = evt.get("type", "") + if evt_type in expected_types: + return evt + elif evt_type == "error": + raise Exception(f"Error event: {evt.get('error')}") + + +class OpenAISTTTranscriptionSession(StreamedTranscriptionSession): + """A transcription session for OpenAI's STT model.""" + + def __init__( + self, + input: StreamedAudioInput, + client: AsyncOpenAI, + model: str, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ): + self.connected: bool = False + self._client = client + self._model = model + self._settings = settings + self._turn_detection = settings.turn_detection or DEFAULT_TURN_DETECTION + self._trace_include_sensitive_data = trace_include_sensitive_data + self._trace_include_sensitive_audio_data = trace_include_sensitive_audio_data + + self._input_queue: asyncio.Queue[npt.NDArray[np.int16 | np.float32]] = input.queue + self._output_queue: asyncio.Queue[str | ErrorSentinel | SessionCompleteSentinel] = ( + asyncio.Queue() + ) + self._websocket: websockets.ClientConnection | None = None + self._event_queue: asyncio.Queue[dict[str, Any] | WebsocketDoneSentinel] = asyncio.Queue() + self._state_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._turn_audio_buffer: list[npt.NDArray[np.int16 | np.float32]] = [] + self._tracing_span: Span[TranscriptionSpanData] | None = None + + # tasks + self._listener_task: asyncio.Task[Any] | None = None + self._process_events_task: asyncio.Task[Any] | None = None + self._stream_audio_task: asyncio.Task[Any] | None = None + self._connection_task: asyncio.Task[Any] | None = None + self._stored_exception: Exception | None = None + + def _start_turn(self) -> None: + self._tracing_span = transcription_span( + model=self._model, + model_config={ + "temperature": self._settings.temperature, + "language": self._settings.language, + "prompt": self._settings.prompt, + "turn_detection": self._turn_detection, + }, + ) + self._tracing_span.start() + + def _end_turn(self, _transcript: str) -> None: + if len(_transcript) < 1: + return + + if self._tracing_span: + if self._trace_include_sensitive_audio_data: + self._tracing_span.span_data.input = _audio_to_base64(self._turn_audio_buffer) + + self._tracing_span.span_data.input_format = "pcm" + + if self._trace_include_sensitive_data: + self._tracing_span.span_data.output = _transcript + + self._tracing_span.finish() + self._turn_audio_buffer = [] + self._tracing_span = None + + async def _event_listener(self) -> None: + assert self._websocket is not None, "Websocket not initialized" + + async for message in self._websocket: + try: + event = json.loads(message) + + if event.get("type") == "error": + raise STTWebsocketConnectionError(f"Error event: {event.get('error')}") + + if event.get("type") in [ + "session.updated", + "transcription_session.updated", + "session.created", + "transcription_session.created", + ]: + await self._state_queue.put(event) + + await self._event_queue.put(event) + except Exception as e: + await self._output_queue.put(ErrorSentinel(e)) + raise STTWebsocketConnectionError("Error parsing events") from e + await self._event_queue.put(WebsocketDoneSentinel()) + + async def _configure_session(self) -> None: + assert self._websocket is not None, "Websocket not initialized" + await self._websocket.send( + json.dumps( + { + "type": "transcription_session.update", + "session": { + "input_audio_format": "pcm16", + "input_audio_transcription": {"model": self._model}, + "turn_detection": self._turn_detection, + }, + } + ) + ) + + async def _setup_connection(self, ws: websockets.ClientConnection) -> None: + self._websocket = ws + self._listener_task = asyncio.create_task(self._event_listener()) + + try: + event = await _wait_for_event( + self._state_queue, + ["session.created", "transcription_session.created"], + SESSION_CREATION_TIMEOUT, + ) + except TimeoutError as e: + wrapped_err = STTWebsocketConnectionError( + "Timeout waiting for transcription_session.created event" + ) + await self._output_queue.put(ErrorSentinel(wrapped_err)) + raise wrapped_err from e + except Exception as e: + await self._output_queue.put(ErrorSentinel(e)) + raise e + + await self._configure_session() + + try: + event = await _wait_for_event( + self._state_queue, + ["session.updated", "transcription_session.updated"], + SESSION_UPDATE_TIMEOUT, + ) + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("Session updated") + else: + logger.debug(f"Session updated: {event}") + except TimeoutError as e: + wrapped_err = STTWebsocketConnectionError( + "Timeout waiting for transcription_session.updated event" + ) + await self._output_queue.put(ErrorSentinel(wrapped_err)) + raise wrapped_err from e + except Exception as e: + await self._output_queue.put(ErrorSentinel(e)) + raise + + async def _handle_events(self) -> None: + while True: + try: + event = await asyncio.wait_for( + self._event_queue.get(), timeout=EVENT_INACTIVITY_TIMEOUT + ) + if isinstance(event, WebsocketDoneSentinel): + # processed all events and websocket is done + break + + event_type = event.get("type", "unknown") + if event_type == "conversation.item.input_audio_transcription.completed": + transcript = cast(str, event.get("transcript", "")) + if len(transcript) > 0: + self._end_turn(transcript) + self._start_turn() + await self._output_queue.put(transcript) + await asyncio.sleep(0) # yield control + except asyncio.TimeoutError: + # No new events for a while. Assume the session is done. + break + except Exception as e: + await self._output_queue.put(ErrorSentinel(e)) + raise e + await self._output_queue.put(SessionCompleteSentinel()) + + async def _stream_audio( + self, audio_queue: asyncio.Queue[npt.NDArray[np.int16 | np.float32]] + ) -> None: + assert self._websocket is not None, "Websocket not initialized" + self._start_turn() + while True: + buffer = await audio_queue.get() + if buffer is None: + break + + self._turn_audio_buffer.append(buffer) + try: + await self._websocket.send( + json.dumps( + { + "type": "input_audio_buffer.append", + "audio": base64.b64encode(buffer.tobytes()).decode("utf-8"), + } + ) + ) + except websockets.ConnectionClosed: + break + except Exception as e: + await self._output_queue.put(ErrorSentinel(e)) + raise e + + await asyncio.sleep(0) # yield control + + async def _process_websocket_connection(self) -> None: + try: + async with websockets.connect( + "wss://api.openai.com/v1/realtime?intent=transcription", + additional_headers={ + "Authorization": f"Bearer {self._client.api_key}", + "OpenAI-Beta": "realtime=v1", + "OpenAI-Log-Session": "1", + }, + ) as ws: + await self._setup_connection(ws) + self._process_events_task = asyncio.create_task(self._handle_events()) + self._stream_audio_task = asyncio.create_task(self._stream_audio(self._input_queue)) + self.connected = True + if self._listener_task: + await self._listener_task + else: + logger.error("Listener task not initialized") + raise AgentsException("Listener task not initialized") + except Exception as e: + await self._output_queue.put(ErrorSentinel(e)) + raise e + + def _check_errors(self) -> None: + if self._connection_task and self._connection_task.done(): + exc = self._connection_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + if self._process_events_task and self._process_events_task.done(): + exc = self._process_events_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + if self._stream_audio_task and self._stream_audio_task.done(): + exc = self._stream_audio_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + if self._listener_task and self._listener_task.done(): + exc = self._listener_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + def _cleanup_tasks(self) -> None: + if self._listener_task and not self._listener_task.done(): + self._listener_task.cancel() + + if self._process_events_task and not self._process_events_task.done(): + self._process_events_task.cancel() + + if self._stream_audio_task and not self._stream_audio_task.done(): + self._stream_audio_task.cancel() + + if self._connection_task and not self._connection_task.done(): + self._connection_task.cancel() + + async def transcribe_turns(self) -> AsyncIterator[str]: + self._connection_task = asyncio.create_task(self._process_websocket_connection()) + + while True: + try: + turn = await self._output_queue.get() + except asyncio.CancelledError: + break + + if ( + turn is None + or isinstance(turn, ErrorSentinel) + or isinstance(turn, SessionCompleteSentinel) + ): + self._output_queue.task_done() + break + yield turn + self._output_queue.task_done() + + if self._tracing_span: + self._end_turn("") + + if self._websocket: + await self._websocket.close() + + self._check_errors() + if self._stored_exception: + raise self._stored_exception + + async def close(self) -> None: + if self._websocket: + await self._websocket.close() + + self._cleanup_tasks() + + +class OpenAISTTModel(STTModel): + """A speech-to-text model for OpenAI.""" + + def __init__( + self, + model: str, + openai_client: AsyncOpenAI, + ): + """Create a new OpenAI speech-to-text model. + + Args: + model: The name of the model to use. + openai_client: The OpenAI client to use. + """ + self.model = model + self._client = openai_client + + @property + def model_name(self) -> str: + return self.model + + def _non_null_or_not_given(self, value: Any) -> Any: + return value if value is not None else None # NOT_GIVEN + + async def transcribe( + self, + input: AudioInput, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ) -> str: + """Transcribe an audio input. + + Args: + input: The audio input to transcribe. + settings: The settings to use for the transcription. + + Returns: + The transcribed text. + """ + with transcription_span( + model=self.model, + input=input.to_base64() if trace_include_sensitive_audio_data else "", + input_format="pcm", + model_config={ + "temperature": self._non_null_or_not_given(settings.temperature), + "language": self._non_null_or_not_given(settings.language), + "prompt": self._non_null_or_not_given(settings.prompt), + }, + ) as span: + try: + response = await self._client.audio.transcriptions.create( + model=self.model, + file=input.to_audio_file(), + prompt=self._non_null_or_not_given(settings.prompt), + language=self._non_null_or_not_given(settings.language), + temperature=self._non_null_or_not_given(settings.temperature), + ) + if trace_include_sensitive_data: + span.span_data.output = response.text + return response.text + except Exception as e: + span.span_data.output = "" + span.set_error(SpanError(message=str(e), data={})) + raise e + + async def create_session( + self, + input: StreamedAudioInput, + settings: STTModelSettings, + trace_include_sensitive_data: bool, + trace_include_sensitive_audio_data: bool, + ) -> StreamedTranscriptionSession: + """Create a new transcription session. + + Args: + input: The audio input to transcribe. + settings: The settings to use for the transcription. + trace_include_sensitive_data: Whether to include sensitive data in traces. + trace_include_sensitive_audio_data: Whether to include sensitive audio data in traces. + + Returns: + A new transcription session. + """ + return OpenAISTTTranscriptionSession( + input, + self._client, + self.model, + settings, + trace_include_sensitive_data, + trace_include_sensitive_audio_data, + ) diff --git a/src/agents/voice/models/openai_tts.py b/src/agents/voice/models/openai_tts.py new file mode 100644 index 0000000..3b7dcf1 --- /dev/null +++ b/src/agents/voice/models/openai_tts.py @@ -0,0 +1,54 @@ +from collections.abc import AsyncIterator +from typing import Literal + +from openai import AsyncOpenAI + +from ..model import TTSModel, TTSModelSettings + +DEFAULT_VOICE: Literal["ash"] = "ash" + + +class OpenAITTSModel(TTSModel): + """A text-to-speech model for OpenAI.""" + + def __init__( + self, + model: str, + openai_client: AsyncOpenAI, + ): + """Create a new OpenAI text-to-speech model. + + Args: + model: The name of the model to use. + openai_client: The OpenAI client to use. + """ + self.model = model + self._client = openai_client + + @property + def model_name(self) -> str: + return self.model + + async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: + """Run the text-to-speech model. + + Args: + text: The text to convert to speech. + settings: The settings to use for the text-to-speech model. + + Returns: + An iterator of audio chunks. + """ + response = self._client.audio.speech.with_streaming_response.create( + model=self.model, + voice=settings.voice or DEFAULT_VOICE, + input=text, + response_format="pcm", + extra_body={ + "instructions": settings.instructions, + }, + ) + + async with response as stream: + async for chunk in stream.iter_bytes(chunk_size=1024): + yield chunk diff --git a/src/agents/voice/pipeline.py b/src/agents/voice/pipeline.py new file mode 100644 index 0000000..d1dac57 --- /dev/null +++ b/src/agents/voice/pipeline.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import asyncio + +from .._run_impl import TraceCtxManager +from ..exceptions import UserError +from ..logger import logger +from .input import AudioInput, StreamedAudioInput +from .model import STTModel, TTSModel +from .pipeline_config import VoicePipelineConfig +from .result import StreamedAudioResult +from .workflow import VoiceWorkflowBase + + +class VoicePipeline: + """An opinionated voice agent pipeline. It works in three steps: + 1. Transcribe audio input into text. + 2. Run the provided `workflow`, which produces a sequence of text responses. + 3. Convert the text responses into streaming audio output. + """ + + def __init__( + self, + *, + workflow: VoiceWorkflowBase, + stt_model: STTModel | str | None = None, + tts_model: TTSModel | str | None = None, + config: VoicePipelineConfig | None = None, + ): + """Create a new voice pipeline. + + Args: + workflow: The workflow to run. See `VoiceWorkflowBase`. + stt_model: The speech-to-text model to use. If not provided, a default OpenAI + model will be used. + tts_model: The text-to-speech model to use. If not provided, a default OpenAI + model will be used. + config: The pipeline configuration. If not provided, a default configuration will be + used. + """ + self.workflow = workflow + self.stt_model = stt_model if isinstance(stt_model, STTModel) else None + self.tts_model = tts_model if isinstance(tts_model, TTSModel) else None + self._stt_model_name = stt_model if isinstance(stt_model, str) else None + self._tts_model_name = tts_model if isinstance(tts_model, str) else None + self.config = config or VoicePipelineConfig() + + async def run(self, audio_input: AudioInput | StreamedAudioInput) -> StreamedAudioResult: + """Run the voice pipeline. + + Args: + audio_input: The audio input to process. This can either be an `AudioInput` instance, + which is a single static buffer, or a `StreamedAudioInput` instance, which is a + stream of audio data that you can append to. + + Returns: + A `StreamedAudioResult` instance. You can use this object to stream audio events and + play them out. + """ + if isinstance(audio_input, AudioInput): + return await self._run_single_turn(audio_input) + elif isinstance(audio_input, StreamedAudioInput): + return await self._run_multi_turn(audio_input) + else: + raise UserError(f"Unsupported audio input type: {type(audio_input)}") + + def _get_tts_model(self) -> TTSModel: + if not self.tts_model: + self.tts_model = self.config.model_provider.get_tts_model(self._tts_model_name) + return self.tts_model + + def _get_stt_model(self) -> STTModel: + if not self.stt_model: + self.stt_model = self.config.model_provider.get_stt_model(self._stt_model_name) + return self.stt_model + + async def _process_audio_input(self, audio_input: AudioInput) -> str: + model = self._get_stt_model() + return await model.transcribe( + audio_input, + self.config.stt_settings, + self.config.trace_include_sensitive_data, + self.config.trace_include_sensitive_audio_data, + ) + + async def _run_single_turn(self, audio_input: AudioInput) -> StreamedAudioResult: + # Since this is single turn, we can use the TraceCtxManager to manage starting/ending the + # trace + with TraceCtxManager( + workflow_name=self.config.workflow_name or "Voice Agent", + trace_id=None, # Automatically generated + group_id=self.config.group_id, + metadata=self.config.trace_metadata, + disabled=self.config.tracing_disabled, + ): + input_text = await self._process_audio_input(audio_input) + + output = StreamedAudioResult( + self._get_tts_model(), self.config.tts_settings, self.config + ) + + async def stream_events(): + try: + async for text_event in self.workflow.run(input_text): + await output._add_text(text_event) + await output._turn_done() + await output._done() + except Exception as e: + logger.error(f"Error processing single turn: {e}") + await output._add_error(e) + raise e + + output._set_task(asyncio.create_task(stream_events())) + return output + + async def _run_multi_turn(self, audio_input: StreamedAudioInput) -> StreamedAudioResult: + with TraceCtxManager( + workflow_name=self.config.workflow_name or "Voice Agent", + trace_id=None, + group_id=self.config.group_id, + metadata=self.config.trace_metadata, + disabled=self.config.tracing_disabled, + ): + output = StreamedAudioResult( + self._get_tts_model(), self.config.tts_settings, self.config + ) + + transcription_session = await self._get_stt_model().create_session( + audio_input, + self.config.stt_settings, + self.config.trace_include_sensitive_data, + self.config.trace_include_sensitive_audio_data, + ) + + async def process_turns(): + try: + async for input_text in transcription_session.transcribe_turns(): + result = self.workflow.run(input_text) + async for text_event in result: + await output._add_text(text_event) + await output._turn_done() + except Exception as e: + logger.error(f"Error processing turns: {e}") + await output._add_error(e) + raise e + finally: + await transcription_session.close() + await output._done() + + output._set_task(asyncio.create_task(process_turns())) + return output diff --git a/src/agents/voice/pipeline_config.py b/src/agents/voice/pipeline_config.py new file mode 100644 index 0000000..a487161 --- /dev/null +++ b/src/agents/voice/pipeline_config.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from ..tracing.util import gen_group_id +from .model import STTModelSettings, TTSModelSettings, VoiceModelProvider +from .models.openai_model_provider import OpenAIVoiceModelProvider + + +@dataclass +class VoicePipelineConfig: + """Configuration for a `VoicePipeline`.""" + + model_provider: VoiceModelProvider = field(default_factory=OpenAIVoiceModelProvider) + """The voice model provider to use for the pipeline. Defaults to OpenAI.""" + + tracing_disabled: bool = False + """Whether to disable tracing of the pipeline. Defaults to `False`.""" + + trace_include_sensitive_data: bool = True + """Whether to include sensitive data in traces. Defaults to `True`. This is specifically for the + voice pipeline, and not for anything that goes on inside your Workflow.""" + + trace_include_sensitive_audio_data: bool = True + """Whether to include audio data in traces. Defaults to `True`.""" + + workflow_name: str = "Voice Agent" + """The name of the workflow to use for tracing. Defaults to `Voice Agent`.""" + + group_id: str = field(default_factory=gen_group_id) + """ + A grouping identifier to use for tracing, to link multiple traces from the same conversation + or process. If not provided, we will create a random group ID. + """ + + trace_metadata: dict[str, Any] | None = None + """ + An optional dictionary of additional metadata to include with the trace. + """ + + stt_settings: STTModelSettings = field(default_factory=STTModelSettings) + """The settings to use for the STT model.""" + + tts_settings: TTSModelSettings = field(default_factory=TTSModelSettings) + """The settings to use for the TTS model.""" diff --git a/src/agents/voice/result.py b/src/agents/voice/result.py new file mode 100644 index 0000000..fea7990 --- /dev/null +++ b/src/agents/voice/result.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import asyncio +import base64 +from collections.abc import AsyncIterator +from typing import Any + +from ..exceptions import UserError +from ..logger import logger +from ..tracing import Span, SpeechGroupSpanData, speech_group_span, speech_span +from ..tracing.util import time_iso +from .events import ( + VoiceStreamEvent, + VoiceStreamEventAudio, + VoiceStreamEventError, + VoiceStreamEventLifecycle, +) +from .imports import np, npt +from .model import TTSModel, TTSModelSettings +from .pipeline_config import VoicePipelineConfig + + +def _audio_to_base64(audio_data: list[bytes]) -> str: + joined_audio_data = b"".join(audio_data) + return base64.b64encode(joined_audio_data).decode("utf-8") + + +class StreamedAudioResult: + """The output of a `VoicePipeline`. Streams events and audio data as they're generated.""" + + def __init__( + self, + tts_model: TTSModel, + tts_settings: TTSModelSettings, + voice_pipeline_config: VoicePipelineConfig, + ): + """Create a new `StreamedAudioResult` instance. + + Args: + tts_model: The TTS model to use. + tts_settings: The TTS settings to use. + voice_pipeline_config: The voice pipeline config to use. + """ + self.tts_model = tts_model + self.tts_settings = tts_settings + self.total_output_text = "" + self.instructions = tts_settings.instructions + self.text_generation_task: asyncio.Task[Any] | None = None + + self._voice_pipeline_config = voice_pipeline_config + self._text_buffer = "" + self._turn_text_buffer = "" + self._queue: asyncio.Queue[VoiceStreamEvent] = asyncio.Queue() + self._tasks: list[asyncio.Task[Any]] = [] + self._ordered_tasks: list[ + asyncio.Queue[VoiceStreamEvent | None] + ] = [] # New: list to hold local queues for each text segment + self._dispatcher_task: asyncio.Task[Any] | None = ( + None # Task to dispatch audio chunks in order + ) + + self._done_processing = False + self._buffer_size = tts_settings.buffer_size + self._started_processing_turn = False + self._first_byte_received = False + self._generation_start_time: str | None = None + self._completed_session = False + self._stored_exception: BaseException | None = None + self._tracing_span: Span[SpeechGroupSpanData] | None = None + + async def _start_turn(self): + if self._started_processing_turn: + return + + self._tracing_span = speech_group_span() + self._tracing_span.start() + self._started_processing_turn = True + self._first_byte_received = False + self._generation_start_time = time_iso() + await self._queue.put(VoiceStreamEventLifecycle(event="turn_started")) + + def _set_task(self, task: asyncio.Task[Any]): + self.text_generation_task = task + + async def _add_error(self, error: Exception): + await self._queue.put(VoiceStreamEventError(error)) + + def _transform_audio_buffer( + self, buffer: list[bytes], output_dtype: npt.DTypeLike + ) -> npt.NDArray[np.int16 | np.float32]: + np_array = np.frombuffer(b"".join(buffer), dtype=np.int16) + + if output_dtype == np.int16: + return np_array + elif output_dtype == np.float32: + return (np_array.astype(np.float32) / 32767.0).reshape(-1, 1) + else: + raise UserError("Invalid output dtype") + + async def _stream_audio( + self, + text: str, + local_queue: asyncio.Queue[VoiceStreamEvent | None], + finish_turn: bool = False, + ): + with speech_span( + model=self.tts_model.model_name, + input=text if self._voice_pipeline_config.trace_include_sensitive_data else "", + model_config={ + "voice": self.tts_settings.voice, + "instructions": self.instructions, + "speed": self.tts_settings.speed, + }, + output_format="pcm", + parent=self._tracing_span, + ) as tts_span: + try: + first_byte_received = False + buffer: list[bytes] = [] + full_audio_data: list[bytes] = [] + + async for chunk in self.tts_model.run(text, self.tts_settings): + if not first_byte_received: + first_byte_received = True + tts_span.span_data.first_content_at = time_iso() + + if chunk: + buffer.append(chunk) + full_audio_data.append(chunk) + if len(buffer) >= self._buffer_size: + audio_np = self._transform_audio_buffer(buffer, self.tts_settings.dtype) + if self.tts_settings.transform_data: + audio_np = self.tts_settings.transform_data(audio_np) + await local_queue.put( + VoiceStreamEventAudio(data=audio_np) + ) # Use local queue + buffer = [] + if buffer: + audio_np = self._transform_audio_buffer(buffer, self.tts_settings.dtype) + if self.tts_settings.transform_data: + audio_np = self.tts_settings.transform_data(audio_np) + await local_queue.put(VoiceStreamEventAudio(data=audio_np)) # Use local queue + + if self._voice_pipeline_config.trace_include_sensitive_audio_data: + tts_span.span_data.output = _audio_to_base64(full_audio_data) + else: + tts_span.span_data.output = "" + + if finish_turn: + await local_queue.put(VoiceStreamEventLifecycle(event="turn_ended")) + else: + await local_queue.put(None) # Signal completion for this segment + except Exception as e: + tts_span.set_error( + { + "message": str(e), + "data": { + "text": text + if self._voice_pipeline_config.trace_include_sensitive_data + else "", + }, + } + ) + logger.error(f"Error streaming audio: {e}") + + # Signal completion for whole session because of error + await local_queue.put(VoiceStreamEventLifecycle(event="session_ended")) + raise e + + async def _add_text(self, text: str): + await self._start_turn() + + self._text_buffer += text + self.total_output_text += text + self._turn_text_buffer += text + + combined_sentences, self._text_buffer = self.tts_settings.text_splitter(self._text_buffer) + + if len(combined_sentences) >= 20: + local_queue: asyncio.Queue[VoiceStreamEvent | None] = asyncio.Queue() + self._ordered_tasks.append(local_queue) + self._tasks.append( + asyncio.create_task(self._stream_audio(combined_sentences, local_queue)) + ) + if self._dispatcher_task is None: + self._dispatcher_task = asyncio.create_task(self._dispatch_audio()) + + async def _turn_done(self): + if self._text_buffer: + local_queue: asyncio.Queue[VoiceStreamEvent | None] = asyncio.Queue() + self._ordered_tasks.append(local_queue) # Append the local queue for the final segment + self._tasks.append( + asyncio.create_task( + self._stream_audio(self._text_buffer, local_queue, finish_turn=True) + ) + ) + self._text_buffer = "" + self._done_processing = True + if self._dispatcher_task is None: + self._dispatcher_task = asyncio.create_task(self._dispatch_audio()) + await asyncio.gather(*self._tasks) + + def _finish_turn(self): + if self._tracing_span: + if self._voice_pipeline_config.trace_include_sensitive_data: + self._tracing_span.span_data.input = self._turn_text_buffer + else: + self._tracing_span.span_data.input = "" + + self._tracing_span.finish() + self._tracing_span = None + self._turn_text_buffer = "" + self._started_processing_turn = False + + async def _done(self): + self._completed_session = True + await self._wait_for_completion() + + async def _dispatch_audio(self): + # Dispatch audio chunks from each segment in the order they were added + while True: + if len(self._ordered_tasks) == 0: + if self._completed_session: + break + await asyncio.sleep(0) + continue + local_queue = self._ordered_tasks.pop(0) + while True: + chunk = await local_queue.get() + if chunk is None: + break + await self._queue.put(chunk) + if isinstance(chunk, VoiceStreamEventLifecycle): + local_queue.task_done() + if chunk.event == "turn_ended": + self._finish_turn() + break + await self._queue.put(VoiceStreamEventLifecycle(event="session_ended")) + + async def _wait_for_completion(self): + tasks: list[asyncio.Task[Any]] = self._tasks + if self._dispatcher_task is not None: + tasks.append(self._dispatcher_task) + await asyncio.gather(*tasks) + + def _cleanup_tasks(self): + self._finish_turn() + + for task in self._tasks: + if not task.done(): + task.cancel() + + if self._dispatcher_task and not self._dispatcher_task.done(): + self._dispatcher_task.cancel() + + if self.text_generation_task and not self.text_generation_task.done(): + self.text_generation_task.cancel() + + def _check_errors(self): + for task in self._tasks: + if task.done(): + if task.exception(): + self._stored_exception = task.exception() + break + + async def stream(self) -> AsyncIterator[VoiceStreamEvent]: + """Stream the events and audio data as they're generated.""" + while True: + try: + event = await self._queue.get() + except asyncio.CancelledError: + break + if isinstance(event, VoiceStreamEventError): + self._stored_exception = event.error + logger.error(f"Error processing output: {event.error}") + break + if event is None: + break + yield event + if event.type == "voice_stream_event_lifecycle" and event.event == "session_ended": + break + + self._check_errors() + self._cleanup_tasks() + + if self._stored_exception: + raise self._stored_exception diff --git a/src/agents/voice/utils.py b/src/agents/voice/utils.py new file mode 100644 index 0000000..1535bd0 --- /dev/null +++ b/src/agents/voice/utils.py @@ -0,0 +1,37 @@ +import re +from typing import Callable + + +def get_sentence_based_splitter( + min_sentence_length: int = 20, +) -> Callable[[str], tuple[str, str]]: + """Returns a function that splits text into chunks based on sentence boundaries. + + Args: + min_sentence_length: The minimum length of a sentence to be included in a chunk. + + Returns: + A function that splits text into chunks based on sentence boundaries. + """ + + def sentence_based_text_splitter(text_buffer: str) -> tuple[str, str]: + """ + A function to split the text into chunks. This is useful if you want to split the text into + chunks before sending it to the TTS model rather than waiting for the whole text to be + processed. + + Args: + text_buffer: The text to split. + + Returns: + A tuple of the text to process and the remaining text buffer. + """ + sentences = re.split(r"(?<=[.!?])\s+", text_buffer.strip()) + if len(sentences) >= 1: + combined_sentences = " ".join(sentences[:-1]) + if len(combined_sentences) >= min_sentence_length: + remaining_text_buffer = sentences[-1] + return combined_sentences, remaining_text_buffer + return "", text_buffer + + return sentence_based_text_splitter diff --git a/src/agents/voice/workflow.py b/src/agents/voice/workflow.py new file mode 100644 index 0000000..c706ec4 --- /dev/null +++ b/src/agents/voice/workflow.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import abc +from collections.abc import AsyncIterator +from typing import Any + +from ..agent import Agent +from ..items import TResponseInputItem +from ..result import RunResultStreaming +from ..run import Runner + + +class VoiceWorkflowBase(abc.ABC): + """ + A base class for a voice workflow. You must implement the `run` method. A "workflow" is any + code you want, that receives a transcription and yields text that will be turned into speech + by a text-to-speech model. + In most cases, you'll create `Agent`s and use `Runner.run_streamed()` to run them, returning + some or all of the text events from the stream. You can use the `VoiceWorkflowHelper` class to + help with extracting text events from the stream. + If you have a simple workflow that has a single starting agent and no custom logic, you can + use `SingleAgentVoiceWorkflow` directly. + """ + + @abc.abstractmethod + def run(self, transcription: str) -> AsyncIterator[str]: + """ + Run the voice workflow. You will receive an input transcription, and must yield text that + will be spoken to the user. You can run whatever logic you want here. In most cases, the + final logic will involve calling `Runner.run_streamed()` and yielding any text events from + the stream. + """ + pass + + +class VoiceWorkflowHelper: + @classmethod + async def stream_text_from(cls, result: RunResultStreaming) -> AsyncIterator[str]: + """Wraps a `RunResultStreaming` object and yields text events from the stream.""" + async for event in result.stream_events(): + if ( + event.type == "raw_response_event" + and event.data.type == "response.output_text.delta" + ): + yield event.data.delta + + +class SingleAgentWorkflowCallbacks: + def on_run(self, workflow: SingleAgentVoiceWorkflow, transcription: str) -> None: + """Called when the workflow is run.""" + pass + + +class SingleAgentVoiceWorkflow(VoiceWorkflowBase): + """A simple voice workflow that runs a single agent. Each transcription and result is added to + the input history. + For more complex workflows (e.g. multiple Runner calls, custom message history, custom logic, + custom configs), subclass `VoiceWorkflowBase` and implement your own logic. + """ + + def __init__(self, agent: Agent[Any], callbacks: SingleAgentWorkflowCallbacks | None = None): + """Create a new single agent voice workflow. + + Args: + agent: The agent to run. + callbacks: Optional callbacks to call during the workflow. + """ + self._input_history: list[TResponseInputItem] = [] + self._current_agent = agent + self._callbacks = callbacks + + async def run(self, transcription: str) -> AsyncIterator[str]: + if self._callbacks: + self._callbacks.on_run(self, transcription) + + # Add the transcription to the input history + self._input_history.append( + { + "role": "user", + "content": transcription, + } + ) + + # Run the agent + result = Runner.run_streamed(self._current_agent, self._input_history) + + # Stream the text from the result + async for chunk in VoiceWorkflowHelper.stream_text_from(result): + yield chunk + + # Update the input history and current agent + self._input_history = result.to_input_list() + self._current_agent = result.last_agent diff --git a/tests/voice/__init__.py b/tests/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/voice/fake_models.py b/tests/voice/fake_models.py new file mode 100644 index 0000000..3febe42 --- /dev/null +++ b/tests/voice/fake_models.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Literal + +import numpy as np +import numpy.typing as npt + +from agents.voice import ( + AudioInput, + StreamedAudioInput, + StreamedTranscriptionSession, + STTModel, + STTModelSettings, + TTSModel, + TTSModelSettings, + VoiceWorkflowBase, +) + + +class FakeTTS(TTSModel): + """Fakes TTS by just returning string bytes.""" + + def __init__(self, strategy: Literal["default", "split_words"] = "default"): + self.strategy = strategy + + @property + def model_name(self) -> str: + return "fake_tts" + + async def run(self, text: str, settings: TTSModelSettings) -> AsyncIterator[bytes]: + if self.strategy == "default": + yield np.zeros(2, dtype=np.int16).tobytes() + elif self.strategy == "split_words": + for _ in text.split(): + yield np.zeros(2, dtype=np.int16).tobytes() + + async def verify_audio(self, text: str, audio: bytes, dtype: npt.DTypeLike = np.int16) -> None: + assert audio == np.zeros(2, dtype=dtype).tobytes() + + async def verify_audio_chunks( + self, text: str, audio_chunks: list[bytes], dtype: npt.DTypeLike = np.int16 + ) -> None: + assert audio_chunks == [np.zeros(2, dtype=dtype).tobytes() for _word in text.split()] + + +class FakeSession(StreamedTranscriptionSession): + """A fake streamed transcription session that yields preconfigured transcripts.""" + + def __init__(self): + self.outputs: list[str] = [] + + async def transcribe_turns(self) -> AsyncIterator[str]: + for t in self.outputs: + yield t + + async def close(self) -> None: + return None + + +class FakeSTT(STTModel): + """A fake STT model that either returns a single transcript or yields multiple.""" + + def __init__(self, outputs: list[str] | None = None): + self.outputs = outputs or [] + + @property + def model_name(self) -> str: + return "fake_stt" + + async def transcribe(self, _: AudioInput, __: STTModelSettings, ___: bool, ____: bool) -> str: + return self.outputs.pop(0) + + async def create_session( + self, + _: StreamedAudioInput, + __: STTModelSettings, + ___: bool, + ____: bool, + ) -> StreamedTranscriptionSession: + session = FakeSession() + session.outputs = self.outputs + return session + + +class FakeWorkflow(VoiceWorkflowBase): + """A fake workflow that yields preconfigured outputs.""" + + def __init__(self, outputs: list[list[str]] | None = None): + self.outputs = outputs or [] + + def add_output(self, output: list[str]) -> None: + self.outputs.append(output) + + def add_multiple_outputs(self, outputs: list[list[str]]) -> None: + self.outputs.extend(outputs) + + async def run(self, _: str) -> AsyncIterator[str]: + if not self.outputs: + raise ValueError("No output configured") + output = self.outputs.pop(0) + for t in output: + yield t + + +class FakeStreamedAudioInput: + @classmethod + async def get(cls, count: int) -> StreamedAudioInput: + input = StreamedAudioInput() + for _ in range(count): + await input.add_audio(np.zeros(2, dtype=np.int16)) + return input diff --git a/tests/voice/helpers.py b/tests/voice/helpers.py new file mode 100644 index 0000000..d2f9ca2 --- /dev/null +++ b/tests/voice/helpers.py @@ -0,0 +1,18 @@ +from agents.voice import StreamedAudioResult + + +async def extract_events(result: StreamedAudioResult) -> tuple[list[str], list[bytes]]: + """Collapse pipeline stream events to simple labels for ordering assertions.""" + flattened: list[str] = [] + audio_chunks: list[bytes] = [] + + async for ev in result.stream(): + if ev.type == "voice_stream_event_audio": + if ev.data is not None: + audio_chunks.append(ev.data.tobytes()) + flattened.append("audio") + elif ev.type == "voice_stream_event_lifecycle": + flattened.append(ev.event) + elif ev.type == "voice_stream_event_error": + flattened.append("error") + return flattened, audio_chunks diff --git a/tests/voice/test_input.py b/tests/voice/test_input.py new file mode 100644 index 0000000..7d4197b --- /dev/null +++ b/tests/voice/test_input.py @@ -0,0 +1,124 @@ +import io +import wave + +import numpy as np +import pytest + +from agents import UserError +from agents.voice import AudioInput, StreamedAudioInput +from agents.voice.input import DEFAULT_SAMPLE_RATE, _buffer_to_audio_file + + +def test_buffer_to_audio_file_int16(): + # Create a simple sine wave in int16 format + t = np.linspace(0, 1, DEFAULT_SAMPLE_RATE) + buffer = (np.sin(2 * np.pi * 440 * t) * 32767).astype(np.int16) + + filename, audio_file, content_type = _buffer_to_audio_file(buffer) + + assert filename == "audio.wav" + assert content_type == "audio/wav" + assert isinstance(audio_file, io.BytesIO) + + # Verify the WAV file contents + with wave.open(audio_file, "rb") as wav_file: + assert wav_file.getnchannels() == 1 + assert wav_file.getsampwidth() == 2 + assert wav_file.getframerate() == DEFAULT_SAMPLE_RATE + assert wav_file.getnframes() == len(buffer) + + +def test_buffer_to_audio_file_float32(): + # Create a simple sine wave in float32 format + t = np.linspace(0, 1, DEFAULT_SAMPLE_RATE) + buffer = np.sin(2 * np.pi * 440 * t).astype(np.float32) + + filename, audio_file, content_type = _buffer_to_audio_file(buffer) + + assert filename == "audio.wav" + assert content_type == "audio/wav" + assert isinstance(audio_file, io.BytesIO) + + # Verify the WAV file contents + with wave.open(audio_file, "rb") as wav_file: + assert wav_file.getnchannels() == 1 + assert wav_file.getsampwidth() == 2 + assert wav_file.getframerate() == DEFAULT_SAMPLE_RATE + assert wav_file.getnframes() == len(buffer) + + +def test_buffer_to_audio_file_invalid_dtype(): + # Create a buffer with invalid dtype (float64) + buffer = np.array([1.0, 2.0, 3.0], dtype=np.float64) + + with pytest.raises(UserError, match="Buffer must be a numpy array of int16 or float32"): + # Purposely ignore the type error + _buffer_to_audio_file(buffer) # type: ignore + + +class TestAudioInput: + def test_audio_input_default_params(self): + # Create a simple sine wave + t = np.linspace(0, 1, DEFAULT_SAMPLE_RATE) + buffer = np.sin(2 * np.pi * 440 * t).astype(np.float32) + + audio_input = AudioInput(buffer=buffer) + + assert audio_input.frame_rate == DEFAULT_SAMPLE_RATE + assert audio_input.sample_width == 2 + assert audio_input.channels == 1 + assert np.array_equal(audio_input.buffer, buffer) + + def test_audio_input_custom_params(self): + # Create a simple sine wave + t = np.linspace(0, 1, 48000) + buffer = np.sin(2 * np.pi * 440 * t).astype(np.float32) + + audio_input = AudioInput(buffer=buffer, frame_rate=48000, sample_width=4, channels=2) + + assert audio_input.frame_rate == 48000 + assert audio_input.sample_width == 4 + assert audio_input.channels == 2 + assert np.array_equal(audio_input.buffer, buffer) + + def test_audio_input_to_audio_file(self): + # Create a simple sine wave + t = np.linspace(0, 1, DEFAULT_SAMPLE_RATE) + buffer = np.sin(2 * np.pi * 440 * t).astype(np.float32) + + audio_input = AudioInput(buffer=buffer) + filename, audio_file, content_type = audio_input.to_audio_file() + + assert filename == "audio.wav" + assert content_type == "audio/wav" + assert isinstance(audio_file, io.BytesIO) + + # Verify the WAV file contents + with wave.open(audio_file, "rb") as wav_file: + assert wav_file.getnchannels() == 1 + assert wav_file.getsampwidth() == 2 + assert wav_file.getframerate() == DEFAULT_SAMPLE_RATE + assert wav_file.getnframes() == len(buffer) + + +class TestStreamedAudioInput: + @pytest.mark.asyncio + async def test_streamed_audio_input(self): + streamed_input = StreamedAudioInput() + + # Create some test audio data + t = np.linspace(0, 1, DEFAULT_SAMPLE_RATE) + audio1 = np.sin(2 * np.pi * 440 * t).astype(np.float32) + audio2 = np.sin(2 * np.pi * 880 * t).astype(np.float32) + + # Add audio to the queue + await streamed_input.add_audio(audio1) + await streamed_input.add_audio(audio2) + + # Verify the queue contents + assert streamed_input.queue.qsize() == 2 + # Test non-blocking get + assert np.array_equal(streamed_input.queue.get_nowait(), audio1) + # Test blocking get + assert np.array_equal(await streamed_input.queue.get(), audio2) + assert streamed_input.queue.empty() diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py new file mode 100644 index 0000000..bb8d3cf --- /dev/null +++ b/tests/voice/test_openai_stt.py @@ -0,0 +1,365 @@ +# test_openai_stt_transcription_session.py + +import asyncio +import json +import time +from unittest.mock import AsyncMock, patch + +import numpy as np +import pytest + +from agents.voice import OpenAISTTTranscriptionSession, StreamedAudioInput, STTModelSettings +from agents.voice.exceptions import STTWebsocketConnectionError +from agents.voice.models.openai_stt import EVENT_INACTIVITY_TIMEOUT + +from .fake_models import FakeStreamedAudioInput + +# ===== Helpers ===== + + +def create_mock_websocket(messages: list[str]) -> AsyncMock: + """ + Creates a mock websocket (AsyncMock) that will return the provided incoming_messages + from __aiter__() as if they came from the server. + """ + + mock_ws = AsyncMock() + mock_ws.__aenter__.return_value = mock_ws + # The incoming_messages are strings that we pretend come from the server + mock_ws.__aiter__.return_value = iter(messages) + return mock_ws + + +def fake_time(increment: int): + current = 1000 + while True: + yield current + current += increment + + +# ===== Tests ===== +@pytest.mark.asyncio +async def test_non_json_messages_should_crash(): + """This tests that non-JSON messages will raise an exception""" + # Setup: mock websockets.connect + mock_ws = create_mock_websocket(["not a json message"]) + with patch("websockets.connect", return_value=mock_ws): + # Instantiate the session + input_audio = await FakeStreamedAudioInput.get(count=2) + stt_settings = STTModelSettings() + + session = OpenAISTTTranscriptionSession( + input=input_audio, + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=stt_settings, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + with pytest.raises(STTWebsocketConnectionError): + # Start reading from transcribe_turns, which triggers _process_websocket_connection + turns = session.transcribe_turns() + + async for _ in turns: + pass + + await session.close() + + +@pytest.mark.asyncio +async def test_session_connects_and_configures_successfully(): + """ + Test that the session: + 1) Connects to the correct URL with correct headers. + 2) Receives a 'session.created' event. + 3) Sends an update message for session config. + 4) Receives a 'session.updated' event. + """ + # Setup: mock websockets.connect + mock_ws = create_mock_websocket( + [ + json.dumps({"type": "transcription_session.created"}), + json.dumps({"type": "transcription_session.updated"}), + ] + ) + with patch("websockets.connect", return_value=mock_ws) as mock_connect: + # Instantiate the session + input_audio = await FakeStreamedAudioInput.get(count=2) + stt_settings = STTModelSettings() + + session = OpenAISTTTranscriptionSession( + input=input_audio, + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=stt_settings, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + # Start reading from transcribe_turns, which triggers _process_websocket_connection + turns = session.transcribe_turns() + + async for _ in turns: + pass + + # Check connect call + args, kwargs = mock_connect.call_args + assert "wss://api.openai.com/v1/realtime?intent=transcription" in args[0] + headers = kwargs.get("additional_headers", {}) + assert headers.get("Authorization") == "Bearer FAKE_KEY" + assert headers.get("OpenAI-Beta") == "realtime=v1" + assert headers.get("OpenAI-Log-Session") == "1" + + # Check that we sent a 'transcription_session.update' message + sent_messages = [call.args[0] for call in mock_ws.send.call_args_list] + assert any('"type": "transcription_session.update"' in msg for msg in sent_messages), ( + f"Expected 'transcription_session.update' in {sent_messages}" + ) + + await session.close() + + +@pytest.mark.asyncio +async def test_stream_audio_sends_correct_json(): + """ + Test that when audio is placed on the input queue, the session: + 1) Base64-encodes the data. + 2) Sends the correct JSON message over the websocket. + """ + # Simulate a single "transcription_session.created" and "transcription_session.updated" event, + # before we test streaming. + mock_ws = create_mock_websocket( + [ + json.dumps({"type": "transcription_session.created"}), + json.dumps({"type": "transcription_session.updated"}), + ] + ) + + with patch("websockets.connect", return_value=mock_ws): + # Prepare + audio_input = StreamedAudioInput() + stt_settings = STTModelSettings() + + session = OpenAISTTTranscriptionSession( + input=audio_input, + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=stt_settings, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + # Kick off the transcribe_turns generator + turn_iter = session.transcribe_turns() + async for _ in turn_iter: + pass + + # Now push some audio data + + buffer1 = np.array([1, 2, 3, 4], dtype=np.int16) + await audio_input.add_audio(buffer1) + await asyncio.sleep(0.1) # give time for _stream_audio to consume + await asyncio.sleep(4) + + # Check that the websocket sent an "input_audio_buffer.append" message + found_audio_append = False + for call_arg in mock_ws.send.call_args_list: + print("call_arg", call_arg) + print("test", session._turn_audio_buffer) + sent_str = call_arg.args[0] + print("sent_str", sent_str) + if '"type": "input_audio_buffer.append"' in sent_str: + msg_dict = json.loads(sent_str) + assert msg_dict["type"] == "input_audio_buffer.append" + assert "audio" in msg_dict + found_audio_append = True + assert found_audio_append, "No 'input_audio_buffer.append' message was sent." + + await session.close() + + +@pytest.mark.asyncio +async def test_transcription_event_puts_output_in_queue(): + """ + Test that a 'conversation.item.input_audio_transcription.completed' event + yields a transcript from transcribe_turns(). + """ + mock_ws = create_mock_websocket( + [ + json.dumps({"type": "transcription_session.created"}), + json.dumps({"type": "transcription_session.updated"}), + # Once configured, we mock a completed transcription event: + json.dumps( + { + "type": "conversation.item.input_audio_transcription.completed", + "transcript": "Hello world!", + } + ), + ] + ) + + with patch("websockets.connect", return_value=mock_ws): + # Prepare + audio_input = await FakeStreamedAudioInput.get(count=2) + stt_settings = STTModelSettings() + + session = OpenAISTTTranscriptionSession( + input=audio_input, + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=stt_settings, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + turns = session.transcribe_turns() + + # We'll collect transcribed turns in a list + collected_turns = [] + async for turn in turns: + collected_turns.append(turn) + await session.close() + + # Check we got "Hello world!" + assert "Hello world!" in collected_turns + # Cleanup + + +@pytest.mark.asyncio +async def test_timeout_waiting_for_created_event(monkeypatch): + """ + If the 'session.created' event does not arrive before SESSION_CREATION_TIMEOUT, + the session should raise a TimeoutError. + """ + time_gen = fake_time(increment=30) # increment by 30 seconds each time + + # Define a replacement function that returns the next time + def fake_time_func(): + return next(time_gen) + + # Monkey-patch time.time with our fake_time_func + monkeypatch.setattr(time, "time", fake_time_func) + + mock_ws = create_mock_websocket( + [ + json.dumps({"type": "unknown"}), + ] + ) # add a fake event to the mock websocket to make sure it doesn't raise a different exception + + with patch("websockets.connect", return_value=mock_ws): + audio_input = await FakeStreamedAudioInput.get(count=2) + stt_settings = STTModelSettings() + + session = OpenAISTTTranscriptionSession( + input=audio_input, + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=stt_settings, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + turns = session.transcribe_turns() + + # We expect an exception once the generator tries to connect + wait for event + with pytest.raises(STTWebsocketConnectionError) as exc_info: + async for _ in turns: + pass + + assert "Timeout waiting for transcription_session.created event" in str(exc_info.value) + + await session.close() + + +@pytest.mark.asyncio +async def test_session_error_event(): + """ + If the session receives an event with "type": "error", it should propagate an exception + and put an ErrorSentinel in the output queue. + """ + mock_ws = create_mock_websocket( + [ + json.dumps({"type": "transcription_session.created"}), + json.dumps({"type": "transcription_session.updated"}), + # Then an error from the server + json.dumps({"type": "error", "error": "Simulated server error!"}), + ] + ) + + with patch("websockets.connect", return_value=mock_ws): + audio_input = await FakeStreamedAudioInput.get(count=2) + stt_settings = STTModelSettings() + + session = OpenAISTTTranscriptionSession( + input=audio_input, + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=stt_settings, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + with pytest.raises(STTWebsocketConnectionError) as exc_info: + turns = session.transcribe_turns() + async for _ in turns: + pass + + assert "Simulated server error!" in str(exc_info.value) + + await session.close() + + +@pytest.mark.asyncio +async def test_inactivity_timeout(): + """ + Test that if no events arrive in EVENT_INACTIVITY_TIMEOUT ms, + _handle_events breaks out and a SessionCompleteSentinel is placed in the output queue. + """ + # We'll feed only the creation + updated events. Then do nothing. + # The handle_events loop should eventually time out. + mock_ws = create_mock_websocket( + [ + json.dumps({"type": "unknown"}), + json.dumps({"type": "unknown"}), + json.dumps({"type": "transcription_session.created"}), + json.dumps({"type": "transcription_session.updated"}), + ] + ) + + # We'll artificially manipulate the "time" to simulate inactivity quickly. + # The code checks time.time() for inactivity over EVENT_INACTIVITY_TIMEOUT. + # We'll increment the return_value manually. + with ( + patch("websockets.connect", return_value=mock_ws), + patch( + "time.time", + side_effect=[ + 1000.0, + 1000.0 + EVENT_INACTIVITY_TIMEOUT + 1, + 2000.0 + EVENT_INACTIVITY_TIMEOUT + 1, + 3000.0 + EVENT_INACTIVITY_TIMEOUT + 1, + 9999, + ], + ), + ): + audio_input = await FakeStreamedAudioInput.get(count=2) + stt_settings = STTModelSettings() + + session = OpenAISTTTranscriptionSession( + input=audio_input, + client=AsyncMock(api_key="FAKE_KEY"), + model="whisper-1", + settings=stt_settings, + trace_include_sensitive_data=False, + trace_include_sensitive_audio_data=False, + ) + + collected_turns: list[str] = [] + with pytest.raises(STTWebsocketConnectionError) as exc_info: + async for turn in session.transcribe_turns(): + collected_turns.append(turn) + + assert "Timeout waiting for transcription_session" in str(exc_info.value) + + assert len(collected_turns) == 0, "No transcripts expected, but we got something?" + + await session.close() diff --git a/tests/voice/test_openai_tts.py b/tests/voice/test_openai_tts.py new file mode 100644 index 0000000..0fbd6ba --- /dev/null +++ b/tests/voice/test_openai_tts.py @@ -0,0 +1,91 @@ +# Tests for the OpenAI text-to-speech model (OpenAITTSModel). + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agents.voice import OpenAITTSModel, TTSModelSettings + + +class _FakeStreamResponse: + """A minimal async context manager to simulate streaming audio bytes.""" + + def __init__(self, chunks: list[bytes]): + self._chunks = chunks + + async def __aenter__(self) -> "_FakeStreamResponse": + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + return None + + async def iter_bytes(self, chunk_size: int = 1024): + for chunk in self._chunks: + yield chunk + + +def _make_fake_openai_client(fake_create) -> SimpleNamespace: + """Construct an object with nested audio.speech.with_streaming_response.create.""" + return SimpleNamespace( + audio=SimpleNamespace( + speech=SimpleNamespace(with_streaming_response=SimpleNamespace(create=fake_create)) + ) + ) + + +@pytest.mark.asyncio +async def test_openai_tts_default_voice_and_instructions() -> None: + """If no voice is specified, OpenAITTSModel uses its default voice and passes instructions.""" + chunks = [b"abc", b"def"] + captured: dict[str, object] = {} + + def fake_create( + *, model: str, voice: str, input: str, response_format: str, extra_body: dict[str, Any] + ) -> _FakeStreamResponse: + captured["model"] = model + captured["voice"] = voice + captured["input"] = input + captured["response_format"] = response_format + captured["extra_body"] = extra_body + return _FakeStreamResponse(chunks) + + client = _make_fake_openai_client(fake_create) + tts_model = OpenAITTSModel(model="test-model", openai_client=client) # type: ignore[arg-type] + settings = TTSModelSettings() + out: list[bytes] = [] + async for b in tts_model.run("hello world", settings): + out.append(b) + assert out == chunks + assert captured["model"] == "test-model" + assert captured["voice"] == "ash" + assert captured["input"] == "hello world" + assert captured["response_format"] == "pcm" + assert captured["extra_body"] == {"instructions": settings.instructions} + + +@pytest.mark.asyncio +async def test_openai_tts_custom_voice_and_instructions() -> None: + """Specifying voice and instructions are forwarded to the API.""" + chunks = [b"x"] + captured: dict[str, object] = {} + + def fake_create( + *, model: str, voice: str, input: str, response_format: str, extra_body: dict[str, Any] + ) -> _FakeStreamResponse: + captured["model"] = model + captured["voice"] = voice + captured["input"] = input + captured["response_format"] = response_format + captured["extra_body"] = extra_body + return _FakeStreamResponse(chunks) + + client = _make_fake_openai_client(fake_create) + tts_model = OpenAITTSModel(model="my-model", openai_client=client) # type: ignore[arg-type] + settings = TTSModelSettings(voice="fable", instructions="Custom instructions") + out: list[bytes] = [] + async for b in tts_model.run("hi", settings): + out.append(b) + assert out == chunks + assert captured["voice"] == "fable" + assert captured["extra_body"] == {"instructions": "Custom instructions"} diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py new file mode 100644 index 0000000..f988026 --- /dev/null +++ b/tests/voice/test_pipeline.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import numpy as np +import numpy.typing as npt +import pytest + +from agents.voice import AudioInput, TTSModelSettings, VoicePipeline, VoicePipelineConfig + +from .fake_models import FakeStreamedAudioInput, FakeSTT, FakeTTS, FakeWorkflow +from .helpers import extract_events + + +@pytest.mark.asyncio +async def test_voicepipeline_run_single_turn() -> None: + # Single turn. Should produce a single audio output, which is the TTS output for "out_1". + + fake_stt = FakeSTT(["first"]) + workflow = FakeWorkflow([["out_1"]]) + fake_tts = FakeTTS() + config = VoicePipelineConfig(tts_settings=TTSModelSettings(buffer_size=1)) + pipeline = VoicePipeline( + workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, config=config + ) + audio_input = AudioInput(buffer=np.zeros(2, dtype=np.int16)) + result = await pipeline.run(audio_input) + events, audio_chunks = await extract_events(result) + assert events == [ + "turn_started", + "audio", + "turn_ended", + "session_ended", + ] + await fake_tts.verify_audio("out_1", audio_chunks[0]) + + +@pytest.mark.asyncio +async def test_voicepipeline_streamed_audio_input() -> None: + # Multi turn. Should produce 2 audio outputs, which are the TTS outputs of "out_1" and "out_2" + + fake_stt = FakeSTT(["first", "second"]) + workflow = FakeWorkflow([["out_1"], ["out_2"]]) + fake_tts = FakeTTS() + pipeline = VoicePipeline(workflow=workflow, stt_model=fake_stt, tts_model=fake_tts) + + streamed_audio_input = await FakeStreamedAudioInput.get(count=2) + + result = await pipeline.run(streamed_audio_input) + events, audio_chunks = await extract_events(result) + assert events == [ + "turn_started", + "audio", # out_1 + "turn_ended", + "turn_started", + "audio", # out_2 + "turn_ended", + "session_ended", + ] + assert len(audio_chunks) == 2 + await fake_tts.verify_audio("out_1", audio_chunks[0]) + await fake_tts.verify_audio("out_2", audio_chunks[1]) + + +@pytest.mark.asyncio +async def test_voicepipeline_run_single_turn_split_words() -> None: + # Single turn. Should produce multiple audio outputs, which are the TTS outputs of "foo bar baz" + # split into words and then "foo2 bar2 baz2" split into words. + + fake_stt = FakeSTT(["first"]) + workflow = FakeWorkflow([["foo bar baz"]]) + fake_tts = FakeTTS(strategy="split_words") + config = VoicePipelineConfig(tts_settings=TTSModelSettings(buffer_size=1)) + pipeline = VoicePipeline( + workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, config=config + ) + audio_input = AudioInput(buffer=np.zeros(2, dtype=np.int16)) + result = await pipeline.run(audio_input) + events, audio_chunks = await extract_events(result) + assert events == [ + "turn_started", + "audio", # foo + "audio", # bar + "audio", # baz + "turn_ended", + "session_ended", + ] + await fake_tts.verify_audio_chunks("foo bar baz", audio_chunks) + + +@pytest.mark.asyncio +async def test_voicepipeline_run_multi_turn_split_words() -> None: + # Multi turn. Should produce multiple audio outputs, which are the TTS outputs of "foo bar baz" + # split into words. + + fake_stt = FakeSTT(["first", "second"]) + workflow = FakeWorkflow([["foo bar baz"], ["foo2 bar2 baz2"]]) + fake_tts = FakeTTS(strategy="split_words") + config = VoicePipelineConfig(tts_settings=TTSModelSettings(buffer_size=1)) + pipeline = VoicePipeline( + workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, config=config + ) + streamed_audio_input = await FakeStreamedAudioInput.get(count=6) + result = await pipeline.run(streamed_audio_input) + events, audio_chunks = await extract_events(result) + assert events == [ + "turn_started", + "audio", # foo + "audio", # bar + "audio", # baz + "turn_ended", + "turn_started", + "audio", # foo2 + "audio", # bar2 + "audio", # baz2 + "turn_ended", + "session_ended", + ] + assert len(audio_chunks) == 6 + await fake_tts.verify_audio_chunks("foo bar baz", audio_chunks[:3]) + await fake_tts.verify_audio_chunks("foo2 bar2 baz2", audio_chunks[3:]) + + +@pytest.mark.asyncio +async def test_voicepipeline_float32() -> None: + # Single turn. Should produce a single audio output, which is the TTS output for "out_1". + + fake_stt = FakeSTT(["first"]) + workflow = FakeWorkflow([["out_1"]]) + fake_tts = FakeTTS() + config = VoicePipelineConfig(tts_settings=TTSModelSettings(buffer_size=1, dtype=np.float32)) + pipeline = VoicePipeline( + workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, config=config + ) + audio_input = AudioInput(buffer=np.zeros(2, dtype=np.int16)) + result = await pipeline.run(audio_input) + events, audio_chunks = await extract_events(result) + assert events == [ + "turn_started", + "audio", + "turn_ended", + "session_ended", + ] + await fake_tts.verify_audio("out_1", audio_chunks[0], dtype=np.float32) + + +@pytest.mark.asyncio +async def test_voicepipeline_transform_data() -> None: + # Single turn. Should produce a single audio output, which is the TTS output for "out_1". + + def _transform_data( + data_chunk: npt.NDArray[np.int16 | np.float32], + ) -> npt.NDArray[np.int16]: + return data_chunk.astype(np.int16) + + fake_stt = FakeSTT(["first"]) + workflow = FakeWorkflow([["out_1"]]) + fake_tts = FakeTTS() + config = VoicePipelineConfig( + tts_settings=TTSModelSettings( + buffer_size=1, + dtype=np.float32, + transform_data=_transform_data, + ) + ) + pipeline = VoicePipeline( + workflow=workflow, stt_model=fake_stt, tts_model=fake_tts, config=config + ) + audio_input = AudioInput(buffer=np.zeros(2, dtype=np.int16)) + result = await pipeline.run(audio_input) + events, audio_chunks = await extract_events(result) + assert events == [ + "turn_started", + "audio", + "turn_ended", + "session_ended", + ] + await fake_tts.verify_audio("out_1", audio_chunks[0], dtype=np.int16) diff --git a/tests/voice/test_workflow.py b/tests/voice/test_workflow.py new file mode 100644 index 0000000..465c124 --- /dev/null +++ b/tests/voice/test_workflow.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterator + +import pytest +from inline_snapshot import snapshot +from openai.types.responses import ResponseCompletedEvent +from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent + +from agents import Agent, Model, ModelSettings, ModelTracing, Tool +from agents.agent_output import AgentOutputSchema +from agents.handoffs import Handoff +from agents.items import ( + ModelResponse, + TResponseInputItem, + TResponseOutputItem, + TResponseStreamEvent, +) +from agents.voice import SingleAgentVoiceWorkflow + +from ..fake_model import get_response_obj +from ..test_responses import get_function_tool, get_function_tool_call, get_text_message + + +class FakeStreamingModel(Model): + def __init__(self): + self.turn_outputs: list[list[TResponseOutputItem]] = [] + + def set_next_output(self, output: list[TResponseOutputItem]): + self.turn_outputs.append(output) + + def add_multiple_turn_outputs(self, outputs: list[list[TResponseOutputItem]]): + self.turn_outputs.extend(outputs) + + def get_next_output(self) -> list[TResponseOutputItem]: + if not self.turn_outputs: + return [] + return self.turn_outputs.pop(0) + + async def get_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + tracing: ModelTracing, + ) -> ModelResponse: + raise NotImplementedError("Not implemented") + + async def stream_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + tracing: ModelTracing, + ) -> AsyncIterator[TResponseStreamEvent]: + output = self.get_next_output() + for item in output: + if ( + item.type == "message" + and len(item.content) == 1 + and item.content[0].type == "output_text" + ): + yield ResponseTextDeltaEvent( + content_index=0, + delta=item.content[0].text, + type="response.output_text.delta", + output_index=0, + item_id=item.id, + ) + + yield ResponseCompletedEvent( + type="response.completed", + response=get_response_obj(output), + ) + + +@pytest.mark.asyncio +async def test_single_agent_workflow(monkeypatch) -> None: + model = FakeStreamingModel() + model.add_multiple_turn_outputs( + [ + # First turn: a message and a tool call + [ + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_text_message("a_message"), + ], + # Second turn: text message + [get_text_message("done")], + ] + ) + + agent = Agent( + "initial_agent", + model=model, + tools=[get_function_tool("some_function", "tool_result")], + ) + + workflow = SingleAgentVoiceWorkflow(agent) + output = [] + async for chunk in workflow.run("transcription_1"): + output.append(chunk) + + # Validate that the text yielded matches our fake events + assert output == ["a_message", "done"] + # Validate that internal state was updated + assert workflow._input_history == snapshot( + [ + {"content": "transcription_1", "role": "user"}, + { + "arguments": '{"a": "b"}', + "call_id": "2", + "name": "some_function", + "type": "function_call", + "id": "1", + }, + { + "id": "1", + "content": [{"annotations": [], "text": "a_message", "type": "output_text"}], + "role": "assistant", + "status": "completed", + "type": "message", + }, + {"call_id": "2", "output": "tool_result", "type": "function_call_output"}, + { + "id": "1", + "content": [{"annotations": [], "text": "done", "type": "output_text"}], + "role": "assistant", + "status": "completed", + "type": "message", + }, + ] + ) + assert workflow._current_agent == agent + + model.set_next_output([get_text_message("done_2")]) + + # Run it again with a new transcription to make sure the input history is updated + output = [] + async for chunk in workflow.run("transcription_2"): + output.append(chunk) + + assert workflow._input_history == snapshot( + [ + {"role": "user", "content": "transcription_1"}, + { + "arguments": '{"a": "b"}', + "call_id": "2", + "name": "some_function", + "type": "function_call", + "id": "1", + }, + { + "id": "1", + "content": [{"annotations": [], "text": "a_message", "type": "output_text"}], + "role": "assistant", + "status": "completed", + "type": "message", + }, + {"call_id": "2", "output": "tool_result", "type": "function_call_output"}, + { + "id": "1", + "content": [{"annotations": [], "text": "done", "type": "output_text"}], + "role": "assistant", + "status": "completed", + "type": "message", + }, + {"role": "user", "content": "transcription_2"}, + { + "id": "1", + "content": [{"annotations": [], "text": "done_2", "type": "output_text"}], + "role": "assistant", + "status": "completed", + "type": "message", + }, + ] + ) + assert workflow._current_agent == agent diff --git a/uv.lock b/uv.lock index 698c557..cff72ab 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,10 @@ version = 1 +revision = 1 requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] [[package]] name = "annotated-types" @@ -65,6 +70,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, +] + [[package]] name = "charset-normalizer" version = "3.4.1" @@ -239,6 +313,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, ] +[[package]] +name = "evdev" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/99/4d24bb6db12fc170a5f209f4c9108054a2c84d289d1e7f743e979b202023/evdev-1.9.1.tar.gz", hash = "sha256:dc640a064cb1c9fe1f8b970dc2039945a2a275d7b7ee62284bf427238abe45ee", size = 33349 } + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -332,14 +412,14 @@ wheels = [ [[package]] name = "griffe" -version = "1.6.1" +version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/ba/1ebe51a22c491a3fc94b44ef9c46a5b5472540e24a5c3f251cebbab7214b/griffe-1.6.1.tar.gz", hash = "sha256:ff0acf706b2680f8c721412623091c891e752b2c61b7037618f7b77d06732cf5", size = 393112 } +sdist = { url = "https://files.pythonhosted.org/packages/2f/f2/b00eb72b853ecb5bf31dd47857cdf6767e380ca24ec2910d43b3fa7cc500/griffe-1.6.2.tar.gz", hash = "sha256:3a46fa7bd83280909b63c12b9a975732a927dd97809efe5b7972290b606c5d91", size = 392836 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/d3/a760d1062e44587230aa65573c70edaad4ee8a0e60e193a3172b304d24d8/griffe-1.6.1-py3-none-any.whl", hash = "sha256:b0131670db16834f82383bcf4f788778853c9bf4dc7a1a2b708bb0808ca56a98", size = 128615 }, + { url = "https://files.pythonhosted.org/packages/4e/bc/bd8b7de5e748e078b6be648e76b47189a9182b1ac1eb7791ff7969f39f27/griffe-1.6.2-py3-none-any.whl", hash = "sha256:6399f7e663150e4278a312a8e8a14d2f3d7bd86e2ef2f8056a1058e38579c2ee", size = 128638 }, ] [[package]] @@ -393,7 +473,7 @@ name = "importlib-metadata" version = "8.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } wheels = [ @@ -402,16 +482,16 @@ wheels = [ [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, ] [[package]] name = "inline-snapshot" -version = "0.20.7" +version = "0.20.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asttokens" }, @@ -419,9 +499,9 @@ dependencies = [ { name = "rich" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/41/9bd2ecd10ef789e8aff6fb68dcc7677dc31b33b2d27c306c0d40fc982fbc/inline_snapshot-0.20.7.tar.gz", hash = "sha256:d55bbb6254d0727dc304729ca7998cde1c1e984c4bf50281514aa9d727a56cf2", size = 92643 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/69/79babd0f6ad54c430fba36fb7677774398225287482cf494a15394c75894/inline_snapshot-0.20.8.tar.gz", hash = "sha256:52373c15b63097215d1136f292962553f325a5e966957b489fe4326d6fbc77c0", size = 92748 } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/8f/1bf23da63ad1a0b14ca2d9114700123ef76732e375548f4f9ca94052817e/inline_snapshot-0.20.7-py3-none-any.whl", hash = "sha256:2df6dd8710d1f0def2c1f9d6c25fd03d7beba01f3addf52fc370343d9ee9959f", size = 48108 }, + { url = "https://files.pythonhosted.org/packages/76/74/5222a632fd8d3202ddef383b71c8b6c31a9d77989030efba5be561163d41/inline_snapshot-0.20.8-py3-none-any.whl", hash = "sha256:bded4e142b8817930e4df428b88c462308a8f01ad699852e7574a54bad7ea9f2", size = 48157 }, ] [[package]] @@ -507,6 +587,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/13/c10f17dcddd1b4c1313418e64ace5e77cc4f7313246140fb09044516a62c/jiter-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e8b36d8a16a61993be33e75126ad3d8aa29cf450b09576f3c427d27647fcb4aa", size = 208879 }, ] +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820 }, +] + [[package]] name = "markdown" version = "3.7" @@ -531,6 +623,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] +plugins = [ + { name = "mdit-py-plugins" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -599,6 +699,18 @@ 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 = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316 }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -728,7 +840,7 @@ python = [ [[package]] name = "mkdocstrings-python" -version = "1.16.6" +version = "1.16.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe" }, @@ -736,9 +848,9 @@ dependencies = [ { name = "mkdocstrings" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/e7/0691e34e807a8f5c28f0988fcfeeb584f0b569ce433bf341944f14bdb3ff/mkdocstrings_python-1.16.6.tar.gz", hash = "sha256:cefe0f0e17ab4a4611f01b0a2af75e4298664e0ff54feb83c91a485bfed82dc9", size = 201565 } +sdist = { url = "https://files.pythonhosted.org/packages/52/e0/cc35acb47593c138efbfc9dc296ccc26b7ad4452e868fd309f05f6ba0ded/mkdocstrings_python-1.16.7.tar.gz", hash = "sha256:cdfc1a99fe5f6f0d90446a364ef7cac12014a4ef46114b2677a58cec84007117", size = 1475398 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/42/ed682687ef5f248e104f82806d5d9893f6dd81d8cb4561692e190ba1a252/mkdocstrings_python-1.16.6-py3-none-any.whl", hash = "sha256:de877dd71f69878c973c4897a39683b7b6961bee7b058879095b69681488453f", size = 123207 }, + { url = "https://files.pythonhosted.org/packages/ab/5e/dc978b9fd6331e2070369579ad8f52145e9ef22a69bfc2811110be95e6d4/mkdocstrings_python-1.16.7-py3-none-any.whl", hash = "sha256:a5589a5be247a28ba651287f83630c69524042f8055d93b5c203d804a3409333", size = 1998312 }, ] [[package]] @@ -794,9 +906,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, ] +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245 }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540 }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623 }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774 }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081 }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451 }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572 }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722 }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170 }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558 }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137 }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552 }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957 }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573 }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330 }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895 }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253 }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074 }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640 }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230 }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803 }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835 }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499 }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497 }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158 }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173 }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174 }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701 }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313 }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179 }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942 }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512 }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976 }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494 }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596 }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099 }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823 }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424 }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809 }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314 }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288 }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793 }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885 }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784 }, +] + +[[package]] +name = "numpy" +version = "2.2.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/89/a79e86e5c1433926ed7d60cb267fb64aa578b6101ab645800fd43b4801de/numpy-2.2.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8146f3550d627252269ac42ae660281d673eb6f8b32f113538e0cc2a9aed42b9", size = 21250661 }, + { url = "https://files.pythonhosted.org/packages/79/c2/f50921beb8afd60ed9589ad880332cfefdb805422210d327fb48f12b7a81/numpy-2.2.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e642d86b8f956098b564a45e6f6ce68a22c2c97a04f5acd3f221f57b8cb850ae", size = 14389926 }, + { url = "https://files.pythonhosted.org/packages/c7/b9/2c4e96130b0b0f97b0ef4a06d6dae3b39d058b21a5e2fa2decd7fd6b1c8f/numpy-2.2.4-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:a84eda42bd12edc36eb5b53bbcc9b406820d3353f1994b6cfe453a33ff101775", size = 5428329 }, + { url = "https://files.pythonhosted.org/packages/7f/a5/3d7094aa898f4fc5c84cdfb26beeae780352d43f5d8bdec966c4393d644c/numpy-2.2.4-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:4ba5054787e89c59c593a4169830ab362ac2bee8a969249dc56e5d7d20ff8df9", size = 6963559 }, + { url = "https://files.pythonhosted.org/packages/4c/22/fb1be710a14434c09080dd4a0acc08939f612ec02efcb04b9e210474782d/numpy-2.2.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7716e4a9b7af82c06a2543c53ca476fa0b57e4d760481273e09da04b74ee6ee2", size = 14368066 }, + { url = "https://files.pythonhosted.org/packages/c2/07/2e5cc71193e3ef3a219ffcf6ca4858e46ea2be09c026ddd480d596b32867/numpy-2.2.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adf8c1d66f432ce577d0197dceaac2ac00c0759f573f28516246351c58a85020", size = 16417040 }, + { url = "https://files.pythonhosted.org/packages/1a/97/3b1537776ad9a6d1a41813818343745e8dd928a2916d4c9edcd9a8af1dac/numpy-2.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:218f061d2faa73621fa23d6359442b0fc658d5b9a70801373625d958259eaca3", size = 15879862 }, + { url = "https://files.pythonhosted.org/packages/b0/b7/4472f603dd45ef36ff3d8e84e84fe02d9467c78f92cc121633dce6da307b/numpy-2.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:df2f57871a96bbc1b69733cd4c51dc33bea66146b8c63cacbfed73eec0883017", size = 18206032 }, + { url = "https://files.pythonhosted.org/packages/0d/bd/6a092963fb82e6c5aa0d0440635827bbb2910da229545473bbb58c537ed3/numpy-2.2.4-cp310-cp310-win32.whl", hash = "sha256:a0258ad1f44f138b791327961caedffbf9612bfa504ab9597157806faa95194a", size = 6608517 }, + { url = "https://files.pythonhosted.org/packages/01/e3/cb04627bc2a1638948bc13e818df26495aa18e20d5be1ed95ab2b10b6847/numpy-2.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:0d54974f9cf14acf49c60f0f7f4084b6579d24d439453d5fc5805d46a165b542", size = 12943498 }, + { url = "https://files.pythonhosted.org/packages/16/fb/09e778ee3a8ea0d4dc8329cca0a9c9e65fed847d08e37eba74cb7ed4b252/numpy-2.2.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e9e0a277bb2eb5d8a7407e14688b85fd8ad628ee4e0c7930415687b6564207a4", size = 21254989 }, + { url = "https://files.pythonhosted.org/packages/a2/0a/1212befdbecab5d80eca3cde47d304cad986ad4eec7d85a42e0b6d2cc2ef/numpy-2.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eeea959168ea555e556b8188da5fa7831e21d91ce031e95ce23747b7609f8a4", size = 14425910 }, + { url = "https://files.pythonhosted.org/packages/2b/3e/e7247c1d4f15086bb106c8d43c925b0b2ea20270224f5186fa48d4fb5cbd/numpy-2.2.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bd3ad3b0a40e713fc68f99ecfd07124195333f1e689387c180813f0e94309d6f", size = 5426490 }, + { url = "https://files.pythonhosted.org/packages/5d/fa/aa7cd6be51419b894c5787a8a93c3302a1ed4f82d35beb0613ec15bdd0e2/numpy-2.2.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cf28633d64294969c019c6df4ff37f5698e8326db68cc2b66576a51fad634880", size = 6967754 }, + { url = "https://files.pythonhosted.org/packages/d5/ee/96457c943265de9fadeb3d2ffdbab003f7fba13d971084a9876affcda095/numpy-2.2.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fa8fa7697ad1646b5c93de1719965844e004fcad23c91228aca1cf0800044a1", size = 14373079 }, + { url = "https://files.pythonhosted.org/packages/c5/5c/ceefca458559f0ccc7a982319f37ed07b0d7b526964ae6cc61f8ad1b6119/numpy-2.2.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4162988a360a29af158aeb4a2f4f09ffed6a969c9776f8f3bdee9b06a8ab7e5", size = 16428819 }, + { url = "https://files.pythonhosted.org/packages/22/31/9b2ac8eee99e001eb6add9fa27514ef5e9faf176169057a12860af52704c/numpy-2.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:892c10d6a73e0f14935c31229e03325a7b3093fafd6ce0af704be7f894d95687", size = 15881470 }, + { url = "https://files.pythonhosted.org/packages/f0/dc/8569b5f25ff30484b555ad8a3f537e0225d091abec386c9420cf5f7a2976/numpy-2.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db1f1c22173ac1c58db249ae48aa7ead29f534b9a948bc56828337aa84a32ed6", size = 18218144 }, + { url = "https://files.pythonhosted.org/packages/5e/05/463c023a39bdeb9bb43a99e7dee2c664cb68d5bb87d14f92482b9f6011cc/numpy-2.2.4-cp311-cp311-win32.whl", hash = "sha256:ea2bb7e2ae9e37d96835b3576a4fa4b3a97592fbea8ef7c3587078b0068b8f09", size = 6606368 }, + { url = "https://files.pythonhosted.org/packages/8b/72/10c1d2d82101c468a28adc35de6c77b308f288cfd0b88e1070f15b98e00c/numpy-2.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:f7de08cbe5551911886d1ab60de58448c6df0f67d9feb7d1fb21e9875ef95e91", size = 12947526 }, + { url = "https://files.pythonhosted.org/packages/a2/30/182db21d4f2a95904cec1a6f779479ea1ac07c0647f064dea454ec650c42/numpy-2.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", size = 20947156 }, + { url = "https://files.pythonhosted.org/packages/24/6d/9483566acfbda6c62c6bc74b6e981c777229d2af93c8eb2469b26ac1b7bc/numpy-2.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", size = 14133092 }, + { url = "https://files.pythonhosted.org/packages/27/f6/dba8a258acbf9d2bed2525cdcbb9493ef9bae5199d7a9cb92ee7e9b2aea6/numpy-2.2.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", size = 5163515 }, + { url = "https://files.pythonhosted.org/packages/62/30/82116199d1c249446723c68f2c9da40d7f062551036f50b8c4caa42ae252/numpy-2.2.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", size = 6696558 }, + { url = "https://files.pythonhosted.org/packages/0e/b2/54122b3c6df5df3e87582b2e9430f1bdb63af4023c739ba300164c9ae503/numpy-2.2.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", size = 14084742 }, + { url = "https://files.pythonhosted.org/packages/02/e2/e2cbb8d634151aab9528ef7b8bab52ee4ab10e076509285602c2a3a686e0/numpy-2.2.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", size = 16134051 }, + { url = "https://files.pythonhosted.org/packages/8e/21/efd47800e4affc993e8be50c1b768de038363dd88865920439ef7b422c60/numpy-2.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", size = 15578972 }, + { url = "https://files.pythonhosted.org/packages/04/1e/f8bb88f6157045dd5d9b27ccf433d016981032690969aa5c19e332b138c0/numpy-2.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", size = 17898106 }, + { url = "https://files.pythonhosted.org/packages/2b/93/df59a5a3897c1f036ae8ff845e45f4081bb06943039ae28a3c1c7c780f22/numpy-2.2.4-cp312-cp312-win32.whl", hash = "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", size = 6311190 }, + { url = "https://files.pythonhosted.org/packages/46/69/8c4f928741c2a8efa255fdc7e9097527c6dc4e4df147e3cadc5d9357ce85/numpy-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", size = 12644305 }, + { url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623 }, + { url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681 }, + { url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759 }, + { url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092 }, + { url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422 }, + { url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202 }, + { url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131 }, + { url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270 }, + { url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141 }, + { url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885 }, + { url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829 }, + { url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419 }, + { url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414 }, + { url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379 }, + { url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725 }, + { url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638 }, + { url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717 }, + { url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998 }, + { url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896 }, + { url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 }, + { url = "https://files.pythonhosted.org/packages/b2/5c/f09c33a511aff41a098e6ef3498465d95f6360621034a3d95f47edbc9119/numpy-2.2.4-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7051ee569db5fbac144335e0f3b9c2337e0c8d5c9fee015f259a5bd70772b7e8", size = 21081956 }, + { url = "https://files.pythonhosted.org/packages/ba/30/74c48b3b6494c4b820b7fa1781d441e94d87a08daa5b35d222f06ba41a6f/numpy-2.2.4-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:ab2939cd5bec30a7430cbdb2287b63151b77cf9624de0532d629c9a1c59b1d5c", size = 6827143 }, + { url = "https://files.pythonhosted.org/packages/54/f5/ab0d2f48b490535c7a80e05da4a98902b632369efc04f0e47bb31ca97d8f/numpy-2.2.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0f35b19894a9e08639fd60a1ec1978cb7f5f7f1eace62f38dd36be8aecdef4d", size = 16233350 }, + { url = "https://files.pythonhosted.org/packages/3b/3a/2f6d8c1f8e45d496bca6baaec93208035faeb40d5735c25afac092ec9a12/numpy-2.2.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b4adfbbc64014976d2f91084915ca4e626fbf2057fb81af209c1a6d776d23e3d", size = 12857565 }, +] + [[package]] name = "openai" -version = "1.66.5" +version = "1.67.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -808,9 +1040,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/10/b19dc682c806e6735a8387f2003afe2abada9f9e5227318de642c6949524/openai-1.66.5.tar.gz", hash = "sha256:f61b8fac29490ca8fdc6d996aa6926c18dbe5639536f8c40219c40db05511b11", size = 398595 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/63/6fd027fa4cb7c3b6bee4c3150f44803b3a7e4335f0b6e49e83a0c51c321b/openai-1.67.0.tar.gz", hash = "sha256:3b386a866396daa4bf80e05a891c50a7746ecd7863b8a27423b62136e3b8f6bc", size = 403596 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/3b/1ba418920ecd1eae7cc4d4ac8a01711ee0879b1a57dd81d10551e5b9a2ea/openai-1.66.5-py3-none-any.whl", hash = "sha256:74be528175f8389f67675830c51a15bd51e874425c86d3de6153bf70ed6c2884", size = 571144 }, + { url = "https://files.pythonhosted.org/packages/42/de/b42ddabe211411645105ae99ad93f4f3984f53be7ced2ad441378c27f62e/openai-1.67.0-py3-none-any.whl", hash = "sha256:dbbb144f38739fc0e1d951bc67864647fca0b9ffa05aef6b70eeea9f71d79663", size = 580168 }, ] [[package]] @@ -819,11 +1051,20 @@ version = "0.0.5" source = { editable = "." } dependencies = [ { name = "griffe" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "openai" }, { name = "pydantic" }, { name = "requests" }, { name = "types-requests" }, { name = "typing-extensions" }, + { name = "websockets" }, +] + +[package.optional-dependencies] +voice = [ + { name = "numpy", version = "2.2.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -835,22 +1076,31 @@ dev = [ { name = "mkdocstrings", extra = ["python"] }, { name = "mypy" }, { name = "playwright" }, + { name = "pynput" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "rich" }, { name = "ruff" }, + { name = "sounddevice" }, + { name = "textual" }, + { name = "types-pynput" }, ] [package.metadata] requires-dist = [ { name = "griffe", specifier = ">=1.5.6,<2" }, + { name = "numpy", specifier = ">=2.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" }, { name = "requests", specifier = ">=2.0,<3" }, { name = "types-requests", specifier = ">=2.0,<3" }, { name = "typing-extensions", specifier = ">=4.12.2,<5" }, + { name = "websockets", specifier = ">=15.0.1" }, + { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<16" }, ] +provides-extras = ["voice"] [package.metadata.requires-dev] dev = [ @@ -861,11 +1111,15 @@ dev = [ { name = "mkdocstrings", extras = ["python"], specifier = ">=0.28.0" }, { name = "mypy" }, { name = "playwright", specifier = "==1.50.0" }, + { name = "pynput" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "rich" }, { name = "ruff", specifier = "==0.9.2" }, + { name = "sounddevice" }, + { name = "textual" }, + { name = "types-pynput" }, ] [[package]] @@ -897,11 +1151,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.3.6" +version = "4.3.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, ] [[package]] @@ -931,6 +1185,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -1076,6 +1339,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/f5/b9e2a42aa8f9e34d52d66de87941ecd236570c7ed2e87775ed23bbe4e224/pymdown_extensions-10.14.3-py3-none-any.whl", hash = "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9", size = 264467 }, ] +[[package]] +name = "pynput" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/c3/dccf44c68225046df5324db0cc7d563a560635355b3e5f1d249468268a6f/pynput-1.8.1.tar.gz", hash = "sha256:70d7c8373ee98911004a7c938742242840a5628c004573d84ba849d4601df81e", size = 82289 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693 }, +] + +[[package]] +name = "pyobjc-core" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/94/a111239b98260869780a5767e5d74bfd3a8c13a40457f479c28dcd91f89d/pyobjc_core-11.0.tar.gz", hash = "sha256:63bced211cb8a8fb5c8ff46473603da30e51112861bd02c438fbbbc8578d9a70", size = 994931 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/21/ccc992b38670176a615fb67686d709e03be989511da687f6f49ddc4ff6c8/pyobjc_core-11.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:10866b3a734d47caf48e456eea0d4815c2c9b21856157db5917b61dee06893a1", size = 732162 }, + { url = "https://files.pythonhosted.org/packages/52/05/fa97309c3b1bc1ec90d701db89902e0bd5e1024023aa2c5387b889458b1b/pyobjc_core-11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:50675c0bb8696fe960a28466f9baf6943df2928a1fd85625d678fa2f428bd0bd", size = 727295 }, + { url = "https://files.pythonhosted.org/packages/56/ce/bf3ff9a9347721a398c3dfb83e29b43fb166b7ef590f3f7b7ddcd283df39/pyobjc_core-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a03061d4955c62ddd7754224a80cdadfdf17b6b5f60df1d9169a3b1b02923f0b", size = 739750 }, + { url = "https://files.pythonhosted.org/packages/72/16/0c468e73dbecb821e3da8819236fe832dfc53eb5f66a11775b055a7589ea/pyobjc_core-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c338c1deb7ab2e9436d4175d1127da2eeed4a1b564b3d83b9f3ae4844ba97e86", size = 743900 }, + { url = "https://files.pythonhosted.org/packages/f3/88/cecec88fd51f62a6cd7775cc4fb6bfde16652f97df88d28c84fb77ca0c18/pyobjc_core-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b4e9dc4296110f251a4033ff3f40320b35873ea7f876bd29a1c9705bb5e08c59", size = 791905 }, + { url = "https://files.pythonhosted.org/packages/14/ba/1c459d0f1fc4c80314040ea6efea433c0641adffa6701679ec3a917b51a3/pyobjc_core-11.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:02406ece449d0f41b31e579e47ca77ced3eb57533df955281bfcecc99da74fba", size = 732648 }, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/fb/4e42573b0d3baa3fa18ec53614cf979f951313f1451e8f2e17df9429da1f/pyobjc_framework_applicationservices-11.0.tar.gz", hash = "sha256:d6ea18dfc7d5626a3ecf4ac72d510405c0d3a648ca38cae8db841acdebecf4d2", size = 224334 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/2e/23d996e8294cc4d4ac719c410b1d210dfb1f64eecf87170d5e72c966592a/pyobjc_framework_ApplicationServices-11.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bc8f34b5b59ffd3c210ae883d794345c1197558ff3da0f5800669cf16435271e", size = 30839 }, + { url = "https://files.pythonhosted.org/packages/99/37/3d4dc6c004aaeb67bd43f7261d7c169ff45b8fc0eefbc7ba8cd6b0c881bc/pyobjc_framework_ApplicationServices-11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61a99eef23abb704257310db4f5271137707e184768f6407030c01de4731b67b", size = 30846 }, + { url = "https://files.pythonhosted.org/packages/74/a9/7a45a67e126d32c61ea22ffd80e87ff7e05b4acf32bede6cce071fbfffc8/pyobjc_framework_ApplicationServices-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5fbeb425897d6129471d451ec61a29ddd5b1386eb26b1dd49cb313e34616ee21", size = 30908 }, + { url = "https://files.pythonhosted.org/packages/82/47/ab4155ec966aff2f8f0f6978b40f12255e8ef46111ca0bda7987959b4052/pyobjc_framework_ApplicationServices-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:59becf3cd87a4f4cedf4be02ff6cf46ed736f5c1123ce629f788aaafad91eff0", size = 30924 }, + { url = "https://files.pythonhosted.org/packages/a3/73/747aab95970e0b7b5d38c650028e5e034c0432d9451335ff790ca104f11a/pyobjc_framework_ApplicationServices-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:44b466e8745fb49e8ac20f29f2ffd7895b45e97aa63a844b2a80a97c3a34346f", size = 31279 }, + { url = "https://files.pythonhosted.org/packages/a7/db/e8895fffa91031ab348ccad426dbd4c7d787ee0f48e1590ccba841669755/pyobjc_framework_ApplicationServices-11.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:74963e15a751d1454c1b8060914f116956e3a68f6a117c2163f491609125283b", size = 30809 }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/32/53809096ad5fc3e7a2c5ddea642590a5f2cb5b81d0ad6ea67fdb2263d9f9/pyobjc_framework_cocoa-11.0.tar.gz", hash = "sha256:00346a8cb81ad7b017b32ff7bf596000f9faa905807b1bd234644ebd47f692c5", size = 6173848 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/16/905a32c5241848ddd91d94bae346342750f28f49fadb3746e9e796f929f3/pyobjc_framework_Cocoa-11.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fbc65f260d617d5463c7fb9dbaaffc23c9a4fabfe3b1a50b039b61870b8daefd", size = 385509 }, + { url = "https://files.pythonhosted.org/packages/23/97/81fd41ad90e9c241172110aa635a6239d56f50d75923aaedbbe351828580/pyobjc_framework_Cocoa-11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3ea7be6e6dd801b297440de02d312ba3fa7fd3c322db747ae1cb237e975f5d33", size = 385534 }, + { url = "https://files.pythonhosted.org/packages/5b/8d/0e2558447c26b3ba64f7c9776a5a6c9d2ae8abf9d34308b174ae0934402e/pyobjc_framework_Cocoa-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:280a577b83c68175a28b2b7138d1d2d3111f2b2b66c30e86f81a19c2b02eae71", size = 385811 }, + { url = "https://files.pythonhosted.org/packages/1d/a5/609281a7e89efefbef9db1d8fe66bc0458c3b4e74e2227c644f9c18926fa/pyobjc_framework_Cocoa-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:15b2bd977ed340074f930f1330f03d42912d5882b697d78bd06f8ebe263ef92e", size = 385889 }, + { url = "https://files.pythonhosted.org/packages/93/f6/2d5a863673ef7b85a3cba875c43e6c495fb1307427a6801001ae94bb5e54/pyobjc_framework_Cocoa-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5750001db544e67f2b66f02067d8f0da96bb2ef71732bde104f01b8628f9d7ea", size = 389831 }, + { url = "https://files.pythonhosted.org/packages/27/29/459cacd815c2e13de60b919c0af3d1056f74ff52172a4841684b5b946492/pyobjc_framework_Cocoa-11.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ddff25b0755d59873d186e1e07d6aaddb19d55e3ae890d69ff2d9babf8627657", size = 385407 }, +] + +[[package]] +name = "pyobjc-framework-coretext" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/e8/9b68dc788828e38143a3e834e66346713751cb83d7f0955016323005c1a2/pyobjc_framework_coretext-11.0.tar.gz", hash = "sha256:a68437153e627847e3898754dd3f13ae0cb852246b016a91f9c9cbccb9f91a43", size = 274222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/af/aa4ab3e029a9f539e782eab894c57590791700d892cda73a324fe22e09a6/pyobjc_framework_CoreText-11.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6939b4ea745b349b5c964823a2071f155f5defdc9b9fc3a13f036d859d7d0439", size = 30395 }, + { url = "https://files.pythonhosted.org/packages/f6/20/b8a967101b585a2425ffe645135f8618edd51e1430aeb668373475a07d1f/pyobjc_framework_CoreText-11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:56a4889858308b0d9f147d568b4d91c441cc0ffd332497cb4f709bb1990450c1", size = 30397 }, + { url = "https://files.pythonhosted.org/packages/0d/14/d300b8bf18acd1d98d40820d2a9b5c5b6cf96325bdfc5020bc963218e001/pyobjc_framework_CoreText-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb90e7f370b3fd7cb2fb442e3dc63fedf0b4af6908db1c18df694d10dc94669d", size = 30456 }, + { url = "https://files.pythonhosted.org/packages/94/f0/53b681481e9429e8f9ac2c039da6a820d7417ca92f763f01d629db36c530/pyobjc_framework_CoreText-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7947f755782456bd663e0b00c7905eeffd10f839f0bf2af031f68ded6a1ea360", size = 30453 }, + { url = "https://files.pythonhosted.org/packages/2a/3f/a6d09952e83d70be6d337a5f1d457018459a57a110a91c3e771a2f2a7de0/pyobjc_framework_CoreText-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5356116bae33ec49f1f212c301378a7d08000440a2d6a7281aab351945528ab9", size = 31092 }, + { url = "https://files.pythonhosted.org/packages/c8/26/d18fd9fbb71dac6f43bd85d74aae3f3b4294ca96f0375878710763140b4b/pyobjc_framework_CoreText-11.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a76e1307747f2ee8180d38844cd62b8bb1701b4203d9234cc41f6603d4ae654", size = 30377 }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/ad/f00f3f53387c23bbf4e0bb1410e11978cbf87c82fa6baff0ee86f74c5fb6/pyobjc_framework_quartz-11.0.tar.gz", hash = "sha256:3205bf7795fb9ae34747f701486b3db6dfac71924894d1f372977c4d70c3c619", size = 3952463 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b3/75fccb0406aac00eecbd14f278a9b6e6fc0e4483220d57eb3aff68666fb1/pyobjc_framework_Quartz-11.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:da3ab13c9f92361959b41b0ad4cdd41ae872f90a6d8c58a9ed699bc08ab1c45c", size = 212343 }, + { url = "https://files.pythonhosted.org/packages/a3/6a/68957c8c5e8f0128d4d419728bac397d48fa7ad7a66e82b70e64d129ffca/pyobjc_framework_Quartz-11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d251696bfd8e8ef72fbc90eb29fec95cb9d1cc409008a183d5cc3246130ae8c2", size = 212349 }, + { url = "https://files.pythonhosted.org/packages/60/5d/df827b78dcb5140652ad08af8038c9ddd7e01e6bdf84462bfee644e6e661/pyobjc_framework_Quartz-11.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cb4a9f2d9d580ea15e25e6b270f47681afb5689cafc9e25712445ce715bcd18e", size = 212061 }, + { url = "https://files.pythonhosted.org/packages/a6/9e/54c48fe8faab06ee5eb80796c8c17ec61fc313d84398540ee70abeaf7070/pyobjc_framework_Quartz-11.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:973b4f9b8ab844574461a038bd5269f425a7368d6e677e3cc81fcc9b27b65498", size = 212478 }, + { url = "https://files.pythonhosted.org/packages/4a/28/456b54a59bfe11a91b7b4e94f8ffdcf174ffd1efa169f4283e5b3bc10194/pyobjc_framework_Quartz-11.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:66ab58d65348863b8707e63b2ec5cdc54569ee8189d1af90d52f29f5fdf6272c", size = 217973 }, + { url = "https://files.pythonhosted.org/packages/89/a9/c7efb146a2b9c9a7754fed1dd725f7342959644d903006dec28aa65a637e/pyobjc_framework_Quartz-11.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1032f63f2a4ee98366764e69c249f1d93813821e17d224cf626cf11fb1801fc4", size = 212182 }, +] + [[package]] name = "pytest" version = "8.3.5" @@ -1129,6 +1496,18 @@ 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-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185 }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1266,6 +1645,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "sounddevice" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/2d/b04ae180312b81dbb694504bee170eada5372242e186f6298139fd3a0513/sounddevice-0.5.1.tar.gz", hash = "sha256:09ca991daeda8ce4be9ac91e15a9a81c8f81efa6b695a348c9171ea0c16cb041", size = 52896 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/d1/464b5fca3decdd0cfec8c47f7b4161a0b12972453201c1bf03811f367c5e/sounddevice-0.5.1-py3-none-any.whl", hash = "sha256:e2017f182888c3f3c280d9fbac92e5dbddac024a7e3442f6e6116bd79dab8a9c", size = 32276 }, + { url = "https://files.pythonhosted.org/packages/6f/f6/6703fe7cf3d7b7279040c792aeec6334e7305956aba4a80f23e62c8fdc44/sounddevice-0.5.1-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:d16cb23d92322526a86a9490c427bf8d49e273d9ccc0bd096feecd229cde6031", size = 107916 }, + { url = "https://files.pythonhosted.org/packages/57/a5/78a5e71f5ec0faedc54f4053775d61407bfbd7d0c18228c7f3d4252fd276/sounddevice-0.5.1-py3-none-win32.whl", hash = "sha256:d84cc6231526e7a08e89beff229c37f762baefe5e0cc2747cbe8e3a565470055", size = 312494 }, + { 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 = "textual" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify", "plugins"] }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/62/4af4689dd971ed4fb3215467624016d53550bff1df9ca02e7625eec07f8b/textual-2.1.2.tar.gz", hash = "sha256:aae3f9fde00c7440be00e3c3ac189e02d014f5298afdc32132f93480f9e09146", size = 1596600 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/81/9df1988c908cbba77f10fecb8587496b3dff2838d4510457877a521d87fd/textual-2.1.2-py3-none-any.whl", hash = "sha256:95f37f49e930838e721bba8612f62114d410a3019665b6142adabc14c2fb9611", size = 680148 }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -1317,6 +1726,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, ] +[[package]] +name = "types-pynput" +version = "1.8.1.20250318" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/ae/92abffd8cc7b257e095bd87caa2e555d236811d9474b20b24dab0cb6b9e2/types_pynput-1.8.1.20250318.tar.gz", hash = "sha256:13d4df97843a7d1e7cddccbf9987aca7f0d463b214a8a35b4f53275d2c5a3576", size = 11694 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/50/7968a8040915d94c36c25b5ae4b3dcd7804a2ecd84ac537983b56201379a/types_pynput-1.8.1.20250318-py3-none-any.whl", hash = "sha256:0c1038aa1550941633114a2728ad85e392f67dfba970aebf755e369ab57aca70", size = 12280 }, +] + [[package]] name = "types-requests" version = "2.32.0.20250306" @@ -1338,6 +1756,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229 }, +] + [[package]] name = "urllib3" version = "2.3.0" @@ -1384,6 +1811,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, ] +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423 }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080 }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329 }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312 }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319 }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631 }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016 }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426 }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360 }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388 }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830 }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423 }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082 }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330 }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878 }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883 }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252 }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521 }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958 }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918 }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388 }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828 }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, + { url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424 }, + { url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077 }, + { url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324 }, + { url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094 }, + { url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094 }, + { url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397 }, + { url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794 }, + { url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194 }, + { url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164 }, + { url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381 }, + { url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841 }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109 }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343 }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599 }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207 }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155 }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884 }, + { url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106 }, + { url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339 }, + { url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597 }, + { url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205 }, + { url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150 }, + { url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877 }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +] + [[package]] name = "zipp" version = "3.21.0" From aec066649ca4ccc47f5c1ec04833ed1286edb737 Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Thu, 20 Mar 2025 09:52:15 -0700 Subject: [PATCH 28/65] fix tests --- examples/financial_research_agent/manager.py | 15 +++++---------- examples/financial_research_agent/printer.py | 1 + tests/voice/conftest.py | 14 ++++++++++++++ uv.lock | 14 ++++++-------- 4 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 tests/voice/conftest.py diff --git a/examples/financial_research_agent/manager.py b/examples/financial_research_agent/manager.py index 9a7722a..a996296 100644 --- a/examples/financial_research_agent/manager.py +++ b/examples/financial_research_agent/manager.py @@ -42,16 +42,14 @@ class FinancialResearchManager: is_done=True, hide_checkmark=True, ) - self.printer.update_item( - "start", "Starting financial research...", is_done=True) + self.printer.update_item("start", "Starting financial research...", is_done=True) search_plan = await self._plan_searches(query) search_results = await self._perform_searches(search_plan) report = await self._write_report(query, search_results) verification = await self._verify_report(report) final_report = f"Report summary\n\n{report.short_summary}" - self.printer.update_item( - "final_report", final_report, is_done=True) + self.printer.update_item("final_report", final_report, is_done=True) self.printer.end() @@ -76,8 +74,7 @@ class FinancialResearchManager: async def _perform_searches(self, search_plan: FinancialSearchPlan) -> Sequence[str]: with custom_span("Search the web"): self.printer.update_item("searching", "Searching...") - tasks = [asyncio.create_task(self._search(item)) - for item in search_plan.searches] + tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches] results: list[str] = [] num_completed = 0 for task in asyncio.as_completed(tasks): @@ -112,8 +109,7 @@ class FinancialResearchManager: tool_description="Use to get a short write‑up of potential red flags", custom_output_extractor=_summary_extractor, ) - writer_with_tools = writer_agent.clone( - tools=[fundamentals_tool, risk_tool]) + writer_with_tools = writer_agent.clone(tools=[fundamentals_tool, risk_tool]) self.printer.update_item("writing", "Thinking about report...") input_data = f"Original query: {query}\nSummarized search results: {search_results}" result = Runner.run_streamed(writer_with_tools, input_data) @@ -126,8 +122,7 @@ class FinancialResearchManager: next_message = 0 async for _ in result.stream_events(): if time.time() - last_update > 5 and next_message < len(update_messages): - self.printer.update_item( - "writing", update_messages[next_message]) + self.printer.update_item("writing", update_messages[next_message]) next_message += 1 last_update = time.time() self.printer.mark_item_done("writing") diff --git a/examples/financial_research_agent/printer.py b/examples/financial_research_agent/printer.py index 16e04d2..4c1a494 100644 --- a/examples/financial_research_agent/printer.py +++ b/examples/financial_research_agent/printer.py @@ -10,6 +10,7 @@ class Printer: Simple wrapper to stream status updates. Used by the financial bot manager as it orchestrates planning, search and writing. """ + def __init__(self, console: Console) -> None: self.live = Live(console=console) self.items: dict[str, tuple[str, bool]] = {} diff --git a/tests/voice/conftest.py b/tests/voice/conftest.py new file mode 100644 index 0000000..7eeb9ce --- /dev/null +++ b/tests/voice/conftest.py @@ -0,0 +1,14 @@ +import os +import sys + +import pytest + + +def pytest_collection_modifyitems(config, items): + if sys.version_info[:2] == (3, 9): + this_dir = os.path.dirname(__file__) + skip_marker = pytest.mark.skip(reason="Skipped on Python 3.9") + + for item in items: + if item.fspath.dirname.startswith(this_dir): + item.add_marker(skip_marker) diff --git a/uv.lock b/uv.lock index cff72ab..0f0d22a 100644 --- a/uv.lock +++ b/uv.lock @@ -1028,21 +1028,24 @@ wheels = [ [[package]] name = "openai" -version = "1.67.0" +version = "1.68.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, { name = "httpx" }, { name = "jiter" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pydantic" }, { name = "sniffio" }, + { name = "sounddevice" }, { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/63/6fd027fa4cb7c3b6bee4c3150f44803b3a7e4335f0b6e49e83a0c51c321b/openai-1.67.0.tar.gz", hash = "sha256:3b386a866396daa4bf80e05a891c50a7746ecd7863b8a27423b62136e3b8f6bc", size = 403596 } +sdist = { url = "https://files.pythonhosted.org/packages/58/ea/58102e9bfda09edc963e6e877e39cca12706b46ebf35d5fc9da7b8af10f2/openai-1.68.0.tar.gz", hash = "sha256:c570c06c9ba10f98b891ac30a3dd7b5c89ed48094c711c7a3f35fb5ade6c0757", size = 413039 } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/de/b42ddabe211411645105ae99ad93f4f3984f53be7ced2ad441378c27f62e/openai-1.67.0-py3-none-any.whl", hash = "sha256:dbbb144f38739fc0e1d951bc67864647fca0b9ffa05aef6b70eeea9f71d79663", size = 580168 }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bd67b7031572cba7d8451d82ac4a990b3a96bbd3b037634726b48ac972c8/openai-1.68.0-py3-none-any.whl", hash = "sha256:20e279b0f3a78cb4a95f3eab2a180f3ee30c6a196aeebd6bf642a4f88ab85ee1", size = 605645 }, ] [[package]] @@ -1051,14 +1054,11 @@ version = "0.0.5" source = { editable = "." } dependencies = [ { name = "griffe" }, - { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "numpy", version = "2.2.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "openai" }, { name = "pydantic" }, { name = "requests" }, { name = "types-requests" }, { name = "typing-extensions" }, - { name = "websockets" }, ] [package.optional-dependencies] @@ -1090,14 +1090,12 @@ dev = [ [package.metadata] requires-dist = [ { name = "griffe", specifier = ">=1.5.6,<2" }, - { name = "numpy", specifier = ">=2.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" }, { name = "requests", specifier = ">=2.0,<3" }, { name = "types-requests", specifier = ">=2.0,<3" }, { name = "typing-extensions", specifier = ">=4.12.2,<5" }, - { name = "websockets", specifier = ">=15.0.1" }, { name = "websockets", marker = "extra == 'voice'", specifier = ">=15.0,<16" }, ] provides-extras = ["voice"] From 1771c1e856c4c7ec22734992e6b4994f123d3279 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Thu, 20 Mar 2025 13:04:24 -0400 Subject: [PATCH 29/65] update tests --- examples/voice/static/main.py | 7 ++--- examples/voice/streamed/agents.py | 10 ++----- examples/voice/streamed/main.py | 3 +- pyproject.toml | 1 + src/agents/__init__.py | 48 ------------------------------- tests/voice/fake_models.py | 23 ++++++++------- tests/voice/helpers.py | 5 +++- tests/voice/test_input.py | 9 ++++-- tests/voice/test_openai_stt.py | 12 +++++--- tests/voice/test_openai_tts.py | 5 +++- tests/voice/test_pipeline.py | 9 ++++-- tests/voice/test_workflow.py | 10 +++++-- uv.lock | 2 ++ 13 files changed, 57 insertions(+), 87 deletions(-) diff --git a/examples/voice/static/main.py b/examples/voice/static/main.py index 5f512db..4e3840f 100644 --- a/examples/voice/static/main.py +++ b/examples/voice/static/main.py @@ -1,15 +1,14 @@ import asyncio import random -from agents import ( - Agent, +from agents import Agent, function_tool +from agents.extensions.handoff_prompt import prompt_with_handoff_instructions +from agents.voice import ( AudioInput, SingleAgentVoiceWorkflow, SingleAgentWorkflowCallbacks, VoicePipeline, - function_tool, ) -from agents.extensions.handoff_prompt import prompt_with_handoff_instructions from .util import AudioPlayer, record_audio diff --git a/examples/voice/streamed/agents.py b/examples/voice/streamed/agents.py index dcf312d..3cb804b 100644 --- a/examples/voice/streamed/agents.py +++ b/examples/voice/streamed/agents.py @@ -2,15 +2,9 @@ import random from collections.abc import AsyncIterator from typing import Callable -from agents import ( - Agent, - Runner, - TResponseInputItem, - VoiceWorkflowBase, - VoiceWorkflowHelper, - function_tool, -) +from agents import Agent, Runner, TResponseInputItem, function_tool from agents.extensions.handoff_prompt import prompt_with_handoff_instructions +from agents.voice import VoiceWorkflowBase, VoiceWorkflowHelper @function_tool diff --git a/examples/voice/streamed/main.py b/examples/voice/streamed/main.py index 3689433..aef3b36 100644 --- a/examples/voice/streamed/main.py +++ b/examples/voice/streamed/main.py @@ -11,8 +11,7 @@ from textual.reactive import reactive from textual.widgets import Button, RichLog, Static from typing_extensions import override -from agents import VoicePipeline -from agents.voice.input import StreamedAudioInput +from agents.voice import StreamedAudioInput, VoicePipeline from .agents import MyWorkflow diff --git a/pyproject.toml b/pyproject.toml index 9d0d8c5..7567013 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "sounddevice", "pynput", "textual", + "websockets", ] [tool.uv.workspace] members = ["agents"] diff --git a/src/agents/__init__.py b/src/agents/__init__.py index c0b47dc..47bb264 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -98,31 +98,6 @@ from .tracing import ( transcription_span, ) from .usage import Usage -from .voice import ( - AudioInput, - OpenAISTTModel, - OpenAISTTTranscriptionSession, - OpenAITTSModel, - OpenAIVoiceModelProvider, - SingleAgentVoiceWorkflow, - SingleAgentWorkflowCallbacks, - StreamedAudioInput, - StreamedAudioResult, - StreamedTranscriptionSession, - STTModel, - STTModelSettings, - TTSModel, - TTSModelSettings, - VoiceModelProvider, - VoicePipeline, - VoicePipelineConfig, - VoiceStreamEvent, - VoiceStreamEventAudio, - VoiceStreamEventLifecycle, - VoiceWorkflowBase, - VoiceWorkflowHelper, - get_sentence_based_splitter, -) def set_default_openai_key(key: str, use_for_tracing: bool = True) -> None: @@ -268,27 +243,4 @@ __all__ = [ "gen_trace_id", "gen_span_id", "default_tool_error_function", - "AudioInput", - "StreamedAudioInput", - "STTModel", - "STTModelSettings", - "TTSModel", - "TTSModelSettings", - "VoiceModelProvider", - "StreamedAudioResult", - "SingleAgentVoiceWorkflow", - "OpenAIVoiceModelProvider", - "OpenAISTTModel", - "OpenAITTSModel", - "VoiceStreamEventAudio", - "VoiceStreamEventLifecycle", - "VoiceStreamEvent", - "VoicePipeline", - "VoicePipelineConfig", - "get_sentence_based_splitter", - "VoiceWorkflowHelper", - "VoiceWorkflowBase", - "StreamedTranscriptionSession", - "OpenAISTTTranscriptionSession", - "SingleAgentWorkflowCallbacks", ] diff --git a/tests/voice/fake_models.py b/tests/voice/fake_models.py index 3febe42..109ee4c 100644 --- a/tests/voice/fake_models.py +++ b/tests/voice/fake_models.py @@ -6,16 +6,19 @@ from typing import Literal import numpy as np import numpy.typing as npt -from agents.voice import ( - AudioInput, - StreamedAudioInput, - StreamedTranscriptionSession, - STTModel, - STTModelSettings, - TTSModel, - TTSModelSettings, - VoiceWorkflowBase, -) +try: + from agents.voice import ( + AudioInput, + StreamedAudioInput, + StreamedTranscriptionSession, + STTModel, + STTModelSettings, + TTSModel, + TTSModelSettings, + VoiceWorkflowBase, + ) +except ImportError: + pass class FakeTTS(TTSModel): diff --git a/tests/voice/helpers.py b/tests/voice/helpers.py index d2f9ca2..ae902dc 100644 --- a/tests/voice/helpers.py +++ b/tests/voice/helpers.py @@ -1,4 +1,7 @@ -from agents.voice import StreamedAudioResult +try: + from agents.voice import StreamedAudioResult +except ImportError: + pass async def extract_events(result: StreamedAudioResult) -> tuple[list[str], list[bytes]]: diff --git a/tests/voice/test_input.py b/tests/voice/test_input.py index 7d4197b..d41d870 100644 --- a/tests/voice/test_input.py +++ b/tests/voice/test_input.py @@ -4,9 +4,12 @@ import wave import numpy as np import pytest -from agents import UserError -from agents.voice import AudioInput, StreamedAudioInput -from agents.voice.input import DEFAULT_SAMPLE_RATE, _buffer_to_audio_file +try: + from agents import UserError + from agents.voice import AudioInput, StreamedAudioInput + from agents.voice.input import DEFAULT_SAMPLE_RATE, _buffer_to_audio_file +except ImportError: + pass def test_buffer_to_audio_file_int16(): diff --git a/tests/voice/test_openai_stt.py b/tests/voice/test_openai_stt.py index bb8d3cf..7555923 100644 --- a/tests/voice/test_openai_stt.py +++ b/tests/voice/test_openai_stt.py @@ -8,11 +8,15 @@ from unittest.mock import AsyncMock, patch import numpy as np import pytest -from agents.voice import OpenAISTTTranscriptionSession, StreamedAudioInput, STTModelSettings -from agents.voice.exceptions import STTWebsocketConnectionError -from agents.voice.models.openai_stt import EVENT_INACTIVITY_TIMEOUT +try: + from agents.voice import OpenAISTTTranscriptionSession, StreamedAudioInput, STTModelSettings + from agents.voice.exceptions import STTWebsocketConnectionError + from agents.voice.models.openai_stt import EVENT_INACTIVITY_TIMEOUT + + from .fake_models import FakeStreamedAudioInput +except ImportError: + pass -from .fake_models import FakeStreamedAudioInput # ===== Helpers ===== diff --git a/tests/voice/test_openai_tts.py b/tests/voice/test_openai_tts.py index 0fbd6ba..b18f9e8 100644 --- a/tests/voice/test_openai_tts.py +++ b/tests/voice/test_openai_tts.py @@ -5,7 +5,10 @@ from typing import Any import pytest -from agents.voice import OpenAITTSModel, TTSModelSettings +try: + from agents.voice import OpenAITTSModel, TTSModelSettings +except ImportError: + pass class _FakeStreamResponse: diff --git a/tests/voice/test_pipeline.py b/tests/voice/test_pipeline.py index f988026..5190446 100644 --- a/tests/voice/test_pipeline.py +++ b/tests/voice/test_pipeline.py @@ -4,10 +4,13 @@ import numpy as np import numpy.typing as npt import pytest -from agents.voice import AudioInput, TTSModelSettings, VoicePipeline, VoicePipelineConfig +try: + from agents.voice import AudioInput, TTSModelSettings, VoicePipeline, VoicePipelineConfig -from .fake_models import FakeStreamedAudioInput, FakeSTT, FakeTTS, FakeWorkflow -from .helpers import extract_events + from .fake_models import FakeStreamedAudioInput, FakeSTT, FakeTTS, FakeWorkflow + from .helpers import extract_events +except ImportError: + pass @pytest.mark.asyncio diff --git a/tests/voice/test_workflow.py b/tests/voice/test_workflow.py index 465c124..3f18c04 100644 --- a/tests/voice/test_workflow.py +++ b/tests/voice/test_workflow.py @@ -17,10 +17,14 @@ from agents.items import ( TResponseOutputItem, TResponseStreamEvent, ) -from agents.voice import SingleAgentVoiceWorkflow -from ..fake_model import get_response_obj -from ..test_responses import get_function_tool, get_function_tool_call, get_text_message +try: + from agents.voice import SingleAgentVoiceWorkflow + + from ..fake_model import get_response_obj + from ..test_responses import get_function_tool, get_function_tool_call, get_text_message +except ImportError: + pass class FakeStreamingModel(Model): diff --git a/uv.lock b/uv.lock index 0f0d22a..15e091a 100644 --- a/uv.lock +++ b/uv.lock @@ -1085,6 +1085,7 @@ dev = [ { name = "sounddevice" }, { name = "textual" }, { name = "types-pynput" }, + { name = "websockets" }, ] [package.metadata] @@ -1118,6 +1119,7 @@ dev = [ { name = "sounddevice" }, { name = "textual" }, { name = "types-pynput" }, + { name = "websockets" }, ] [[package]] From fb8e5c2baf7f03b5ca1652f52d6185dcb5fb3c61 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Thu, 20 Mar 2025 13:10:54 -0400 Subject: [PATCH 30/65] v0.0.6 (voice support) --- README.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fc98b2b..85670df 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ source env/bin/activate pip install openai-agents ``` +For voice support, install with the optional `voice` group: `pip install openai-agents[voice]`. + ## Hello world example ```python diff --git a/pyproject.toml b/pyproject.toml index 7567013..667ab35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.0.5" +version = "0.0.6" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.9" From 7a4c71f23b96626f16daf3c41df4002591ad2dbe Mon Sep 17 00:00:00 2001 From: Dmitry Pimenov Date: Thu, 20 Mar 2025 15:51:02 -0700 Subject: [PATCH 31/65] include reference to new audio span related concepts --- examples/basic/hello_world.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/basic/hello_world.py b/examples/basic/hello_world.py index 169290d..e972e37 100644 --- a/examples/basic/hello_world.py +++ b/examples/basic/hello_world.py @@ -1,12 +1,18 @@ import asyncio -from agents import Agent, Runner +from agents import Agent, Runner, ModelSettings, OpenAIResponsesModel async def main(): agent = Agent( - name="Assistant", - instructions="You only respond in haikus.", + name="English agent", + instructions="You only speak English", + model=OpenAIResponsesModel( + model="gpt-4o", + model_settings=ModelSettings( + store=False, + ) + ) ) result = await Runner.run(agent, "Tell me about recursion in programming.") From 21634f31d5056f09981a6144631c7a1c9ce1adf2 Mon Sep 17 00:00:00 2001 From: Dmitry Pimenov Date: Thu, 20 Mar 2025 15:53:41 -0700 Subject: [PATCH 32/65] removing erroneous changes --- docs/tracing.md | 9 ++++++++- examples/basic/hello_world.py | 14 ++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/tracing.md b/docs/tracing.md index 98574d3..a1f83ec 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -35,6 +35,9 @@ By default, the SDK traces the following: - Function tool calls are each wrapped in `function_span()` - Guardrails are wrapped in `guardrail_span()` - Handoffs are wrapped in `handoff_span()` +- Audio inputs (speech-to-text) are wrapped in a `transcription_span()` +- Audio outputs (text-to-speech) are wrapped in a `speech_span()` +- Related audio spans may be parented under a `speech_group_span()` By default, the trace is named "Agent trace". You can set this name if you use `trace`, or you can can configure the name and other properties with the [`RunConfig`][agents.run.RunConfig]. @@ -76,7 +79,11 @@ Spans are automatically part of the current trace, and are nested under the near ## Sensitive data -Some spans track potentially sensitive data. For example, the `generation_span()` stores the inputs/outputs of the LLM generation, and `function_span()` stores the inputs/outputs of function calls. These may contain sensitive data, so you can disable capturing that data via [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]. +Certain spans may capture potentially sensitive data. + +The `generation_span()` stores the inputs/outputs of the LLM generation, and `function_span()` stores the inputs/outputs of function calls. These may contain sensitive data, so you can disable capturing that data via [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]. + +Similarly, Audio spans include base64-encoded PCM data for input and output audio by default. You can disable capturing this audio data by configuring [`RunConfig.trace_include_sensitive_audio_data`][agents.run.RunConfig.trace_include_sensitive_audio_data]. ## Custom tracing processors diff --git a/examples/basic/hello_world.py b/examples/basic/hello_world.py index e972e37..a9514f3 100644 --- a/examples/basic/hello_world.py +++ b/examples/basic/hello_world.py @@ -1,18 +1,12 @@ import asyncio -from agents import Agent, Runner, ModelSettings, OpenAIResponsesModel +from agents import Agent, Runner async def main(): agent = Agent( - name="English agent", - instructions="You only speak English", - model=OpenAIResponsesModel( - model="gpt-4o", - model_settings=ModelSettings( - store=False, - ) - ) + name="Assistant", + instructions="You only respond in haikus.", ) result = await Runner.run(agent, "Tell me about recursion in programming.") @@ -23,4 +17,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(main()) \ No newline at end of file From 1f348b837086b320a1c4394bcd13bbdaedcf9d8a Mon Sep 17 00:00:00 2001 From: Dmitry Pimenov Date: Thu, 20 Mar 2025 16:05:54 -0700 Subject: [PATCH 33/65] fixing whitespace --- examples/basic/hello_world.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/basic/hello_world.py b/examples/basic/hello_world.py index a9514f3..169290d 100644 --- a/examples/basic/hello_world.py +++ b/examples/basic/hello_world.py @@ -17,4 +17,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) From 0dd5b3793627cb82362e800e6551b5e11e0fd1ef Mon Sep 17 00:00:00 2001 From: Yoshinori Sano Date: Fri, 21 Mar 2025 08:37:45 +0900 Subject: [PATCH 34/65] [doc] fix invalid imports --- docs/voice/quickstart.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/voice/quickstart.md b/docs/voice/quickstart.md index 144b748..98ebe4f 100644 --- a/docs/voice/quickstart.md +++ b/docs/voice/quickstart.md @@ -91,7 +91,7 @@ agent = Agent( We'll set up a simple voice pipeline, using [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] as the workflow. ```python -from agents import SingleAgentVoiceWorkflow, VoicePipeline, +from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline, pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ``` @@ -128,11 +128,13 @@ import sounddevice as sd from agents import ( Agent, + function_tool, + set_tracing_disabled, +) +from agents.voice import ( AudioInput, SingleAgentVoiceWorkflow, VoicePipeline, - function_tool, - set_tracing_disabled, ) from agents.extensions.handoff_prompt import prompt_with_handoff_instructions From 1b12fce95d01c23ece9f98f0e2f6204a69af0397 Mon Sep 17 00:00:00 2001 From: Dmitry Pimenov Date: Thu, 20 Mar 2025 17:03:52 -0700 Subject: [PATCH 35/65] fixing object path --- docs/tracing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tracing.md b/docs/tracing.md index a1f83ec..72c4da4 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -83,7 +83,7 @@ Certain spans may capture potentially sensitive data. The `generation_span()` stores the inputs/outputs of the LLM generation, and `function_span()` stores the inputs/outputs of function calls. These may contain sensitive data, so you can disable capturing that data via [`RunConfig.trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]. -Similarly, Audio spans include base64-encoded PCM data for input and output audio by default. You can disable capturing this audio data by configuring [`RunConfig.trace_include_sensitive_audio_data`][agents.run.RunConfig.trace_include_sensitive_audio_data]. +Similarly, Audio spans include base64-encoded PCM data for input and output audio by default. You can disable capturing this audio data by configuring [`VoicePipelineConfig.trace_include_sensitive_audio_data`][agents.voice.pipeline_config.VoicePipelineConfig.trace_include_sensitive_audio_data]. ## Custom tracing processors From 98c4b45b6a81981dc28ea0ebb0fca662c08423bb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 21 Mar 2025 10:01:24 +0900 Subject: [PATCH 36/65] Make the optional dependency installation compatible with zsh --- README.md | 2 +- docs/voice/quickstart.md | 2 +- src/agents/voice/imports.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 85670df..bbd4a5a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ source env/bin/activate pip install openai-agents ``` -For voice support, install with the optional `voice` group: `pip install openai-agents[voice]`. +For voice support, install with the optional `voice` group: `pip install 'openai-agents[voice]'`. ## Hello world example diff --git a/docs/voice/quickstart.md b/docs/voice/quickstart.md index 98ebe4f..1b96d0c 100644 --- a/docs/voice/quickstart.md +++ b/docs/voice/quickstart.md @@ -5,7 +5,7 @@ Make sure you've followed the base [quickstart instructions](../quickstart.md) for the Agents SDK, and set up a virtual environment. Then, install the optional voice dependencies from the SDK: ```bash -pip install openai-agents[voice] +pip install 'openai-agents[voice]' ``` ## Concepts diff --git a/src/agents/voice/imports.py b/src/agents/voice/imports.py index 37062da..b1c0950 100644 --- a/src/agents/voice/imports.py +++ b/src/agents/voice/imports.py @@ -5,7 +5,7 @@ try: except ImportError as _e: raise ImportError( "`numpy` + `websockets` are required to use voice. You can install them via the optional " - "dependency group: `pip install openai-agents[voice]`." + "dependency group: `pip install 'openai-agents[voice]'`." ) from _e __all__ = ["np", "npt", "websockets"] From 5f7a0b950823eef6fa4aac6872277bc95fc9a733 Mon Sep 17 00:00:00 2001 From: Sir Qasim Date: Fri, 21 Mar 2025 06:58:47 +0500 Subject: [PATCH 37/65] fixed from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline in quickstart.md from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline, remove extra "," from the first line --- docs/voice/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/voice/quickstart.md b/docs/voice/quickstart.md index 1b96d0c..49c1026 100644 --- a/docs/voice/quickstart.md +++ b/docs/voice/quickstart.md @@ -91,7 +91,7 @@ agent = Agent( We'll set up a simple voice pipeline, using [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] as the workflow. ```python -from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline, +from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ``` From 3da3b51b872495cb29f78c0496157c3121e13671 Mon Sep 17 00:00:00 2001 From: Pepijn <> Date: Fri, 21 Mar 2025 06:47:12 +0100 Subject: [PATCH 38/65] Fix voice pipeline code examples in quickstart docs --- docs/voice/quickstart.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/voice/quickstart.md b/docs/voice/quickstart.md index 1b96d0c..a9513f4 100644 --- a/docs/voice/quickstart.md +++ b/docs/voice/quickstart.md @@ -91,7 +91,7 @@ agent = Agent( We'll set up a simple voice pipeline, using [`SingleAgentVoiceWorkflow`][agents.voice.workflow.SingleAgentVoiceWorkflow] as the workflow. ```python -from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline, +from agents.voice import SingleAgentVoiceWorkflow, VoicePipeline pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ``` @@ -100,10 +100,13 @@ pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent)) ```python import numpy as np import sounddevice as sd +from agents.voice import AudioInput # For simplicity, we'll just create 3 seconds of silence # In reality, you'd get microphone data audio = np.zeros(24000 * 3, dtype=np.int16) +audio_input = AudioInput(buffer=buffer) + result = await pipeline.run(audio_input) # Create an audio player using `sounddevice` From 37ddc4e5a1a79fc6b555dcf1895f86625b4894a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jannik=20Maierh=C3=B6fer?= <48529566+jannikmaierhoefer@users.noreply.github.com> Date: Fri, 21 Mar 2025 13:39:17 +0100 Subject: [PATCH 39/65] docs: add Langfuse to tracing documentation --- docs/tracing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tracing.md b/docs/tracing.md index 72c4da4..5ecb45d 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -109,3 +109,4 @@ To customize this default setup, to send traces to alternative or additional bac - [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/trace_with_openai_agents_sdk) - [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) From b5305810d719dd46acd46a8f5643960a2cfd6bf2 Mon Sep 17 00:00:00 2001 From: Richie Caputo <43445060+arcaputo3@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:16:24 -0400 Subject: [PATCH 40/65] Create py.typed - Ensure library is properly typehinted --- src/agents/py.typed | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/agents/py.typed diff --git a/src/agents/py.typed b/src/agents/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/agents/py.typed @@ -0,0 +1 @@ + From cb0eb8e254dbb83259b9ca89de646da138032007 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 21 Mar 2025 18:09:19 +0200 Subject: [PATCH 41/65] More fetch_normalized_spans --- tests/test_tracing.py | 99 +++++++++++++++++++------------------- tests/testing_processor.py | 6 ++- uv.lock | 2 +- 3 files changed, 55 insertions(+), 52 deletions(-) diff --git a/tests/test_tracing.py b/tests/test_tracing.py index c54c3d8..8368ff2 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -4,6 +4,7 @@ import asyncio from typing import Any import pytest +from inline_snapshot import snapshot from agents.tracing import ( Span, @@ -17,7 +18,13 @@ from agents.tracing import ( ) from agents.tracing.spans import SpanError -from .testing_processor import fetch_events, fetch_ordered_spans, fetch_traces +from .testing_processor import ( + SPAN_PROCESSOR_TESTING, + fetch_events, + fetch_normalized_spans, + fetch_ordered_spans, + fetch_traces, +) ### HELPERS @@ -129,11 +136,11 @@ def test_ctxmanager_spans() -> None: async def run_subtask(span_id: str | None = None) -> None: with generation_span(span_id=span_id): - await asyncio.sleep(0.01) + await asyncio.sleep(0.0001) async def simple_async_tracing(): - with trace(workflow_name="test", trace_id="123", group_id="456"): + with trace(workflow_name="test", trace_id="trace_123", group_id="group_456"): await run_subtask(span_id="span_1") await run_subtask(span_id="span_2") @@ -142,21 +149,18 @@ async def simple_async_tracing(): async def test_async_tracing() -> None: await simple_async_tracing() - spans, traces = fetch_ordered_spans(), fetch_traces() - assert len(spans) == 2 - assert len(traces) == 1 - - trace = traces[0] - standard_trace_checks(trace, name_check="test") - trace_id = trace.trace_id - - # We don't care about ordering here, just that they're there - for s in spans: - standard_span_checks(s, trace_id=trace_id, parent_id=None, span_type="generation") - - ids = [span.span_id for span in spans] - assert "span_1" in ids - assert "span_2" in ids + assert fetch_normalized_spans(keep_span_id=True) == snapshot( + [ + { + "workflow_name": "test", + "group_id": "group_456", + "children": [ + {"type": "generation", "id": "span_1"}, + {"type": "generation", "id": "span_2"}, + ], + } + ] + ) async def run_tasks_parallel(span_ids: list[str]) -> None: @@ -171,13 +175,11 @@ async def run_tasks_as_children(first_span_id: str, second_span_id: str) -> None async def complex_async_tracing(): - with trace(workflow_name="test", trace_id="123", group_id="456"): - await asyncio.sleep(0.01) + with trace(workflow_name="test", trace_id="trace_123", group_id="456"): await asyncio.gather( run_tasks_parallel(["span_1", "span_2"]), run_tasks_parallel(["span_3", "span_4"]), ) - await asyncio.sleep(0.01) await asyncio.gather( run_tasks_as_children("span_5", "span_6"), run_tasks_as_children("span_7", "span_8"), @@ -186,35 +188,34 @@ async def complex_async_tracing(): @pytest.mark.asyncio async def test_complex_async_tracing() -> None: - await complex_async_tracing() + for _ in range(300): + SPAN_PROCESSOR_TESTING.clear() + await complex_async_tracing() - spans, traces = fetch_ordered_spans(), fetch_traces() - assert len(spans) == 8 - assert len(traces) == 1 - - trace = traces[0] - standard_trace_checks(trace, name_check="test") - trace_id = trace.trace_id - - # First ensure 1,2,3,4 exist and are in parallel with the trace as parent - for span_id in ["span_1", "span_2", "span_3", "span_4"]: - span = next((s for s in spans if s.span_id == span_id), None) - assert span is not None - standard_span_checks(span, trace_id=trace_id, parent_id=None, span_type="generation") - - # Ensure 5 and 7 exist and have the trace as parent - for span_id in ["span_5", "span_7"]: - span = next((s for s in spans if s.span_id == span_id), None) - assert span is not None - standard_span_checks(span, trace_id=trace_id, parent_id=None, span_type="generation") - - # Ensure 6 and 8 exist and have 5 and 7 as parents - six = next((s for s in spans if s.span_id == "span_6"), None) - assert six is not None - standard_span_checks(six, trace_id=trace_id, parent_id="span_5", span_type="generation") - eight = next((s for s in spans if s.span_id == "span_8"), None) - assert eight is not None - standard_span_checks(eight, trace_id=trace_id, parent_id="span_7", span_type="generation") + assert fetch_normalized_spans(keep_span_id=True) == ( + [ + { + "workflow_name": "test", + "group_id": "456", + "children": [ + {"type": "generation", "id": "span_1"}, + {"type": "generation", "id": "span_2"}, + {"type": "generation", "id": "span_3"}, + {"type": "generation", "id": "span_4"}, + { + "type": "generation", + "id": "span_5", + "children": [{"type": "generation", "id": "span_6"}], + }, + { + "type": "generation", + "id": "span_7", + "children": [{"type": "generation", "id": "span_8"}], + }, + ], + } + ] + ) def spans_with_setters(): diff --git a/tests/testing_processor.py b/tests/testing_processor.py index 371ea86..b07dea5 100644 --- a/tests/testing_processor.py +++ b/tests/testing_processor.py @@ -80,7 +80,7 @@ def fetch_events() -> list[TestSpanProcessorEvent]: return SPAN_PROCESSOR_TESTING._events -def fetch_normalized_spans(): +def fetch_normalized_spans(keep_span_id: bool = False): nodes: dict[tuple[str, str | None], dict[str, Any]] = {} traces = [] for trace_obj in fetch_traces(): @@ -99,7 +99,9 @@ def fetch_normalized_spans(): span = span_obj.export() assert span assert span.pop("object") == "trace.span" - assert span.pop("id").startswith("span_") + assert span["id"].startswith("span_") + if not keep_span_id: + del span["id"] assert datetime.fromisoformat(span.pop("started_at")) assert datetime.fromisoformat(span.pop("ended_at")) parent_id = span.pop("parent_id") diff --git a/uv.lock b/uv.lock index 15e091a..a9c79e2 100644 --- a/uv.lock +++ b/uv.lock @@ -1050,7 +1050,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.0.5" +version = "0.0.6" source = { editable = "." } dependencies = [ { name = "griffe" }, From 7581696b38112814b3edbc0c1e02795eaa677af8 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 21 Mar 2025 18:13:04 +0200 Subject: [PATCH 42/65] More fetch_normalized_spans --- tests/test_tracing.py | 93 +++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 8368ff2..9ee7a49 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -54,7 +54,7 @@ def simple_tracing(): x = trace("test") x.start() - span_1 = agent_span(name="agent_1", parent=x) + span_1 = agent_span(name="agent_1", span_id="span_1", parent=x) span_1.start() span_1.finish() @@ -73,33 +73,36 @@ def simple_tracing(): def test_simple_tracing() -> None: simple_tracing() - spans, traces = fetch_ordered_spans(), fetch_traces() - assert len(spans) == 3 - assert len(traces) == 1 - - trace = traces[0] - standard_trace_checks(trace, name_check="test") - trace_id = trace.trace_id - - first_span = spans[0] - standard_span_checks(first_span, trace_id=trace_id, parent_id=None, span_type="agent") - assert first_span.span_data.name == "agent_1" - - second_span = spans[1] - standard_span_checks(second_span, trace_id=trace_id, parent_id=None, span_type="custom") - assert second_span.span_id == "span_2" - assert second_span.span_data.name == "custom_1" - - third_span = spans[2] - standard_span_checks( - third_span, trace_id=trace_id, parent_id=second_span.span_id, span_type="custom" + assert fetch_normalized_spans(keep_span_id=True) == snapshot( + [ + { + "workflow_name": "test", + "children": [ + { + "type": "agent", + "id": "span_1", + "data": {"name": "agent_1"}, + }, + { + "type": "custom", + "id": "span_2", + "data": {"name": "custom_1", "data": {}}, + "children": [ + { + "type": "custom", + "id": "span_3", + "data": {"name": "custom_2", "data": {}}, + } + ], + }, + ], + } + ] ) - assert third_span.span_id == "span_3" - assert third_span.span_data.name == "custom_2" def ctxmanager_spans(): - with trace(workflow_name="test", trace_id="123", group_id="456"): + with trace(workflow_name="test", trace_id="trace_123", group_id="456"): with custom_span(name="custom_1", span_id="span_1"): with custom_span(name="custom_2", span_id="span_1_inner"): pass @@ -111,27 +114,29 @@ def ctxmanager_spans(): def test_ctxmanager_spans() -> None: ctxmanager_spans() - spans, traces = fetch_ordered_spans(), fetch_traces() - assert len(spans) == 3 - assert len(traces) == 1 - - trace = traces[0] - standard_trace_checks(trace, name_check="test") - trace_id = trace.trace_id - - first_span = spans[0] - standard_span_checks(first_span, trace_id=trace_id, parent_id=None, span_type="custom") - assert first_span.span_id == "span_1" - - first_inner_span = spans[1] - standard_span_checks( - first_inner_span, trace_id=trace_id, parent_id=first_span.span_id, span_type="custom" + assert fetch_normalized_spans(keep_span_id=True) == snapshot( + [ + { + "workflow_name": "test", + "group_id": "456", + "children": [ + { + "type": "custom", + "id": "span_1", + "data": {"name": "custom_1", "data": {}}, + "children": [ + { + "type": "custom", + "id": "span_1_inner", + "data": {"name": "custom_2", "data": {}}, + } + ], + }, + {"type": "custom", "id": "span_2", "data": {"name": "custom_2", "data": {}}}, + ], + } + ] ) - assert first_inner_span.span_id == "span_1_inner" - - second_span = spans[2] - standard_span_checks(second_span, trace_id=trace_id, parent_id=None, span_type="custom") - assert second_span.span_id == "span_2" async def run_subtask(span_id: str | None = None) -> None: From 153f703211de54cd831cf8fddbda4417ae010e22 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 21 Mar 2025 18:14:59 +0200 Subject: [PATCH 43/65] More fetch_normalized_spans --- tests/test_tracing.py | 57 +++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 9ee7a49..43f6224 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -224,7 +224,7 @@ async def test_complex_async_tracing() -> None: def spans_with_setters(): - with trace(workflow_name="test", trace_id="123", group_id="456"): + with trace(workflow_name="test", trace_id="trace_123", group_id="456"): with agent_span(name="agent_1") as span_a: span_a.span_data.name = "agent_2" @@ -242,34 +242,33 @@ def spans_with_setters(): def test_spans_with_setters() -> None: spans_with_setters() - spans, traces = fetch_ordered_spans(), fetch_traces() - assert len(spans) == 4 - assert len(traces) == 1 - - trace = traces[0] - standard_trace_checks(trace, name_check="test") - trace_id = trace.trace_id - - # Check the spans - first_span = spans[0] - standard_span_checks(first_span, trace_id=trace_id, parent_id=None, span_type="agent") - assert first_span.span_data.name == "agent_2" - - second_span = spans[1] - standard_span_checks( - second_span, trace_id=trace_id, parent_id=first_span.span_id, span_type="function" - ) - assert second_span.span_data.input == "i" - assert second_span.span_data.output == "o" - - third_span = spans[2] - standard_span_checks( - third_span, trace_id=trace_id, parent_id=first_span.span_id, span_type="generation" - ) - - fourth_span = spans[3] - standard_span_checks( - fourth_span, trace_id=trace_id, parent_id=first_span.span_id, span_type="handoff" + assert fetch_normalized_spans() == snapshot( + [ + { + "workflow_name": "test", + "group_id": "456", + "children": [ + { + "type": "agent", + "data": {"name": "agent_2"}, + "children": [ + { + "type": "function", + "data": {"name": "function_1", "input": "i", "output": "o"}, + }, + { + "type": "generation", + "data": {"input": [{"foo": "bar"}]}, + }, + { + "type": "handoff", + "data": {"from_agent": "agent_1", "to_agent": "agent_2"}, + }, + ], + } + ], + } + ] ) From a00b61f355f09d34d4c70e8a22f4a7e3960ac70c Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 21 Mar 2025 18:15:52 +0200 Subject: [PATCH 44/65] More fetch_normalized_spans --- tests/test_tracing.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 43f6224..a4520cd 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -288,7 +288,7 @@ def test_disabled_tracing(): def enabled_trace_disabled_span(): - with trace(workflow_name="test", trace_id="123"): + with trace(workflow_name="test", trace_id="trace_123"): with agent_span(name="agent_1"): with function_span(name="function_1", disabled=True): with generation_span(): @@ -298,17 +298,19 @@ def enabled_trace_disabled_span(): def test_enabled_trace_disabled_span(): enabled_trace_disabled_span() - spans, traces = fetch_ordered_spans(), fetch_traces() - assert len(spans) == 1 # Only the agent span is recorded - assert len(traces) == 1 # The trace is recorded - - trace = traces[0] - standard_trace_checks(trace, name_check="test") - trace_id = trace.trace_id - - first_span = spans[0] - standard_span_checks(first_span, trace_id=trace_id, parent_id=None, span_type="agent") - assert first_span.span_data.name == "agent_1" + assert fetch_normalized_spans() == snapshot( + [ + { + "workflow_name": "test", + "children": [ + { + "type": "agent", + "data": {"name": "agent_1"}, + } + ], + } + ] + ) def test_start_and_end_called_manual(): From 6b509e33f6041bf11ddda12ebb744210268b1834 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 21 Mar 2025 18:26:04 +0200 Subject: [PATCH 45/65] empty assertions --- tests/test_agent_tracing.py | 12 +++++------- tests/test_responses_tracing.py | 21 ++++++++------------- tests/test_tracing.py | 10 +++------- tests/testing_processor.py | 16 ++++++++++++++-- 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/tests/test_agent_tracing.py b/tests/test_agent_tracing.py index 8318b60..489e9b6 100644 --- a/tests/test_agent_tracing.py +++ b/tests/test_agent_tracing.py @@ -9,7 +9,7 @@ from agents import Agent, RunConfig, Runner, trace from .fake_model import FakeModel from .test_responses import get_text_message -from .testing_processor import fetch_normalized_spans, fetch_traces +from .testing_processor import assert_no_traces, fetch_normalized_spans, fetch_traces @pytest.mark.asyncio @@ -164,7 +164,7 @@ async def test_parent_disabled_trace_disabled_agent_trace(): await Runner.run(agent, input="first_test") - assert fetch_normalized_spans() == snapshot([]) + assert_no_traces() @pytest.mark.asyncio @@ -178,7 +178,7 @@ async def test_manual_disabling_works(): await Runner.run(agent, input="first_test", run_config=RunConfig(tracing_disabled=True)) - assert fetch_normalized_spans() == snapshot([]) + assert_no_traces() @pytest.mark.asyncio @@ -370,8 +370,7 @@ async def test_parent_disabled_trace_disables_streaming_agent_trace(): async for _ in x.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 0, f"Expected 0 traces, got {len(traces)}" + assert_no_traces() @pytest.mark.asyncio @@ -392,5 +391,4 @@ async def test_manual_streaming_disabling_works(): async for _ in x.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 0, f"Expected 0 traces, got {len(traces)}" + assert_no_traces() diff --git a/tests/test_responses_tracing.py b/tests/test_responses_tracing.py index eda65cf..95a960f 100644 --- a/tests/test_responses_tracing.py +++ b/tests/test_responses_tracing.py @@ -7,7 +7,7 @@ from agents import ModelSettings, ModelTracing, OpenAIResponsesModel, trace from agents.tracing.span_data import ResponseSpanData from tests import fake_model -from .testing_processor import fetch_normalized_spans, fetch_ordered_spans +from .testing_processor import fetch_normalized_spans, fetch_ordered_spans, assert_no_spans class DummyTracing: @@ -89,9 +89,8 @@ async def test_non_data_tracing_doesnt_set_response_id(monkeypatch): [{"workflow_name": "test", "children": [{"type": "response"}]}] ) - spans = fetch_ordered_spans() - assert len(spans) == 1 - assert spans[0].span_data.response is None + [span] = fetch_ordered_spans() + assert span.span_data.response is None @pytest.mark.allow_call_model_methods @@ -116,9 +115,7 @@ async def test_disable_tracing_does_not_create_span(monkeypatch): assert fetch_normalized_spans() == snapshot([{"workflow_name": "test"}]) - spans = fetch_ordered_spans() - assert len(spans) == 0 - + assert_no_spans() @pytest.mark.allow_call_model_methods @pytest.mark.asyncio @@ -190,10 +187,9 @@ async def test_stream_non_data_tracing_doesnt_set_response_id(monkeypatch): [{"workflow_name": "test", "children": [{"type": "response"}]}] ) - spans = fetch_ordered_spans() - assert len(spans) == 1 - assert isinstance(spans[0].span_data, ResponseSpanData) - assert spans[0].span_data.response is None + [span] = fetch_ordered_spans() + assert isinstance(span.span_data, ResponseSpanData) + assert span.span_data.response is None @pytest.mark.allow_call_model_methods @@ -226,5 +222,4 @@ async def test_stream_disabled_tracing_doesnt_create_span(monkeypatch): assert fetch_normalized_spans() == snapshot([{"workflow_name": "test"}]) - spans = fetch_ordered_spans() - assert len(spans) == 0 + assert_no_spans() \ No newline at end of file diff --git a/tests/test_tracing.py b/tests/test_tracing.py index a4520cd..773d8b9 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -24,6 +24,7 @@ from .testing_processor import ( fetch_normalized_spans, fetch_ordered_spans, fetch_traces, + assert_no_traces, ) ### HELPERS @@ -281,10 +282,7 @@ def disabled_tracing(): def test_disabled_tracing(): disabled_tracing() - - spans, traces = fetch_ordered_spans(), fetch_traces() - assert len(spans) == 0 - assert len(traces) == 0 + assert_no_traces() def enabled_trace_disabled_span(): @@ -374,9 +372,7 @@ async def test_noop_span_doesnt_record(): with custom_span(name="span_1") as span: span.set_error(SpanError(message="test", data={})) - spans, traces = fetch_ordered_spans(), fetch_traces() - assert len(spans) == 0 - assert len(traces) == 0 + assert_no_traces() assert t.export() is None assert span.export() is None diff --git a/tests/testing_processor.py b/tests/testing_processor.py index b07dea5..a8e0d40 100644 --- a/tests/testing_processor.py +++ b/tests/testing_processor.py @@ -80,6 +80,19 @@ def fetch_events() -> list[TestSpanProcessorEvent]: return SPAN_PROCESSOR_TESTING._events +def assert_no_spans(): + spans = fetch_ordered_spans() + if spans: + raise AssertionError(f"Expected 0 spans, got {len(spans)}") + + +def assert_no_traces(): + traces = fetch_traces() + if traces: + raise AssertionError(f"Expected 0 traces, got {len(traces)}") + assert_no_spans() + + def fetch_normalized_spans(keep_span_id: bool = False): nodes: dict[tuple[str, str | None], dict[str, Any]] = {} traces = [] @@ -92,8 +105,7 @@ def fetch_normalized_spans(keep_span_id: bool = False): nodes[(trace_obj.trace_id, None)] = trace traces.append(trace) - if not traces: - assert not fetch_ordered_spans() + assert traces, "Use assert_no_traces() to check for empty traces" for span_obj in fetch_ordered_spans(): span = span_obj.export() From dacbb9ba445700901bee9136fedb9d01e32c9bc5 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 21 Mar 2025 18:31:06 +0200 Subject: [PATCH 46/65] More fetch_normalized_spans --- tests/test_agent_tracing.py | 161 ++++++++++++++++++++++++++++---- tests/test_responses_tracing.py | 5 +- tests/test_tracing.py | 4 +- tests/testing_processor.py | 8 +- 4 files changed, 154 insertions(+), 24 deletions(-) diff --git a/tests/test_agent_tracing.py b/tests/test_agent_tracing.py index 489e9b6..bb16cab 100644 --- a/tests/test_agent_tracing.py +++ b/tests/test_agent_tracing.py @@ -9,7 +9,7 @@ from agents import Agent, RunConfig, Runner, trace from .fake_model import FakeModel from .test_responses import get_text_message -from .testing_processor import assert_no_traces, fetch_normalized_spans, fetch_traces +from .testing_processor import assert_no_traces, fetch_normalized_spans @pytest.mark.asyncio @@ -193,16 +193,29 @@ async def test_trace_config_works(): await Runner.run( agent, input="first_test", - run_config=RunConfig(workflow_name="Foo bar", group_id="123", trace_id="456"), + run_config=RunConfig(workflow_name="Foo bar", group_id="123", trace_id="trace_456"), ) - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" - export = traces[0].export() - assert export is not None, "Trace export should not be None" - assert export["workflow_name"] == "Foo bar" - assert export["group_id"] == "123" - assert export["id"] == "456" + assert fetch_normalized_spans(keep_trace_id=True) == snapshot( + [ + { + "id": "trace_456", + "workflow_name": "Foo bar", + "group_id": "123", + "children": [ + { + "type": "agent", + "data": { + "name": "test_agent", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + } + ], + } + ] + ) @pytest.mark.asyncio @@ -259,8 +272,24 @@ async def test_streaming_single_run_is_single_trace(): async for _ in x.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + assert fetch_normalized_spans() == snapshot( + [ + { + "workflow_name": "Agent workflow", + "children": [ + { + "type": "agent", + "data": { + "name": "test_agent", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + } + ], + } + ] + ) @pytest.mark.asyncio @@ -285,8 +314,38 @@ async def test_multiple_streamed_runs_are_multiple_traces(): async for _ in x.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 2, f"Expected 2 traces, got {len(traces)}" + assert fetch_normalized_spans() == snapshot( + [ + { + "workflow_name": "Agent workflow", + "children": [ + { + "type": "agent", + "data": { + "name": "test_agent_1", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + } + ], + }, + { + "workflow_name": "Agent workflow", + "children": [ + { + "type": "agent", + "data": { + "name": "test_agent_1", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + } + ], + }, + ] + ) @pytest.mark.asyncio @@ -317,8 +376,42 @@ async def test_wrapped_streaming_trace_is_single_trace(): async for _ in x.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + assert fetch_normalized_spans() == snapshot( + [ + { + "workflow_name": "test_workflow", + "children": [ + { + "type": "agent", + "data": { + "name": "test_agent_1", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + }, + { + "type": "agent", + "data": { + "name": "test_agent_1", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + }, + { + "type": "agent", + "data": { + "name": "test_agent_1", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + }, + ], + } + ] + ) @pytest.mark.asyncio @@ -347,8 +440,42 @@ async def test_wrapped_mixed_trace_is_single_trace(): async for _ in x.stream_events(): pass - traces = fetch_traces() - assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + assert fetch_normalized_spans() == snapshot( + [ + { + "workflow_name": "test_workflow", + "children": [ + { + "type": "agent", + "data": { + "name": "test_agent_1", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + }, + { + "type": "agent", + "data": { + "name": "test_agent_1", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + }, + { + "type": "agent", + "data": { + "name": "test_agent_1", + "handoffs": [], + "tools": [], + "output_type": "str", + }, + }, + ], + } + ] + ) @pytest.mark.asyncio diff --git a/tests/test_responses_tracing.py b/tests/test_responses_tracing.py index 95a960f..40bdfaf 100644 --- a/tests/test_responses_tracing.py +++ b/tests/test_responses_tracing.py @@ -7,7 +7,7 @@ from agents import ModelSettings, ModelTracing, OpenAIResponsesModel, trace from agents.tracing.span_data import ResponseSpanData from tests import fake_model -from .testing_processor import fetch_normalized_spans, fetch_ordered_spans, assert_no_spans +from .testing_processor import assert_no_spans, fetch_normalized_spans, fetch_ordered_spans class DummyTracing: @@ -117,6 +117,7 @@ async def test_disable_tracing_does_not_create_span(monkeypatch): assert_no_spans() + @pytest.mark.allow_call_model_methods @pytest.mark.asyncio async def test_stream_response_creates_trace(monkeypatch): @@ -222,4 +223,4 @@ async def test_stream_disabled_tracing_doesnt_create_span(monkeypatch): assert fetch_normalized_spans() == snapshot([{"workflow_name": "test"}]) - assert_no_spans() \ No newline at end of file + assert_no_spans() diff --git a/tests/test_tracing.py b/tests/test_tracing.py index 773d8b9..8f76350 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -20,11 +20,9 @@ from agents.tracing.spans import SpanError from .testing_processor import ( SPAN_PROCESSOR_TESTING, + assert_no_traces, fetch_events, fetch_normalized_spans, - fetch_ordered_spans, - fetch_traces, - assert_no_traces, ) ### HELPERS diff --git a/tests/testing_processor.py b/tests/testing_processor.py index a8e0d40..a38c395 100644 --- a/tests/testing_processor.py +++ b/tests/testing_processor.py @@ -93,14 +93,18 @@ def assert_no_traces(): assert_no_spans() -def fetch_normalized_spans(keep_span_id: bool = False): +def fetch_normalized_spans( + keep_span_id: bool = False, keep_trace_id: bool = False +) -> list[dict[str, Any]]: nodes: dict[tuple[str, str | None], dict[str, Any]] = {} traces = [] for trace_obj in fetch_traces(): trace = trace_obj.export() assert trace assert trace.pop("object") == "trace" - assert trace.pop("id").startswith("trace_") + assert trace["id"].startswith("trace_") + if not keep_trace_id: + del trace["id"] trace = {k: v for k, v in trace.items() if v is not None} nodes[(trace_obj.trace_id, None)] = trace traces.append(trace) From c0794a90eca9ccece51b1cb4a8d8161675a71dfc Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Fri, 21 Mar 2025 14:33:27 -0400 Subject: [PATCH 47/65] Read tracing API data lazily --- src/agents/tracing/processors.py | 21 ++++++++++++++++--- tests/tracing/test_processor_api_key.py | 27 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 tests/tracing/test_processor_api_key.py diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 1b39ded..29eb0f5 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -5,6 +5,7 @@ import queue import random import threading import time +from functools import cached_property from typing import Any import httpx @@ -50,9 +51,9 @@ class BackendSpanExporter(TracingExporter): base_delay: Base delay (in seconds) for the first backoff. max_delay: Maximum delay (in seconds) for backoff growth. """ - self.api_key = api_key or os.environ.get("OPENAI_API_KEY") - self.organization = organization or os.environ.get("OPENAI_ORG_ID") - self.project = project or os.environ.get("OPENAI_PROJECT_ID") + self._api_key = api_key + self._organization = organization + self._project = project self.endpoint = endpoint self.max_retries = max_retries self.base_delay = base_delay @@ -68,8 +69,22 @@ class BackendSpanExporter(TracingExporter): api_key: The OpenAI API key to use. This is the same key used by the OpenAI Python client. """ + # We're specifically setting the underlying cached property as well + self._api_key = api_key self.api_key = api_key + @cached_property + def api_key(self): + return self._api_key or os.environ.get("OPENAI_API_KEY") + + @cached_property + def organization(self): + return self._organization or os.environ.get("OPENAI_ORG_ID") + + @cached_property + def project(self): + return self._project or os.environ.get("OPENAI_PROJECT_ID") + def export(self, items: list[Trace | Span[Any]]) -> None: if not items: return diff --git a/tests/tracing/test_processor_api_key.py b/tests/tracing/test_processor_api_key.py new file mode 100644 index 0000000..b0a0218 --- /dev/null +++ b/tests/tracing/test_processor_api_key.py @@ -0,0 +1,27 @@ +import pytest + +from agents.tracing.processors import BackendSpanExporter + + +@pytest.mark.asyncio +async def test_processor_api_key(monkeypatch): + # If the API key is not set, it should be None + monkeypatch.delenv("OPENAI_API_KEY", None) + processor = BackendSpanExporter() + assert processor.api_key is None + + # If we set it afterwards, it should be the new value + processor.set_api_key("test_api_key") + assert processor.api_key == "test_api_key" + + +@pytest.mark.asyncio +async def test_processor_api_key_from_env(monkeypatch): + # If the API key is not set at creation time but set before access time, it should be the new + # value + monkeypatch.delenv("OPENAI_API_KEY", None) + processor = BackendSpanExporter() + + # If we set it afterwards, it should be the new value + monkeypatch.setenv("OPENAI_API_KEY", "foo_bar_123") + assert processor.api_key == "foo_bar_123" From fa1c3f40a145398ca681d7bcbbffbc1fdf50e5d3 Mon Sep 17 00:00:00 2001 From: James Hills <70035505+jhills20@users.noreply.github.com> Date: Fri, 21 Mar 2025 15:58:34 -0400 Subject: [PATCH 48/65] fix line in guardrails --- docs/guardrails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guardrails.md b/docs/guardrails.md index 70d9649..2f0be0f 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -29,7 +29,7 @@ Output guardrails run in 3 steps: !!! Note - Output guardrails are intended to run on the final agent input, so an agent's guardrails only run if the agent is the *last* agent. Similar to the input guardrails, we do this because guardrails tend to be related to the actual Agent - you'd run different guardrails for different agents, so colocating the code is useful for readability. + Output guardrails are intended to run on the final agent output, so an agent's guardrails only run if the agent is the *last* agent. Similar to the input guardrails, we do this because guardrails tend to be related to the actual Agent - you'd run different guardrails for different agents, so colocating the code is useful for readability. ## Tripwires From 7432347a941d46758fc6692d4b4b0df190958d99 Mon Sep 17 00:00:00 2001 From: Aviral Garg Date: Fri, 21 Mar 2025 13:25:46 -0700 Subject: [PATCH 49/65] Fix circular dependency in voice streamed example by renaming agents.py to my_workflow.py --- examples/voice/streamed/main.py | 8 +++++++- examples/voice/streamed/{agents.py => my_workflow.py} | 0 2 files changed, 7 insertions(+), 1 deletion(-) rename examples/voice/streamed/{agents.py => my_workflow.py} (100%) diff --git a/examples/voice/streamed/main.py b/examples/voice/streamed/main.py index aef3b36..c429890 100644 --- a/examples/voice/streamed/main.py +++ b/examples/voice/streamed/main.py @@ -13,7 +13,13 @@ from typing_extensions import override from agents.voice import StreamedAudioInput, VoicePipeline -from .agents import MyWorkflow +# Use absolute import when running as script directly +try: + # First try relative import (for package use) + from .my_workflow import MyWorkflow +except ImportError: + # Fall back to direct import (for script use) + from my_workflow import MyWorkflow CHUNK_LENGTH_S = 0.05 # 100ms SAMPLE_RATE = 24000 diff --git a/examples/voice/streamed/agents.py b/examples/voice/streamed/my_workflow.py similarity index 100% rename from examples/voice/streamed/agents.py rename to examples/voice/streamed/my_workflow.py From d56047be51a949d1ee8c3bb9b0bd0e6e7b089324 Mon Sep 17 00:00:00 2001 From: Han Hwang Lim Date: Fri, 21 Mar 2025 21:10:30 +0000 Subject: [PATCH 50/65] fix annotation numbering in context management Fix inconsistent numbering between code and explanatory annotations in the context management documentation. --- docs/context.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/context.md b/docs/context.md index 69c43fb..4176ec5 100644 --- a/docs/context.md +++ b/docs/context.md @@ -41,14 +41,14 @@ async def fetch_user_age(wrapper: RunContextWrapper[UserInfo]) -> str: # (2)! return f"User {wrapper.context.name} is 47 years old" async def main(): - user_info = UserInfo(name="John", uid=123) # (3)! + user_info = UserInfo(name="John", uid=123) - agent = Agent[UserInfo]( # (4)! + agent = Agent[UserInfo]( # (3)! name="Assistant", tools=[fetch_user_age], ) - result = await Runner.run( + result = await Runner.run( # (4)! starting_agent=agent, input="What is the age of the user?", context=user_info, From 13eca63732ec8a4656421c24cca9fbc71812cb44 Mon Sep 17 00:00:00 2001 From: Scott Condron Date: Fri, 21 Mar 2025 21:37:54 +0000 Subject: [PATCH 51/65] Add Weights & Biases to tracing docs --- docs/tracing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tracing.md b/docs/tracing.md index 5ecb45d..8c68c20 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -99,6 +99,7 @@ To customize this default setup, to send traces to alternative or additional bac ## External tracing processors list +- [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) - [Braintrust](https://braintrust.dev/docs/guides/traces/integrations#openai-agents-sdk) From 9473c788bad6f3bfb2ca88e03a1493e31a238ed8 Mon Sep 17 00:00:00 2001 From: Aviral Garg Date: Fri, 21 Mar 2025 16:18:04 -0700 Subject: [PATCH 52/65] Fix type-checking for circular dependency in voice streamed example --- examples/voice/streamed/main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/voice/streamed/main.py b/examples/voice/streamed/main.py index c429890..230738c 100644 --- a/examples/voice/streamed/main.py +++ b/examples/voice/streamed/main.py @@ -13,13 +13,13 @@ from typing_extensions import override from agents.voice import StreamedAudioInput, VoicePipeline -# Use absolute import when running as script directly -try: - # First try relative import (for package use) +# Use absolute or relative import based on context +if __name__ == "__main__": + # When running as script, use absolute import + from my_workflow import MyWorkflow # type: ignore +else: + # When imported as module, use relative import from .my_workflow import MyWorkflow -except ImportError: - # Fall back to direct import (for script use) - from my_workflow import MyWorkflow CHUNK_LENGTH_S = 0.05 # 100ms SAMPLE_RATE = 24000 From fdf340495bf3e444e150ac069537acb1a1d9f659 Mon Sep 17 00:00:00 2001 From: Aviral Garg Date: Fri, 21 Mar 2025 16:26:19 -0700 Subject: [PATCH 53/65] Fix circular dependency in voice streamed example with cleaner import pattern --- examples/voice/streamed/main.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/examples/voice/streamed/main.py b/examples/voice/streamed/main.py index 230738c..95e9379 100644 --- a/examples/voice/streamed/main.py +++ b/examples/voice/streamed/main.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from typing import TYPE_CHECKING import numpy as np import sounddevice as sd @@ -13,13 +14,18 @@ from typing_extensions import override from agents.voice import StreamedAudioInput, VoicePipeline -# Use absolute or relative import based on context -if __name__ == "__main__": - # When running as script, use absolute import - from my_workflow import MyWorkflow # type: ignore -else: - # When imported as module, use relative import +# Import MyWorkflow class - handle both module and package use cases +if TYPE_CHECKING: + # For type checking, use the relative import from .my_workflow import MyWorkflow +else: + # At runtime, try both import styles + try: + # Try relative import first (when used as a package) + from .my_workflow import MyWorkflow + except ImportError: + # Fall back to direct import (when run as a script) + from my_workflow import MyWorkflow CHUNK_LENGTH_S = 0.05 # 100ms SAMPLE_RATE = 24000 From ab0d940f19fa9657999b8089ae14371c74fa2e4a Mon Sep 17 00:00:00 2001 From: Raduan77 Date: Sat, 22 Mar 2025 01:06:01 +0100 Subject: [PATCH 54/65] revert src/ change per request --- src/agents/_run_impl.py | 2 +- src/agents/stream_events.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py index ad722dc..2849538 100644 --- a/src/agents/_run_impl.py +++ b/src/agents/_run_impl.py @@ -670,7 +670,7 @@ class RunImpl: elif isinstance(item, HandoffCallItem): event = RunItemStreamEvent(item=item, name="handoff_requested") elif isinstance(item, HandoffOutputItem): - event = RunItemStreamEvent(item=item, name="handoff_occurred") + event = RunItemStreamEvent(item=item, name="handoff_occured") elif isinstance(item, ToolCallItem): event = RunItemStreamEvent(item=item, name="tool_called") elif isinstance(item, ToolCallOutputItem): diff --git a/src/agents/stream_events.py b/src/agents/stream_events.py index eff345b..bd37d11 100644 --- a/src/agents/stream_events.py +++ b/src/agents/stream_events.py @@ -31,7 +31,7 @@ class RunItemStreamEvent: name: Literal[ "message_output_created", "handoff_requested", - "handoff_occurred", + "handoff_occured", "tool_called", "tool_output", "reasoning_item_created", From bbcda753df84b008d79b1107ca5c7c2f908da206 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sat, 22 Mar 2025 14:10:09 +0800 Subject: [PATCH 55/65] 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 a60ae1d..1f896d7 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 e01a5f0..b47c4d9 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 8f2f76cb65b340a0371021f98a3bf9276a5b8cf1 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sat, 22 Mar 2025 14:22:47 +0800 Subject: [PATCH 56/65] 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 c9c39ae..1e04f7e 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 6ed0bee67242032f2a82c3ca133e6571e2a73866 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sun, 23 Mar 2025 17:20:23 +0800 Subject: [PATCH 57/65] 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 1f896d7..1370462 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 b47c4d9..7dae6f6 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 0c747af743ffd7cc4128e94e84496afad7743360 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sun, 23 Mar 2025 17:29:48 +0800 Subject: [PATCH 58/65] 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 1370462..0272520 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 07a4af1fe20c7068df59529b9c4f40316a9b1930 Mon Sep 17 00:00:00 2001 From: xianghuijin Date: Sun, 23 Mar 2025 20:41:18 +0800 Subject: [PATCH 59/65] 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 0272520..df3db42 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 ca49991fe8edbc379bc3b101e55a8620f437ed64 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Sun, 23 Mar 2025 12:19:07 -0400 Subject: [PATCH 60/65] Update issues.yml --- .github/workflows/issues.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml index fd8f5c1..6447f83 100644 --- a/.github/workflows/issues.yml +++ b/.github/workflows/issues.yml @@ -17,7 +17,10 @@ jobs: stale-issue-label: "stale" stale-issue-message: "This issue is stale because it has been open for 7 days with no activity." close-issue-message: "This issue was closed because it has been inactive for 3 days since being marked as stale." - days-before-pr-stale: -1 - days-before-pr-close: -1 - any-of-labels: 'question,needs-more-info' + any-of-issue-labels: 'question,needs-more-info' + days-before-pr-stale: 10 + days-before-pr-close: 7 + stale-pr-label: "stale" + stale-pr-message: "This PR is stale because it has been open for 10 days with no activity." + close-pr-message: "This PR was closed because it has been inactive for 7 days since being marked as stale." repo-token: ${{ secrets.GITHUB_TOKEN }} From 791a6f6812f6c4f8b0febd03183323e243321783 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Sun, 23 Mar 2025 17:56:55 -0400 Subject: [PATCH 61/65] Update quickstart.md --- docs/voice/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/voice/quickstart.md b/docs/voice/quickstart.md index a9513f4..896ffe8 100644 --- a/docs/voice/quickstart.md +++ b/docs/voice/quickstart.md @@ -104,7 +104,7 @@ from agents.voice import AudioInput # For simplicity, we'll just create 3 seconds of silence # In reality, you'd get microphone data -audio = np.zeros(24000 * 3, dtype=np.int16) +buffer = np.zeros(24000 * 3, dtype=np.int16) audio_input = AudioInput(buffer=buffer) result = await pipeline.run(audio_input) From 668fac0f74443a6e6d38d37a79e003e8ff2db90d Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Sun, 23 Mar 2025 18:14:10 -0400 Subject: [PATCH 62/65] Improve tracing error messages --- src/agents/tracing/processors.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 29eb0f5..5d8f4d8 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -117,18 +117,22 @@ class BackendSpanExporter(TracingExporter): # If the response is a client error (4xx), we wont retry if 400 <= response.status_code < 500: - logger.error(f"Tracing client error {response.status_code}: {response.text}") + logger.error( + f"[non-fatal] Tracing client error {response.status_code}: {response.text}" + ) return # For 5xx or other unexpected codes, treat it as transient and retry - logger.warning(f"Server error {response.status_code}, retrying.") + logger.warning( + f"[non-fatal] Tracing: server error {response.status_code}, retrying." + ) except httpx.RequestError as exc: # Network or other I/O error, we'll retry - logger.warning(f"Request failed: {exc}") + logger.warning(f"[non-fatal] Tracing: request failed: {exc}") # If we reach here, we need to retry or give up if attempt >= self.max_retries: - logger.error("Max retries reached, giving up on this batch.") + logger.error("[non-fatal] Tracing: max retries reached, giving up on this batch.") return # Exponential backoff + jitter From e96e364f08c47defe8ad23e782e8c4105abf138e Mon Sep 17 00:00:00 2001 From: madroid Date: Mon, 24 Mar 2025 18:30:14 +0800 Subject: [PATCH 63/65] chore: ignore PyCharm .idea/ directory Uncomment .idea/ directory in .gitignore to ensure PyCharm IDE project configuration files are excluded from version control. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1def8a6..2ab5a81 100644 --- a/.gitignore +++ b/.gitignore @@ -135,7 +135,7 @@ dmypy.json cython_debug/ # PyCharm -#.idea/ +.idea/ # Ruff stuff: .ruff_cache/ From a7a6fe715f55a578480116bac12dcd7ea46b4a53 Mon Sep 17 00:00:00 2001 From: Rohan Mehta Date: Mon, 24 Mar 2025 15:07:32 -0400 Subject: [PATCH 64/65] [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 c9a56e8..4f93980 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 f6b779e..accf2d9 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 326ff09127a80af934542e4dddca699408f51918 Mon Sep 17 00:00:00 2001 From: apeccaud Date: Tue, 25 Mar 2025 14:52:48 +0000 Subject: [PATCH 65/65] 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 3eea39c..b954712 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)