commit aaec57a426b35079480d96cef473a7494c95b0ad Author: Rohan Mehta Date: Tue Mar 11 09:42:28 2025 -0700 Initial commit diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..bf01524 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,26 @@ +name: Deploy docs + +on: + workflow_run: + workflows: ["Tests"] + types: + - completed + +permissions: + contents: write # This allows pushing to gh-pages + +jobs: + deploy_docs: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: make sync + - name: Deploy docs + run: make deploy-docs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5d8f8a6 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,35 @@ +name: Publish to PyPI + +on: + release: + types: + - created + +jobs: + publish: + environment: + name: pypi + url: https://pypi.org/p/openai-agents + permissions: + id-token: write # Important for trusted publishing to PyPI + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: make sync + - name: Run tests + run: make tests + - name: Run mypy + run: make mypy + - name: Run Python 3.9 tests + run: make old_version_tests + - name: Build package + run: uv build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6dce5c8 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,86 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: make sync + - name: Run lint + run: make lint + + typecheck: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: make sync + - name: Run typecheck + run: make mypy + + tests: + runs-on: ubuntu-latest + env: + OPENAI_API_KEY: fake-for-tests + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: make sync + - name: Run tests + run: make tests + + build-docs: + runs-on: ubuntu-latest + env: + OPENAI_API_KEY: fake-for-tests + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: make sync + - name: Build docs + run: make build-docs + + old_versions: + runs-on: ubuntu-latest + env: + OPENAI_API_KEY: fake-for-tests + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Install dependencies + run: make sync + - name: Run tests + run: make old_version_tests diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..32ab3e7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 4, + "overrides": [ + { + "files": "*.yml", + "options": { + "tabWidth": 2 + } + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e5ad2c5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7dd9bbd --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +.PHONY: sync +sync: + uv sync --all-extras --all-packages --group dev + +.PHONY: format +format: + uv run ruff format + +.PHONY: lint +lint: + uv run ruff check + +.PHONY: mypy +mypy: + uv run mypy . + +.PHONY: tests +tests: + uv run pytest + +.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: + uv run mkdocs build + +.PHONY: serve-docs +serve-docs: + uv run mkdocs serve + +.PHONY: deploy-docs +deploy-docs: + uv run mkdocs gh-deploy --force --verbose + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8acd13c --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# OpenAI Agents SDK + +The OpenAI Agents SDK is a lightweight yet powerful framework for building multi-agent workflows. + +Image of the Agents Tracing UI + +### Core concepts: + +1. [**Agents**](docs/agents.md): LLMs configured with instructions, tools, guardrails, and handoffs +2. [**Handoffs**](docs/handoffs.md): Allow agents to transfer control to other agents for specific tasks +3. [**Guardrails**](docs/guardrails.md): Configurable safety checks for input and output validation +4. [**Tracing**](docs/tracing.md): Built-in tracking of agent runs, allowing you to view, debug and optimize your workflows + +Explore the [examples](examples) directory to see the SDK in action. + +## Get started + +1. Set up your Python environment + +``` +python -m venv env +source env/bin/activate +``` + +2. Install Agents SDK + +``` +pip install openai-agents +``` + +## Hello world example + +```python +from agents import Agent, Runner + +agent = Agent(name="Assistant", instructions="You are a helpful assistant") + +result = Runner.run_sync(agent, "Write a haiku about recursion in programming.") +print(result.final_output) + +# Code within the code, +# Functions calling themselves, +# Infinite loop's dance. +``` + +(_If running this, ensure you set the `OPENAI_API_KEY` environment variable_) + +## Handoffs example + +```py +from agents import Agent, Runner +import asyncio + +spanish_agent = Agent( + name="Spanish agent", + instructions="You only speak Spanish.", +) + +english_agent = Agent( + name="English agent", + instructions="You only speak English", +) + +triage_agent = Agent( + name="Triage agent", + instructions="Handoff to the appropriate agent based on the language of the request.", + handoffs=[spanish_agent, english_agent], +) + + +async def main(): + result = await Runner.run(triage_agent, input="Hola, ¿cómo estás?") + print(result.final_output) + # ¡Hola! Estoy bien, gracias por preguntar. ¿Y tú, cómo estás? + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Functions example + +```python +import asyncio + +from agents import Agent, Runner, function_tool + + +@function_tool +def get_weather(city: str) -> str: + return f"The weather in {city} is sunny." + + +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()) +``` + +## The agent loop + +When you call `Runner.run()`, we run a loop until we get a final output. + +1. We call the LLM, using the model and settings on the agent, and the message history. +2. The LLM returns a response, which may include tool calls. +3. If the response has a final output (see below for the more on this), we return it and end the loop. +4. If the response has a handoff, we set the agent to the new agent and go back to step 1. +5. We process the tool calls (if any) and append the tool responses messsages. Then we go to step 1. + +There is a `max_turns` parameter that you can use to limit the number of times the loop executes. + +### Final output + +Final output is the last thing the agent produces in the loop. + +1. If you set an `output_type` on the agent, the final output is when the LLM returns something of that type. We use [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) for this. +2. If there's no `output_type` (i.e. plain text responses), then the first LLM response without any tool calls or handoffs is considered as the final output. + +As a result, the mental model for the agent loop is: + +1. If the current agent has an `output_type`, the loop runs until the agent produces structured output matching that type. +2. If the current agent does not have an `output_type`, the loop runs until the current agent produces a message without any tool calls/handoffs. + +## Common agent patterns + +The Agents SDK is designed to be highly flexible, allowing you to model a wide range of LLM workflows including deterministic flows, iterative loops, and more. See examples in [`examples/agent_patterns`](examples/agent_patterns). + +## Tracing + +The Agents SDK includes built-in tracing, 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). See [Tracing](http://openai.github.io/openai-agents-python/tracing.md) for more details. + +## Development (only needed if you need to edit the SDK/examples) + +0. Ensure you have [`uv`](https://docs.astral.sh/uv/) installed. + +```bash +uv --version +``` + +1. Install dependencies + +```bash +make sync +``` + +2. (After making changes) lint/test + +``` +make tests # run tests +make mypy # run typechecker +make lint # run linter +``` + +## Acknowledgements + +We'd like to acknowledge the excellent work of the open-source community, especially: + +- [Pydantic](https://docs.pydantic.dev/latest/) (data validation) and [PydanticAI](https://ai.pydantic.dev/) (advanced agent framework) +- [MkDocs](https://github.com/squidfunk/mkdocs-material) +- [Griffe](https://github.com/mkdocstrings/griffe) +- [uv](https://github.com/astral-sh/uv) and [ruff](https://github.com/astral-sh/ruff) + +We're committed to continuing to build the Agents SDK as an open source framework so others in the community can expand on our approach. diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..9b6264b --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,131 @@ +# Agents + +Agents are the core building block in your apps. An agent is a large language model (LLM), configured with instructions and tools. + +## Basic configuration + +The most common properties of an agent you'll configure are: + +- `instructions`: also known as a developer message or system prompt. +- `model`: which LLM to use, and optional `model_settings` to configure model tuning parameters like temperature, top_p, etc. +- `tools`: Tools that the agent can use to achieve its tasks. + +```python +from agents import Agent, ModelSettings, function_tool + +def get_weather(city: str) -> str: + return f"The weather in {city} is sunny" + +agent = Agent( + name="Haiku agent", + instructions="Always respond in haiku form", + model="o3-mini", + tools=[function_tool(get_weather)], +) +``` + +## Context + +Agents are generic on their `context` type. Context is a dependency-injection tool: it's an object you create and pass to `Runner.run()`, that is passed to every agent, tool, handoff etc, and it serves as a grab bag of dependencies and state for the agent run. You can provide any Python object as the context. + +```python +@dataclass +class UserContext: + uid: str + is_pro_user: bool + + async def fetch_purchases() -> list[Purchase]: + return ... + +agent = Agent[UserContext]( + ..., +) +``` + +## Output types + +By default, agents produce plain text (i.e. `str`) outputs. If you want the agent to produce a particular type of output, you can use the `output_type` parameter. A common choice is to use [Pydantic](https://docs.pydantic.dev/) objects, but we support any type that can be wrapped in a Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) - dataclasses, lists, TypedDict, etc. + +```python +from pydantic import BaseModel +from agents import Agent + + +class CalendarEvent(BaseModel): + name: str + date: str + participants: list[str] + +agent = Agent( + name="Calendar extractor", + instructions="Extract calendar events from text", + output_type=CalendarEvent, +) +``` + +!!! note + + When you pass an `output_type`, that tells the model to use [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) instead of regular plain text responses. + +## Handoffs + +Handoffs are sub-agents that the agent can delegate to. You provide a list of handoffs, and the agent can choose to delegate to them if relevant. This is a powerful pattern that allows orchestrating modular, specialized agents that excel at a single task. Read more in the [handoffs](handoffs.md) documentation. + +```python +from agents import Agent + +booking_agent = Agent(...) +refund_agent = Agent(...) + +triage_agent = Agent( + name="Triage agent", + instructions=( + "Help the user with their questions." + "If they ask about booking, handoff to the booking agent." + "If they ask about refunds, handoff to the refund agent." + ), + handoffs=[booking_agent, refund_agent], +) +``` + +## Dynamic instructions + +In most cases, you can provide instructions when you create the agent. However, you can also provide dynamic instructions via a function. The function will receive the agent and context, and must return the prompt. Both regular and `async` functions are accepted. + +```python +def dynamic_instructions( + context: RunContextWrapper[UserContext], agent: Agent[UserContext] +) -> str: + return f"The user's name is {context.context.name}. Help them with their questions." + + +agent = Agent[UserContext]( + name="Triage agent", + instructions=dynamic_instructions, +) +``` + +## Lifecycle events (hooks) + +Sometimes, you want to observe the lifecycle of an agent. For example, you may want to log events, or pre-fetch data when certain events occur. You can hook into the agent lifecycle with the `hooks` property. Subclass the [`AgentHooks`][agents.lifecycle.AgentHooks] class, and override the methods you're interested in. + +## Guardrails + +Guardrails allow you to run checks/validations on user input, in parallel to the agent running. For example, you could screen the user's input for relevance. Read more in the [guardrails](guardrails.md) documentation. + +## Cloning/copying agents + +By using the `clone()` method on an agent, you can duplicate an Agent, and optionally change any properties you like. + +```python +pirate_agent = Agent( + name="Pirate", + instructions="Write like a pirate", + model="o3-mini", +) + +robot_agent = pirate_agent.clone( + name="Robot", + instructions="Write like a robot", +) +``` diff --git a/docs/assets/images/favicon-platform.svg b/docs/assets/images/favicon-platform.svg new file mode 100644 index 0000000..91ef0ae --- /dev/null +++ b/docs/assets/images/favicon-platform.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/orchestration.png b/docs/assets/images/orchestration.png new file mode 100644 index 0000000..621a833 Binary files /dev/null and b/docs/assets/images/orchestration.png differ diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg new file mode 100644 index 0000000..ba36fc2 --- /dev/null +++ b/docs/assets/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..198d7b7 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,94 @@ +# Configuring the SDK + +## API keys and clients + +By default, the SDK looks for the `OPENAI_API_KEY` environment variable for LLM requests and tracing, as soon as it is imported. If you are unable to set that environment variable before your app starts, you can use the [set_default_openai_key()][agents.set_default_openai_key] function to set the key. + +```python +from agents import set_default_openai_key + +set_default_openai_key("sk-...") +``` + +Alternatively, you can also configure an OpenAI client to be used. By default, the SDK creates an `AsyncOpenAI` instance, using the API key from the environment variable or the default key set above. You can chnage this by using the [set_default_openai_client()][agents.set_default_openai_client] function. + +```python +from openai import AsyncOpenAI +from agents import set_default_openai_client + +custom_client = AsyncOpenAI(base_url="...", api_key="...") +set_default_openai_client(client) +``` + +Finally, you can also customize the OpenAI API that is used. By default, we use the OpenAI Responses API. You can override this to use the Chat Completions API by using the [set_default_openai_api()][agents.set_default_openai_api] function. + +```python +from agents import set_default_openai_api + +set_default_openai_api("chat_completions") +``` + +## Tracing + +Tracing is enabled by default. It uses the OpenAI API keys from the section above by default (i.e. the environment variable or the default key you set). You can specifically set the API key used for tracing by using the [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] function. + +```python +from agents import set_tracing_export_api_key + +set_tracing_export_api_key("sk-...") +``` + +You can also disable tracing entirely by using the [`set_tracing_disabled()`][agents.set_tracing_disabled] function. + +```python +from agents import set_tracing_disabled + +set_tracing_disabled(True) +``` + +## Debug logging + +The SDK has two Python loggers without any handlers set. By default, this means that warnings and errors are sent to `stdout`, but other logs are suppressed. + +To enable verbose logging, use the [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] function. + +```python +from agents import enable_verbose_stdout_logging + +enable_verbose_stdout_logging() +``` + +Alternatively, you can customize the logs by adding handlers, filters, formatters, etc. You can read more in the [Python logging guide](https://docs.python.org/3/howto/logging.html). + +```python +import logging + +logger = logging.getLogger("openai.agents") # or openai.agents.tracing for the Tracing logger + +# To make all logs show up +logger.setLevel(logging.DEBUG) +# To make info and above show up +logger.setLevel(logging.INFO) +# To make warning and above show up +logger.setLevel(logging.WARNING) +# etc + +# You can customize this as needed, but this will output to `stderr` by default +logger.addHandler(logging.StreamHandler()) +``` + +### Sensitive data in logs + +Certain logs may contain sensitive data (for example, user data). If you want to disable this data from being logged, set the following environment variables. + +To disable logging LLM inputs and outputs: + +```bash +export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 +``` + +To disable logging tool inputs and outputs: + +```bash +export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 +``` diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 0000000..5dcaceb --- /dev/null +++ b/docs/context.md @@ -0,0 +1,76 @@ +# Context management + +Context is an overloaded term. There are two main classes of context you might care about: + +1. Context available locally to your code: this is data and dependencies you might need when tool functions run, during callbacks like `on_handoff`, in lifecycle hooks, etc. +2. Context available to LLMs: this is data the LLM sees when generating a response. + +## Local context + +This is represented via the [`RunContextWrapper`][agents.run_context.RunContextWrapper] class and the [`context`][agents.run_context.RunContextWrapper.context] property within it. The way this works is: + +1. You create any Python object you want. A common pattern is to use a dataclass or a Pydantic object. +2. You pass that object to the various run methods (e.g. `Runner.run(..., **context=whatever**))`. +3. All your tool calls, lifecycle hooks etc will be passed a wrapper object, `RunContextWrapper[T]`, where `T` represents your context object type which you can access via `wrapper.context`. + +The **most important** thing to be aware of: every agent, tool function, lifecycle etc for a given agent run must use the same _type_ of context. + +You can use the context for things like: + +- Contextual data for your run (e.g. things like a username/uid or other information about the user) +- Dependencies (e.g. logger objects, data fetchers, etc) +- Helper functions + +!!! danger "Note" + + The context object is **not** sent to the LLM. It is purely a local object that you can read from, write to and call methods on it. + +```python +import asyncio +from dataclasses import dataclass + +from agents import Agent, RunContextWrapper, Runner, function_tool + +@dataclass +class UserInfo: # (1)! + name: str + uid: int + +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)! + + agent = Agent[UserInfo]( # (4)! + name="Assistant", + tools=[function_tool(fetch_user_age)], + ) + + result = await Runner.run( + starting_agent=agent, + input="What is the age of the user?", + context=user_info, + ) + + print(result.final_output) # (5)! + # The user John is 47 years old. + +if __name__ == "__main__": + asyncio.run(main()) +``` + +1. This is the context object. We've used a dataclass here, but you can use any type. +2. This is a tool. You can see it takes a `RunContextWrapper[UserInfo]`. The tool implementation reads from the context. +3. We mark the agent with the generic `UserInfo`, so that the typechecker can catch errors (for example, if we tried to pass a tool that took a different context type). +4. The context is passed to the `run` function. +5. The agent correctly calls the tool and gets the age. + +## Agent/LLM context + +When an LLM is called, the **only** data it can see is from the conversation history. This means that if you want to make some new data available to the LLM, you must do it in a way that makes it available in that history. There are a few ways to do this: + +1. You can add it to the Agent `instructions`. This is also known as a "system prompt" or "developer message". System prompts can be static strings, or they can be dynamic functions that receive the context and output a string. This is a common tactic for information that is always useful (for example, the user's name or the current date). +2. Add it to the `input` when calling the `Runner.run` functions. This is similar to the `instructions` tactic, but allows you to have messages that are lower in the [chain of command](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command). +3. Expose it via function tools. This is useful for _on-demand_ context - the LLM decides when it needs some data, and can call the tool to fetch that data. +4. Use retrieval or web search. These are special tools that are able to fetch relevant data from files or databases (retrieval), or from the web (web search). This is useful for "grounding" the response in relevant contextual data. diff --git a/docs/guardrails.md b/docs/guardrails.md new file mode 100644 index 0000000..2b7369c --- /dev/null +++ b/docs/guardrails.md @@ -0,0 +1,154 @@ +# Guardrails + +Guardrails run _in parallel_ to your agents, enabling you to do checks and validations of user input. For example, imagine you have an agent that uses a very smart (and hence slow/expensive) model to help with customer requests. You wouldn't want malicious users to ask the model to help them with their math homework. So, you can run a guardrail with a fast/cheap model. If the guardrail detects malicious usage, it can immediately raise an error, which stops the expensive model from running and saves you time/money. + +There are two kinds of guardrails: + +1. Input guardrails run on the initial user input +2. Output guardrails run on the final agent output + +## Input guardrails + +Input guardrails run in 3 steps: + +1. First, the guardrail receives the same input passed to the agent. +2. Next, the guardrail function runs to produce a [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput], which is then wrapped in an [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] +3. Finally, we check if [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] is true. If true, an [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] exception is raised, so you can appropriately respond to the user or handle the exception. + +!!! Note + + Input guardrails are intended to run on user input, so an agent's guardrails only run if the agent is the *first* agent. You might wonder, why is the `guardrails` property on the agent instead of passed to `Runner.run`? It's 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 + +Output guardrailas run in 3 steps: + +1. First, the guardrail receives the same input passed to the agent. +2. Next, the guardrail function runs to produce a [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput], which is then wrapped in an [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] +3. Finally, we check if [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] is true. If true, an [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] exception is raised, so you can appropriately respond to the user or handle the exception. + +!!! 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. + +## Tripwires + +If the input or output fails the guardrail, the Guardrail can signal this with a tripwire. As soon as we see a guardail that has triggered the tripwires, we immediately raise a `{Input,Output}GuardrailTripwireTriggered` exception and halt the Agent execution. + +## Implementing a guardrail + +You need to provide a function that receives input, and returns a [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]. In this example, we'll do this by running an Agent under the hood. + +```python +from pydantic import BaseModel +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + TResponseInputItem, + input_guardrail, +) + +class MathHomeworkOutput(BaseModel): + is_math_homework: bool + reasoning: str + +guardrail_agent = Agent( # (1)! + name="Guardrail check", + instructions="Check if the user is asking you to do their math homework.", + output_type=MathHomeworkOutput, +) + + +@input_guardrail +async def math_guardrail( # (2)! + ctx: RunContextWrapper[None], agent: Agent, input: str | list[TResponseInputItem] +) -> GuardrailFunctionOutput: + result = await Runner.run(guardrail_agent, input, context=ctx.context) + + return GuardrailFunctionOutput( + output_info=result.final_output, # (3)! + tripwire_triggered=result.final_output.is_math_homework, + ) + + +agent = Agent( # (4)! + name="Customer support agent", + instructions="You are a customer support agent. You help customers with their questions.", + input_guardrails=[math_guardrail], +) + +async def main(): + # This should trip the guardrail + try: + await Runner.run(agent, "Hello, can you help me solve for x: 2x + 3 = 11?") + print("Guardrail didn't trip - this is unexpected") + + except InputGuardrailTripwireTriggered: + print("Math homework guardrail tripped") +``` + +1. We'll use this agent in our guardrail function. +2. This is the guardrail function that receives the agent's input/context, and returns the result. +3. We can include extra information in the guardrail result. +4. This is the actual agent that defines the workflow. + +Output guardrails are similar. + +```python +from pydantic import BaseModel +from agents import ( + Agent, + GuardrailFunctionOutput, + OutputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + output_guardrail, +) +class MessageOutput(BaseModel): # (1)! + response: str + +class MathOutput(BaseModel): # (2)! + is_math: bool + reasoning: str + +guardrail_agent = Agent( + name="Guardrail check", + instructions="Check if the output includes any math.", + output_type=MathOutput, +) + +@output_guardrail +async def math_guardrail( # (3)! + ctx: RunContextWrapper, agent: Agent, output: MessageOutput +) -> GuardrailFunctionOutput: + result = await Runner.run(guardrail_agent, output.response, context=ctx.context) + + return GuardrailFunctionOutput( + output_info=result.final_output, + tripwire_triggered=result.final_output.is_math, + ) + +agent = Agent( # (4)! + name="Customer support agent", + instructions="You are a customer support agent. You help customers with their questions.", + output_guardrails=[math_guardrail], + output_type=MessageOutput, +) + +async def main(): + # This should trip the guardrail + try: + await Runner.run(agent, "Hello, can you help me solve for x: 2x + 3 = 11?") + print("Guardrail didn't trip - this is unexpected") + + except OutputGuardrailTripwireTriggered: + print("Math output guardrail tripped") +``` + +1. This is the actual agent's output type. +2. This is the guardrail's output type. +3. This is the guardrail function that receives the agent's output, and returns the result. +4. This is the actual agent that defines the workflow. diff --git a/docs/handoffs.md b/docs/handoffs.md new file mode 100644 index 0000000..0b868c4 --- /dev/null +++ b/docs/handoffs.md @@ -0,0 +1,113 @@ +# Handoffs + +Handoffs allow an agent to delegate tasks to another agent. This is particularly useful in scenarios where different agents specialize in distinct areas. For example, a customer support app might have agents that each specifically handle tasks like order status, refunds, FAQs, etc. + +Handoffs are represented as tools to the LLM. So if there's a handoff to an agent named `Refund Agent`, the tool would be called `transfer_to_refund_agent`. + +## Creating a handoff + +All agents have a [`handoffs`][agents.agent.Agent.handoffs] param, which can either take an `Agent` directly, or a `Handoff` object that customizes the Handoff. + +You can create a handoff using the [`handoff()`][agents.handoffs.handoff] function provided by the Agents SDK. This function allows you to specify the agent to hand off to, along with optional overrides and input filters. + +### Basic Usage + +Here's how you can create a simple handoff: + +```python +from agents import Agent, handoff + +billing_agent = Agent(name="Billing agent") +refund_agent = Agent(name="Refund agent") + +# (1)! +triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refund_agent)]) +``` + +1. You can use the agent directly (as in `billing_agent`), or you can use the `handoff()` function. + +### Customizing handoffs via the `handoff()` function + +The [`handoff()`][agents.handoffs.handoff] function lets you customize things. + +- `agent`: This is the agent to which things will be handed off. +- `tool_name_override`: By default, the `Handoff.default_tool_name()` function is used, which resolves to `transfer_to_`. You can override this. +- `tool_description_override`: Override the default tool description from `Handoff.default_tool_description()` +- `on_handoff`: A callback function executed when the handoff is invoked. This is useful for things like kicking off some data fetching as soon as you know a handoff is being invoked. This function receives the agent context, and can optionally also receive LLM generated input. The input data is controlled by the `input_type` param. +- `input_type`: The type of input expected by the handoff (optional). +- `input_filter`: This lets you filter the input received by the next agent. See below for more. + +```python +from agents import Agent, handoff, RunContextWrapper + +def on_handoff(ctx: RunContextWrapper[None]): + print("Handoff called") + +agent = Agent(name="My agent") + +handoff_obj = handoff( + agent=agent, + on_handoff=on_handoff, + tool_name_override="custom_handoff_tool", + tool_description_override="Custom description", +) +``` + +## Handoff inputs + +In certain situations, you want the LLM to provide some data when it calls a handoff. For example, imagine a handoff to an "Escalation agent". You might want a reason to be provided, so you can log it. + +```python +from pydantic import BaseModel + +from agents import Agent, handoff, RunContextWrapper + +class EscalationData(BaseModel): + reason: str + +async def on_handoff(ctx: RunContextWrapper[None], input_data: EscalationData): + print(f"Escalation agent called with reason: {input_data.reason}") + +agent = Agent(name="Escalation agent") + +handoff_obj = handoff( + agent=agent, + on_handoff=on_handoff, + input_type=EscalationData, +) +``` + +## Input filters + +When a handoff occurs, it's as though the new agent takes over the conversation, and gets to see the entire previous conversation history. If you want to change this, you can set an [`input_filter`][agents.handoffs.Handoff.input_filter]. An input filter is a function that receives the existing input via a [`HandoffInputData`][agents.handoffs.HandoffInputData], and must return a new `HandoffInputData`. + +There are some common patterns (for example removing all tool calls from the history), which are implemented for you in [`agents.extensions.handoff_filters`][] + +```python +from agents import Agent, handoff +from agents.extensions import handoff_filters + +agent = Agent(name="FAQ agent") + +handoff_obj = handoff( + agent=agent, + input_filter=handoff_filters.remove_all_tools, # (1)! +) +``` + +1. This will automatically remove all tools from the history when `FAQ agent` is called. + +## Recommended prompts + +To make sure that LLMs understand handoffs properly, we recommend including information about handoffs in your agents. We have a suggested prefix in [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][], or you can call [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] to automatically add recommended data to your prompts. + +```python +from agents import Agent +from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX + +billing_agent = Agent( + name="Billing agent", + instructions=f"""{RECOMMENDED_PROMPT_PREFIX} + .""", +) +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..28c6870 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,52 @@ +# OpenAI Agents SDK + +The OpenAI Agents SDK enables you to build agentic AI apps in a lightweight, easy to use package with very few abstractions. It's a production-ready upgrade of our previous experimentation for agents, [Swarm](https://github.com/openai/swarm/tree/main). The Agents SDK has a very small set of primitives: + +- **Agents**, which are LLMs equipped with instructions and tools +- **Handoffs**, which allow agents to delegate to other agents for specific tasks +- **Guardrails**, which enable the inputs to agents to be validated + +In combination with Python, these primitives are powerful enough to express complex relationships between tools and agents, and allow you to build real world applications without a steep learning curve. In addition, the SDK comes with built-in **tracing** that lets you visualize and debug your agentic flows, as well as evaluate them and even fine-tune models for your application. + +## Why use the Agents SDK + +The SDK has two driving design principles: + +1. Enough features to be worth using, but few enough primitives to make it quick to learn. +2. Works great out of the box, but you can customize exactly what happens. + +Here are the main features of the SDK: + +- Agent loop: Built-in agent loop that handles calling tools, sending results to the LLM, and looping until the LLM is done. +- Python-first: Use built-in language features to orchestrate and chain agents, rather than needing to learn new abstractions. +- Handoffs: A powerful feature to coordinate and delegate between multiple agents. +- Guardrails: Run input validations and checks in parallel to your agents, breaking early if the checks fail. +- Function tools: Turn any Python function into a tool, with automatic schema generation and Pydantic-powered validation. +- Tracing: Built-in tracing that lets you visualize, debug and monitor your workflows, as well as use the OpenAI suite of evaluation, fine-tuning and distillation tools. + +## Installation + +```bash +pip install openai-agents +``` + +## Hello world example + +```python +from agents import Agent, Runner + +agent = Agent(name="Assistant", instructions="You are a helpful assistant") + +result = Runner.run_sync(agent, "Write a haiku about recursion in programming.") +print(result.final_output) + +# Code within the code, +# Functions calling themselves, +# Infinite loop's dance. +``` + +(_If running this, ensure you set the `OPENAI_API_KEY` environment variable_) + +```bash +export OPENAI_API_KEY=sk-... +``` diff --git a/docs/models.md b/docs/models.md new file mode 100644 index 0000000..7d2ff1f --- /dev/null +++ b/docs/models.md @@ -0,0 +1,73 @@ +# Models + +The Agents SDK comes with out of the box support for OpenAI models in two flavors: + +- **Recommended**: the [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel], which calls OpenAI APIs using the new [Responses API](https://platform.openai.com/docs/api-reference/responses). +- The [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel], which calls OpenAI APIs using the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). + +## Mixing and matching models + +Within a single workflow, you may want to use different models for each agent. For example, you could use a smaller, faster model for triage, while using a larger, more capable model for complex tasks. When configuring an [`Agent`][agents.Agent], you can select a specific model by either: + +1. Passing the name of an OpenAI model. +2. Passing any model name + a [`ModelProvider`][agents.models.interface.ModelProvider] that can map that name to a Model instance. +3. Directly providing a [`Model`][agents.models.interface.Model] implementation. + +!!!note + + While our SDK supports both the [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] and the[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] shapes, we recommend using a single model shape for each workflow because the two shapes support a different set of features and tools. If your workflow requires mixing and matching model shapes, make sure that all the features you're using are available on both. + +```python +from agents import Agent, Runner, AsyncOpenAI, OpenAIChatCompletionsModel +import asyncio + +spanish_agent = Agent( + name="Spanish agent", + instructions="You only speak Spanish.", + model="o3-mini", # (1)! +) + +english_agent = Agent( + name="English agent", + instructions="You only speak English", + model=OpenAIChatCompletionsModel( # (2)! + model="gpt-4o", + openai_client=AsyncOpenAI() + ), +) + +triage_agent = Agent( + name="Triage agent", + instructions="Handoff to the appropriate agent based on the language of the request.", + handoffs=[spanish_agent, english_agent], + model="gpt-3.5-turbo", +) + +async def main(): + result = await Runner.run(triage_agent, input="Hola, ¿cómo estás?") + print(result.final_output) +``` + +1. Sets the the name of an OpenAI model directly. +2. Provides a [`Model`][agents.models.interface.Model] implementation. + +## Using other LLM providers + +Many providers also support the OpenAI API format, which means you can pass a `base_url` to the existing OpenAI model implementations and use them easily. `ModelSettings` is used to configure tuning parameters (e.g., temperature, top_p) for the model you select. + +```python +external_client = AsyncOpenAI( + api_key="EXTERNAL_API_KEY", + base_url="https://api.external.com/v1/", +) + +spanish_agent = Agent( + name="Spanish agent", + instructions="You only speak Spanish.", + model=OpenAIChatCompletionsModel( + model="EXTERNAL_MODEL_NAME", + openai_client=external_client, + ), + model_settings=ModelSettings(temperature=0.5), +) +``` diff --git a/docs/multi_agent.md b/docs/multi_agent.md new file mode 100644 index 0000000..c118249 --- /dev/null +++ b/docs/multi_agent.md @@ -0,0 +1,37 @@ +# Orchestrating multiple agents + +Orchestration refers to the flow of agents in your app. Which agents run, in what order, and how do they decide what happens next? There are two main ways to orchestrate agents: + +1. Allowing the LLM to make decisions: this uses the intelligence of an LLM to plan, reason, and decide on what steps to take based on that. +2. Orchestrating via code: determining the flow of agents via your code. + +You can mix and match these patterns. Each has their own tradeoffs, described below. + +## Orchestrating via LLM + +An agent is an LLM equipped with instructions, tools and handoffs. This means that given an open-ended task, the LLM can autonomously plan how it will tackle the task, using tools to take actions and acquire data, and using handoffs to delegate tasks to sub-agents. For example, a research agent could be equipped with tools like: + +- Web search to find information online +- File search and retrieval to search through proprietary data and connections +- Computer use to take actions on a computer +- Code execution to do data analysis +- Handoffs to specialized agents that are great at planning, report writing and more. + +This pattern is great when the task is open-ended and you want to rely on the intelligence of an LLM. The most important tactics here are: + +1. Invest in good prompts. Make it clear what tools are available, how to use them, and what parameters it must operate within. +2. Monitor your app and iterate on it. See where things go wrong, and iterate on your prompts. +3. Allow the agent to introspect and improve. For example, run it in a loop, and let it critique itself; or, provide error messages and let it improve. +4. Have specialized agents that excel in one task, rather than having a general purpose agent that is expected to be good at anything. +5. Invest in [evals](https://platform.openai.com/docs/guides/evals). This lets you train your agents to improve and get better at tasks. + +## Orchestrating via code + +While orchestrating via LLM is powerful, orchestrating via LLM makes tasks more deterministic and predictable, in terms of speed, cost and performance. Common patterns here are: + +- Using [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) to generate well formed data that you can inspect with your code. For example, you might ask an agent to classify the task into a few categories, and then pick the next agent based on the category. +- Chaining multiple agents by transforming the output of one into the input of the next. You can decompose a task like writing a blog post into a series of steps - do research, write an outline, write the blog post, critique it, and then improve it. +- Running the agent that performs the task in a `while` loop with an agent that evaluates and provides feedback, until the evaluator says the output passes certain criteria. +- Running multiple agents in parallel, e.g. via Python primitives like `asyncio.gather`. This is useful for speed when you have multiple tasks that don't depend on each other. + +We have a number of examples in [`examples/agent_patterns`](https://github.com/openai/openai-agents-python/examples/agent_patterns). diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..19051f4 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,186 @@ +# Quickstart + +## Create a project and virtual environment + +You'll only need to do this once. + +```bash +mkdir my_project +cd my_project +python -m venv .venv +``` + +### Activate the virtual environment + +Do this every time you start a new terminal session. + +```bash +source .venv/bin/activate +``` + +### Install the Agents SDK + +```bash +pip install openai-agents # or `uv add openai-agents`, etc +``` + +### Set an OpenAI API key + +If you don't have one, follow [these instructions](https://platform.openai.com/docs/quickstart#create-and-export-an-api-key) to create an OpenAI API key. + +```bash +export OPENAI_API_KEY=sk-... +``` + +## Create your first agent + +Agents are defined with instructions, a name, and optional config (such as `model_config`) + +```python +from agents import Agent + +agent = Agent( + name="Math Tutor", + instructions="You provide help with math problems. Explain your reasoning at each step and include examples", +) +``` + +## Add a few more agents + +Additional agents can be defined in the same way. `handoff_descriptions` provide additional context for determining handoff routing + +```python +from agents import Agent + +history_tutor_agent = Agent( + name="History Tutor", + handoff_description="Specialist agent for historical questions", + instructions="You provide assistance with historical queries. Explain important events and context clearly.", +) + +math_tutor_agent = Agent( + name="Math Tutor", + handoff_description="Specialist agent for math questions", + instructions="You provide help with math problems. Explain your reasoning at each step and include examples", +) +``` + +## Define your handoffs + +On each agent, you can define an inventory of outgoing handoff options that the agent can choose from to decide how to make progress on their task. + +```python +triage_agent = Agent( + name="Triage Agent", + instructions="You determine which agent to use based on the user's homework question", + handoffs=[history_tutor_agent, math_tutor_agent] +) +``` + +## Run the agent orchestration + +Let's check that the workflow runs and the triage agent correctly routes between the two specialist agents. + +```python +from agents import Runner + +async def main(): + result = await Runner.run(triage_agent, "What is the capital of France?") + print(result.final_output) +``` + +## Add a guardrail + +You can define custom guardrails to run on the input or output. + +```python +from agents import GuardrailFunctionOutput, Agent, Runner +from pydantic import BaseModel + +class HomeworkOutput(BaseModel): + is_homework: bool + reasoning: str + +guardrail_agent = Agent( + name="Guardrail check", + instructions="Check if the user is asking about homework.", + output_type=HomeworkOutput, +) + +async def homework_guardrail(ctx, agent, input_data): + result = await Runner.run(guardrail_agent, input_data, context=ctx.context) + final_output = result.final_output_as(HomeworkOutput) + return GuardrailFunctionOutput( + output_info=final_output, + tripwire_triggered=not final_output.is_homework, + ) +``` + +## Put it all together + +Let's put it all together and run the entire workflow, using handoffs and the input guardrail. + +```python +from agents import Agent, InputGuardrail,GuardrailFunctionOutput, Runner +from pydantic import BaseModel +import asyncio + +class HomeworkOutput(BaseModel): + is_homework: bool + reasoning: str + +guardrail_agent = Agent( + name="Guardrail check", + instructions="Check if the user is asking about homework.", + output_type=HomeworkOutput, +) + +math_tutor_agent = Agent( + name="Math Tutor", + handoff_description="Specialist agent for math questions", + instructions="You provide help with math problems. Explain your reasoning at each step and include examples", +) + +history_tutor_agent = Agent( + name="History Tutor", + handoff_description="Specialist agent for historical questions", + instructions="You provide assistance with historical queries. Explain important events and context clearly.", +) + + +async def homework_guardrail(ctx, agent, input_data): + result = await Runner.run(guardrail_agent, input_data, context=ctx.context) + final_output = result.final_output_as(HomeworkOutput) + return GuardrailFunctionOutput( + output_info=final_output, + tripwire_triggered=not final_output.is_homework, + ) + +triage_agent = Agent( + name="Triage Agent", + instructions="You determine which agent to use based on the user's homework question", + handoffs=[history_tutor_agent, math_tutor_agent], + input_guardrails=[ + InputGuardrail(guardrail_function=homework_guardrail), + ], +) + +async def main(): + result = await Runner.run(triage_agent, "what is life") + print(result.final_output) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## View your traces + +To review what happened during your agent run, navigate to the [Trace viewer in the OpenAI Dashboard](https://platform.openai.com/traces) to view traces of your agent runs. + +## Next steps + +Learn how to build more complex agentic flows: + +- Learn about how to configure [Agents](agents.md). +- Learn about [running agents](running_agents.md). +- Learn about [tools](tools.md), [guardrails](guardrails.md) and [models](models.md). diff --git a/docs/ref/agent.md b/docs/ref/agent.md new file mode 100644 index 0000000..9f8b10d --- /dev/null +++ b/docs/ref/agent.md @@ -0,0 +1,3 @@ +# `Agents` + +::: agents.agent diff --git a/docs/ref/agent_output.md b/docs/ref/agent_output.md new file mode 100644 index 0000000..e453de0 --- /dev/null +++ b/docs/ref/agent_output.md @@ -0,0 +1,3 @@ +# `Agent output` + +::: agents.agent_output diff --git a/docs/ref/exceptions.md b/docs/ref/exceptions.md new file mode 100644 index 0000000..7c1a254 --- /dev/null +++ b/docs/ref/exceptions.md @@ -0,0 +1,3 @@ +# `Exceptions` + +::: agents.exceptions diff --git a/docs/ref/extensions/handoff_filters.md b/docs/ref/extensions/handoff_filters.md new file mode 100644 index 0000000..0ffcb13 --- /dev/null +++ b/docs/ref/extensions/handoff_filters.md @@ -0,0 +1,3 @@ +# `Handoff filters` + +::: agents.extensions.handoff_filters diff --git a/docs/ref/extensions/handoff_prompt.md b/docs/ref/extensions/handoff_prompt.md new file mode 100644 index 0000000..ca80076 --- /dev/null +++ b/docs/ref/extensions/handoff_prompt.md @@ -0,0 +1,8 @@ +# `Handoff prompt` + +::: agents.extensions.handoff_prompt + + options: + members: + - RECOMMENDED_PROMPT_PREFIX + - prompt_with_handoff_instructions diff --git a/docs/ref/function_schema.md b/docs/ref/function_schema.md new file mode 100644 index 0000000..06aac2a --- /dev/null +++ b/docs/ref/function_schema.md @@ -0,0 +1,3 @@ +# `Function schema` + +::: agents.function_schema diff --git a/docs/ref/guardrail.md b/docs/ref/guardrail.md new file mode 100644 index 0000000..17ec929 --- /dev/null +++ b/docs/ref/guardrail.md @@ -0,0 +1,3 @@ +# `Guardrails` + +::: agents.guardrail diff --git a/docs/ref/handoffs.md b/docs/ref/handoffs.md new file mode 100644 index 0000000..717a918 --- /dev/null +++ b/docs/ref/handoffs.md @@ -0,0 +1,3 @@ +# `Handoffs` + +::: agents.handoffs diff --git a/docs/ref/index.md b/docs/ref/index.md new file mode 100644 index 0000000..1b8439f --- /dev/null +++ b/docs/ref/index.md @@ -0,0 +1,13 @@ +# Agents module + +::: agents + + options: + members: + - set_default_openai_key + - set_default_openai_client + - set_default_openai_api + - set_tracing_export_api_key + - set_tracing_disabled + - set_trace_processors + - enable_verbose_stdout_logging diff --git a/docs/ref/items.md b/docs/ref/items.md new file mode 100644 index 0000000..29279e1 --- /dev/null +++ b/docs/ref/items.md @@ -0,0 +1,3 @@ +# `Items` + +::: agents.items diff --git a/docs/ref/lifecycle.md b/docs/ref/lifecycle.md new file mode 100644 index 0000000..432af14 --- /dev/null +++ b/docs/ref/lifecycle.md @@ -0,0 +1,6 @@ +# `Lifecycle` + +::: agents.lifecycle + + options: + show_source: false diff --git a/docs/ref/model_settings.md b/docs/ref/model_settings.md new file mode 100644 index 0000000..f7f411f --- /dev/null +++ b/docs/ref/model_settings.md @@ -0,0 +1,3 @@ +# `Model settings` + +::: agents.model_settings diff --git a/docs/ref/models/interface.md b/docs/ref/models/interface.md new file mode 100644 index 0000000..e7bd89a --- /dev/null +++ b/docs/ref/models/interface.md @@ -0,0 +1,3 @@ +# `Model interface` + +::: agents.models.interface diff --git a/docs/ref/models/openai_chatcompletions.md b/docs/ref/models/openai_chatcompletions.md new file mode 100644 index 0000000..76cf563 --- /dev/null +++ b/docs/ref/models/openai_chatcompletions.md @@ -0,0 +1,3 @@ +# `OpenAI Chat Completions model` + +::: agents.models.openai_chatcompletions diff --git a/docs/ref/models/openai_responses.md b/docs/ref/models/openai_responses.md new file mode 100644 index 0000000..e1794ba --- /dev/null +++ b/docs/ref/models/openai_responses.md @@ -0,0 +1,3 @@ +# `OpenAI Responses model` + +::: agents.models.openai_responses diff --git a/docs/ref/result.md b/docs/ref/result.md new file mode 100644 index 0000000..3a9e4a9 --- /dev/null +++ b/docs/ref/result.md @@ -0,0 +1,3 @@ +# `Results` + +::: agents.result diff --git a/docs/ref/run.md b/docs/ref/run.md new file mode 100644 index 0000000..ddf4475 --- /dev/null +++ b/docs/ref/run.md @@ -0,0 +1,8 @@ +# `Runner` + +::: agents.run + + options: + members: + - Runner + - RunConfig diff --git a/docs/ref/run_context.md b/docs/ref/run_context.md new file mode 100644 index 0000000..49e8730 --- /dev/null +++ b/docs/ref/run_context.md @@ -0,0 +1,3 @@ +# `Run context` + +::: agents.run_context diff --git a/docs/ref/stream_events.md b/docs/ref/stream_events.md new file mode 100644 index 0000000..ea48431 --- /dev/null +++ b/docs/ref/stream_events.md @@ -0,0 +1,3 @@ +# `Streaming events` + +::: agents.stream_events diff --git a/docs/ref/tool.md b/docs/ref/tool.md new file mode 100644 index 0000000..887bef7 --- /dev/null +++ b/docs/ref/tool.md @@ -0,0 +1,3 @@ +# `Tools` + +::: agents.tool diff --git a/docs/ref/tracing/create.md b/docs/ref/tracing/create.md new file mode 100644 index 0000000..c983e33 --- /dev/null +++ b/docs/ref/tracing/create.md @@ -0,0 +1,3 @@ +# `Creating traces/spans` + +::: agents.tracing.create diff --git a/docs/ref/tracing/index.md b/docs/ref/tracing/index.md new file mode 100644 index 0000000..88a0fe6 --- /dev/null +++ b/docs/ref/tracing/index.md @@ -0,0 +1,3 @@ +# Tracing module + +::: agents.tracing diff --git a/docs/ref/tracing/processor_interface.md b/docs/ref/tracing/processor_interface.md new file mode 100644 index 0000000..9fb04e8 --- /dev/null +++ b/docs/ref/tracing/processor_interface.md @@ -0,0 +1,3 @@ +# `Processor interface` + +::: agents.tracing.processor_interface diff --git a/docs/ref/tracing/processors.md b/docs/ref/tracing/processors.md new file mode 100644 index 0000000..d7ac4af --- /dev/null +++ b/docs/ref/tracing/processors.md @@ -0,0 +1,3 @@ +# `Processors` + +::: agents.tracing.processors diff --git a/docs/ref/tracing/scope.md b/docs/ref/tracing/scope.md new file mode 100644 index 0000000..7b5b9fd --- /dev/null +++ b/docs/ref/tracing/scope.md @@ -0,0 +1,3 @@ +# `Scope` + +::: agents.tracing.scope diff --git a/docs/ref/tracing/setup.md b/docs/ref/tracing/setup.md new file mode 100644 index 0000000..1dc6a0f --- /dev/null +++ b/docs/ref/tracing/setup.md @@ -0,0 +1,3 @@ +# `Setup` + +::: agents.tracing.setup diff --git a/docs/ref/tracing/span_data.md b/docs/ref/tracing/span_data.md new file mode 100644 index 0000000..6ace7a8 --- /dev/null +++ b/docs/ref/tracing/span_data.md @@ -0,0 +1,3 @@ +# `Span data` + +::: agents.tracing.span_data diff --git a/docs/ref/tracing/spans.md b/docs/ref/tracing/spans.md new file mode 100644 index 0000000..9071707 --- /dev/null +++ b/docs/ref/tracing/spans.md @@ -0,0 +1,9 @@ +# `Spans` + +::: agents.tracing.spans + + options: + members: + - Span + - NoOpSpan + - SpanImpl diff --git a/docs/ref/tracing/traces.md b/docs/ref/tracing/traces.md new file mode 100644 index 0000000..0b7377f --- /dev/null +++ b/docs/ref/tracing/traces.md @@ -0,0 +1,3 @@ +# `Traces` + +::: agents.tracing.traces diff --git a/docs/ref/tracing/util.md b/docs/ref/tracing/util.md new file mode 100644 index 0000000..2be3d58 --- /dev/null +++ b/docs/ref/tracing/util.md @@ -0,0 +1,3 @@ +# `Util` + +::: agents.tracing.util diff --git a/docs/ref/usage.md b/docs/ref/usage.md new file mode 100644 index 0000000..b8b29db --- /dev/null +++ b/docs/ref/usage.md @@ -0,0 +1,3 @@ +# `Usage` + +::: agents.usage diff --git a/docs/results.md b/docs/results.md new file mode 100644 index 0000000..d1864fa --- /dev/null +++ b/docs/results.md @@ -0,0 +1,52 @@ +# Results + +When you call the `Runner.run` methods, you either get a: + +- [`RunResult`][agents.result.RunResult] if you call `run` or `run_sync` +- [`RunResultStreaming`][agents.result.RunResultStreaming] if you call `run_streamed` + +Both of these inherit from [`RunResultBase`][agents.result.RunResultBase], which is where most useful information is present. + +## Final output + +The [`final_output`][agents.result.RunResultBase.final_output] property contains the final output of the last agent that ran. This is either: + +- a `str`, if the last agent didn't have an `output_type` defined +- an object of type `last_agent.output_type`, if the agent had an output type defined. + +!!! note + + `final_output` is of type `Any`. We can't statically type this, because of handoffs. If handoffs occur, that means any Agent might be the last agent, so we don't statically know the set of possible output types. + +## Inputs for the next turn + +You can use [`result.to_input_list()`][agents.result.RunResultBase.to_input_list] to turn the result into an input list that concatenates the original input you provided, to the items generated during the agent run. This makes it convenient to take the outputs of one agent run and pass them into another run, or to run it in a loop and append new user inputs each time. + +## Last agent + +The [`last_agent`][agents.result.RunResultBase.last_agent] property contains the last agent that ran. Depending on your application, this is often useful for the next time the user inputs something. For example, if you have a frontline triage agent that hands off to a language-specific agent, you can store the last agent, and re-use it the next time the user messages the agent. + +## New items + +The [`new_items`][agents.result.RunResultBase.new_items] property contains the new items generated during the run. The items are [`RunItem`][agents.items.RunItem]s. A run item wraps the raw item generated by the LLM. + +- [`MessageOutputItem`][agents.items.MessageOutputItem] indicates a message from the LLM. The raw item is the message generated. +- [`HandoffCallItem`][agents.items.HandoffCallItem] indicates that the LLM called the handoff tool. The raw item is the tool call item from the LLM. +- [`HandoffOutputItem`][agents.items.HandoffOutputItem] indicates that a handoff occured. The raw item is the tool response to the handoff tool call. You can also access the source/target agents from the item. +- [`ToolCallItem`][agents.items.ToolCallItem] indicates that the LLM invoked a tool. +- [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] indicates that a tool was called. The raw item is the tool response. You can also access the tool output from the item. +- [`ReasoningItem`][agents.items.ReasoningItem] indicates a reasoning item from the LLM. The raw item is the reasoning generated. + +## Other information + +### Guardrail results + +The [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] and [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] properties contain the results of the guardrails, if any. Guardrail results can sometimes contain useful information you want to log or store, so we make these available to you. + +### Raw responses + +The [`raw_responses`][agents.result.RunResultBase.raw_responses] property contains the [`ModelResponse`][agents.items.ModelResponse]s generated by the LLM. + +### Original input + +The [`input`][agents.result.RunResultBase.input] property contains the original input you provided to the `run` method. In most cases you won't need this, but it's available in case you do. diff --git a/docs/running_agents.md b/docs/running_agents.md new file mode 100644 index 0000000..a2f137c --- /dev/null +++ b/docs/running_agents.md @@ -0,0 +1,95 @@ +# Running agents + +You can run agents via the [`Runner`][agents.run.Runner] class. You have 3 options: + +1. [`Runner.run()`][agents.run.Runner.run], which runs async and returns a [`RunResult`][agents.result.RunResult]. +2. [`Runner.run_sync()`][agents.run.Runner.run_sync], which is a sync method and just runs `.run()` under the hood. +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed], which runs async and returns a [`RunResultStreaming`][agents.result.RunResultStreaming]. It calls the LLM in streaming mode, and streams those events to you as they are received. + +```python +from agents import Agent, Runner + +async def main(): + agent = Agent(name="Assistant", instructions="You are a helpful assistant") + + result = await Runner.run(agent, "Write a haiku about recursion in programming.") + print(result.final_output) + # Code within the code, + # Functions calling themselves, + # Infinite loop's dance. +``` + +Read more in the [results guide](results.md). + +## The agent loop + +When you use the run method in `Runner`, you pass in a starting agent and input. The input can either be a string (which is considered a user message), or a list of input items, which are the items in the OpenAI Responses API. + +The runner then runs a loop: + +1. We call the LLM for the current agent, with the current input. +2. The LLM produces its output. + 1. If the LLM returns a `final_output`, the loop ends and we return the result. + 2. If the LLM does a handoff, we update the current agent and input, and re-run the loop. + 3. If the LLM produces tool calls, we run those tool calls, append the results, and re-run the loop. +3. If we exceed the `max_turns` passed, we raise a [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] exception. + +!!! note + + The rule for whether the LLM output is considered as a "final output" is that it produces text output with the desired type, and there are no tool calls. + +## Streaming + +Streaming allows you to additionally receive streaming events as the LLM runs. Once the stream is done, the [`RunResultStreaming`][agents.result.RunResultStreaming] will contain the complete information about the run, including all the new outputs produces. You can call `.stream_events()` for the streaming events. Read more in the [streaming guide](streaming.md). + +## Run config + +The `run_config` parameter lets you configure some global settings for the agent run: + +- [`model`][agents.run.RunConfig.model]: Allows setting a global LLM model to use, irrespective of what `model` each Agent has. +- [`model_provider`][agents.run.RunConfig.model_provider]: A model provider for looking up model names, which defaults to OpenAI. +- [`model_settings`][agents.run.RunConfig.model_settings]: Overrides agent-specific settings. For example, you can set a global `temperature` or `top_p`. +- [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: A list of input or output guardrails to include on all runs. +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: A global input filter to apply to all handoffs, if the handoff doesn't already have one. The input filter allows you to edit the inputs that are sent to the new agent. See the documentation in [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] for more details. +- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: Allows you to disable [tracing](tracing.md) for the entire run. +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: Configures whether traces will include potentially sensitive data, such as LLM and tool call inputs/outputs. +- [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: Sets the tracing workflow name, trace ID and trace group ID for the run. We recommend at least setting `workflow_name`. The session ID is an optional field that lets you link traces across multiple runs. +- [`trace_metadata`][agents.run.RunConfig.trace_metadata]: Metadata to include on all traces. + +## Conversations/chat threads + +Calling any of the run methods can result in one or more agents running (and hence one or more LLM calls), but it represents a single logical turn in a chat conversation. For example: + +1. User turn: user enter text +2. Runner run: first agent calls LLM, runs tools, does a handoff to a second agent, second agent runs more tools, and then produces an output. + +At the end of the agent run, you can choose what to show to the user. For example, you might show the user every new item generated by the agents, or just the final output. Either way, the user might then ask a followup question, in which case you can call the run method again. + +You can use the base [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] method to get the inputs for the next turn. + +```python +async def main(): + agent = Agent(name="Assistant", instructions="Reply very concisely.") + + with trace(workflow_name="Conversation", group_id=thread_id): + # First turn + result = await Runner.run(agent, "What city is the Golden Gate Bridge in?") + print(result.final_output) + # San Francisco + + # Second turn + new_input = output.to_input_list() + [{"role": "user", "content": "What state is it in?"}] + result = await Runner.run(agent, new_input) + print(result.final_output) + # California +``` + +## Exceptions + +The SDK raises exceptions in certain cases. The full list is in [`agents.exceptions`][]. As an overview: + +- [`AgentsException`][agents.exceptions.AgentsException] is the base class for all exceptions raised in the SDK. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] is raised when the run exceeds the `max_turns` passed to the run methods. +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError] is raised when the model produces invalid outputs, e.g. malformed JSON or using non-existent tools. +- [`UserError`][agents.exceptions.UserError] is raised when you (the person writing code using the SDK) make an error using the SDK. +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] is raised when a [guardrail](guardrails.md) is tripped. diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 0000000..b2c7c09 --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,87 @@ +# Streaming + +Streaming lets you subscribe to updates of the agent run as it proceeds. This can be useful for showing the end-user progress updates and partial responses. + +To stream, you can call [`Runner.run_streamed()`][agents.run.Runner.run_streamed], which will give you a [`RunResultStreaming`][agents.result.RunResultStreaming]. Calling `result.stream_events()` gives you an async stream of [`StreamEvent`][agents.stream_events.StreamEvent] objects, which are described below. + +## Raw response events + +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] are raw events passed directly from the LLM. They are in OpenAI Responses API format, which means each event has a type (like `response.created`, `response.output_text.delta`, etc) and data. These events are useful if you want to stream response messages to the user as soon as they are generated. + +For example, this will output the text generated by the LLM token-by-token. + +```python +import asyncio +from openai.types.responses import ResponseTextDeltaEvent +from agents import Agent, Runner + +async def main(): + agent = Agent( + name="Joker", + instructions="You are a helpful assistant.", + ) + + result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") + async for event in result.stream_events(): + if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent): + print(event.data.delta, end="", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Run item events and agent events + +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]s are higher level events. They inform you when an item has been fully generated. This allows you to push progress updates at the level of "message generated", "tool ran", etc, instead of each token. Similarly, [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] gives you updates when the current agent changes (e.g. as the result of a handoff). + +For example, this will ignore raw events and stream updates to the user. + +```python +import asyncio +import random +from agents import Agent, ItemHelpers, Runner, function_tool + +@function_tool +def how_many_jokes() -> int: + return random.randint(1, 10) + + +async def main(): + agent = Agent( + name="Joker", + instructions="First call the `how_many_jokes` tool, then tell that many jokes.", + tools=[how_many_jokes], + ) + + result = Runner.run_streamed( + agent, + input="Hello", + ) + print("=== Run starting ===") + + async for event in result.stream_events(): + # We'll ignore the raw responses event deltas + if event.type == "raw_response_event": + continue + # When the agent updates, print that + elif event.type == "agent_updated_stream_event": + print(f"Agent updated: {event.new_agent.name}") + continue + # When items are generated, print them + elif event.type == "run_item_stream_event": + if event.item.type == "tool_call_item": + print("-- Tool was called") + elif event.item.type == "tool_call_output_item": + print(f"-- Tool output: {event.item.output}") + elif event.item.type == "message_output_item": + print(f"-- Message output:\n {ItemHelpers.text_message_output(event.item)}") + else: + pass # Ignore other event types + + print("=== Run complete ===") + + +if __name__ == "__main__": + asyncio.run(main()) +``` diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..89cf164 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,194 @@ +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: normal; + font-weight: 400; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-Regular.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: italic; + font-weight: 400; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-RegularItalic.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: normal; + font-weight: 500; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-Medium.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: italic; + font-weight: 500; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-MediumItalic.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: normal; + font-weight: 600; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-Semibold.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: italic; + font-weight: 600; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-SemiboldItalic.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: normal; + font-weight: 700; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-Bold.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: italic; + font-weight: 700; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-BoldItalic.woff2") + format("woff2"); +} + +/* + Root variables that apply to all color schemes. + Material for MkDocs automatically switches data-md-color-scheme + between "default" (light) and "slate" (dark) when you use the toggles. +*/ +:root { + /* Font families */ + --md-text-font: "OpenAI Sans", -apple-system, system-ui, Helvetica, Arial, + sans-serif; + --md-typeface-heading: "OpenAI Sans", -apple-system, system-ui, Helvetica, + Arial, sans-serif; + + /* Global color variables */ + --md-default-fg-color: #212121; + --md-default-bg-color: #ffffff; + --md-primary-fg-color: #000; + --md-accent-fg-color: #000; + + /* Code block theming */ + --md-code-fg-color: red; + --md-code-bg-color: #f5f5f5; + + /* Tables, blockquotes, etc. */ + --md-table-row-border-color: #e0e0e0; + --md-admonition-bg-color: #f8f8f8; + --md-admonition-title-fg-color: #373737; + --md-default-fg-color--light: #000; + + --md-typeset-a-color: #000; + --md-accent-fg-color: #000; + + --md-code-fg-color: #000; +} + +/* Header styling */ +.md-header { + background-color: #000; +} + +.md-header--shadow { + box-shadow: none; +} + +.md-content .md-typeset h1 { + color: #000; +} + +.md-typeset p, +.md-typeset li { + font-size: 16px; +} + +.md-typeset__table p { + line-height: 1em; +} + +.md-nav { + font-size: 14px; +} +.md-nav__title { + color: #000; + font-weight: 600; +} + +.md-typeset h1, +.md-typeset h2, +.md-typeset h3, +.md-typeset h4 { + font-weight: 600; +} + +.md-typeset h1 code { + color: #000; + padding: 0; + background-color: transparent; +} +.md-footer { + display: none; +} + +.md-header__title { + margin-left: 0 !important; +} + +.md-typeset .admonition, +.md-typeset details { + border: none; + outline: none; + border-radius: 8px; + overflow: hidden; +} + +.md-typeset pre > code { + font-size: 14px; +} + +.md-typeset__table code { + font-size: 14px; +} + +/* Custom link styling */ +.md-content a { + text-decoration: none; +} + +.md-content a:hover { + text-decoration: underline; +} + +/* Code block styling */ +.md-content .md-code__content { + border-radius: 8px; +} + +.md-clipboard.md-icon { + color: #9e9e9e; +} + +/* Reset scrollbar styling to browser default with high priority */ +.md-sidebar__scrollwrap { + scrollbar-color: auto !important; +} diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..f7a8869 --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,270 @@ +# Tools + +Tools let agents take actions: things like fetching data, running code, calling external APIs, and even using a computer. There are three classes of tools in the Agent SDK: + +- Hosted tools: these run on LLM servers alongside the AI models. OpenAI offers retrieval, web search and computer use as hosted tools. +- Function calling: these allow you to use any Python function as a tool. +- Agents as tools: this allows you to use an agent as a tool, allowing Agents to call other agents without handing off to them. + +## Hosted tools + +OpenAI offers a few built-in tools when using the [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]: + +- The [`WebSearchTool`][agents.tool.WebSearchTool] lets an agent search the web. +- The [`FileSearchTool`][agents.tool.FileSearchTool] allows retrieving information from your OpenAI Vector Stores. +- The [`ComputerTool`][agents.tool.ComputerTool] allows automating computer use tasks. + +```python +from agents import Agent, FileSearchTool, Runner, WebSearchTool + +agent = Agent( + name="Assistant", + tools=[ + WebSearchTool(), + FileSearchTool( + max_num_results=3, + vector_store_ids=["VECTOR_STORE_ID"], + ), + ], +) + +async def main(): + result = await Runner.run(agent, "Which coffee shop should I go to, taking into account my preferences and the weather today in SF?") + print(result.final_output) +``` + +## Function tools + +You can use any Python function as a tool. The Agents SDK will setup the tool automatically: + +- The name of the tool will be the name of the Python function (or you can provide a name) +- Tool description will be taken from the docstring of the function (or you can provide a description) +- The schema for the function inputs is automatically created from the function's arguments +- Descriptions for each input are taken from the docstring of the function, unless disabled + +We use Python's `inspect` module to extract the function signature, along with [`griffe`](https://mkdocstrings.github.io/griffe/) to parse docstrings and `pydantic` for schema creation. + +```python +import json + +from typing_extensions import TypedDict, Any + +from agents import Agent, FunctionTool, RunContextWrapper, function_tool + + +class Location(TypedDict): + lat: float + long: float + +@function_tool # (1)! +async def fetch_weather(location: Location) -> str: + # (2)! + """Fetch the weather for a given location. + + Args: + location: The location to fetch the weather for. + """ + # In real life, we'd fetch the weather from a weather API + return "sunny" + + +@function_tool(name_override="fetch_data") # (3)! +def read_file(ctx: RunContextWrapper[Any], path: str, directory: str | None = None) -> str: + """Read the contents of a file. + + Args: + path: The path to the file to read. + directory: The directory to read the file from. + """ + # In real life, we'd read the file from the file system + return "" + + +agent = Agent( + name="Assistant", + tools=[fetch_weather, read_file], # (4)! +) + +for tool in agent.tools: + if isinstance(tool, FunctionTool): + print(tool.name) + print(tool.description) + print(json.dumps(tool.params_json_schema, indent=2)) + print() + +``` + +1. You can use any Python types as arguments to your functions, and the function can be sync or async. +2. Docstrings, if present, are used to capture descriptions and argument descriptions +3. Functions can optionally take the `context` (must be the first argument). You can also set overrides, like the name of the tool, description, which docstring style to use, etc. +4. You can pass the decorated functions to the list of tools. + +??? note "Expand to see output" + + ``` + fetch_weather + Fetch the weather for a given location. + { + "$defs": { + "Location": { + "properties": { + "lat": { + "title": "Lat", + "type": "number" + }, + "long": { + "title": "Long", + "type": "number" + } + }, + "required": [ + "lat", + "long" + ], + "title": "Location", + "type": "object" + } + }, + "properties": { + "location": { + "$ref": "#/$defs/Location", + "description": "The location to fetch the weather for." + } + }, + "required": [ + "location" + ], + "title": "fetch_weather_args", + "type": "object" + } + + fetch_data + Read the contents of a file. + { + "properties": { + "path": { + "description": "The path to the file to read.", + "title": "Path", + "type": "string" + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The directory to read the file from.", + "title": "Directory" + } + }, + "required": [ + "path" + ], + "title": "fetch_data_args", + "type": "object" + } + ``` + +### Custom function tools + +Sometimes, you don't want to use a Python function as a tool. You can directly create a [`FunctionTool`][agents.tool.FunctionTool] if you prefer. You'll need to provide: + +- `name` +- `description` +- `params_json_schema`, which is the JSON schema for the arguments +- `on_invoke_tool`, which is an async function that receives the context and the arguments as a JSON string, and must return the tool output as a string. + +```python +from typing import Any + +from pydantic import BaseModel + +from agents import RunContextWrapper, FunctionTool + + + +def do_some_work(data: str) -> str: + return "done" + + +class FunctionArgs(BaseModel): + username: str + age: int + + +async def run_function(ctx: RunContextWrapper[Any], args: str) -> str: + parsed = FunctionArgs.model_validate_json(args) + return do_some_work(data=f"{parsed.username} is {parsed.age} years old") + + +tool = FunctionTool( + name="process_user", + description="Processes extracted user data", + params_json_schema=FunctionArgs.model_json_schema(), + on_invoke_tool=run_function, +) +``` + +### Automatic argument and docstring parsing + +As mentioned before, we automatically parse the function signature to extract the schema for the tool, and we parse the docstring to extract descriptions for the tool and for individual arguments. Some notes on that: + +1. The signature parsing is done via the `inspect` module. We use type annotations to understand the types for the arguments, and dynamically build a Pydantic model to represent the overall schema. It supports most types, including Python primitives, Pydantic models, TypedDicts, and more. +2. We use `griffe` to parse docstrings. Supported docstring formats are `google`, `sphinx` and `numpy`. We attempt to automatically detect the docstring format, but this is best-effort and you can explicitly set it when calling `function_tool`. You can also disable docstring parsing by setting `use_docstring_info` to `False`. + +The code for the schema extraction lives in [`agents.function_schema`][]. + +## Agents as tools + +In some workflows, you may want a central agent to orchestrate a network of specialized agents, instead of handing off control. You can do this by modeling agents as tools. + +```python +from agents import Agent, Runner +import asyncio + +spanish_agent = Agent( + name="Spanish agent", + instructions="You translate the user's message to Spanish", +) + +french_agent = Agent( + name="French agent", + instructions="You translate the user's message to French", +) + +orchestrator_agent = Agent( + name="orchestrator_agent", + instructions=( + "You are a translation agent. You use the tools given to you to translate." + "If asked for multiple translations, you call the relevant tools." + ), + tools=[ + spanish_agent.as_tool( + tool_name="translate_to_spanish", + tool_description="Translate the user's message to Spanish", + ), + french_agent.as_tool( + tool_name="translate_to_french", + tool_description="Translate the user's message to French", + ), + ], +) + +async def main(): + result = await Runner.run(orchestrator_agent, input="Say 'Hello, how are you?' in Spanish.") + print(result.final_output) +``` + +## Handling errors in function tools + +When you create a function tool via `@function_tool`, you can pass a `failure_error_function`. This is a function that provides an error response to the LLM in case the tool call crashes. + +- By default (i.e. if you don't pass anything), it runs a `default_tool_error_function` which tells the LLM an error occurred. +- If you pass your own error function, it runs that instead, and sends the response to the LLM. +- If you explicitly pass `None`, then any tool call errors will be re-raised for you to handle. This could be a `ModelBehaviorError` if the model produced invalid JSON, or a `UserError` if your code crashed, etc. + +If you are manually creating a `FunctionTool` object, then you must handle errors inside the `on_invoke_tool` function. diff --git a/docs/tracing.md b/docs/tracing.md new file mode 100644 index 0000000..fbf2ae4 --- /dev/null +++ b/docs/tracing.md @@ -0,0 +1,95 @@ +# Tracing + +The Agents SDK includes built-in tracing, collecting a comprehensive record of events during an agent run: LLM generations, tool calls, handoffs, guardrails, and even custom events that occur. Using the [Traces dashboard](https://platform.openai.com/traces), you can debug, visualize, and monitor your workflows during development and in production. + +!!!note + + Tracing is enabled by default. There are two ways to disable tracing: + + 1. You can globally disable tracing by setting the env var `OPENAI_AGENTS_DISABLE_TRACING=1` + 2. You can disable tracing for a single run by setting [`agents.run.RunConfig.tracing_disabled`][] to `True` + +## Traces and spans + +- **Traces** represent a single end-to-end operation of a "workflow". They're composed of Spans. Traces have the following properties: + - `workflow_name`: This is the logical workflow or app. For example "Code generation" or "Customer service". + - `trace_id`: A unique ID for the trace. Automatically generated if you don't pass one. Must have the format `trace_<32_alphanumeric>`. + - `group_id`: Optional group ID, to link multiple traces from the same conversation. For example, you might use a chat thread ID. + - `disabled`: If True, the trace will not be recorded. + - `metadata`: Optiona metadata for the trace. +- **Spans** represent operations that have a start and end time. Spans have: + - `started_at` and `ended_at` timestamps. + - `trace_id`, to represent the trace they belong to + - `parent_id`, which points to the parent Span of this Span (if any) + - `span_data`, which is information about the Span. For example, `AgentSpanData` contains information about the Agent, `GenerationSpanData` contains information about the LLM generation, etc. + +## Default tracing + +By default, the SDK traces the following: + +- The entire `Runner.{run, run_sync, run_streamed}()` is wrapped in a `trace()`. +- Each time an agent runs, it is wrapped in `agent_span()` +- LLM generations are wrapped in `generation_span()` +- Function tool calls are each wrapped in `function_span()` +- Guardrails are wrapped in `guardrail_span()` +- Handoffs are wrapped in `handoff_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]. + +In addition, you can set up [custom trace processors](#custom-tracing-processors) to push traces to other destinations (as a replacement, or secondary destination). + +## Higher level traces + +Sometimes, you might want multiple calls to `run()` to be part of a single trace. You can do this by wrapping the entire code in a `trace()`. + +```python +from agents import Agent, Runner, trace + +async def main(): + agent = Agent(name="Joke generator", instructions="Tell funny jokes.") + + with trace("Joke workflow"): # (1)! + first_result = await Runner.run(agent, "Tell me a joke") + second_result = await Runner.run(agent, f"Rate this joke: {first_output.final_output}") + print(f"Joke: {first_result.final_output}") + print(f"Rating: {second_result.final_output}") +``` + +1. Because the two calls to `Runner.run` are wrapped in a `with trace()`, the individual runs will be part of the overall trace rather than creating two traces. + +## Creating traces + +You can use the [`trace()`][agents.tracing.trace] function to create a trace. Traces need to be started and finished. You have two options to do so: + +1. **Recommended**: use the trace as a context manager, i.e. `with trace(...) as my_trace`. This will automatically start and end the trace at the right time. +2. You can also manually call [`trace.start()`][agents.tracing.Trace.start] and [`trace.finish()`][agents.tracing.Trace.finish]. + +The current trace is tracked via a Python [`contextvar`](https://docs.python.org/3/library/contextvars.html). This means that it works with concurrency automatically. If you manually start/end a trace, you'll need to pass `mark_as_current` and `reset_current` to `start()`/`finish()` to update the current trace. + +## Creating spans + +You can use the various [`*_span()`][agents.tracing.create] methods to create a span. In general, you don't need to manually create spans. A [`custom_span()`][agents.tracing.custom_span] function is available for tracking custom span information. + +Spans are automatically part of the current trace, and are nested under the nearest current span, which is tracked via a Python [`contextvar`](https://docs.python.org/3/library/contextvars.html). + +## 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]. + +## Custom tracing processors + +The high level architecture for tracing is: + +- At initialization, we create a global [`TraceProvider`][agents.tracing.setup.TraceProvider], which is responsible for creating traces. +- We configure the `TraceProvider` with a [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] that sends traces/spans in batches to a [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter], which exports the spans and traces to the OpenAI backend in batches. + +To customize this default setup, to send traces to alternative or additional backends or modifying exporter behavior, you have two options: + +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] lets you add an **additional** trace processor that will receive traces and spans as they are ready. This lets you do your own processing in addition to sending traces to OpenAI's backend. +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] lets you **replace** the default processors with your own trace processors. This means traces will not be sent to the OpenAI backend unless you include a `TracingProcessor` that does so. + +External trace processors include: + +- [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) diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e333a2e --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1,3 @@ +# Make the examples directory into a package to avoid top-level module name collisions. +# This is needed so that mypy treats files like examples/customer_service/main.py and +# examples/researcher_app/main.py as distinct modules rather than both named "main". diff --git a/examples/__pycache__/__init__.cpython-313.pyc b/examples/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b690047 Binary files /dev/null and b/examples/__pycache__/__init__.cpython-313.pyc differ diff --git a/examples/agent_patterns/README.md b/examples/agent_patterns/README.md new file mode 100644 index 0000000..4599b00 --- /dev/null +++ b/examples/agent_patterns/README.md @@ -0,0 +1,54 @@ +# Common agentic patterns + +This folder contains examples of different common patterns for agents. + +## Deterministic flows + +A common tactic is to break down a task into a series of smaller steps. Each task can be performed by an agent, and the output of one agent is used as input to the next. For example, if your task was to generate a story, you could break it down into the following steps: + +1. Generate an outline +2. Generate the story +3. Generate the ending + +Each of these steps can be performed by an agent. The output of one agent is used as input to the next. + +See the [`deterministic.py`](./deterministic.py) file for an example of this. + +## Handoffs and routing + +In many situations, you have specialized sub-agents that handle specific tasks. You can use handoffs to route the task to the right agent. + +For example, you might have a frontline agent that receives a request, and then hands off to a specialized agent based on the language of the request. +See the [`routing.py`](./routing.py) file for an example of this. + +## Agents as tools + +The mental model for handoffs is that the new agent "takes over". It sees the previous conversation history, and owns the conversation from that point onwards. However, this is not the only way to use agents. You can also use agents as a tool - the tool agent goes off and runs on its own, and then returns the result to the original agent. + +For example, you could model the translation task above as tool calls instead: rather than handing over to the language-specific agent, you could call the agent as a tool, and then use the result in the next step. This enables things like translating multiple languages at once. + +See the [`agents_as_tools.py`](./agents_as_tools.py) file for an example of this. + +## LLM-as-a-judge + +LLMs can often improve the quality of their output if given feedback. A common pattern is to generate a response using a model, and then use a second model to provide feedback. You can even use a small model for the initial generation and a larger model for the feedback, to optimize cost. + +For example, you could use an LLM to generate an outline for a story, and then use a second LLM to evaluate the outline and provide feedback. You can then use the feedback to improve the outline, and repeat until the LLM is satisfied with the outline. + +See the [`llm_as_a_judge.py`](./llm_as_a_judge.py) file for an example of this. + +## Parallelization + +Running multiple agents in parallel is a common pattern. This can be useful for both latency (e.g. if you have multiple steps that don't depend on each other) and also for other reasons e.g. generating multiple responses and picking the best one. + +See the [`parallelization.py`](./parallelization.py) file for an example of this. It runs a translation agent multiple times in parallel, and then picks the best translation. + +## Guardrails + +Related to parallelization, you often want to run input guardrails to make sure the inputs to your agents are valid. For example, if you have a customer support agent, you might want to make sure that the user isn't trying to ask for help with a math problem. + +You can definitely do this without any special Agents SDK features by using parallelization, but we support a special guardrail primitive. Guardrails can have a "tripwire" - if the tripwire is triggered, the agent execution will immediately stop and a `GuardrailTripwireTriggered` exception will be raised. + +This is really useful for latency: for example, you might have a very fast model that runs the guardrail and a slow model that runs the actual agent. You wouldn't want to wait for the slow model to finish, so guardrails let you quickly reject invalid inputs. + +See the [`guardrails.py`](./guardrails.py) file for an example of this. diff --git a/examples/agent_patterns/agents_as_tools.py b/examples/agent_patterns/agents_as_tools.py new file mode 100644 index 0000000..9fd118e --- /dev/null +++ b/examples/agent_patterns/agents_as_tools.py @@ -0,0 +1,79 @@ +import asyncio + +from agents import Agent, ItemHelpers, MessageOutputItem, Runner, trace + +""" +This example shows the agents-as-tools pattern. The frontline agent receives a user message and +then picks which agents to call, as tools. In this case, it picks from a set of translation +agents. +""" + +spanish_agent = Agent( + name="spanish_agent", + instructions="You translate the user's message to Spanish", + handoff_description="An english to spanish translator", +) + +french_agent = Agent( + name="french_agent", + instructions="You translate the user's message to French", + handoff_description="An english to french translator", +) + +italian_agent = Agent( + name="italian_agent", + instructions="You translate the user's message to Italian", + handoff_description="An english to italian translator", +) + +orchestrator_agent = Agent( + name="orchestrator_agent", + instructions=( + "You are a translation agent. You use the tools given to you to translate." + "If asked for multiple translations, you call the relevant tools in order." + "You never translate on your own, you always use the provided tools." + ), + tools=[ + spanish_agent.as_tool( + tool_name="translate_to_spanish", + tool_description="Translate the user's message to Spanish", + ), + french_agent.as_tool( + tool_name="translate_to_french", + tool_description="Translate the user's message to French", + ), + italian_agent.as_tool( + tool_name="translate_to_italian", + tool_description="Translate the user's message to Italian", + ), + ], +) + +synthesizer_agent = Agent( + name="synthesizer_agent", + instructions="You inspect translations, correct them if needed, and produce a final concatenated response.", +) + + +async def main(): + msg = input("Hi! What would you like translated, and to which languages? ") + + # Run the entire orchestration in a single trace + with trace("Orchestrator evaluator"): + orchestrator_result = await Runner.run(orchestrator_agent, msg) + + for item in orchestrator_result.new_items: + if isinstance(item, MessageOutputItem): + text = ItemHelpers.text_message_output(item) + if text: + print(f" - Translation step: {text}") + + synthesizer_result = await Runner.run( + synthesizer_agent, orchestrator_result.to_input_list() + ) + + print(f"\n\nFinal response:\n{synthesizer_result.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agent_patterns/deterministic.py b/examples/agent_patterns/deterministic.py new file mode 100644 index 0000000..0c163af --- /dev/null +++ b/examples/agent_patterns/deterministic.py @@ -0,0 +1,80 @@ +import asyncio + +from pydantic import BaseModel + +from agents import Agent, Runner, trace + +""" +This example demonstrates a deterministic flow, where each step is performed by an agent. +1. The first agent generates a story outline +2. We feed the outline into the second agent +3. The second agent checks if the outline is good quality and if it is a scifi story +4. If the outline is not good quality or not a scifi story, we stop here +5. If the outline is good quality and a scifi story, we feed the outline into the third agent +6. The third agent writes the story +""" + +story_outline_agent = Agent( + name="story_outline_agent", + instructions="Generate a very short story outline based on the user's input.", +) + + +class OutlineCheckerOutput(BaseModel): + good_quality: bool + is_scifi: bool + + +outline_checker_agent = Agent( + name="outline_checker_agent", + instructions="Read the given story outline, and judge the quality. Also, determine if it is a scifi story.", + output_type=OutlineCheckerOutput, +) + +story_agent = Agent( + name="story_agent", + instructions="Write a short story based on the given outline.", + output_type=str, +) + + +async def main(): + input_prompt = input("What kind of story do you want? ") + + # Ensure the entire workflow is a single trace + with trace("Deterministic story flow"): + # 1. Generate an outline + outline_result = await Runner.run( + story_outline_agent, + input_prompt, + ) + print("Outline generated") + + # 2. Check the outline + outline_checker_result = await Runner.run( + outline_checker_agent, + outline_result.final_output, + ) + + # 3. Add a gate to stop if the outline is not good quality or not a scifi story + assert isinstance(outline_checker_result.final_output, OutlineCheckerOutput) + if not outline_checker_result.final_output.good_quality: + print("Outline is not good quality, so we stop here.") + exit(0) + + if not outline_checker_result.final_output.is_scifi: + print("Outline is not a scifi story, so we stop here.") + exit(0) + + print("Outline is good quality and a scifi story, so we continue to write the story.") + + # 4. Write the story + story_result = await Runner.run( + story_agent, + outline_result.final_output, + ) + print(f"Story: {story_result.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agent_patterns/input_guardrails.py b/examples/agent_patterns/input_guardrails.py new file mode 100644 index 0000000..6259188 --- /dev/null +++ b/examples/agent_patterns/input_guardrails.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio + +from pydantic import BaseModel + +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + TResponseInputItem, + input_guardrail, +) + +""" +This example shows how to use guardrails. + +Guardrails are checks that run in parallel to the agent's execution. +They can be used to do things like: +- Check if input messages are off-topic +- Check that output messages don't violate any policies +- Take over control of the agent's execution if an unexpected input is detected + +In this example, we'll setup an input guardrail that trips if the user is asking to do math homework. +If the guardrail trips, we'll respond with a refusal message. +""" + + +### 1. An agent-based guardrail that is triggered if the user is asking to do math homework +class MathHomeworkOutput(BaseModel): + is_math_homework: bool + reasoning: str + + +guardrail_agent = Agent( + name="Guardrail check", + instructions="Check if the user is asking you to do their math homework.", + output_type=MathHomeworkOutput, +) + + +@input_guardrail +async def math_guardrail( + context: RunContextWrapper[None], agent: Agent, input: str | list[TResponseInputItem] +) -> GuardrailFunctionOutput: + """This is an input guardrail function, which happens to call an agent to check if the input + is a math homework question. + """ + result = await Runner.run(guardrail_agent, input, context=context.context) + final_output = result.final_output_as(MathHomeworkOutput) + + return GuardrailFunctionOutput( + output_info=final_output, + tripwire_triggered=not final_output.is_math_homework, + ) + + +### 2. The run loop + + +async def main(): + agent = Agent( + name="Customer support agent", + instructions="You are a customer support agent. You help customers with their questions.", + input_guardrails=[math_guardrail], + ) + + input_data: list[TResponseInputItem] = [] + + while True: + user_input = input("Enter a message: ") + input_data.append( + { + "role": "user", + "content": user_input, + } + ) + + try: + result = await Runner.run(agent, input_data) + print(result.final_output) + # If the guardrail didn't trigger, we use the result as the input for the next run + input_data = result.to_input_list() + except InputGuardrailTripwireTriggered: + # If the guardrail triggered, we instead add a refusal message to the input + message = "Sorry, I can't help you with your math homework." + print(message) + input_data.append( + { + "role": "assistant", + "content": message, + } + ) + + # Sample run: + # Enter a message: What's the capital of California? + # The capital of California is Sacramento. + # Enter a message: Can you help me solve for x: 2x + 5 = 11 + # Sorry, I can't help you with your math homework. + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agent_patterns/llm_as_a_judge.py b/examples/agent_patterns/llm_as_a_judge.py new file mode 100644 index 0000000..d13a67c --- /dev/null +++ b/examples/agent_patterns/llm_as_a_judge.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Literal + +from agents import Agent, ItemHelpers, Runner, TResponseInputItem, trace + +""" +This example shows the LLM as a judge pattern. The first agent generates an outline for a story. +The second agent judges the outline and provides feedback. We loop until the judge is satisfied +with the outline. +""" + +story_outline_generator = Agent( + name="story_outline_generator", + instructions=( + "You generate a very short story outline based on the user's input." + "If there is any feedback provided, use it to improve the outline." + ), +) + + +@dataclass +class EvaluationFeedback: + score: Literal["pass", "needs_improvement", "fail"] + feedback: str + + +evaluator = Agent[None]( + name="evaluator", + instructions=( + "You evaluate a story outline and decide if it's good enough." + "If it's not good enough, you provide feedback on what needs to be improved." + "Never give it a pass on the first try." + ), + output_type=EvaluationFeedback, +) + + +async def main() -> None: + msg = input("What kind of story would you like to hear? ") + input_items: list[TResponseInputItem] = [{"content": msg, "role": "user"}] + + latest_outline: str | None = None + + # We'll run the entire workflow in a single trace + with trace("LLM as a judge"): + while True: + story_outline_result = await Runner.run( + story_outline_generator, + input_items, + ) + + input_items = story_outline_result.to_input_list() + latest_outline = ItemHelpers.text_message_outputs(story_outline_result.new_items) + print("Story outline generated") + + evaluator_result = await Runner.run(evaluator, input_items) + result: EvaluationFeedback = evaluator_result.final_output + + print(f"Evaluator score: {result.score}") + + if result.score == "pass": + print("Story outline is good enough, exiting.") + break + + print("Re-running with feedback") + + input_items.append({"content": f"Feedback: {result.feedback}", "role": "user"}) + + print(f"Final story outline: {latest_outline}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agent_patterns/output_guardrails.py b/examples/agent_patterns/output_guardrails.py new file mode 100644 index 0000000..526a085 --- /dev/null +++ b/examples/agent_patterns/output_guardrails.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import asyncio +import json + +from pydantic import BaseModel, Field + +from agents import ( + Agent, + GuardrailFunctionOutput, + OutputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + output_guardrail, +) + +""" +This example shows how to use output guardrails. + +Output guardrails are checks that run on the final output of an agent. +They can be used to do things like: +- Check if the output contains sensitive data +- Check if the output is a valid response to the user's message + +In this example, we'll use a (contrived) example where we check if the agent's response contains +a phone number. +""" + + +# The agent's output type +class MessageOutput(BaseModel): + reasoning: str = Field(description="Thoughts on how to respond to the user's message") + response: str = Field(description="The response to the user's message") + user_name: str | None = Field(description="The name of the user who sent the message, if known") + + +@output_guardrail +async def sensitive_data_check( + context: RunContextWrapper, agent: Agent, output: MessageOutput +) -> GuardrailFunctionOutput: + phone_number_in_response = "650" in output.response + phone_number_in_reasoning = "650" in output.reasoning + + return GuardrailFunctionOutput( + output_info={ + "phone_number_in_response": phone_number_in_response, + "phone_number_in_reasoning": phone_number_in_reasoning, + }, + tripwire_triggered=phone_number_in_response or phone_number_in_reasoning, + ) + + +agent = Agent( + name="Assistant", + instructions="You are a helpful assistant.", + output_type=MessageOutput, + output_guardrails=[sensitive_data_check], +) + + +async def main(): + # This should be ok + await Runner.run(agent, "What's the capital of California?") + print("First message passed") + + # This should trip the guardrail + try: + result = await Runner.run( + agent, "My phone number is 650-123-4567. Where do you think I live?" + ) + print( + f"Guardrail didn't trip - this is unexpected. Output: {json.dumps(result.final_output.model_dump(), indent=2)}" + ) + + except OutputGuardrailTripwireTriggered as e: + print(f"Guardrail tripped. Info: {e.guardrail_result.output.output_info}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agent_patterns/parallelization.py b/examples/agent_patterns/parallelization.py new file mode 100644 index 0000000..fe2a8ec --- /dev/null +++ b/examples/agent_patterns/parallelization.py @@ -0,0 +1,61 @@ +import asyncio + +from agents import Agent, ItemHelpers, Runner, trace + +""" +This example shows the parallelization pattern. We run the agent three times in parallel, and pick +the best result. +""" + +spanish_agent = Agent( + name="spanish_agent", + instructions="You translate the user's message to Spanish", +) + +translation_picker = Agent( + name="translation_picker", + instructions="You pick the best Spanish translation from the given options.", +) + + +async def main(): + msg = input("Hi! Enter a message, and we'll translate it to Spanish.\n\n") + + # Ensure the entire workflow is a single trace + with trace("Parallel translation"): + res_1, res_2, res_3 = await asyncio.gather( + Runner.run( + spanish_agent, + msg, + ), + Runner.run( + spanish_agent, + msg, + ), + Runner.run( + spanish_agent, + msg, + ), + ) + + outputs = [ + ItemHelpers.text_message_outputs(res_1.new_items), + ItemHelpers.text_message_outputs(res_2.new_items), + ItemHelpers.text_message_outputs(res_3.new_items), + ] + + translations = "\n\n".join(outputs) + print(f"\n\nTranslations:\n\n{translations}") + + best_translation = await Runner.run( + translation_picker, + f"Input: {msg}\n\nTranslations:\n{translations}", + ) + + print("\n\n-----") + + print(f"Best translation: {best_translation.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agent_patterns/routing.py b/examples/agent_patterns/routing.py new file mode 100644 index 0000000..3dcaefa --- /dev/null +++ b/examples/agent_patterns/routing.py @@ -0,0 +1,70 @@ +import asyncio +import uuid + +from openai.types.responses import ResponseContentPartDoneEvent, ResponseTextDeltaEvent + +from agents import Agent, RawResponsesStreamEvent, Runner, TResponseInputItem, trace + +""" +This example shows the handoffs/routing pattern. The triage agent receives the first message, and +then hands off to the appropriate agent based on the language of the request. Responses are +streamed to the user. +""" + +french_agent = Agent( + name="french_agent", + instructions="You only speak French", +) + +spanish_agent = Agent( + name="spanish_agent", + instructions="You only speak Spanish", +) + +english_agent = Agent( + name="english_agent", + instructions="You only speak English", +) + +triage_agent = Agent( + name="triage_agent", + instructions="Handoff to the appropriate agent based on the language of the request.", + handoffs=[french_agent, spanish_agent, english_agent], +) + + +async def main(): + # We'll create an ID for this conversation, so we can link each trace + conversation_id = str(uuid.uuid4().hex[:16]) + + msg = input("Hi! We speak French, Spanish and English. How can I help? ") + agent = triage_agent + inputs: list[TResponseInputItem] = [{"content": msg, "role": "user"}] + + while True: + # Each conversation turn is a single trace. Normally, each input from the user would be an + # API request to your app, and you can wrap the request in a trace() + with trace("Routing example", group_id=conversation_id): + result = Runner.run_streamed( + agent, + input=inputs, + ) + async for event in result.stream_events(): + if not isinstance(event, RawResponsesStreamEvent): + continue + data = event.data + if isinstance(data, ResponseTextDeltaEvent): + print(data.delta, end="", flush=True) + elif isinstance(data, ResponseContentPartDoneEvent): + print("\n") + + inputs = result.to_input_list() + print("\n") + + user_msg = input("Enter a message: ") + inputs.append({"content": user_msg, "role": "user"}) + agent = result.current_agent + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/basic/agent_lifecycle_example.py b/examples/basic/agent_lifecycle_example.py new file mode 100644 index 0000000..bc0bbe4 --- /dev/null +++ b/examples/basic/agent_lifecycle_example.py @@ -0,0 +1,112 @@ +import asyncio +import random +from typing import Any + +from pydantic import BaseModel + +from agents import Agent, AgentHooks, RunContextWrapper, Runner, Tool, function_tool + + +class CustomAgentHooks(AgentHooks): + def __init__(self, display_name: str): + self.event_counter = 0 + self.display_name = display_name + + async def on_start(self, context: RunContextWrapper, agent: Agent) -> None: + self.event_counter += 1 + print(f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started") + + async def on_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} ended with output {output}" + ) + + async def on_handoff(self, context: RunContextWrapper, agent: Agent, source: Agent) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {source.name} handed off to {agent.name}" + ) + + async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started tool {tool.name}" + ) + + async def on_tool_end( + self, context: RunContextWrapper, agent: Agent, tool: Tool, result: str + ) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} ended tool {tool.name} with result {result}" + ) + + +### + + +@function_tool +def random_number(max: int) -> int: + """ + Generate a random number up to the provided maximum. + """ + return random.randint(0, max) + + +@function_tool +def multiply_by_two(x: int) -> int: + """Simple multiplication by two.""" + return x * 2 + + +class FinalResult(BaseModel): + number: int + + +multiply_agent = Agent( + name="Multiply Agent", + instructions="Multiply the number by 2 and then return the final result.", + tools=[multiply_by_two], + output_type=FinalResult, + hooks=CustomAgentHooks(display_name="Multiply 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.", + tools=[random_number], + output_type=FinalResult, + handoffs=[multiply_agent], + hooks=CustomAgentHooks(display_name="Start Agent"), +) + + +async def main() -> None: + user_input = input("Enter a max number: ") + await Runner.run( + start_agent, + input=f"Generate a random number between 0 and {user_input}.", + ) + + print("Done!") + + +if __name__ == "__main__": + asyncio.run(main()) +""" +$ python examples/basic/agent_lifecycle_example.py + +Enter a max number: 250 +### (Start Agent) 1: Agent Start Agent started +### (Start Agent) 2: Agent Start Agent started tool random_number +### (Start Agent) 3: Agent Start Agent ended tool random_number with result 37 +### (Start Agent) 4: Agent Start Agent started +### (Start Agent) 5: Agent Start Agent handed off to Multiply Agent +### (Multiply Agent) 1: Agent Multiply Agent started +### (Multiply Agent) 2: Agent Multiply Agent started tool multiply_by_two +### (Multiply Agent) 3: Agent Multiply Agent ended tool multiply_by_two with result 74 +### (Multiply Agent) 4: Agent Multiply Agent started +### (Multiply Agent) 5: Agent Multiply Agent ended with output number=74 +Done! +""" diff --git a/examples/basic/dynamic_system_prompt.py b/examples/basic/dynamic_system_prompt.py new file mode 100644 index 0000000..7bcf90c --- /dev/null +++ b/examples/basic/dynamic_system_prompt.py @@ -0,0 +1,69 @@ +import asyncio +import random +from typing import Literal + +from agents import Agent, RunContextWrapper, Runner + + +class CustomContext: + def __init__(self, style: Literal["haiku", "pirate", "robot"]): + self.style = style + + +def custom_instructions( + run_context: RunContextWrapper[CustomContext], agent: Agent[CustomContext] +) -> str: + context = run_context.context + if context.style == "haiku": + return "Only respond in haikus." + elif context.style == "pirate": + return "Respond as a pirate." + else: + return "Respond as a robot and say 'beep boop' a lot." + + +agent = Agent( + name="Chat agent", + instructions=custom_instructions, +) + + +async def main(): + choice: Literal["haiku", "pirate", "robot"] = random.choice(["haiku", "pirate", "robot"]) + context = CustomContext(style=choice) + print(f"Using style: {choice}\n") + + user_message = "Tell me a joke." + print(f"User: {user_message}") + result = await Runner.run(agent, user_message, context=context) + + print(f"Assistant: {result.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +$ python examples/basic/dynamic_system_prompt.py + +Using style: haiku + +User: Tell me a joke. +Assistant: Why don't eggs tell jokes? +They might crack each other's shells, +leaving yolk on face. + +$ python examples/basic/dynamic_system_prompt.py +Using style: robot + +User: Tell me a joke. +Assistant: Beep boop! Why was the robot so bad at soccer? Beep boop... because it kept kicking up a debug! Beep boop! + +$ python examples/basic/dynamic_system_prompt.py +Using style: pirate + +User: Tell me a joke. +Assistant: Why did the pirate go to school? + +To improve his arrr-ticulation! Har har har! 🏴‍☠️ +""" diff --git a/examples/basic/hello_world.py b/examples/basic/hello_world.py new file mode 100644 index 0000000..169290d --- /dev/null +++ b/examples/basic/hello_world.py @@ -0,0 +1,20 @@ +import asyncio + +from agents import Agent, Runner + + +async def main(): + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + ) + + result = await Runner.run(agent, "Tell me about recursion in programming.") + print(result.final_output) + # Function calls itself, + # Looping in smaller pieces, + # Endless by design. + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/basic/lifecycle_example.py b/examples/basic/lifecycle_example.py new file mode 100644 index 0000000..9b36510 --- /dev/null +++ b/examples/basic/lifecycle_example.py @@ -0,0 +1,118 @@ +import asyncio +import random +from typing import Any + +from pydantic import BaseModel + +from agents import Agent, RunContextWrapper, RunHooks, Runner, Tool, Usage, function_tool + + +class ExampleHooks(RunHooks): + def __init__(self): + self.event_counter = 0 + + def _usage_to_str(self, usage: Usage) -> str: + return f"{usage.requests} requests, {usage.input_tokens} input tokens, {usage.output_tokens} output tokens, {usage.total_tokens} total tokens" + + async def on_agent_start(self, context: RunContextWrapper, agent: Agent) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Agent {agent.name} started. Usage: {self._usage_to_str(context.usage)}" + ) + + async def on_agent_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Agent {agent.name} ended with output {output}. Usage: {self._usage_to_str(context.usage)}" + ) + + async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Tool {tool.name} started. Usage: {self._usage_to_str(context.usage)}" + ) + + async def on_tool_end( + self, context: RunContextWrapper, agent: Agent, tool: Tool, result: str + ) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Tool {tool.name} ended with result {result}. Usage: {self._usage_to_str(context.usage)}" + ) + + async def on_handoff( + self, context: RunContextWrapper, from_agent: Agent, to_agent: Agent + ) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Handoff from {from_agent.name} to {to_agent.name}. Usage: {self._usage_to_str(context.usage)}" + ) + + +hooks = ExampleHooks() + +### + + +@function_tool +def random_number(max: int) -> int: + """Generate a random number up to the provided max.""" + return random.randint(0, max) + + +@function_tool +def multiply_by_two(x: int) -> int: + """Return x times two.""" + return x * 2 + + +class FinalResult(BaseModel): + number: int + + +multiply_agent = Agent( + name="Multiply Agent", + instructions="Multiply the number by 2 and then return the final result.", + tools=[multiply_by_two], + output_type=FinalResult, +) + +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.", + tools=[random_number], + output_type=FinalResult, + handoffs=[multiply_agent], +) + + +async def main() -> None: + user_input = input("Enter a max number: ") + await Runner.run( + start_agent, + hooks=hooks, + input=f"Generate a random number between 0 and {user_input}.", + ) + + print("Done!") + + +if __name__ == "__main__": + asyncio.run(main()) +""" +$ python examples/basic/lifecycle_example.py + +Enter a max number: 250 +### 1: Agent Start Agent started. Usage: 0 requests, 0 input tokens, 0 output tokens, 0 total tokens +### 2: Tool random_number started. Usage: 1 requests, 148 input tokens, 15 output tokens, 163 total tokens +### 3: Tool random_number ended with result 101. Usage: 1 requests, 148 input tokens, 15 output tokens, 163 total tokens +### 4: Agent Start Agent started. Usage: 1 requests, 148 input tokens, 15 output tokens, 163 total tokens +### 5: Handoff from Start Agent to Multiply Agent. Usage: 2 requests, 323 input tokens, 30 output tokens, 353 total tokens +### 6: Agent Multiply Agent started. Usage: 2 requests, 323 input tokens, 30 output tokens, 353 total tokens +### 7: Tool multiply_by_two started. Usage: 3 requests, 504 input tokens, 46 output tokens, 550 total tokens +### 8: Tool multiply_by_two ended with result 202. Usage: 3 requests, 504 input tokens, 46 output tokens, 550 total tokens +### 9: Agent Multiply Agent started. Usage: 3 requests, 504 input tokens, 46 output tokens, 550 total tokens +### 10: Agent Multiply Agent ended with output number=202. Usage: 4 requests, 714 input tokens, 63 output tokens, 777 total tokens +Done! + +""" diff --git a/examples/basic/stream_items.py b/examples/basic/stream_items.py new file mode 100644 index 0000000..c1f2257 --- /dev/null +++ b/examples/basic/stream_items.py @@ -0,0 +1,65 @@ +import asyncio +import random + +from agents import Agent, ItemHelpers, Runner, function_tool + + +@function_tool +def how_many_jokes() -> int: + return random.randint(1, 10) + + +async def main(): + agent = Agent( + name="Joker", + instructions="First call the `how_many_jokes` tool, then tell that many jokes.", + tools=[how_many_jokes], + ) + + result = Runner.run_streamed( + agent, + input="Hello", + ) + print("=== Run starting ===") + async for event in result.stream_events(): + # We'll ignore the raw responses event deltas + if event.type == "raw_response_event": + continue + elif event.type == "agent_updated_stream_event": + print(f"Agent updated: {event.new_agent.name}") + continue + elif event.type == "run_item_stream_event": + if event.item.type == "tool_call_item": + print("-- Tool was called") + elif event.item.type == "tool_call_output_item": + print(f"-- Tool output: {event.item.output}") + elif event.item.type == "message_output_item": + print(f"-- Message output:\n {ItemHelpers.text_message_output(event.item)}") + else: + pass # Ignore other event types + + print("=== Run complete ===") + + +if __name__ == "__main__": + asyncio.run(main()) + + # === Run starting === + # Agent updated: Joker + # -- Tool was called + # -- Tool output: 4 + # -- Message output: + # Sure, here are four jokes for you: + + # 1. **Why don't skeletons fight each other?** + # They don't have the guts! + + # 2. **What do you call fake spaghetti?** + # An impasta! + + # 3. **Why did the scarecrow win an award?** + # Because he was outstanding in his field! + + # 4. **Why did the bicycle fall over?** + # Because it was two-tired! + # === Run complete === diff --git a/examples/basic/stream_text.py b/examples/basic/stream_text.py new file mode 100644 index 0000000..a73c1fe --- /dev/null +++ b/examples/basic/stream_text.py @@ -0,0 +1,21 @@ +import asyncio + +from openai.types.responses import ResponseTextDeltaEvent + +from agents import Agent, Runner + + +async def main(): + agent = Agent( + name="Joker", + instructions="You are a helpful assistant.", + ) + + result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") + async for event in result.stream_events(): + if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent): + print(event.data.delta, end="", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/customer_service/main.py b/examples/customer_service/main.py new file mode 100644 index 0000000..bd802e2 --- /dev/null +++ b/examples/customer_service/main.py @@ -0,0 +1,169 @@ +from __future__ import annotations as _annotations + +import asyncio +import random +import uuid + +from pydantic import BaseModel + +from agents import ( + Agent, + HandoffOutputItem, + ItemHelpers, + MessageOutputItem, + RunContextWrapper, + Runner, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, + function_tool, + handoff, + trace, +) +from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX + +### CONTEXT + + +class AirlineAgentContext(BaseModel): + passenger_name: str | None = None + confirmation_number: str | None = None + seat_number: str | None = None + flight_number: str | None = None + + +### TOOLS + + +@function_tool( + name_override="faq_lookup_tool", description_override="Lookup frequently asked questions." +) +async def faq_lookup_tool(question: str) -> str: + if "bag" in question or "baggage" in question: + return ( + "You are allowed to bring one bag on the plane. " + "It must be under 50 pounds and 22 inches x 14 inches x 9 inches." + ) + elif "seats" in question or "plane" in question: + return ( + "There are 120 seats on the plane. " + "There are 22 business class seats and 98 economy seats. " + "Exit rows are rows 4 and 16. " + "Rows 5-8 are Economy Plus, with extra legroom. " + ) + elif "wifi" in question: + return "We have free wifi on the plane, join Airline-Wifi" + return "I'm sorry, I don't know the answer to that question." + + +@function_tool +async def update_seat( + context: RunContextWrapper[AirlineAgentContext], confirmation_number: str, new_seat: str +) -> str: + """ + Update the seat for a given confirmation number. + + Args: + confirmation_number: The confirmation number for the flight. + new_seat: The new seat to update to. + """ + # Update the context based on the customer's input + context.context.confirmation_number = confirmation_number + context.context.seat_number = new_seat + # Ensure that the flight number has been set by the incoming handoff + assert context.context.flight_number is not None, "Flight number is required" + return f"Updated seat to {new_seat} for confirmation number {confirmation_number}" + + +### HOOKS + + +async def on_seat_booking_handoff(context: RunContextWrapper[AirlineAgentContext]) -> None: + flight_number = f"FLT-{random.randint(100, 999)}" + context.context.flight_number = flight_number + + +### AGENTS + +faq_agent = Agent[AirlineAgentContext]( + name="FAQ Agent", + handoff_description="A helpful agent that can answer questions about the airline.", + instructions=f"""{RECOMMENDED_PROMPT_PREFIX} + You are an FAQ agent. If you are speaking to a customer, you probably were transferred to from the triage agent. + Use the following routine to support the customer. + # Routine + 1. Identify the last question asked by the customer. + 2. Use the faq lookup tool to answer the question. Do not rely on your own knowledge. + 3. If you cannot answer the question, transfer back to the triage agent.""", + tools=[faq_lookup_tool], +) + +seat_booking_agent = Agent[AirlineAgentContext]( + name="Seat Booking Agent", + handoff_description="A helpful agent that can update a seat on a flight.", + instructions=f"""{RECOMMENDED_PROMPT_PREFIX} + You are a seat booking agent. If you are speaking to a customer, you probably were transferred to from the triage agent. + Use the following routine to support the customer. + # Routine + 1. Ask for their confirmation number. + 2. Ask the customer what their desired seat number is. + 3. Use the update seat tool to update the seat on the flight. + If the customer asks a question that is not related to the routine, transfer back to the triage agent. """, + tools=[update_seat], +) + +triage_agent = Agent[AirlineAgentContext]( + name="Triage Agent", + handoff_description="A triage agent that can delegate a customer's request to the appropriate agent.", + instructions=( + f"{RECOMMENDED_PROMPT_PREFIX} " + "You are a helpful triaging agent. You can use your tools to delegate questions to other appropriate agents." + ), + handoffs=[ + faq_agent, + handoff(agent=seat_booking_agent, on_handoff=on_seat_booking_handoff), + ], +) + +faq_agent.handoffs.append(triage_agent) +seat_booking_agent.handoffs.append(triage_agent) + + +### RUN + + +async def main(): + current_agent: Agent[AirlineAgentContext] = triage_agent + input_items: list[TResponseInputItem] = [] + context = AirlineAgentContext() + + # Normally, each input from the user would be an API request to your app, and you can wrap the request in a trace() + # Here, we'll just use a random UUID for the conversation ID + conversation_id = uuid.uuid4().hex[:16] + + while True: + user_input = input("Enter your message: ") + with trace("Customer service", group_id=conversation_id): + input_items.append({"content": user_input, "role": "user"}) + result = await Runner.run(current_agent, input_items, context=context) + + for new_item in result.new_items: + agent_name = new_item.agent.name + if isinstance(new_item, MessageOutputItem): + print(f"{agent_name}: {ItemHelpers.text_message_output(new_item)}") + elif isinstance(new_item, HandoffOutputItem): + print( + f"Handed off from {new_item.source_agent.name} to {new_item.target_agent.name}" + ) + elif isinstance(new_item, ToolCallItem): + print(f"{agent_name}: Calling a tool") + elif isinstance(new_item, ToolCallOutputItem): + print(f"{agent_name}: Tool call output: {new_item.output}") + else: + print(f"{agent_name}: Skipping item: {new_item.__class__.__name__}") + input_items = result.to_input_list() + current_agent = result.last_agent + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/handoffs/message_filter.py b/examples/handoffs/message_filter.py new file mode 100644 index 0000000..9dd56ef --- /dev/null +++ b/examples/handoffs/message_filter.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +import random + +from agents import Agent, HandoffInputData, Runner, function_tool, handoff, trace +from agents.extensions import handoff_filters + + +@function_tool +def random_number_tool(max: int) -> int: + """Return a random integer between 0 and the given maximum.""" + return random.randint(0, max) + + +def spanish_handoff_message_filter(handoff_message_data: HandoffInputData) -> HandoffInputData: + # First, we'll remove any tool-related messages from the message history + handoff_message_data = handoff_filters.remove_all_tools(handoff_message_data) + + # Second, we'll also remove the first two items from the history, just for demonstration + history = ( + tuple(handoff_message_data.input_history[2:]) + if isinstance(handoff_message_data.input_history, tuple) + else handoff_message_data.input_history + ) + + return HandoffInputData( + input_history=history, + pre_handoff_items=tuple(handoff_message_data.pre_handoff_items), + new_items=tuple(handoff_message_data.new_items), + ) + + +first_agent = Agent( + name="Assistant", + instructions="Be extremely concise.", + tools=[random_number_tool], +) + +spanish_agent = Agent( + name="Spanish Assistant", + instructions="You only speak Spanish and are extremely concise.", + handoff_description="A Spanish-speaking assistant.", +) + +second_agent = Agent( + name="Assistant", + instructions=( + "Be a helpful assistant. If the user speaks Spanish, handoff to the Spanish assistant." + ), + handoffs=[handoff(spanish_agent, input_filter=spanish_handoff_message_filter)], +) + + +async def main(): + # Trace the entire run as a single workflow + with trace(workflow_name="Message filtering"): + # 1. Send a regular message to the first agent + result = await Runner.run(first_agent, input="Hi, my name is Sora.") + + print("Step 1 done") + + # 2. Ask it to square a number + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [{"content": "Can you generate a random number between 0 and 100?", "role": "user"}], + ) + + print("Step 2 done") + + # 3. Call the second agent + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [ + { + "content": "I live in New York City. Whats the population of the city?", + "role": "user", + } + ], + ) + + print("Step 3 done") + + # 4. Cause a handoff to occur + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [ + { + "content": "Por favor habla en español. ¿Cuál es mi nombre y dónde vivo?", + "role": "user", + } + ], + ) + + print("Step 4 done") + + print("\n===Final messages===\n") + + # 5. That should have caused spanish_handoff_message_filter to be called, which means the + # output should be missing the first two messages, and have no tool calls. + # Let's print the messages to see what happened + for message in result.to_input_list(): + print(json.dumps(message, indent=2)) + # tool_calls = message.tool_calls if isinstance(message, AssistantMessage) else None + + # print(f"{message.role}: {message.content}\n - Tool calls: {tool_calls or 'None'}") + """ + $python examples/handoffs/message_filter.py + Step 1 done + Step 2 done + Step 3 done + Step 4 done + + ===Final messages=== + + { + "content": "Can you generate a random number between 0 and 100?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "Sure! Here's a random number between 0 and 100: **42**.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + { + "content": "I live in New York City. Whats the population of the city?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "As of the most recent estimates, the population of New York City is approximately 8.6 million people. However, this number is constantly changing due to various factors such as migration and birth rates. For the latest and most accurate information, it's always a good idea to check the official data from sources like the U.S. Census Bureau.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + { + "content": "Por favor habla en espa\u00f1ol. \u00bfCu\u00e1l es mi nombre y d\u00f3nde vivo?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "No tengo acceso a esa informaci\u00f3n personal, solo s\u00e9 lo que me has contado: vives en Nueva York.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + """ + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/handoffs/message_filter_streaming.py b/examples/handoffs/message_filter_streaming.py new file mode 100644 index 0000000..8d1b420 --- /dev/null +++ b/examples/handoffs/message_filter_streaming.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +import random + +from agents import Agent, HandoffInputData, Runner, function_tool, handoff, trace +from agents.extensions import handoff_filters + + +@function_tool +def random_number_tool(max: int) -> int: + """Return a random integer between 0 and the given maximum.""" + return random.randint(0, max) + + +def spanish_handoff_message_filter(handoff_message_data: HandoffInputData) -> HandoffInputData: + # First, we'll remove any tool-related messages from the message history + handoff_message_data = handoff_filters.remove_all_tools(handoff_message_data) + + # Second, we'll also remove the first two items from the history, just for demonstration + history = ( + tuple(handoff_message_data.input_history[2:]) + if isinstance(handoff_message_data.input_history, tuple) + else handoff_message_data.input_history + ) + + return HandoffInputData( + input_history=history, + pre_handoff_items=tuple(handoff_message_data.pre_handoff_items), + new_items=tuple(handoff_message_data.new_items), + ) + + +first_agent = Agent( + name="Assistant", + instructions="Be extremely concise.", + tools=[random_number_tool], +) + +spanish_agent = Agent( + name="Spanish Assistant", + instructions="You only speak Spanish and are extremely concise.", + handoff_description="A Spanish-speaking assistant.", +) + +second_agent = Agent( + name="Assistant", + instructions=( + "Be a helpful assistant. If the user speaks Spanish, handoff to the Spanish assistant." + ), + handoffs=[handoff(spanish_agent, input_filter=spanish_handoff_message_filter)], +) + + +async def main(): + # Trace the entire run as a single workflow + with trace(workflow_name="Streaming message filter"): + # 1. Send a regular message to the first agent + result = await Runner.run(first_agent, input="Hi, my name is Sora.") + + print("Step 1 done") + + # 2. Ask it to square a number + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [{"content": "Can you generate a random number between 0 and 100?", "role": "user"}], + ) + + print("Step 2 done") + + # 3. Call the second agent + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [ + { + "content": "I live in New York City. Whats the population of the city?", + "role": "user", + } + ], + ) + + print("Step 3 done") + + # 4. Cause a handoff to occur + stream_result = Runner.run_streamed( + second_agent, + input=result.to_input_list() + + [ + { + "content": "Por favor habla en español. ¿Cuál es mi nombre y dónde vivo?", + "role": "user", + } + ], + ) + async for _ in stream_result.stream_events(): + pass + + print("Step 4 done") + + print("\n===Final messages===\n") + + # 5. That should have caused spanish_handoff_message_filter to be called, which means the + # output should be missing the first two messages, and have no tool calls. + # Let's print the messages to see what happened + for item in stream_result.to_input_list(): + print(json.dumps(item, indent=2)) + """ + $python examples/handoffs/message_filter_streaming.py + Step 1 done + Step 2 done + Step 3 done + Tu nombre y lugar de residencia no los tengo disponibles. Solo sé que mencionaste vivir en la ciudad de Nueva York. + Step 4 done + + ===Final messages=== + + { + "content": "Can you generate a random number between 0 and 100?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "Sure! Here's a random number between 0 and 100: **37**.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + { + "content": "I live in New York City. Whats the population of the city?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "As of the latest estimates, New York City's population is approximately 8.5 million people. Would you like more information about the city?", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + { + "content": "Por favor habla en espa\u00f1ol. \u00bfCu\u00e1l es mi nombre y d\u00f3nde vivo?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "No s\u00e9 tu nombre, pero me dijiste que vives en Nueva York.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + """ + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/research_bot/README.md b/examples/research_bot/README.md new file mode 100644 index 0000000..4060983 --- /dev/null +++ b/examples/research_bot/README.md @@ -0,0 +1,25 @@ +# Research bot + +This is a simple example of a multi-agent research bot. To run it: + +```bash +python -m examples.research_bot.main +``` + +## Architecture + +The flow is: + +1. User enters their research topic +2. `planner_agent` comes up with a plan to search the web for information. The plan is a list of search queries, with a search term and a reason for each query. +3. For each search item, we run a `search_agent`, which uses the Web Search tool to search for that term and summarize the results. These all run in parallel. +4. Finally, the `writer_agent` receives the search summaries, and creates a written report. + +## Suggested improvements + +If you're building your own research bot, some ideas to add to this are: + +1. Retrieval: Add support for fetching relevant information from a vector store. You could use the File Search tool for this. +2. Image and file upload: Allow users to attach PDFs or other files, as baseline context for the research. +3. More planning and thinking: Models often produce better results given more time to think. Improve the planning process to come up with a better plan, and add an evaluation step so that the model can choose to improve it's results, search for more stuff, etc. +4. Code execution: Allow running code, which is useful for data analysis. diff --git a/examples/research_bot/__init__.py b/examples/research_bot/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/research_bot/__init__.py @@ -0,0 +1 @@ + diff --git a/examples/research_bot/__pycache__/__init__.cpython-313.pyc b/examples/research_bot/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3d1acc9 Binary files /dev/null and b/examples/research_bot/__pycache__/__init__.cpython-313.pyc differ diff --git a/examples/research_bot/__pycache__/main.cpython-313.pyc b/examples/research_bot/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..394e462 Binary files /dev/null and b/examples/research_bot/__pycache__/main.cpython-313.pyc differ diff --git a/examples/research_bot/__pycache__/manager.cpython-313.pyc b/examples/research_bot/__pycache__/manager.cpython-313.pyc new file mode 100644 index 0000000..aaef5de Binary files /dev/null and b/examples/research_bot/__pycache__/manager.cpython-313.pyc differ diff --git a/examples/research_bot/__pycache__/printer.cpython-313.pyc b/examples/research_bot/__pycache__/printer.cpython-313.pyc new file mode 100644 index 0000000..3e9ac76 Binary files /dev/null and b/examples/research_bot/__pycache__/printer.cpython-313.pyc differ diff --git a/examples/research_bot/agents/__init__.py b/examples/research_bot/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/research_bot/agents/__pycache__/__init__.cpython-313.pyc b/examples/research_bot/agents/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a094b5a Binary files /dev/null and b/examples/research_bot/agents/__pycache__/__init__.cpython-313.pyc differ diff --git a/examples/research_bot/agents/__pycache__/base_agent.cpython-313.pyc b/examples/research_bot/agents/__pycache__/base_agent.cpython-313.pyc new file mode 100644 index 0000000..f33d418 Binary files /dev/null and b/examples/research_bot/agents/__pycache__/base_agent.cpython-313.pyc differ diff --git a/examples/research_bot/agents/__pycache__/planner_agent.cpython-313.pyc b/examples/research_bot/agents/__pycache__/planner_agent.cpython-313.pyc new file mode 100644 index 0000000..b836aac Binary files /dev/null and b/examples/research_bot/agents/__pycache__/planner_agent.cpython-313.pyc differ diff --git a/examples/research_bot/agents/__pycache__/research_manager_agent.cpython-313.pyc b/examples/research_bot/agents/__pycache__/research_manager_agent.cpython-313.pyc new file mode 100644 index 0000000..edc3f5f Binary files /dev/null and b/examples/research_bot/agents/__pycache__/research_manager_agent.cpython-313.pyc differ diff --git a/examples/research_bot/agents/__pycache__/search_agent.cpython-313.pyc b/examples/research_bot/agents/__pycache__/search_agent.cpython-313.pyc new file mode 100644 index 0000000..b328124 Binary files /dev/null and b/examples/research_bot/agents/__pycache__/search_agent.cpython-313.pyc differ diff --git a/examples/research_bot/agents/__pycache__/summarization_agent.cpython-313.pyc b/examples/research_bot/agents/__pycache__/summarization_agent.cpython-313.pyc new file mode 100644 index 0000000..b809d7c Binary files /dev/null and b/examples/research_bot/agents/__pycache__/summarization_agent.cpython-313.pyc differ diff --git a/examples/research_bot/agents/__pycache__/writer_agent.cpython-313.pyc b/examples/research_bot/agents/__pycache__/writer_agent.cpython-313.pyc new file mode 100644 index 0000000..be550b1 Binary files /dev/null and b/examples/research_bot/agents/__pycache__/writer_agent.cpython-313.pyc differ diff --git a/examples/research_bot/agents/planner_agent.py b/examples/research_bot/agents/planner_agent.py new file mode 100644 index 0000000..e80a8e6 --- /dev/null +++ b/examples/research_bot/agents/planner_agent.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel + +from agents import Agent + +PROMPT = ( + "You are a helpful research assistant. Given a query, come up with a set of web searches " + "to perform to best answer the query. Output between 5 and 20 terms to query for." +) + + +class WebSearchItem(BaseModel): + reason: str + "Your reasoning for why this search is important to the query." + + query: str + "The search term to use for the web search." + + +class WebSearchPlan(BaseModel): + searches: list[WebSearchItem] + """A list of web searches to perform to best answer the query.""" + + +planner_agent = Agent( + name="PlannerAgent", + instructions=PROMPT, + model="gpt-4o", + output_type=WebSearchPlan, +) diff --git a/examples/research_bot/agents/search_agent.py b/examples/research_bot/agents/search_agent.py new file mode 100644 index 0000000..72cbc8e --- /dev/null +++ b/examples/research_bot/agents/search_agent.py @@ -0,0 +1,18 @@ +from agents import Agent, WebSearchTool +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" + "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." +) + +search_agent = Agent( + name="Search agent", + instructions=INSTRUCTIONS, + tools=[WebSearchTool()], + model_settings=ModelSettings(tool_choice="required"), +) diff --git a/examples/research_bot/agents/writer_agent.py b/examples/research_bot/agents/writer_agent.py new file mode 100644 index 0000000..7b7d01a --- /dev/null +++ b/examples/research_bot/agents/writer_agent.py @@ -0,0 +1,33 @@ +# Agent used to synthesize a final report from the individual summaries. +from pydantic import BaseModel + +from agents import Agent + +PROMPT = ( + "You are a senior researcher tasked with writing a cohesive report for a research query. " + "You will be provided with the original query, and some initial research done by a research " + "assistant.\n" + "You should first come up with an outline for the report that describes the structure and " + "flow of the report. Then, generate the report and return that as your final output.\n" + "The final output should be in markdown format, and it should be lengthy and detailed. Aim " + "for 5-10 pages of content, at least 1000 words." +) + + +class ReportData(BaseModel): + short_summary: str + """A short 2-3 sentence summary of the findings.""" + + markdown_report: str + """The final report""" + + follow_up_questions: list[str] + """Suggested topics to research further""" + + +writer_agent = Agent( + name="WriterAgent", + instructions=PROMPT, + model="o3-mini", + output_type=ReportData, +) diff --git a/examples/research_bot/main.py b/examples/research_bot/main.py new file mode 100644 index 0000000..a0fd43d --- /dev/null +++ b/examples/research_bot/main.py @@ -0,0 +1,12 @@ +import asyncio + +from .manager import ResearchManager + + +async def main() -> None: + query = input("What would you like to research? ") + await ResearchManager().run(query) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/research_bot/manager.py b/examples/research_bot/manager.py new file mode 100644 index 0000000..47306f1 --- /dev/null +++ b/examples/research_bot/manager.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +import time + +from rich.console import Console + +from agents import Runner, custom_span, gen_trace_id, trace + +from .agents.planner_agent import WebSearchItem, WebSearchPlan, planner_agent +from .agents.search_agent import search_agent +from .agents.writer_agent import ReportData, writer_agent +from .printer import Printer + + +class ResearchManager: + def __init__(self): + self.console = Console() + self.printer = Printer(self.console) + + async def run(self, query: str) -> None: + trace_id = gen_trace_id() + with trace("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( + "starting", + "Starting research...", + is_done=True, + hide_checkmark=True, + ) + search_plan = await self._plan_searches(query) + search_results = await self._perform_searches(search_plan) + report = await self._write_report(query, search_results) + + 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("\n\n=====REPORT=====\n\n") + print(f"Report: {report.markdown_report}") + print("\n\n=====FOLLOW UP QUESTIONS=====\n\n") + follow_up_questions = "\n".join(report.follow_up_questions) + print(f"Follow up questions: {follow_up_questions}") + + async def _plan_searches(self, query: str) -> WebSearchPlan: + 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(WebSearchPlan) + + async def _perform_searches(self, search_plan: WebSearchPlan) -> list[str]: + with custom_span("Search the web"): + self.printer.update_item("searching", "Searching...") + num_completed = 0 + tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches] + results = [] + 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: WebSearchItem) -> str | None: + input = f"Search term: {item.query}\nReason for searching: {item.reason}" + try: + result = await Runner.run( + search_agent, + input, + ) + return str(result.final_output) + except Exception: + return None + + async def _write_report(self, query: str, search_results: list[str]) -> ReportData: + self.printer.update_item("writing", "Thinking about report...") + input = f"Original query: {query}\nSummarized search results: {search_results}" + result = Runner.run_streamed( + writer_agent, + input, + ) + update_messages = [ + "Thinking about report...", + "Planning report structure...", + "Writing outline...", + "Creating sections...", + "Cleaning up formatting...", + "Finalizing report...", + "Finishing 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(ReportData) diff --git a/examples/research_bot/printer.py b/examples/research_bot/printer.py new file mode 100644 index 0000000..e820c75 --- /dev/null +++ b/examples/research_bot/printer.py @@ -0,0 +1,41 @@ +from typing import Any + +from rich.console import Console, Group +from rich.live import Live +from rich.spinner import Spinner + + +class Printer: + def __init__(self, console: Console): + 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)) diff --git a/examples/research_bot/sample_outputs/product_recs.md b/examples/research_bot/sample_outputs/product_recs.md new file mode 100644 index 0000000..70789eb --- /dev/null +++ b/examples/research_bot/sample_outputs/product_recs.md @@ -0,0 +1,180 @@ +# Comprehensive Guide on Best Surfboards for Beginners: Transitioning, Features, and Budget Options + +Surfing is not only a sport but a lifestyle that hooks its enthusiasts with the allure of riding waves and connecting with nature. For beginners, selecting the right surfboard is critical to safety, learning, and performance. This comprehensive guide has been crafted to walk through the essential aspects of choosing the ideal surfboard for beginners, especially those looking to transition from an 11-foot longboard to a shorter, more dynamic board. We discuss various board types, materials, design elements, and budget ranges, providing a detailed road map for both new surfers and those in the process of progression. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Board Types and Design Considerations](#board-types-and-design-considerations) +3. [Key Board Dimensions and Features](#key-board-dimensions-and-features) +4. [Materials: Soft-Top vs. Hard-Top Boards](#materials-soft-top-vs-hard-top-boards) +5. [Tips for Transitioning from Longboards to Shorter Boards](#tips-for-transitioning-from-longboards-to-shorter-boards) +6. [Budget and Pricing Options](#budget-and-pricing-options) +7. [Recommended Models and Buying Options](#recommended-models-and-buying-options) +8. [Conclusion](#conclusion) +9. [Follow-up Questions](#follow-up-questions) + +--- + +## Introduction + +Surfing is a dynamic sport that requires not only skill and technique but also the proper equipment. For beginners, the right surfboard can make the difference between a frustrating experience and one that builds confidence and enthusiasm. Many newcomers start with longboards due to their stability and ease of paddling; however, as skills develop, transitioning to a shorter board might be desirable for enhancing maneuverability and performance. This guide is designed for surfers who can already catch waves on an 11-foot board and are now considering stepping down to a more versatile option. + +The overarching goal of this document is to help beginners identify which surfboard characteristics are most important, including board length, width, thickness, volume, and materials, while also considering factors like weight distribution, buoyancy, and control. We will also take a look at board types that are particularly welcoming for beginners and discuss gradual transitioning strategies. + +--- + +## Board Types and Design Considerations + +Choosing a board involves understanding the variety of designs available. Below are the main types of surfboards that cater to beginners and transitional surfers: + +### Longboards and Mini-Mals + +Longboards, typically 8 to 11 feet in length, provide ample stability, smoother paddling, and are well-suited for wave-catching. Their generous volume and width allow beginners to build confidence when standing up and riding waves. Mini-mal or mini-malibus (often around 8 to 9 feet) are a popular bridge between the longboard and the more agile shortboard, offering both stability and moderate maneuverability, which makes them excellent for gradual progress. + +### Funboards and Hybrids + +Funboards and hybrid boards blend the benefits of longboards and shortboards. They typically range from 6’6" to 8’0" in length, with extra volume and width that help preserve stability while introducing elements of sharper turning and improved agility. Hybrids are particularly helpful for surfers transitioning from longboards, as they maintain some of the buoyancy and ease of catching waves, yet offer a taste of the performance found in smaller boards. + +### Shortboards + +Shortboards emphasize performance, maneuverability, and a more responsive ride. However, they have less volume and require stronger paddling, quicker pop-up techniques, and more refined balance. For beginners, moving to a traditional shortboard immediately can be challenging. It is generally advised to make a gradual transition, potentially starting with a funboard or hybrid before making a direct leap to a performance shortboard. + +--- + +## Key Board Dimensions and Features + +When selecting a beginner surfboard, several key dimensions and features drastically affect performance, ease of learning, and safety: + +### Length and Width + +- **Length**: Starting with an 8 to 9-foot board is ideal. Longer boards offer enhanced stability and improved paddling capabilities. Gradual downsizing is recommended if you plan to move from an 11-foot board. +- **Width**: A board with a width over 20 inches provides greater stability and facilitates balance, especially vital for beginners. + +### Thickness and Volume + +- **Thickness**: Typically around 2.5 to 3 inches. Thicker decks increase buoyancy, allowing the surfer to paddle easier while catching waves. +- **Volume**: Measured in liters, volume is critical in understanding a board's flotation capacity. Higher volumes (e.g., 60-100 liters) are essential for beginners as they make the board more forgiving and stable. Suitable volumes might vary according to the surfer’s weight and experience level. + +### Nose and Tail Shape + +- **Nose Shape**: A wide, rounded nose expands the board’s planing surface, which can help in catching waves sooner and maintaining stability as you ride. +- **Tail Design**: Square or rounded tails are generally recommended as they enhance stability and allow for controlled turns, essential during the learning phase. + +### Rocker + +- **Rocker**: This is the curvature of the board from nose to tail. For beginners, a minimal or relaxed rocker provides better stability and ease during paddling. A steeper rocker might be introduced progressively as the surfer’s skills improve. + +--- + +## Materials: Soft-Top vs. Hard-Top Boards + +The material composition of a surfboard is a crucial factor in determining its performance, durability, and safety. Beginners have two primary choices: + +### Soft-Top (Foam) Boards + +Soft-top boards are constructed almost entirely from foam. Their attributes include: + +- **Safety and Forgiveness**: The foam construction minimizes injury upon impact which is advantageous for beginners who might fall frequently. +- **Stability and Buoyancy**: These boards typically offer greater buoyancy due to their softer material and thicker construction, easing the initial learning process. +- **Maintenance**: They often require less maintenance—there is typically no need for waxing and they are more resistant to dings and scratches. + +However, as a surfer’s skills progress, a soft-top might limit maneuverability and overall performance. + +### Hard-Top Boards + +Hard-tops, in contrast, offer a more traditional surfboard feel. They generally rely on a foam core encased in resin, with two prevalent combinations: + +- **PU (Polyurethane) Core with Polyester Resin**: This combination gives a classic feel and is relatively economical; however, these boards can be heavier and, as they age, more prone to damage. +- **EPS (Expanded Polystyrene) Core with Epoxy Resin**: Lightweight and durable, EPS boards are often more buoyant and resistant to damage, although they usually carry a higher price tag and may be less forgiving. + +Deciding between soft-top and hard-top boards often depends on a beginner’s progression goals, overall comfort, and budget constraints. + +--- + +## Tips for Transitioning from Longboards to Shorter Boards + +For surfers who have mastered the basics on an 11-foot board, the transition to a shorter board requires careful consideration, patience, and incremental changes. Here are some key tips: + +### Gradual Downsizing + +Experts recommend reducing the board length gradually—by about a foot at a time—to allow the body to adjust slowly to a board with less buoyancy and more responsiveness. This process helps maintain wave-catching ability and reduces the shock of transitioning to a very different board feel. + +### Strengthening Core Skills + +Before transitioning, make sure your surfing fundamentals are solid. Focus on practicing: + +- **Steep Take-offs**: Ensure that your pop-up is swift and robust to keep pace with shorter boards that demand a rapid transition from paddling to standing. +- **Angling and Paddling Techniques**: Learn to angle your takeoffs properly to compensate for the lower buoyancy and increased maneuverability of shorter boards. + +### Experimenting with Rentals or Borrowed Boards + +If possible, try out a friend’s shorter board or rent one for a day to experience firsthand the differences in performance. This practical trial can provide valuable insights and inform your decision before making a purchase. + +--- + +## Budget and Pricing Options + +Surfboards are available across a range of prices to match different budgets. Whether you are looking for an affordable beginner board or a more expensive model that grows with your skills, it’s important to understand what features you can expect at different price points. + +### Budget-Friendly Options + +For those on a tight budget, several entry-level models offer excellent value. Examples include: + +- **Wavestorm 8' Classic Pinline Surfboard**: Priced affordably, this board is popular for its ease of use, ample volume, and forgiving nature. Despite its low cost, it delivers the stability needed to get started. +- **Liquid Shredder EZ Slider Foamie**: A smaller board catering to younger or lighter surfers, this budget option provides easy paddling and a minimal risk of injury due to its soft construction. + +### Moderate Price Range + +As you move into the intermediate range, boards typically become slightly more specialized in their design, offering features such as improved stringer systems or versatile fin setups. These are excellent for surfers who wish to continue progressing their skills without compromising stability. Many surfboard packages from retailers also bundle a board with essential accessories like board bags, leashes, and wax for additional savings. + +### Higher-End Models and Transitional Packages + +For surfers looking for durability, performance, and advanced design features, investing in an EPS/epoxy board might be ideal. Although they come at a premium, these boards are lightweight, strong, and customizable with various fin configurations. Some options include boards from brands like South Bay Board Co. and ISLE, which combine high-quality construction with beginner-friendly features that help mediate the transition from longboard to shortboard performance. + +--- + +## Recommended Models and Buying Options + +Based on extensive research and community recommendations, here are some standout models and tips on where to buy: + +### Recommended Models + +- **South Bay Board Co. 8'8" Heritage**: Combining foam and resin construction, this board is ideal for beginners who need stability and a forgiving surface. Its 86-liter volume suits both lightweight and somewhat heavier surfers. +- **Rock-It 8' Big Softy**: With a high volume and an easy paddling profile, this board is designed for beginners, offering ample buoyancy to smooth out the learning curve. +- **Wave Bandit EZ Rider Series**: Available in multiple lengths (7', 8', 9'), these boards offer versatility, with construction features that balance the stability of longboards and the agility required for shorter boards. +- **Hybrid/Funboards Like the Poacher Funboard**: Perfect for transitioning surfers, these boards blend the ease of catching waves with the capability for more dynamic maneuvers. + +### Buying Options + +- **Surf Shops and Local Retailers**: Traditional surf shops allow you to test different boards, which is ideal for assessing the board feel and condition—especially if you are considering a used board. +- **Online Retailers and Marketplaces**: Websites like Evo, Surfboards Direct, and even local online marketplaces like Craigslist and Facebook Marketplace provide options that range from new to gently used boards. Always inspect reviews and verify seller policies before purchase. +- **Package Deals and Bundles**: Many retailers offer bundled packages that include not just the board, but also essentials like a leash, wax, fins, and board bags. These packages can be more cost-effective and are great for beginners who need a complete surf kit. + +--- + +## Conclusion + +Selecting the right surfboard as a beginner is about balancing various factors: stability, buoyancy, maneuverability, and budget. + +For those who have honed the basics using an 11-foot longboard, the transition to a shorter board should be gradual. Start by focusing on boards that preserve stability—such as funboards and hybrids—before moving to the more performance-oriented shortboards. Key characteristics like board length, width, thickness, volume, and material profoundly influence your surfing experience. Soft-top boards provide a forgiving entry point, while hard-top boards, especially those with EPS cores and epoxy resin, offer benefits for more advanced progression despite the increased learning curve. + +Emphasizing fundamentals like proper pop-up technique and effective paddle work will ease the transition and ensure that the new board complements your evolving skills. Additionally, understanding the pricing spectrum—from budget-friendly models to premium options—allows you to make an informed purchase that suits both your financial and performance needs. + +With a thoughtful approach to board selection, you can enhance your learning curve, enjoy safer sessions in the water, and ultimately develop the skills necessary to master the diverse challenges surfing presents. Whether your goal is to ride gentle waves or eventually experiment with sharper turns and dynamic maneuvers, choosing the right board is your first step towards a rewarding and sustainable surfing journey. + +--- + +## Follow-up Questions + +1. What is your current budget range for a new surfboard, or are you considering buying used? +2. How frequently do you plan to surf, and in what type of wave conditions? +3. Are you interested in a board that you can grow into as your skills progress, or do you prefer one that is more specialized for certain conditions? +4. Would you be interested in additional equipment bundles (like fins, leashes, boards bags) offered by local retailers or online shops? +5. Have you had the opportunity to test ride any boards before, and what feedback did you gather from that experience? + +--- + +With this detailed guide, beginners should now have a comprehensive understanding of the surfboard market and the key factors influencing board performance, safety, and ease of progression. Happy surfing, and may you find the perfect board that rides the waves as beautifully as your passion for the sport! diff --git a/examples/research_bot/sample_outputs/product_recs.txt b/examples/research_bot/sample_outputs/product_recs.txt new file mode 100644 index 0000000..78865f2 --- /dev/null +++ b/examples/research_bot/sample_outputs/product_recs.txt @@ -0,0 +1,212 @@ +# Terminal output for a product recommendation related query. See product_recs.md for final report. + +$ uv run python -m examples.research_bot.main + +What would you like to research? Best surfboards for beginners. I can catch my own waves, but previously used an 11ft board. What should I look for, what are my options? Various budget ranges. +View trace: https://platform.openai.com/traces/trace_... +Starting research... +✅ Will perform 15 searches +✅ Searching... 15/15 completed +✅ Finishing report... +✅ Report summary + +This report provides a detailed guide on selecting the best surfboards for beginners, especially for those transitioning from an 11-foot longboard to a +shorter board. It covers design considerations such as board dimensions, shape, materials, and volume, while comparing soft-top and hard-top boards. In +addition, the report discusses various budget ranges, recommended board models, buying options (both new and used), and techniques to ease the transition to +more maneuverable boards. By understanding these factors, beginner surfers can select a board that not only enhances their skills but also suits their +individual needs. + + +=====REPORT===== + + +Report: # Comprehensive Guide on Best Surfboards for Beginners: Transitioning, Features, and Budget Options + +Surfing is not only a sport but a lifestyle that hooks its enthusiasts with the allure of riding waves and connecting with nature. For beginners, selecting the right surfboard is critical to safety, learning, and performance. This comprehensive guide has been crafted to walk through the essential aspects of choosing the ideal surfboard for beginners, especially those looking to transition from an 11-foot longboard to a shorter, more dynamic board. We discuss various board types, materials, design elements, and budget ranges, providing a detailed road map for both new surfers and those in the process of progression. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Board Types and Design Considerations](#board-types-and-design-considerations) +3. [Key Board Dimensions and Features](#key-board-dimensions-and-features) +4. [Materials: Soft-Top vs. Hard-Top Boards](#materials-soft-top-vs-hard-top-boards) +5. [Tips for Transitioning from Longboards to Shorter Boards](#tips-for-transitioning-from-longboards-to-shorter-boards) +6. [Budget and Pricing Options](#budget-and-pricing-options) +7. [Recommended Models and Buying Options](#recommended-models-and-buying-options) +8. [Conclusion](#conclusion) +9. [Follow-up Questions](#follow-up-questions) + +--- + +## Introduction + +Surfing is a dynamic sport that requires not only skill and technique but also the proper equipment. For beginners, the right surfboard can make the difference between a frustrating experience and one that builds confidence and enthusiasm. Many newcomers start with longboards due to their stability and ease of paddling; however, as skills develop, transitioning to a shorter board might be desirable for enhancing maneuverability and performance. This guide is designed for surfers who can already catch waves on an 11-foot board and are now considering stepping down to a more versatile option. + +The overarching goal of this document is to help beginners identify which surfboard characteristics are most important, including board length, width, thickness, volume, and materials, while also considering factors like weight distribution, buoyancy, and control. We will also take a look at board types that are particularly welcoming for beginners and discuss gradual transitioning strategies. + +--- + +## Board Types and Design Considerations + +Choosing a board involves understanding the variety of designs available. Below are the main types of surfboards that cater to beginners and transitional surfers: + +### Longboards and Mini-Mals + +Longboards, typically 8 to 11 feet in length, provide ample stability, smoother paddling, and are well-suited for wave-catching. Their generous volume and width allow beginners to build confidence when standing up and riding waves. Mini-mal or mini-malibus (often around 8 to 9 feet) are a popular bridge between the longboard and the more agile shortboard, offering both stability and moderate maneuverability, which makes them excellent for gradual progress. + +### Funboards and Hybrids + +Funboards and hybrid boards blend the benefits of longboards and shortboards. They typically range from 6’6" to 8’0" in length, with extra volume and width that help preserve stability while introducing elements of sharper turning and improved agility. Hybrids are particularly helpful for surfers transitioning from longboards, as they maintain some of the buoyancy and ease of catching waves, yet offer a taste of the performance found in smaller boards. + +### Shortboards + +Shortboards emphasize performance, maneuverability, and a more responsive ride. However, they have less volume and require stronger paddling, quicker pop-up techniques, and more refined balance. For beginners, moving to a traditional shortboard immediately can be challenging. It is generally advised to make a gradual transition, potentially starting with a funboard or hybrid before making a direct leap to a performance shortboard. + +--- + +## Key Board Dimensions and Features + +When selecting a beginner surfboard, several key dimensions and features drastically affect performance, ease of learning, and safety: + +### Length and Width + +- **Length**: Starting with an 8 to 9-foot board is ideal. Longer boards offer enhanced stability and improved paddling capabilities. Gradual downsizing is recommended if you plan to move from an 11-foot board. +- **Width**: A board with a width over 20 inches provides greater stability and facilitates balance, especially vital for beginners. + +### Thickness and Volume + +- **Thickness**: Typically around 2.5 to 3 inches. Thicker decks increase buoyancy, allowing the surfer to paddle easier while catching waves. +- **Volume**: Measured in liters, volume is critical in understanding a board's flotation capacity. Higher volumes (e.g., 60-100 liters) are essential for beginners as they make the board more forgiving and stable. Suitable volumes might vary according to the surfer’s weight and experience level. + +### Nose and Tail Shape + +- **Nose Shape**: A wide, rounded nose expands the board’s planing surface, which can help in catching waves sooner and maintaining stability as you ride. +- **Tail Design**: Square or rounded tails are generally recommended as they enhance stability and allow for controlled turns, essential during the learning phase. + +### Rocker + +- **Rocker**: This is the curvature of the board from nose to tail. For beginners, a minimal or relaxed rocker provides better stability and ease during paddling. A steeper rocker might be introduced progressively as the surfer’s skills improve. + +--- + +## Materials: Soft-Top vs. Hard-Top Boards + +The material composition of a surfboard is a crucial factor in determining its performance, durability, and safety. Beginners have two primary choices: + +### Soft-Top (Foam) Boards + +Soft-top boards are constructed almost entirely from foam. Their attributes include: + +- **Safety and Forgiveness**: The foam construction minimizes injury upon impact which is advantageous for beginners who might fall frequently. +- **Stability and Buoyancy**: These boards typically offer greater buoyancy due to their softer material and thicker construction, easing the initial learning process. +- **Maintenance**: They often require less maintenance—there is typically no need for waxing and they are more resistant to dings and scratches. + +However, as a surfer’s skills progress, a soft-top might limit maneuverability and overall performance. + +### Hard-Top Boards + +Hard-tops, in contrast, offer a more traditional surfboard feel. They generally rely on a foam core encased in resin, with two prevalent combinations: + +- **PU (Polyurethane) Core with Polyester Resin**: This combination gives a classic feel and is relatively economical; however, these boards can be heavier and, as they age, more prone to damage. +- **EPS (Expanded Polystyrene) Core with Epoxy Resin**: Lightweight and durable, EPS boards are often more buoyant and resistant to damage, although they usually carry a higher price tag and may be less forgiving. + +Deciding between soft-top and hard-top boards often depends on a beginner’s progression goals, overall comfort, and budget constraints. + +--- + +## Tips for Transitioning from Longboards to Shorter Boards + +For surfers who have mastered the basics on an 11-foot board, the transition to a shorter board requires careful consideration, patience, and incremental changes. Here are some key tips: + +### Gradual Downsizing + +Experts recommend reducing the board length gradually—by about a foot at a time—to allow the body to adjust slowly to a board with less buoyancy and more responsiveness. This process helps maintain wave-catching ability and reduces the shock of transitioning to a very different board feel. + +### Strengthening Core Skills + +Before transitioning, make sure your surfing fundamentals are solid. Focus on practicing: + +- **Steep Take-offs**: Ensure that your pop-up is swift and robust to keep pace with shorter boards that demand a rapid transition from paddling to standing. +- **Angling and Paddling Techniques**: Learn to angle your takeoffs properly to compensate for the lower buoyancy and increased maneuverability of shorter boards. + +### Experimenting with Rentals or Borrowed Boards + +If possible, try out a friend’s shorter board or rent one for a day to experience firsthand the differences in performance. This practical trial can provide valuable insights and inform your decision before making a purchase. + +--- + +## Budget and Pricing Options + +Surfboards are available across a range of prices to match different budgets. Whether you are looking for an affordable beginner board or a more expensive model that grows with your skills, it’s important to understand what features you can expect at different price points. + +### Budget-Friendly Options + +For those on a tight budget, several entry-level models offer excellent value. Examples include: + +- **Wavestorm 8' Classic Pinline Surfboard**: Priced affordably, this board is popular for its ease of use, ample volume, and forgiving nature. Despite its low cost, it delivers the stability needed to get started. +- **Liquid Shredder EZ Slider Foamie**: A smaller board catering to younger or lighter surfers, this budget option provides easy paddling and a minimal risk of injury due to its soft construction. + +### Moderate Price Range + +As you move into the intermediate range, boards typically become slightly more specialized in their design, offering features such as improved stringer systems or versatile fin setups. These are excellent for surfers who wish to continue progressing their skills without compromising stability. Many surfboard packages from retailers also bundle a board with essential accessories like board bags, leashes, and wax for additional savings. + +### Higher-End Models and Transitional Packages + +For surfers looking for durability, performance, and advanced design features, investing in an EPS/epoxy board might be ideal. Although they come at a premium, these boards are lightweight, strong, and customizable with various fin configurations. Some options include boards from brands like South Bay Board Co. and ISLE, which combine high-quality construction with beginner-friendly features that help mediate the transition from longboard to shortboard performance. + +--- + +## Recommended Models and Buying Options + +Based on extensive research and community recommendations, here are some standout models and tips on where to buy: + +### Recommended Models + +- **South Bay Board Co. 8'8" Heritage**: Combining foam and resin construction, this board is ideal for beginners who need stability and a forgiving surface. Its 86-liter volume suits both lightweight and somewhat heavier surfers. +- **Rock-It 8' Big Softy**: With a high volume and an easy paddling profile, this board is designed for beginners, offering ample buoyancy to smooth out the learning curve. +- **Wave Bandit EZ Rider Series**: Available in multiple lengths (7', 8', 9'), these boards offer versatility, with construction features that balance the stability of longboards and the agility required for shorter boards. +- **Hybrid/Funboards Like the Poacher Funboard**: Perfect for transitioning surfers, these boards blend the ease of catching waves with the capability for more dynamic maneuvers. + +### Buying Options + +- **Surf Shops and Local Retailers**: Traditional surf shops allow you to test different boards, which is ideal for assessing the board feel and condition—especially if you are considering a used board. +- **Online Retailers and Marketplaces**: Websites like Evo, Surfboards Direct, and even local online marketplaces like Craigslist and Facebook Marketplace provide options that range from new to gently used boards. Always inspect reviews and verify seller policies before purchase. +- **Package Deals and Bundles**: Many retailers offer bundled packages that include not just the board, but also essentials like a leash, wax, fins, and board bags. These packages can be more cost-effective and are great for beginners who need a complete surf kit. + +--- + +## Conclusion + +Selecting the right surfboard as a beginner is about balancing various factors: stability, buoyancy, maneuverability, and budget. + +For those who have honed the basics using an 11-foot longboard, the transition to a shorter board should be gradual. Start by focusing on boards that preserve stability—such as funboards and hybrids—before moving to the more performance-oriented shortboards. Key characteristics like board length, width, thickness, volume, and material profoundly influence your surfing experience. Soft-top boards provide a forgiving entry point, while hard-top boards, especially those with EPS cores and epoxy resin, offer benefits for more advanced progression despite the increased learning curve. + +Emphasizing fundamentals like proper pop-up technique and effective paddle work will ease the transition and ensure that the new board complements your evolving skills. Additionally, understanding the pricing spectrum—from budget-friendly models to premium options—allows you to make an informed purchase that suits both your financial and performance needs. + +With a thoughtful approach to board selection, you can enhance your learning curve, enjoy safer sessions in the water, and ultimately develop the skills necessary to master the diverse challenges surfing presents. Whether your goal is to ride gentle waves or eventually experiment with sharper turns and dynamic maneuvers, choosing the right board is your first step towards a rewarding and sustainable surfing journey. + +--- + +## Follow-up Questions + +1. What is your current budget range for a new surfboard, or are you considering buying used? +2. How frequently do you plan to surf, and in what type of wave conditions? +3. Are you interested in a board that you can grow into as your skills progress, or do you prefer one that is more specialized for certain conditions? +4. Would you be interested in additional equipment bundles (like fins, leashes, boards bags) offered by local retailers or online shops? +5. Have you had the opportunity to test ride any boards before, and what feedback did you gather from that experience? + +--- + +With this detailed guide, beginners should now have a comprehensive understanding of the surfboard market and the key factors influencing board performance, safety, and ease of progression. Happy surfing, and may you find the perfect board that rides the waves as beautifully as your passion for the sport! + + +=====FOLLOW UP QUESTIONS===== + + +Follow up questions: What is your current budget range for a new surfboard, or are you considering a used board? +What types of waves do you typically surf, and how might that affect your board choice? +Would you be interested in a transitional board that grows with your skills, or are you looking for a more specialized design? +Have you had experience with renting or borrowing boards to try different sizes before making a purchase? +Do you require additional equipment bundles (like fins, leash, or wax), or do you already have those? diff --git a/examples/research_bot/sample_outputs/vacation.md b/examples/research_bot/sample_outputs/vacation.md new file mode 100644 index 0000000..82c137a --- /dev/null +++ b/examples/research_bot/sample_outputs/vacation.md @@ -0,0 +1,177 @@ +Report: # Caribbean Adventure in April: Surfing, Hiking, and Water Sports Exploration + +The Caribbean is renowned for its crystal-clear waters, vibrant culture, and diverse outdoor activities. April is an especially attractive month for visitors: warm temperatures, clear skies, and the promise of abundant activities. This report explores the best Caribbean destinations in April, with a focus on optimizing your vacation for surfing, hiking, and water sports. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Why April is the Perfect Time in the Caribbean](#why-april-is-the-perfect-time-in-the-caribbean) +3. [Surfing in the Caribbean](#surfing-in-the-caribbean) + - 3.1 [Barbados: The Tale of Two Coasts](#barbados-the-tale-of-two-coasts) + - 3.2 [Puerto Rico: Rincón and Beyond](#puerto-rico-rinc%C3%B3n-and-beyond) + - 3.3 [Dominican Republic and Other Hotspots](#dominican-republic-and-other-hotspots) +4. [Hiking Adventures Across the Caribbean](#hiking-adventures-across-the-caribbean) + - 4.1 [Trekking Through Tropical Rainforests](#trekking-through-tropical-rainforests) + - 4.2 [Volcanic Peaks and Rugged Landscapes](#volcanic-peaks-and-rugged-landscapes) +5. [Diverse Water Sports Experiences](#diverse-water-sports-experiences) + - 5.1 [Snorkeling, Diving, and Jet Skiing](#snorkeling-diving-and-jet-skiing) + - 5.2 [Kiteboarding and Windsurfing](#kiteboarding-and-windsurfing) +6. [Combining Adventures: Multi-Activity Destinations](#combining-adventures-multi-activity-destinations) +7. [Practical Advice and Travel Tips](#practical-advice-and-travel-tips) +8. [Conclusion](#conclusion) + +--- + +## Introduction + +Caribbean vacations are much more than just beach relaxation; they offer adventure, exploration, and a lively cultural tapestry waiting to be discovered. For travelers seeking an adrenaline-filled getaway, April provides optimal conditions. This report synthesizes diverse research findings and travel insights to help you create an itinerary that combines the thrill of surfing, the challenge of hiking, and the excitement of water sports. + +Whether you're standing on the edge of a powerful reef break or trekking through lush tropical landscapes, the Caribbean in April invites you to dive into nature, adventure, and culture. The following sections break down the best destinations and activities, ensuring that every aspect of your trip is meticulously planned for an unforgettable experience. + +--- + +## Why April is the Perfect Time in the Caribbean + +April stands at the crossroads of seasons in many Caribbean destinations. It marks the tail end of the dry season, ensuring: + +- **Consistent Warm Temperatures:** Average daytime highs around 29°C (84°F) foster comfortable conditions for both land and water activities. +- **Pleasant Sea Temperatures:** With sea temperatures near 26°C (79°F), swimmers, surfers, and divers are treated to inviting waters. +- **Clear Skies and Minimal Rainfall:** Crisp, blue skies make for excellent visibility during snorkeling and diving, as well as clear panoramic views while hiking. +- **Festivals and Cultural Events:** Many islands host seasonal festivals such as Barbados' Fish Festival and Antigua's Sailing Week, adding a cultural layer to your vacation. + +These factors create an ideal backdrop for balancing your outdoor pursuits, whether you’re catching epic waves, trekking rugged trails, or partaking in water sports. + +--- + +## Surfing in the Caribbean + +Surfing in the Caribbean offers diverse wave experiences, ranging from gentle, beginner-friendly rollers to powerful reef breaks that challenge even seasoned surfers. April, in particular, provides excellent conditions for those looking to ride its picturesque waves. + +### Barbados: The Tale of Two Coasts + +Barbados is a prime destination: + +- **Soup Bowl in Bathsheba:** On the east coast, the Soup Bowl is famous for its consistent, powerful waves. This spot attracts experienced surfers who appreciate its challenging right-hand reef break with steep drops, providing the kind of performance wave rarely found elsewhere. +- **Freights Bay:** On the south coast, visitors find more forgiving, gentle wave conditions. Ideal for beginners and longboarders, this spot offers the perfect balance for those still mastering their craft. + +Barbados not only excels in its surfing credentials but also complements the experience with a rich local culture and events in April, making it a well-rounded destination. + +### Puerto Rico: Rincón and Beyond + +Rincón in Puerto Rico is hailed as the Caribbean’s surfing capital: + +- **Diverse Breaks:** With spots ranging from challenging reef breaks such as Tres Palmas and Dogman's to more inviting waves at Domes and Maria's, Puerto Rico offers a spectrum for all surfing skill levels. +- **Local Culture:** Aside from its surf culture, the island boasts vibrant local food scenes, historic sites, and exciting nightlife, enriching your overall travel experience. + +In addition, Puerto Rico’s coasts often feature opportunities for hiking and other outdoor adventures, making it an attractive option for multi-activity travelers. + +### Dominican Republic and Other Hotspots + +Other islands such as the Dominican Republic, with Playa Encuentro on its north coast, provide consistent surf year-round. Highlights include: + +- **Playa Encuentro:** A hotspot known for its dependable breaks, ideal for both intermediate and advanced surfers during the cooler months of October to April. +- **Jamaica and The Bahamas:** Jamaica’s Boston Bay offers a mix of beginner and intermediate waves, and The Bahamas’ Surfer’s Beach on Eleuthera draws parallels to the legendary surf spots of Hawaii, especially during the winter months. + +These destinations not only spotlight surfing but also serve as gateways to additional outdoor activities, ensuring there's never a dull moment whether you're balancing waves with hikes or cultural exploration. + +--- + +## Hiking Adventures Across the Caribbean + +The Caribbean's topography is as varied as it is beautiful. Its network of hiking trails traverses volcanic peaks, ancient rainforests, and dramatic coastal cliffs, offering breathtaking vistas to intrepid explorers. + +### Trekking Through Tropical Rainforests + +For nature enthusiasts, the lush forests of the Caribbean present an immersive encounter with biodiversity: + +- **El Yunque National Forest, Puerto Rico:** The only tropical rainforest within the U.S. National Forest System, El Yunque is rich in endemic species such as the Puerto Rican parrot and the famous coquí frog. Trails like the El Yunque Peak Trail and La Mina Falls Trail provide both challenging hikes and scenic rewards. +- **Virgin Islands National Park, St. John:** With over 20 well-defined trails, this park offers hikes that reveal historical petroglyphs, colonial ruins, and stunning coastal views along the Reef Bay Trail. + +### Volcanic Peaks and Rugged Landscapes + +For those seeking more rugged challenges, several destinations offer unforgettable adventures: + +- **Morne Trois Pitons National Park, Dominica:** A UNESCO World Heritage Site showcasing volcanic landscapes, hot springs, the famed Boiling Lake, and lush trails that lead to hidden waterfalls. +- **Gros Piton, Saint Lucia:** The iconic hike up Gros Piton provides a moderately challenging trek that ends with panoramic views of the Caribbean Sea, a truly rewarding experience for hikers. +- **La Soufrière, St. Vincent:** This active volcano not only offers a dynamic hiking environment but also the opportunity to observe the ongoing geological transformations up close. + +Other noteworthy hiking spots include the Blue Mountains in Jamaica for coffee plantation tours and expansive views, as well as trails in Martinique around Montagne Pelée, which combine historical context with natural beauty. + +--- + +## Diverse Water Sports Experiences + +While surfing and hiking attract a broad range of adventurers, the Caribbean also scores high on other water sports. Whether you're drawn to snorkeling, jet skiing, or wind- and kiteboarding, the islands offer a plethora of aquatic activities. + +### Snorkeling, Diving, and Jet Skiing + +Caribbean waters teem with life and color, making them ideal for underwater exploration: + +- **Bonaire:** Its protected marine parks serve as a magnet for divers and snorkelers. With vibrant coral reefs and diverse marine species, Bonaire is a top destination for those who appreciate the underwater world. +- **Cayman Islands:** Unique attractions such as Stingray City provide opportunities to interact with friendly stingrays in clear, calm waters. Additionally, the Underwater Sculpture Park is an innovative blend of art and nature. +- **The Bahamas:** In places like Eleuthera, excursions often cater to families and thrill-seekers alike. Options include jet ski rentals, where groups can explore hidden beaches and pristine coves while enjoying the vibrant marine life. + +### Kiteboarding and Windsurfing + +Harnessing the steady trade winds and warm Caribbean waters, several islands have become hubs for kiteboarding and windsurfing: + +- **Aruba:** Known as "One Happy Island," Aruba’s Fisherman's Huts area provides consistent winds, perfect for enthusiasts of windsurfing and kiteboarding alike. +- **Cabarete, Dominican Republic and Silver Rock, Barbados:** Both destinations benefit from reliable trade winds, making them popular among kitesurfers. These spots often combine water sports with a lively beach culture, ensuring that the fun continues on land as well. + +Local operators provide equipment rental and lessons, ensuring that even first-time adventurers can safely and confidently enjoy these exciting sports. + +--- + +## Combining Adventures: Multi-Activity Destinations + +For travelers seeking a comprehensive vacation where surfing, hiking, and water sports converge, several Caribbean destinations offer the best of all worlds. + +- **Puerto Rico:** With its robust surf scene in Rincón, world-class hiking in El Yunque, and opportunities for snorkeling and jet skiing in San Juan Bay, Puerto Rico is a true multi-adventure destination. +- **Barbados:** In addition to the surf breaks along its coasts, Barbados offers a mix of cultural events, local cuisine, and even hiking excursions to scenic rural areas, making for a well-rounded experience. +- **Dominican Republic and Jamaica:** Both are renowned not only for their consistent surf conditions but also for expansive hiking trails and water sports. From the rugged landscapes of the Dominican Republic to Jamaica’s blend of cultural history and natural exploration, these islands allow travelers to mix and match activities seamlessly. + +Group tours and local guides further enhance these experiences, providing insider tips, safe excursions, and personalized itineraries that cater to multiple interests within one trip. + +--- + +## Practical Advice and Travel Tips + +### Weather and Timing + +- **Optimal Climate:** April offers ideal weather conditions across the Caribbean. With minimal rainfall and warm temperatures, it is a great time to schedule outdoor activities. +- **Surfing Seasons:** While April marks the end of the prime surf season in some areas (like Rincón in Puerto Rico), many destinations maintain consistent conditions during this month. + +### Booking and Costs + +- **Surfing Lessons:** Expect to pay between $40 and $110 per session depending on the location. For instance, Puerto Rico typically charges around $75 for beginner lessons, while group lessons in the Dominican Republic average approximately $95. +- **Equipment Rentals:** Pricing for jet ski, surfboard, and snorkeling equipment may vary. In the Bahamas, an hour-long jet ski tour might cost about $120 per group, whereas a similar experience might be available at a lower cost in other regions. +- **Accommodations:** Prices also vary by island. Many travelers find that even affordable stays do not skimp on amenities, allowing you to invest more in guided excursions and local experiences. + +### Cultural Considerations + +- **Festivals and Events:** Check local event calendars. Destinations like Barbados and Antigua host festivals in April that combine cultural heritage with festive outdoor activities. +- **Local Cuisine:** Incorporate food tours into your itinerary. Caribbean cuisine—with its fusion of flavors—can be as adventurous as the outdoor activities. + +### Health and Safety + +- **Staying Hydrated:** The warm temperatures demand that you stay properly hydrated. Always carry water, especially during long hikes. +- **Sun Protection:** Use sunscreen, hats, and sunglasses to protect yourself during extended periods outdoors on both land and water. +- **Local Guides:** Utilize local tour operators for both hiking and water sports. Their expertise not only enriches your experience but also ensures safety in unfamiliar terrain or water bodies. + +--- + +## Conclusion + +The Caribbean in April is a haven for adventure seekers. With its pristine beaches, diverse ecosystems, and rich cultural tapestry, it offers something for every type of traveler. Whether you're chasing the perfect wave along the shores of Barbados and Puerto Rico, trekking through the lush landscapes of El Yunque or Morne Trois Pitons, or engaging in an array of water sports from snorkeling to kiteboarding, your ideal vacation is only a booking away. + +This report has outlined the best destinations and provided practical advice to optimize your vacation for surfing, hiking, and water sports. By considering the diverse offerings—from epic surf breaks and challenging hiking trails to vibrant water sports—the Caribbean stands out as a multi-adventure destination where every day brings a new experience. + +Plan carefully, pack wisely, and get ready to explore the vibrant mosaic of landscapes and activities that make the Caribbean in April a truly unforgettable adventure. + +Happy travels! + +--- + +_References available upon request. Many insights were drawn from trusted sources including Lonely Planet, TravelPug, and various Caribbean-centric exploration sites, ensuring a well-rounded and practical guide for your vacation planning._ diff --git a/examples/research_bot/sample_outputs/vacation.txt b/examples/research_bot/sample_outputs/vacation.txt new file mode 100644 index 0000000..b264998 --- /dev/null +++ b/examples/research_bot/sample_outputs/vacation.txt @@ -0,0 +1,206 @@ +# Terminal output for a vacation related query. See vacation.md for final report. + +$ uv run python -m examples.research_bot.main +What would you like to research? Caribbean vacation spots in April, optimizing for surfing, hiking and water sports +View trace: https://platform.openai.com/traces/trace_.... +Starting research... +✅ Will perform 15 searches +✅ Searching... 15/15 completed +✅ Finishing report... +✅ Report summary + +This report provides an in-depth exploration of selected Caribbean vacation spots in April that are ideal for surfing, hiking, and water sports. Covering +destinations from Barbados and Puerto Rico to the Bahamas and Jamaica, it examines favorable weather conditions, recommended surf breaks, scenic hiking +trails, and various water sports activities. Detailed destination profiles, activity highlights, and travel tips are integrated to help travelers design a +multi-adventure itinerary in the Caribbean during April. + + +=====REPORT===== + + +Report: # Caribbean Adventure in April: Surfing, Hiking, and Water Sports Exploration + +The Caribbean is renowned for its crystal-clear waters, vibrant culture, and diverse outdoor activities. April is an especially attractive month for visitors: warm temperatures, clear skies, and the promise of abundant activities. This report explores the best Caribbean destinations in April, with a focus on optimizing your vacation for surfing, hiking, and water sports. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Why April is the Perfect Time in the Caribbean](#why-april-is-the-perfect-time-in-the-caribbean) +3. [Surfing in the Caribbean](#surfing-in-the-caribbean) + - 3.1 [Barbados: The Tale of Two Coasts](#barbados-the-tale-of-two-coasts) + - 3.2 [Puerto Rico: Rincón and Beyond](#puerto-rico-rinc%C3%B3n-and-beyond) + - 3.3 [Dominican Republic and Other Hotspots](#dominican-republic-and-other-hotspots) +4. [Hiking Adventures Across the Caribbean](#hiking-adventures-across-the-caribbean) + - 4.1 [Trekking Through Tropical Rainforests](#trekking-through-tropical-rainforests) + - 4.2 [Volcanic Peaks and Rugged Landscapes](#volcanic-peaks-and-rugged-landscapes) +5. [Diverse Water Sports Experiences](#diverse-water-sports-experiences) + - 5.1 [Snorkeling, Diving, and Jet Skiing](#snorkeling-diving-and-jet-skiing) + - 5.2 [Kiteboarding and Windsurfing](#kiteboarding-and-windsurfing) +6. [Combining Adventures: Multi-Activity Destinations](#combining-adventures-multi-activity-destinations) +7. [Practical Advice and Travel Tips](#practical-advice-and-travel-tips) +8. [Conclusion](#conclusion) + +--- + +## Introduction + +Caribbean vacations are much more than just beach relaxation; they offer adventure, exploration, and a lively cultural tapestry waiting to be discovered. For travelers seeking an adrenaline-filled getaway, April provides optimal conditions. This report synthesizes diverse research findings and travel insights to help you create an itinerary that combines the thrill of surfing, the challenge of hiking, and the excitement of water sports. + +Whether you're standing on the edge of a powerful reef break or trekking through lush tropical landscapes, the Caribbean in April invites you to dive into nature, adventure, and culture. The following sections break down the best destinations and activities, ensuring that every aspect of your trip is meticulously planned for an unforgettable experience. + +--- + +## Why April is the Perfect Time in the Caribbean + +April stands at the crossroads of seasons in many Caribbean destinations. It marks the tail end of the dry season, ensuring: + +- **Consistent Warm Temperatures:** Average daytime highs around 29°C (84°F) foster comfortable conditions for both land and water activities. +- **Pleasant Sea Temperatures:** With sea temperatures near 26°C (79°F), swimmers, surfers, and divers are treated to inviting waters. +- **Clear Skies and Minimal Rainfall:** Crisp, blue skies make for excellent visibility during snorkeling and diving, as well as clear panoramic views while hiking. +- **Festivals and Cultural Events:** Many islands host seasonal festivals such as Barbados' Fish Festival and Antigua's Sailing Week, adding a cultural layer to your vacation. + +These factors create an ideal backdrop for balancing your outdoor pursuits, whether you’re catching epic waves, trekking rugged trails, or partaking in water sports. + +--- + +## Surfing in the Caribbean + +Surfing in the Caribbean offers diverse wave experiences, ranging from gentle, beginner-friendly rollers to powerful reef breaks that challenge even seasoned surfers. April, in particular, provides excellent conditions for those looking to ride its picturesque waves. + +### Barbados: The Tale of Two Coasts + +Barbados is a prime destination: + +- **Soup Bowl in Bathsheba:** On the east coast, the Soup Bowl is famous for its consistent, powerful waves. This spot attracts experienced surfers who appreciate its challenging right-hand reef break with steep drops, providing the kind of performance wave rarely found elsewhere. +- **Freights Bay:** On the south coast, visitors find more forgiving, gentle wave conditions. Ideal for beginners and longboarders, this spot offers the perfect balance for those still mastering their craft. + +Barbados not only excels in its surfing credentials but also complements the experience with a rich local culture and events in April, making it a well-rounded destination. + +### Puerto Rico: Rincón and Beyond + +Rincón in Puerto Rico is hailed as the Caribbean’s surfing capital: + +- **Diverse Breaks:** With spots ranging from challenging reef breaks such as Tres Palmas and Dogman's to more inviting waves at Domes and Maria's, Puerto Rico offers a spectrum for all surfing skill levels. +- **Local Culture:** Aside from its surf culture, the island boasts vibrant local food scenes, historic sites, and exciting nightlife, enriching your overall travel experience. + +In addition, Puerto Rico’s coasts often feature opportunities for hiking and other outdoor adventures, making it an attractive option for multi-activity travelers. + +### Dominican Republic and Other Hotspots + +Other islands such as the Dominican Republic, with Playa Encuentro on its north coast, provide consistent surf year-round. Highlights include: + +- **Playa Encuentro:** A hotspot known for its dependable breaks, ideal for both intermediate and advanced surfers during the cooler months of October to April. +- **Jamaica and The Bahamas:** Jamaica’s Boston Bay offers a mix of beginner and intermediate waves, and The Bahamas’ Surfer’s Beach on Eleuthera draws parallels to the legendary surf spots of Hawaii, especially during the winter months. + +These destinations not only spotlight surfing but also serve as gateways to additional outdoor activities, ensuring there's never a dull moment whether you're balancing waves with hikes or cultural exploration. + +--- + +## Hiking Adventures Across the Caribbean + +The Caribbean's topography is as varied as it is beautiful. Its network of hiking trails traverses volcanic peaks, ancient rainforests, and dramatic coastal cliffs, offering breathtaking vistas to intrepid explorers. + +### Trekking Through Tropical Rainforests + +For nature enthusiasts, the lush forests of the Caribbean present an immersive encounter with biodiversity: + +- **El Yunque National Forest, Puerto Rico:** The only tropical rainforest within the U.S. National Forest System, El Yunque is rich in endemic species such as the Puerto Rican parrot and the famous coquí frog. Trails like the El Yunque Peak Trail and La Mina Falls Trail provide both challenging hikes and scenic rewards. +- **Virgin Islands National Park, St. John:** With over 20 well-defined trails, this park offers hikes that reveal historical petroglyphs, colonial ruins, and stunning coastal views along the Reef Bay Trail. + +### Volcanic Peaks and Rugged Landscapes + +For those seeking more rugged challenges, several destinations offer unforgettable adventures: + +- **Morne Trois Pitons National Park, Dominica:** A UNESCO World Heritage Site showcasing volcanic landscapes, hot springs, the famed Boiling Lake, and lush trails that lead to hidden waterfalls. +- **Gros Piton, Saint Lucia:** The iconic hike up Gros Piton provides a moderately challenging trek that ends with panoramic views of the Caribbean Sea, a truly rewarding experience for hikers. +- **La Soufrière, St. Vincent:** This active volcano not only offers a dynamic hiking environment but also the opportunity to observe the ongoing geological transformations up close. + +Other noteworthy hiking spots include the Blue Mountains in Jamaica for coffee plantation tours and expansive views, as well as trails in Martinique around Montagne Pelée, which combine historical context with natural beauty. + +--- + +## Diverse Water Sports Experiences + +While surfing and hiking attract a broad range of adventurers, the Caribbean also scores high on other water sports. Whether you're drawn to snorkeling, jet skiing, or wind- and kiteboarding, the islands offer a plethora of aquatic activities. + +### Snorkeling, Diving, and Jet Skiing + +Caribbean waters teem with life and color, making them ideal for underwater exploration: + +- **Bonaire:** Its protected marine parks serve as a magnet for divers and snorkelers. With vibrant coral reefs and diverse marine species, Bonaire is a top destination for those who appreciate the underwater world. +- **Cayman Islands:** Unique attractions such as Stingray City provide opportunities to interact with friendly stingrays in clear, calm waters. Additionally, the Underwater Sculpture Park is an innovative blend of art and nature. +- **The Bahamas:** In places like Eleuthera, excursions often cater to families and thrill-seekers alike. Options include jet ski rentals, where groups can explore hidden beaches and pristine coves while enjoying the vibrant marine life. + +### Kiteboarding and Windsurfing + +Harnessing the steady trade winds and warm Caribbean waters, several islands have become hubs for kiteboarding and windsurfing: + +- **Aruba:** Known as "One Happy Island," Aruba’s Fisherman's Huts area provides consistent winds, perfect for enthusiasts of windsurfing and kiteboarding alike. +- **Cabarete, Dominican Republic and Silver Rock, Barbados:** Both destinations benefit from reliable trade winds, making them popular among kitesurfers. These spots often combine water sports with a lively beach culture, ensuring that the fun continues on land as well. + +Local operators provide equipment rental and lessons, ensuring that even first-time adventurers can safely and confidently enjoy these exciting sports. + +--- + +## Combining Adventures: Multi-Activity Destinations + +For travelers seeking a comprehensive vacation where surfing, hiking, and water sports converge, several Caribbean destinations offer the best of all worlds. + +- **Puerto Rico:** With its robust surf scene in Rincón, world-class hiking in El Yunque, and opportunities for snorkeling and jet skiing in San Juan Bay, Puerto Rico is a true multi-adventure destination. +- **Barbados:** In addition to the surf breaks along its coasts, Barbados offers a mix of cultural events, local cuisine, and even hiking excursions to scenic rural areas, making for a well-rounded experience. +- **Dominican Republic and Jamaica:** Both are renowned not only for their consistent surf conditions but also for expansive hiking trails and water sports. From the rugged landscapes of the Dominican Republic to Jamaica’s blend of cultural history and natural exploration, these islands allow travelers to mix and match activities seamlessly. + +Group tours and local guides further enhance these experiences, providing insider tips, safe excursions, and personalized itineraries that cater to multiple interests within one trip. + +--- + +## Practical Advice and Travel Tips + +### Weather and Timing + +- **Optimal Climate:** April offers ideal weather conditions across the Caribbean. With minimal rainfall and warm temperatures, it is a great time to schedule outdoor activities. +- **Surfing Seasons:** While April marks the end of the prime surf season in some areas (like Rincón in Puerto Rico), many destinations maintain consistent conditions during this month. + +### Booking and Costs + +- **Surfing Lessons:** Expect to pay between $40 and $110 per session depending on the location. For instance, Puerto Rico typically charges around $75 for beginner lessons, while group lessons in the Dominican Republic average approximately $95. +- **Equipment Rentals:** Pricing for jet ski, surfboard, and snorkeling equipment may vary. In the Bahamas, an hour-long jet ski tour might cost about $120 per group, whereas a similar experience might be available at a lower cost in other regions. +- **Accommodations:** Prices also vary by island. Many travelers find that even affordable stays do not skimp on amenities, allowing you to invest more in guided excursions and local experiences. + +### Cultural Considerations + +- **Festivals and Events:** Check local event calendars. Destinations like Barbados and Antigua host festivals in April that combine cultural heritage with festive outdoor activities. +- **Local Cuisine:** Incorporate food tours into your itinerary. Caribbean cuisine—with its fusion of flavors—can be as adventurous as the outdoor activities. + +### Health and Safety + +- **Staying Hydrated:** The warm temperatures demand that you stay properly hydrated. Always carry water, especially during long hikes. +- **Sun Protection:** Use sunscreen, hats, and sunglasses to protect yourself during extended periods outdoors on both land and water. +- **Local Guides:** Utilize local tour operators for both hiking and water sports. Their expertise not only enriches your experience but also ensures safety in unfamiliar terrain or water bodies. + +--- + +## Conclusion + +The Caribbean in April is a haven for adventure seekers. With its pristine beaches, diverse ecosystems, and rich cultural tapestry, it offers something for every type of traveler. Whether you're chasing the perfect wave along the shores of Barbados and Puerto Rico, trekking through the lush landscapes of El Yunque or Morne Trois Pitons, or engaging in an array of water sports from snorkeling to kiteboarding, your ideal vacation is only a booking away. + +This report has outlined the best destinations and provided practical advice to optimize your vacation for surfing, hiking, and water sports. By considering the diverse offerings—from epic surf breaks and challenging hiking trails to vibrant water sports—the Caribbean stands out as a multi-adventure destination where every day brings a new experience. + +Plan carefully, pack wisely, and get ready to explore the vibrant mosaic of landscapes and activities that make the Caribbean in April a truly unforgettable adventure. + +Happy travels! + +--- + +*References available upon request. Many insights were drawn from trusted sources including Lonely Planet, TravelPug, and various Caribbean-centric exploration sites, ensuring a well-rounded and practical guide for your vacation planning.* + + + +=====FOLLOW UP QUESTIONS===== + + +Follow up questions: Would you like detailed profiles for any of the highlighted destinations (e.g., Puerto Rico or Barbados)? +Are you interested in more information about booking details and local tour operators in specific islands? +Do you need guidance on combining cultural events with outdoor adventures during your Caribbean vacation? \ No newline at end of file diff --git a/examples/tools/computer_use.py b/examples/tools/computer_use.py new file mode 100644 index 0000000..35fc865 --- /dev/null +++ b/examples/tools/computer_use.py @@ -0,0 +1,165 @@ +import asyncio +import base64 +import logging +from typing import Literal, Union + +from playwright.async_api import Browser, Page, Playwright, async_playwright + +from agents import ( + Agent, + AsyncComputer, + Button, + ComputerTool, + Environment, + ModelSettings, + Runner, + trace, +) + +logging.getLogger("openai.agents").setLevel(logging.DEBUG) +logging.getLogger("openai.agents").addHandler(logging.StreamHandler()) + + +async def main(): + async with LocalPlaywrightComputer() as computer: + with trace("Computer use example"): + agent = Agent( + name="Browser user", + instructions="You are a helpful agent.", + tools=[ComputerTool(computer)], + # Use the computer using model, and set truncation to auto because its required + model="computer-use-preview-2025-02-04", + model_settings=ModelSettings(truncation="auto"), + ) + result = await Runner.run(agent, "Search for SF sports news and summarize.") + print(result.final_output) + + +CUA_KEY_TO_PLAYWRIGHT_KEY = { + "/": "Divide", + "\\": "Backslash", + "alt": "Alt", + "arrowdown": "ArrowDown", + "arrowleft": "ArrowLeft", + "arrowright": "ArrowRight", + "arrowup": "ArrowUp", + "backspace": "Backspace", + "capslock": "CapsLock", + "cmd": "Meta", + "ctrl": "Control", + "delete": "Delete", + "end": "End", + "enter": "Enter", + "esc": "Escape", + "home": "Home", + "insert": "Insert", + "option": "Alt", + "pagedown": "PageDown", + "pageup": "PageUp", + "shift": "Shift", + "space": " ", + "super": "Meta", + "tab": "Tab", + "win": "Meta", +} + + +class LocalPlaywrightComputer(AsyncComputer): + """A computer, implemented using a local Playwright browser.""" + + def __init__(self): + self._playwright: Union[Playwright, None] = None + self._browser: Union[Browser, None] = None + self._page: Union[Page, None] = None + + async def _get_browser_and_page(self) -> tuple[Browser, Page]: + width, height = self.dimensions + launch_args = [f"--window-size={width},{height}"] + browser = await self.playwright.chromium.launch(headless=False, args=launch_args) + page = await browser.new_page() + await page.set_viewport_size({"width": width, "height": height}) + await page.goto("https://www.bing.com") + return browser, page + + async def __aenter__(self): + # Start Playwright and call the subclass hook for getting browser/page + self._playwright = await async_playwright().start() + self._browser, self._page = await self._get_browser_and_page() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._browser: + await self._browser.close() + if self._playwright: + await self._playwright.stop() + + @property + def playwright(self) -> Playwright: + assert self._playwright is not None + return self._playwright + + @property + def browser(self) -> Browser: + assert self._browser is not None + return self._browser + + @property + def page(self) -> Page: + assert self._page is not None + return self._page + + @property + def environment(self) -> Environment: + return "browser" + + @property + def dimensions(self) -> tuple[int, int]: + return (1024, 768) + + async def screenshot(self) -> str: + """Capture only the viewport (not full_page).""" + png_bytes = await self.page.screenshot(full_page=False) + return base64.b64encode(png_bytes).decode("utf-8") + + async def click(self, x: int, y: int, button: Button = "left") -> None: + playwright_button: Literal["left", "middle", "right"] = "left" + + # Playwright only supports left, middle, right buttons + if button in ("left", "right", "middle"): + playwright_button = button # type: ignore + + await self.page.mouse.click(x, y, button=playwright_button) + + async def double_click(self, x: int, y: int) -> None: + await self.page.mouse.dblclick(x, y) + + async def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + await self.page.mouse.move(x, y) + await self.page.evaluate(f"window.scrollBy({scroll_x}, {scroll_y})") + + async def type(self, text: str) -> None: + await self.page.keyboard.type(text) + + async def wait(self) -> None: + await asyncio.sleep(1) + + async def move(self, x: int, y: int) -> None: + await self.page.mouse.move(x, y) + + async def keypress(self, keys: list[str]) -> None: + for key in keys: + mapped_key = CUA_KEY_TO_PLAYWRIGHT_KEY.get(key.lower(), key) + await self.page.keyboard.press(mapped_key) + + async def drag(self, path: list[tuple[int, int]]) -> None: + if not path: + return + await self.page.mouse.move(path[0][0], path[0][1]) + await self.page.mouse.down() + for px, py in path[1:]: + await self.page.mouse.move(px, py) + await self.page.mouse.up() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tools/file_search.py b/examples/tools/file_search.py new file mode 100644 index 0000000..2a3d4cf --- /dev/null +++ b/examples/tools/file_search.py @@ -0,0 +1,36 @@ +import asyncio + +from agents import Agent, FileSearchTool, Runner, trace + + +async def main(): + agent = Agent( + name="File searcher", + instructions="You are a helpful agent.", + tools=[ + FileSearchTool( + max_num_results=3, + vector_store_ids=["vs_67bf88953f748191be42b462090e53e7"], + include_search_results=True, + ) + ], + ) + + with trace("File search example"): + result = await Runner.run( + agent, "Be concise, and tell me 1 sentence about Arrakis I might not know." + ) + print(result.final_output) + """ + Arrakis, the desert planet in Frank Herbert's "Dune," was inspired by the scarcity of water + as a metaphor for oil and other finite resources. + """ + + print("\n".join([str(out) for out in result.new_items])) + """ + {"id":"...", "queries":["Arrakis"], "results":[...]} + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tools/web_search.py b/examples/tools/web_search.py new file mode 100644 index 0000000..35eeb68 --- /dev/null +++ b/examples/tools/web_search.py @@ -0,0 +1,23 @@ +import asyncio + +from agents import Agent, Runner, WebSearchTool, trace + + +async def main(): + agent = Agent( + name="Web searcher", + instructions="You are a helpful agent.", + tools=[WebSearchTool(user_location={"type": "approximate", "city": "New York"})], + ) + + with trace("Web search example"): + result = await Runner.run( + agent, + "search the web for 'local sports news' and give me 1 interesting update in a sentence.", + ) + print(result.final_output) + # The New York Giants are reportedly pursuing quarterback Aaron Rodgers after his ... + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..398fb74 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,121 @@ +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 +nav: + - Intro: index.md + - Quickstart: quickstart.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 + +extra: + # 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 + +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + anchors: warn + +extra_css: + - stylesheets/extra.css + +watch: + - "src/agents" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..24e08eb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,119 @@ +[project] +name = "openai-agents" +version = "0.0.1" +description = "OpenAI Agents SDK" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [ + { name = "OpenAI", email = "support@openai.com" }, +] +dependencies = [ + "openai>=1.66.0", + "pydantic>=2.10, <3", + "griffe>=1.5.6, <2", + "typing-extensions>=4.12.2, <5", + "requests>=2.0, <3", + "types-requests>=2.0, <3", +] +classifiers = [ + "Typing :: Typed", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License" +] + +[project.urls] +Homepage = "https://github.com/openai/openai-agents-python" +Repository = "https://github.com/openai/openai-agents-python" + +[dependency-groups] +dev = [ + "mypy", + "ruff==0.9.2", + "pytest", + "pytest-asyncio", + "pytest-mock>=3.14.0", + "rich", + "mkdocs>=1.6.0", + "mkdocs-material>=9.6.0", + "mkdocstrings[python]>=0.28.0", + "coverage>=7.6.12", + "playwright==1.50.0", +] +[tool.uv.workspace] +members = ["agents"] + +[tool.uv.sources] +agents = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/agents"] + + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +isort = { combine-as-imports = true, known-first-party = ["agents"] } + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"examples/**/*.py" = ["E501"] + +[tool.mypy] +strict = true +disallow_incomplete_defs = false +disallow_untyped_defs = false +disallow_untyped_calls = false + +[tool.coverage.run] +source = [ + "tests", + "src/agents", +] + +[tool.coverage.report] +show_missing = true +sort = "-Cover" +exclude_also = [ + # This is only executed while typechecking + "if TYPE_CHECKING:", + "@abc.abstractmethod", + "raise NotImplementedError", + "logger.debug", +] + +[tool.pytest.ini_options] +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 + "ignore:coroutine 'test_async_input_filter_fails..invalid_input_filter' was never awaited:RuntimeWarning", +] +markers = [ + "allow_call_model_methods: mark test as allowing calls to real model implementations", +] \ No newline at end of file diff --git a/src/agents/__init__.py b/src/agents/__init__.py new file mode 100644 index 0000000..69c500a --- /dev/null +++ b/src/agents/__init__.py @@ -0,0 +1,223 @@ +import logging +import sys +from typing import Literal + +from openai import AsyncOpenAI + +from . import _config +from .agent import Agent +from .agent_output import AgentOutputSchema +from .computer import AsyncComputer, Button, Computer, Environment +from .exceptions import ( + AgentsException, + InputGuardrailTripwireTriggered, + MaxTurnsExceeded, + ModelBehaviorError, + OutputGuardrailTripwireTriggered, + UserError, +) +from .guardrail import ( + GuardrailFunctionOutput, + InputGuardrail, + InputGuardrailResult, + OutputGuardrail, + OutputGuardrailResult, + input_guardrail, + output_guardrail, +) +from .handoffs import Handoff, HandoffInputData, HandoffInputFilter, handoff +from .items import ( + HandoffCallItem, + HandoffOutputItem, + ItemHelpers, + MessageOutputItem, + ModelResponse, + ReasoningItem, + RunItem, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, +) +from .lifecycle import AgentHooks, RunHooks +from .model_settings import ModelSettings +from .models.interface import Model, ModelProvider, ModelTracing +from .models.openai_chatcompletions import OpenAIChatCompletionsModel +from .models.openai_provider import OpenAIProvider +from .models.openai_responses import OpenAIResponsesModel +from .result import RunResult, RunResultStreaming +from .run import RunConfig, Runner +from .run_context import RunContextWrapper, TContext +from .stream_events import ( + AgentUpdatedStreamEvent, + RawResponsesStreamEvent, + RunItemStreamEvent, + StreamEvent, +) +from .tool import ( + ComputerTool, + FileSearchTool, + FunctionTool, + Tool, + WebSearchTool, + default_tool_error_function, + function_tool, +) +from .tracing import ( + AgentSpanData, + CustomSpanData, + FunctionSpanData, + GenerationSpanData, + GuardrailSpanData, + HandoffSpanData, + Span, + SpanData, + SpanError, + Trace, + add_trace_processor, + agent_span, + custom_span, + function_span, + gen_span_id, + gen_trace_id, + generation_span, + get_current_span, + get_current_trace, + guardrail_span, + handoff_span, + set_trace_processors, + set_tracing_disabled, + set_tracing_export_api_key, + trace, +) +from .usage import Usage + + +def set_default_openai_key(key: str) -> None: + """Set the default OpenAI API key to use for LLM requests and tracing. This is only necessary if + the OPENAI_API_KEY environment variable is not already set. + + If provided, this key will be used instead of the OPENAI_API_KEY environment variable. + """ + _config.set_default_openai_key(key) + + +def set_default_openai_client(client: AsyncOpenAI, use_for_tracing: bool = True) -> None: + """Set the default OpenAI client to use for LLM requests and/or tracing. If provided, this + client will be used instead of the default OpenAI client. + + Args: + client: The OpenAI client to use. + use_for_tracing: Whether to use the API key from this client for uploading traces. If False, + you'll either need to set the OPENAI_API_KEY environment variable or call + set_tracing_export_api_key() with the API key you want to use for tracing. + """ + _config.set_default_openai_client(client, use_for_tracing) + + +def set_default_openai_api(api: Literal["chat_completions", "responses"]) -> None: + """Set the default API to use for OpenAI LLM requests. By default, we will use the responses API + but you can set this to use the chat completions API instead. + """ + _config.set_default_openai_api(api) + + +def enable_verbose_stdout_logging(): + """Enables verbose logging to stdout. This is useful for debugging.""" + for name in ["openai.agents", "openai.agents.tracing"]: + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + logger.addHandler(logging.StreamHandler(sys.stdout)) + + +__all__ = [ + "Agent", + "Runner", + "Model", + "ModelProvider", + "ModelTracing", + "ModelSettings", + "OpenAIChatCompletionsModel", + "OpenAIProvider", + "OpenAIResponsesModel", + "AgentOutputSchema", + "Computer", + "AsyncComputer", + "Environment", + "Button", + "AgentsException", + "InputGuardrailTripwireTriggered", + "OutputGuardrailTripwireTriggered", + "MaxTurnsExceeded", + "ModelBehaviorError", + "UserError", + "InputGuardrail", + "InputGuardrailResult", + "OutputGuardrail", + "OutputGuardrailResult", + "GuardrailFunctionOutput", + "input_guardrail", + "output_guardrail", + "handoff", + "Handoff", + "HandoffInputData", + "HandoffInputFilter", + "TResponseInputItem", + "MessageOutputItem", + "ModelResponse", + "RunItem", + "HandoffCallItem", + "HandoffOutputItem", + "ToolCallItem", + "ToolCallOutputItem", + "ReasoningItem", + "ModelResponse", + "ItemHelpers", + "RunHooks", + "AgentHooks", + "RunContextWrapper", + "TContext", + "RunResult", + "RunResultStreaming", + "RunConfig", + "RawResponsesStreamEvent", + "RunItemStreamEvent", + "AgentUpdatedStreamEvent", + "StreamEvent", + "FunctionTool", + "ComputerTool", + "FileSearchTool", + "Tool", + "WebSearchTool", + "function_tool", + "Usage", + "add_trace_processor", + "agent_span", + "custom_span", + "function_span", + "generation_span", + "get_current_span", + "get_current_trace", + "guardrail_span", + "handoff_span", + "set_trace_processors", + "set_tracing_disabled", + "trace", + "Trace", + "SpanError", + "Span", + "SpanData", + "AgentSpanData", + "CustomSpanData", + "FunctionSpanData", + "GenerationSpanData", + "GuardrailSpanData", + "HandoffSpanData", + "set_default_openai_key", + "set_default_openai_client", + "set_default_openai_api", + "set_tracing_export_api_key", + "enable_verbose_stdout_logging", + "gen_trace_id", + "gen_span_id", + "default_tool_error_function", +] diff --git a/src/agents/__pycache__/__init__.cpython-311.pyc b/src/agents/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..daf05e7 Binary files /dev/null and b/src/agents/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/agents/__pycache__/__init__.cpython-313.pyc b/src/agents/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8d9218c Binary files /dev/null and b/src/agents/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agents/__pycache__/__init__.cpython-39.pyc b/src/agents/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..f7f3ec7 Binary files /dev/null and b/src/agents/__pycache__/__init__.cpython-39.pyc differ diff --git a/src/agents/__pycache__/_config.cpython-311.pyc b/src/agents/__pycache__/_config.cpython-311.pyc new file mode 100644 index 0000000..6fd0d6a Binary files /dev/null and b/src/agents/__pycache__/_config.cpython-311.pyc differ diff --git a/src/agents/__pycache__/_config.cpython-313.pyc b/src/agents/__pycache__/_config.cpython-313.pyc new file mode 100644 index 0000000..b038691 Binary files /dev/null and b/src/agents/__pycache__/_config.cpython-313.pyc differ diff --git a/src/agents/__pycache__/_config.cpython-39.pyc b/src/agents/__pycache__/_config.cpython-39.pyc new file mode 100644 index 0000000..3e54f44 Binary files /dev/null and b/src/agents/__pycache__/_config.cpython-39.pyc differ diff --git a/src/agents/__pycache__/_debug.cpython-313.pyc b/src/agents/__pycache__/_debug.cpython-313.pyc new file mode 100644 index 0000000..19e2913 Binary files /dev/null and b/src/agents/__pycache__/_debug.cpython-313.pyc differ diff --git a/src/agents/__pycache__/_debug.cpython-39.pyc b/src/agents/__pycache__/_debug.cpython-39.pyc new file mode 100644 index 0000000..796f509 Binary files /dev/null and b/src/agents/__pycache__/_debug.cpython-39.pyc differ diff --git a/src/agents/__pycache__/_run_impl.cpython-313.pyc b/src/agents/__pycache__/_run_impl.cpython-313.pyc new file mode 100644 index 0000000..22bd441 Binary files /dev/null and b/src/agents/__pycache__/_run_impl.cpython-313.pyc differ diff --git a/src/agents/__pycache__/_run_impl.cpython-39.pyc b/src/agents/__pycache__/_run_impl.cpython-39.pyc new file mode 100644 index 0000000..b26111b Binary files /dev/null and b/src/agents/__pycache__/_run_impl.cpython-39.pyc differ diff --git a/src/agents/__pycache__/_strict_schema.cpython-313.pyc b/src/agents/__pycache__/_strict_schema.cpython-313.pyc new file mode 100644 index 0000000..54b08cf Binary files /dev/null and b/src/agents/__pycache__/_strict_schema.cpython-313.pyc differ diff --git a/src/agents/__pycache__/_utils.cpython-313.pyc b/src/agents/__pycache__/_utils.cpython-313.pyc new file mode 100644 index 0000000..af2c299 Binary files /dev/null and b/src/agents/__pycache__/_utils.cpython-313.pyc differ diff --git a/src/agents/__pycache__/_utils.cpython-39.pyc b/src/agents/__pycache__/_utils.cpython-39.pyc new file mode 100644 index 0000000..bf6557a Binary files /dev/null and b/src/agents/__pycache__/_utils.cpython-39.pyc differ diff --git a/src/agents/__pycache__/agent.cpython-311.pyc b/src/agents/__pycache__/agent.cpython-311.pyc new file mode 100644 index 0000000..3579bbe Binary files /dev/null and b/src/agents/__pycache__/agent.cpython-311.pyc differ diff --git a/src/agents/__pycache__/agent.cpython-313.pyc b/src/agents/__pycache__/agent.cpython-313.pyc new file mode 100644 index 0000000..5347fb1 Binary files /dev/null and b/src/agents/__pycache__/agent.cpython-313.pyc differ diff --git a/src/agents/__pycache__/agent.cpython-39.pyc b/src/agents/__pycache__/agent.cpython-39.pyc new file mode 100644 index 0000000..b494ffe Binary files /dev/null and b/src/agents/__pycache__/agent.cpython-39.pyc differ diff --git a/src/agents/__pycache__/agent_output.cpython-313.pyc b/src/agents/__pycache__/agent_output.cpython-313.pyc new file mode 100644 index 0000000..b527f54 Binary files /dev/null and b/src/agents/__pycache__/agent_output.cpython-313.pyc differ diff --git a/src/agents/__pycache__/agent_output.cpython-39.pyc b/src/agents/__pycache__/agent_output.cpython-39.pyc new file mode 100644 index 0000000..dfc5353 Binary files /dev/null and b/src/agents/__pycache__/agent_output.cpython-39.pyc differ diff --git a/src/agents/__pycache__/call_agent_tool.cpython-313.pyc b/src/agents/__pycache__/call_agent_tool.cpython-313.pyc new file mode 100644 index 0000000..281eefc Binary files /dev/null and b/src/agents/__pycache__/call_agent_tool.cpython-313.pyc differ diff --git a/src/agents/__pycache__/call_agent_tool.cpython-39.pyc b/src/agents/__pycache__/call_agent_tool.cpython-39.pyc new file mode 100644 index 0000000..3502ff9 Binary files /dev/null and b/src/agents/__pycache__/call_agent_tool.cpython-39.pyc differ diff --git a/src/agents/__pycache__/computer.cpython-313.pyc b/src/agents/__pycache__/computer.cpython-313.pyc new file mode 100644 index 0000000..b108425 Binary files /dev/null and b/src/agents/__pycache__/computer.cpython-313.pyc differ diff --git a/src/agents/__pycache__/computer.cpython-39.pyc b/src/agents/__pycache__/computer.cpython-39.pyc new file mode 100644 index 0000000..df273d5 Binary files /dev/null and b/src/agents/__pycache__/computer.cpython-39.pyc differ diff --git a/src/agents/__pycache__/exceptions.cpython-311.pyc b/src/agents/__pycache__/exceptions.cpython-311.pyc new file mode 100644 index 0000000..4d851c8 Binary files /dev/null and b/src/agents/__pycache__/exceptions.cpython-311.pyc differ diff --git a/src/agents/__pycache__/exceptions.cpython-313.pyc b/src/agents/__pycache__/exceptions.cpython-313.pyc new file mode 100644 index 0000000..7fa3645 Binary files /dev/null and b/src/agents/__pycache__/exceptions.cpython-313.pyc differ diff --git a/src/agents/__pycache__/exceptions.cpython-39.pyc b/src/agents/__pycache__/exceptions.cpython-39.pyc new file mode 100644 index 0000000..f18ec51 Binary files /dev/null and b/src/agents/__pycache__/exceptions.cpython-39.pyc differ diff --git a/src/agents/__pycache__/function_schema.cpython-313.pyc b/src/agents/__pycache__/function_schema.cpython-313.pyc new file mode 100644 index 0000000..3f2547f Binary files /dev/null and b/src/agents/__pycache__/function_schema.cpython-313.pyc differ diff --git a/src/agents/__pycache__/function_schema.cpython-39.pyc b/src/agents/__pycache__/function_schema.cpython-39.pyc new file mode 100644 index 0000000..5e7ffdd Binary files /dev/null and b/src/agents/__pycache__/function_schema.cpython-39.pyc differ diff --git a/src/agents/__pycache__/guardrail.cpython-311.pyc b/src/agents/__pycache__/guardrail.cpython-311.pyc new file mode 100644 index 0000000..5a7e4df Binary files /dev/null and b/src/agents/__pycache__/guardrail.cpython-311.pyc differ diff --git a/src/agents/__pycache__/guardrail.cpython-313.pyc b/src/agents/__pycache__/guardrail.cpython-313.pyc new file mode 100644 index 0000000..edcd014 Binary files /dev/null and b/src/agents/__pycache__/guardrail.cpython-313.pyc differ diff --git a/src/agents/__pycache__/guardrail.cpython-39.pyc b/src/agents/__pycache__/guardrail.cpython-39.pyc new file mode 100644 index 0000000..ce0385b Binary files /dev/null and b/src/agents/__pycache__/guardrail.cpython-39.pyc differ diff --git a/src/agents/__pycache__/guardrail_base.cpython-313.pyc b/src/agents/__pycache__/guardrail_base.cpython-313.pyc new file mode 100644 index 0000000..525e91e Binary files /dev/null and b/src/agents/__pycache__/guardrail_base.cpython-313.pyc differ diff --git a/src/agents/__pycache__/guardrail_base.cpython-39.pyc b/src/agents/__pycache__/guardrail_base.cpython-39.pyc new file mode 100644 index 0000000..400cd36 Binary files /dev/null and b/src/agents/__pycache__/guardrail_base.cpython-39.pyc differ diff --git a/src/agents/__pycache__/guardrails.cpython-313.pyc b/src/agents/__pycache__/guardrails.cpython-313.pyc new file mode 100644 index 0000000..bec8e3f Binary files /dev/null and b/src/agents/__pycache__/guardrails.cpython-313.pyc differ diff --git a/src/agents/__pycache__/handoff.cpython-313.pyc b/src/agents/__pycache__/handoff.cpython-313.pyc new file mode 100644 index 0000000..5442e41 Binary files /dev/null and b/src/agents/__pycache__/handoff.cpython-313.pyc differ diff --git a/src/agents/__pycache__/handoff.cpython-39.pyc b/src/agents/__pycache__/handoff.cpython-39.pyc new file mode 100644 index 0000000..753a02e Binary files /dev/null and b/src/agents/__pycache__/handoff.cpython-39.pyc differ diff --git a/src/agents/__pycache__/handoffs.cpython-313.pyc b/src/agents/__pycache__/handoffs.cpython-313.pyc new file mode 100644 index 0000000..9fe8788 Binary files /dev/null and b/src/agents/__pycache__/handoffs.cpython-313.pyc differ diff --git a/src/agents/__pycache__/handoffs.cpython-39.pyc b/src/agents/__pycache__/handoffs.cpython-39.pyc new file mode 100644 index 0000000..64f8bb0 Binary files /dev/null and b/src/agents/__pycache__/handoffs.cpython-39.pyc differ diff --git a/src/agents/__pycache__/items.cpython-311.pyc b/src/agents/__pycache__/items.cpython-311.pyc new file mode 100644 index 0000000..6b0149f Binary files /dev/null and b/src/agents/__pycache__/items.cpython-311.pyc differ diff --git a/src/agents/__pycache__/items.cpython-313.pyc b/src/agents/__pycache__/items.cpython-313.pyc new file mode 100644 index 0000000..8410c67 Binary files /dev/null and b/src/agents/__pycache__/items.cpython-313.pyc differ diff --git a/src/agents/__pycache__/items.cpython-39.pyc b/src/agents/__pycache__/items.cpython-39.pyc new file mode 100644 index 0000000..7245ae2 Binary files /dev/null and b/src/agents/__pycache__/items.cpython-39.pyc differ diff --git a/src/agents/__pycache__/lifecycle.cpython-313.pyc b/src/agents/__pycache__/lifecycle.cpython-313.pyc new file mode 100644 index 0000000..e1ccf5e Binary files /dev/null and b/src/agents/__pycache__/lifecycle.cpython-313.pyc differ diff --git a/src/agents/__pycache__/lifecycle.cpython-39.pyc b/src/agents/__pycache__/lifecycle.cpython-39.pyc new file mode 100644 index 0000000..82a6eef Binary files /dev/null and b/src/agents/__pycache__/lifecycle.cpython-39.pyc differ diff --git a/src/agents/__pycache__/logger.cpython-313.pyc b/src/agents/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000..24bf916 Binary files /dev/null and b/src/agents/__pycache__/logger.cpython-313.pyc differ diff --git a/src/agents/__pycache__/logger.cpython-39.pyc b/src/agents/__pycache__/logger.cpython-39.pyc new file mode 100644 index 0000000..360a1a3 Binary files /dev/null and b/src/agents/__pycache__/logger.cpython-39.pyc differ diff --git a/src/agents/__pycache__/model_settings.cpython-313.pyc b/src/agents/__pycache__/model_settings.cpython-313.pyc new file mode 100644 index 0000000..c746516 Binary files /dev/null and b/src/agents/__pycache__/model_settings.cpython-313.pyc differ diff --git a/src/agents/__pycache__/model_settings.cpython-39.pyc b/src/agents/__pycache__/model_settings.cpython-39.pyc new file mode 100644 index 0000000..1ac8110 Binary files /dev/null and b/src/agents/__pycache__/model_settings.cpython-39.pyc differ diff --git a/src/agents/__pycache__/output_tool.cpython-313.pyc b/src/agents/__pycache__/output_tool.cpython-313.pyc new file mode 100644 index 0000000..3569e33 Binary files /dev/null and b/src/agents/__pycache__/output_tool.cpython-313.pyc differ diff --git a/src/agents/__pycache__/output_tool.cpython-39.pyc b/src/agents/__pycache__/output_tool.cpython-39.pyc new file mode 100644 index 0000000..3bc8d2e Binary files /dev/null and b/src/agents/__pycache__/output_tool.cpython-39.pyc differ diff --git a/src/agents/__pycache__/result.cpython-313.pyc b/src/agents/__pycache__/result.cpython-313.pyc new file mode 100644 index 0000000..d1c647a Binary files /dev/null and b/src/agents/__pycache__/result.cpython-313.pyc differ diff --git a/src/agents/__pycache__/result.cpython-39.pyc b/src/agents/__pycache__/result.cpython-39.pyc new file mode 100644 index 0000000..94a7e8c Binary files /dev/null and b/src/agents/__pycache__/result.cpython-39.pyc differ diff --git a/src/agents/__pycache__/run.cpython-313.pyc b/src/agents/__pycache__/run.cpython-313.pyc new file mode 100644 index 0000000..d127b93 Binary files /dev/null and b/src/agents/__pycache__/run.cpython-313.pyc differ diff --git a/src/agents/__pycache__/run.cpython-39.pyc b/src/agents/__pycache__/run.cpython-39.pyc new file mode 100644 index 0000000..eb73263 Binary files /dev/null and b/src/agents/__pycache__/run.cpython-39.pyc differ diff --git a/src/agents/__pycache__/run_context.cpython-313.pyc b/src/agents/__pycache__/run_context.cpython-313.pyc new file mode 100644 index 0000000..72a6585 Binary files /dev/null and b/src/agents/__pycache__/run_context.cpython-313.pyc differ diff --git a/src/agents/__pycache__/run_context.cpython-39.pyc b/src/agents/__pycache__/run_context.cpython-39.pyc new file mode 100644 index 0000000..2f8fc21 Binary files /dev/null and b/src/agents/__pycache__/run_context.cpython-39.pyc differ diff --git a/src/agents/__pycache__/stream_events.cpython-313.pyc b/src/agents/__pycache__/stream_events.cpython-313.pyc new file mode 100644 index 0000000..ee957ee Binary files /dev/null and b/src/agents/__pycache__/stream_events.cpython-313.pyc differ diff --git a/src/agents/__pycache__/stream_events.cpython-39.pyc b/src/agents/__pycache__/stream_events.cpython-39.pyc new file mode 100644 index 0000000..3946c1b Binary files /dev/null and b/src/agents/__pycache__/stream_events.cpython-39.pyc differ diff --git a/src/agents/__pycache__/strict_schema.cpython-313.pyc b/src/agents/__pycache__/strict_schema.cpython-313.pyc new file mode 100644 index 0000000..79b6205 Binary files /dev/null and b/src/agents/__pycache__/strict_schema.cpython-313.pyc differ diff --git a/src/agents/__pycache__/strict_schema.cpython-39.pyc b/src/agents/__pycache__/strict_schema.cpython-39.pyc new file mode 100644 index 0000000..50d5644 Binary files /dev/null and b/src/agents/__pycache__/strict_schema.cpython-39.pyc differ diff --git a/src/agents/__pycache__/tool.cpython-313.pyc b/src/agents/__pycache__/tool.cpython-313.pyc new file mode 100644 index 0000000..cb3a119 Binary files /dev/null and b/src/agents/__pycache__/tool.cpython-313.pyc differ diff --git a/src/agents/__pycache__/tool.cpython-39.pyc b/src/agents/__pycache__/tool.cpython-39.pyc new file mode 100644 index 0000000..8318baa Binary files /dev/null and b/src/agents/__pycache__/tool.cpython-39.pyc differ diff --git a/src/agents/__pycache__/tool_converter.cpython-313.pyc b/src/agents/__pycache__/tool_converter.cpython-313.pyc new file mode 100644 index 0000000..e6400c5 Binary files /dev/null and b/src/agents/__pycache__/tool_converter.cpython-313.pyc differ diff --git a/src/agents/__pycache__/tool_converter.cpython-39.pyc b/src/agents/__pycache__/tool_converter.cpython-39.pyc new file mode 100644 index 0000000..9ee20bd Binary files /dev/null and b/src/agents/__pycache__/tool_converter.cpython-39.pyc differ diff --git a/src/agents/__pycache__/usage.cpython-313.pyc b/src/agents/__pycache__/usage.cpython-313.pyc new file mode 100644 index 0000000..057a30b Binary files /dev/null and b/src/agents/__pycache__/usage.cpython-313.pyc differ diff --git a/src/agents/__pycache__/usage.cpython-39.pyc b/src/agents/__pycache__/usage.cpython-39.pyc new file mode 100644 index 0000000..f07f1c9 Binary files /dev/null and b/src/agents/__pycache__/usage.cpython-39.pyc differ diff --git a/src/agents/__pycache__/version.cpython-313.pyc b/src/agents/__pycache__/version.cpython-313.pyc new file mode 100644 index 0000000..9730494 Binary files /dev/null and b/src/agents/__pycache__/version.cpython-313.pyc differ diff --git a/src/agents/__pycache__/version.cpython-39.pyc b/src/agents/__pycache__/version.cpython-39.pyc new file mode 100644 index 0000000..fea6e35 Binary files /dev/null and b/src/agents/__pycache__/version.cpython-39.pyc differ diff --git a/src/agents/_config.py b/src/agents/_config.py new file mode 100644 index 0000000..55ded64 --- /dev/null +++ b/src/agents/_config.py @@ -0,0 +1,23 @@ +from openai import AsyncOpenAI +from typing_extensions import Literal + +from .models import _openai_shared +from .tracing import set_tracing_export_api_key + + +def set_default_openai_key(key: str) -> None: + set_tracing_export_api_key(key) + _openai_shared.set_default_openai_key(key) + + +def set_default_openai_client(client: AsyncOpenAI, use_for_tracing: bool) -> None: + if use_for_tracing: + set_tracing_export_api_key(client.api_key) + _openai_shared.set_default_openai_client(client) + + +def set_default_openai_api(api: Literal["chat_completions", "responses"]) -> None: + if api == "chat_completions": + _openai_shared.set_use_responses_by_default(False) + else: + _openai_shared.set_use_responses_by_default(True) diff --git a/src/agents/_debug.py b/src/agents/_debug.py new file mode 100644 index 0000000..4da91be --- /dev/null +++ b/src/agents/_debug.py @@ -0,0 +1,17 @@ +import os + + +def _debug_flag_enabled(flag: str) -> bool: + flag_value = os.getenv(flag) + return flag_value is not None and (flag_value == "1" or flag_value.lower() == "true") + + +DONT_LOG_MODEL_DATA = _debug_flag_enabled("OPENAI_AGENTS_DONT_LOG_MODEL_DATA") +"""By default we don't log LLM inputs/outputs, to prevent exposing sensitive information. Set this +flag to enable logging them. +""" + +DONT_LOG_TOOL_DATA = _debug_flag_enabled("OPENAI_AGENTS_DONT_LOG_TOOL_DATA") +"""By default we don't log tool call inputs/outputs, to prevent exposing sensitive information. Set +this flag to enable logging them. +""" diff --git a/src/agents/_run_impl.py b/src/agents/_run_impl.py new file mode 100644 index 0000000..112819c --- /dev/null +++ b/src/agents/_run_impl.py @@ -0,0 +1,792 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from openai.types.responses import ( + ResponseComputerToolCall, + ResponseFileSearchToolCall, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseOutputMessage, +) +from openai.types.responses.response_computer_tool_call import ( + ActionClick, + ActionDoubleClick, + ActionDrag, + ActionKeypress, + ActionMove, + ActionScreenshot, + ActionScroll, + ActionType, + ActionWait, +) +from openai.types.responses.response_input_param import ComputerCallOutput +from openai.types.responses.response_output_item import Reasoning + +from . import _utils +from .agent import Agent +from .agent_output import AgentOutputSchema +from .computer import AsyncComputer, Computer +from .exceptions import AgentsException, ModelBehaviorError, UserError +from .guardrail import InputGuardrail, InputGuardrailResult, OutputGuardrail, OutputGuardrailResult +from .handoffs import Handoff, HandoffInputData +from .items import ( + HandoffCallItem, + HandoffOutputItem, + ItemHelpers, + MessageOutputItem, + ModelResponse, + ReasoningItem, + RunItem, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, +) +from .lifecycle import RunHooks +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 .tracing import ( + SpanError, + Trace, + function_span, + get_current_trace, + guardrail_span, + handoff_span, + trace, +) + +if TYPE_CHECKING: + from .run import RunConfig + + +class QueueCompleteSentinel: + pass + + +QUEUE_COMPLETE_SENTINEL = QueueCompleteSentinel() + + +@dataclass +class ToolRunHandoff: + handoff: Handoff + tool_call: ResponseFunctionToolCall + + +@dataclass +class ToolRunFunction: + tool_call: ResponseFunctionToolCall + function_tool: FunctionTool + + +@dataclass +class ToolRunComputerAction: + tool_call: ResponseComputerToolCall + computer_tool: ComputerTool + + +@dataclass +class ProcessedResponse: + new_items: list[RunItem] + handoffs: list[ToolRunHandoff] + functions: list[ToolRunFunction] + computer_actions: list[ToolRunComputerAction] + + def has_tools_to_run(self) -> bool: + # Handoffs, functions and computer actions need local processing + # Hosted tools have already run, so there's nothing to do. + return any( + [ + self.handoffs, + self.functions, + self.computer_actions, + ] + ) + + +@dataclass +class NextStepHandoff: + new_agent: Agent[Any] + + +@dataclass +class NextStepFinalOutput: + output: Any + + +@dataclass +class NextStepRunAgain: + pass + + +@dataclass +class SingleStepResult: + original_input: str | list[TResponseInputItem] + """The input items i.e. the items before run() was called. May be mutated by handoff input + filters.""" + + model_response: ModelResponse + """The model response for the current step.""" + + pre_step_items: list[RunItem] + """Items generated before the current step.""" + + new_step_items: list[RunItem] + """Items generated during this current step.""" + + next_step: NextStepHandoff | NextStepFinalOutput | NextStepRunAgain + """The next step to take.""" + + @property + def generated_items(self) -> list[RunItem]: + """Items generated during the agent run (i.e. everything generated after + `original_input`).""" + return self.pre_step_items + self.new_step_items + + +def get_model_tracing_impl( + tracing_disabled: bool, trace_include_sensitive_data: bool +) -> ModelTracing: + if tracing_disabled: + return ModelTracing.DISABLED + elif trace_include_sensitive_data: + return ModelTracing.ENABLED + else: + return ModelTracing.ENABLED_WITHOUT_DATA + + +class RunImpl: + @classmethod + async def execute_tools_and_side_effects( + cls, + *, + agent: Agent[TContext], + # The original input to the Runner + original_input: str | list[TResponseInputItem], + # Eveything generated by Runner since the original input, but before the current step + pre_step_items: list[RunItem], + new_response: ModelResponse, + processed_response: ProcessedResponse, + output_schema: AgentOutputSchema | None, + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ) -> SingleStepResult: + # Make a copy of the generated items + pre_step_items = list(pre_step_items) + + new_step_items: list[RunItem] = [] + new_step_items.extend(processed_response.new_items) + + # First, lets run the tool calls - function tools and computer actions + function_results, computer_results = await asyncio.gather( + cls.execute_function_tool_calls( + agent=agent, + tool_runs=processed_response.functions, + hooks=hooks, + context_wrapper=context_wrapper, + config=run_config, + ), + cls.execute_computer_actions( + agent=agent, + actions=processed_response.computer_actions, + hooks=hooks, + context_wrapper=context_wrapper, + config=run_config, + ), + ) + new_step_items.extend(function_results) + new_step_items.extend(computer_results) + + # Second, check if there are any handoffs + if run_handoffs := processed_response.handoffs: + return await cls.execute_handoffs( + agent=agent, + original_input=original_input, + pre_step_items=pre_step_items, + new_step_items=new_step_items, + new_response=new_response, + run_handoffs=run_handoffs, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + + # 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)] + + # We'll use the last content output as the final output + potential_final_output_text = ( + ItemHelpers.extract_last_text(message_items[-1].raw_item) if message_items else None + ) + + # There are two possibilities that lead to a final output: + # 1. Structured output schema => always leads to a final output + # 2. Plain text output schema => only leads to a final output if there are no tool calls + if output_schema and not output_schema.is_plain_text() and potential_final_output_text: + final_output = output_schema.validate_json(potential_final_output_text) + 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=final_output, + hooks=hooks, + context_wrapper=context_wrapper, + ) + elif ( + not output_schema or output_schema.is_plain_text() + ) and not processed_response.has_tools_to_run(): + 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=potential_final_output_text or "", + hooks=hooks, + context_wrapper=context_wrapper, + ) + else: + # If there's no final output, we can just run again + return SingleStepResult( + original_input=original_input, + model_response=new_response, + pre_step_items=pre_step_items, + new_step_items=new_step_items, + next_step=NextStepRunAgain(), + ) + + @classmethod + def process_model_response( + cls, + *, + agent: Agent[Any], + response: ModelResponse, + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + ) -> ProcessedResponse: + items: list[RunItem] = [] + + run_handoffs = [] + functions = [] + computer_actions = [] + + handoff_map = {handoff.tool_name: handoff for handoff in handoffs} + function_map = {tool.name: tool for tool in agent.tools if isinstance(tool, FunctionTool)} + computer_tool = next((tool for tool in agent.tools if isinstance(tool, ComputerTool)), None) + + for output in response.output: + if isinstance(output, ResponseOutputMessage): + items.append(MessageOutputItem(raw_item=output, agent=agent)) + elif isinstance(output, ResponseFileSearchToolCall): + items.append(ToolCallItem(raw_item=output, agent=agent)) + elif isinstance(output, ResponseFunctionWebSearch): + items.append(ToolCallItem(raw_item=output, agent=agent)) + elif isinstance(output, Reasoning): + items.append(ReasoningItem(raw_item=output, agent=agent)) + elif isinstance(output, ResponseComputerToolCall): + items.append(ToolCallItem(raw_item=output, agent=agent)) + if not computer_tool: + _utils.attach_error_to_current_span( + SpanError( + message="Computer tool not found", + data={}, + ) + ) + raise ModelBehaviorError( + "Model produced computer action without a computer tool." + ) + computer_actions.append( + ToolRunComputerAction(tool_call=output, computer_tool=computer_tool) + ) + elif not isinstance(output, ResponseFunctionToolCall): + logger.warning(f"Unexpected output type, ignoring: {type(output)}") + continue + + # At this point we know it's a function tool call + if not isinstance(output, ResponseFunctionToolCall): + continue + + # Handoffs + if output.name in handoff_map: + items.append(HandoffCallItem(raw_item=output, agent=agent)) + handoff = ToolRunHandoff( + tool_call=output, + handoff=handoff_map[output.name], + ) + run_handoffs.append(handoff) + # Regular function tool call + else: + if output.name not in function_map: + _utils.attach_error_to_current_span( + SpanError( + message="Tool not found", + data={"tool_name": output.name}, + ) + ) + raise ModelBehaviorError(f"Tool {output.name} not found in agent {agent.name}") + items.append(ToolCallItem(raw_item=output, agent=agent)) + functions.append( + ToolRunFunction( + tool_call=output, + function_tool=function_map[output.name], + ) + ) + + return ProcessedResponse( + new_items=items, + handoffs=run_handoffs, + functions=functions, + computer_actions=computer_actions, + ) + + @classmethod + async def execute_function_tool_calls( + cls, + *, + agent: Agent[TContext], + tool_runs: list[ToolRunFunction], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + config: RunConfig, + ) -> list[RunItem]: + async def run_single_tool( + func_tool: FunctionTool, tool_call: ResponseFunctionToolCall + ) -> str: + with function_span(func_tool.name) as span_fn: + if config.trace_include_sensitive_data: + span_fn.span_data.input = tool_call.arguments + try: + _, _, result = await asyncio.gather( + hooks.on_tool_start(context_wrapper, agent, func_tool), + ( + agent.hooks.on_tool_start(context_wrapper, agent, func_tool) + if agent.hooks + else _utils.noop_coroutine() + ), + func_tool.on_invoke_tool(context_wrapper, tool_call.arguments), + ) + + await asyncio.gather( + hooks.on_tool_end(context_wrapper, agent, func_tool, result), + ( + agent.hooks.on_tool_end(context_wrapper, agent, func_tool, result) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + except Exception as e: + _utils.attach_error_to_current_span( + SpanError( + message="Error running tool", + data={"tool_name": func_tool.name, "error": str(e)}, + ) + ) + if isinstance(e, AgentsException): + raise e + raise UserError(f"Error running tool {func_tool.name}: {e}") from e + + if config.trace_include_sensitive_data: + span_fn.span_data.output = result + return result + + tasks = [] + for tool_run in tool_runs: + function_tool = tool_run.function_tool + tasks.append(run_single_tool(function_tool, tool_run.tool_call)) + + 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, + ) + for tool_run, result in zip(tool_runs, results) + ] + + @classmethod + async def execute_computer_actions( + cls, + *, + agent: Agent[TContext], + actions: list[ToolRunComputerAction], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + config: RunConfig, + ) -> list[RunItem]: + results: list[RunItem] = [] + # Need to run these serially, because each action can affect the computer state + for action in actions: + results.append( + await ComputerAction.execute( + agent=agent, + action=action, + hooks=hooks, + context_wrapper=context_wrapper, + config=config, + ) + ) + + return results + + @classmethod + async def execute_handoffs( + cls, + *, + agent: Agent[TContext], + original_input: str | list[TResponseInputItem], + pre_step_items: list[RunItem], + new_step_items: list[RunItem], + new_response: ModelResponse, + run_handoffs: list[ToolRunHandoff], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ) -> SingleStepResult: + # If there is more than one handoff, add tool responses that reject those handoffs + if len(run_handoffs) > 1: + output_message = "Multiple handoffs detected, ignoring this one." + new_step_items.extend( + [ + ToolCallOutputItem( + output=output_message, + raw_item=ItemHelpers.tool_call_output_item( + handoff.tool_call, output_message + ), + agent=agent, + ) + for handoff in run_handoffs[1:] + ] + ) + + actual_handoff = run_handoffs[0] + with handoff_span(from_agent=agent.name) as span_handoff: + handoff = actual_handoff.handoff + new_agent: Agent[Any] = await handoff.on_invoke_handoff( + context_wrapper, actual_handoff.tool_call.arguments + ) + span_handoff.span_data.to_agent = new_agent.name + + # Append a tool output item for the handoff + new_step_items.append( + HandoffOutputItem( + agent=agent, + raw_item=ItemHelpers.tool_call_output_item( + actual_handoff.tool_call, + handoff.get_transfer_message(new_agent), + ), + source_agent=agent, + target_agent=new_agent, + ) + ) + + # Execute handoff hooks + await asyncio.gather( + hooks.on_handoff( + context=context_wrapper, + from_agent=agent, + to_agent=new_agent, + ), + ( + agent.hooks.on_handoff( + context_wrapper, + agent=new_agent, + source=agent, + ) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + + # If there's an input filter, filter the input for the next agent + input_filter = handoff.input_filter or ( + run_config.handoff_input_filter if run_config else None + ) + if input_filter: + logger.debug("Filtering inputs for handoff") + handoff_input_data = HandoffInputData( + input_history=tuple(original_input) + if isinstance(original_input, list) + else original_input, + pre_handoff_items=tuple(pre_step_items), + new_items=tuple(new_step_items), + ) + if not callable(input_filter): + _utils.attach_error_to_span( + span_handoff, + SpanError( + message="Invalid input filter", + data={"details": "not callable()"}, + ), + ) + raise UserError(f"Invalid input filter: {input_filter}") + filtered = input_filter(handoff_input_data) + if not isinstance(filtered, HandoffInputData): + _utils.attach_error_to_span( + span_handoff, + SpanError( + message="Invalid input filter result", + data={"details": "not a HandoffInputData"}, + ), + ) + raise UserError(f"Invalid input filter result: {filtered}") + + original_input = ( + filtered.input_history + if isinstance(filtered.input_history, str) + else list(filtered.input_history) + ) + pre_step_items = list(filtered.pre_handoff_items) + new_step_items = list(filtered.new_items) + + return SingleStepResult( + original_input=original_input, + model_response=new_response, + pre_step_items=pre_step_items, + new_step_items=new_step_items, + next_step=NextStepHandoff(new_agent), + ) + + @classmethod + async def execute_final_output( + cls, + *, + agent: Agent[TContext], + original_input: str | list[TResponseInputItem], + new_response: ModelResponse, + pre_step_items: list[RunItem], + new_step_items: list[RunItem], + final_output: Any, + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + ) -> SingleStepResult: + # Run the on_end hooks + await cls.run_final_output_hooks(agent, hooks, context_wrapper, final_output) + + return SingleStepResult( + original_input=original_input, + model_response=new_response, + pre_step_items=pre_step_items, + new_step_items=new_step_items, + next_step=NextStepFinalOutput(final_output), + ) + + @classmethod + async def run_final_output_hooks( + cls, + agent: Agent[TContext], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + final_output: Any, + ): + await asyncio.gather( + hooks.on_agent_end(context_wrapper, agent, final_output), + agent.hooks.on_end(context_wrapper, agent, final_output) + if agent.hooks + else _utils.noop_coroutine(), + ) + + @classmethod + async def run_single_input_guardrail( + cls, + agent: Agent[Any], + guardrail: InputGuardrail[TContext], + input: str | list[TResponseInputItem], + context: RunContextWrapper[TContext], + ) -> InputGuardrailResult: + with guardrail_span(guardrail.get_name()) as span_guardrail: + result = await guardrail.run(agent, input, context) + span_guardrail.span_data.triggered = result.output.tripwire_triggered + return result + + @classmethod + async def run_single_output_guardrail( + cls, + guardrail: OutputGuardrail[TContext], + agent: Agent[Any], + agent_output: Any, + context: RunContextWrapper[TContext], + ) -> OutputGuardrailResult: + with guardrail_span(guardrail.get_name()) as span_guardrail: + result = await guardrail.run(agent=agent, agent_output=agent_output, context=context) + span_guardrail.span_data.triggered = result.output.tripwire_triggered + return result + + @classmethod + def stream_step_result_to_queue( + cls, + step_result: SingleStepResult, + queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel], + ): + for item in step_result.new_step_items: + if isinstance(item, MessageOutputItem): + event = RunItemStreamEvent(item=item, name="message_output_created") + elif isinstance(item, HandoffCallItem): + event = RunItemStreamEvent(item=item, name="handoff_requested") + elif isinstance(item, HandoffOutputItem): + event = RunItemStreamEvent(item=item, name="handoff_occured") + elif isinstance(item, ToolCallItem): + event = RunItemStreamEvent(item=item, name="tool_called") + elif isinstance(item, ToolCallOutputItem): + event = RunItemStreamEvent(item=item, name="tool_output") + elif isinstance(item, ReasoningItem): + event = RunItemStreamEvent(item=item, name="reasoning_item_created") + else: + logger.warning(f"Unexpected item type: {type(item)}") + event = None + + if event: + queue.put_nowait(event) + + +class TraceCtxManager: + """Creates a trace only if there is no current trace, and manages the trace lifecycle.""" + + def __init__( + self, + workflow_name: str, + trace_id: str | None, + group_id: str | None, + metadata: dict[str, Any] | None, + disabled: bool, + ): + self.trace: Trace | None = None + self.workflow_name = workflow_name + self.trace_id = trace_id + self.group_id = group_id + self.metadata = metadata + self.disabled = disabled + + def __enter__(self) -> TraceCtxManager: + current_trace = get_current_trace() + if not current_trace: + self.trace = trace( + workflow_name=self.workflow_name, + trace_id=self.trace_id, + group_id=self.group_id, + metadata=self.metadata, + disabled=self.disabled, + ) + self.trace.start(mark_as_current=True) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.trace: + self.trace.finish(reset_current=True) + + +class ComputerAction: + @classmethod + async def execute( + cls, + *, + agent: Agent[TContext], + action: ToolRunComputerAction, + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + config: RunConfig, + ) -> RunItem: + output_func = ( + cls._get_screenshot_async(action.computer_tool.computer, action.tool_call) + if isinstance(action.computer_tool.computer, AsyncComputer) + else cls._get_screenshot_sync(action.computer_tool.computer, action.tool_call) + ) + + _, _, output = await asyncio.gather( + hooks.on_tool_start(context_wrapper, agent, action.computer_tool), + ( + agent.hooks.on_tool_start(context_wrapper, agent, action.computer_tool) + if agent.hooks + else _utils.noop_coroutine() + ), + output_func, + ) + + await asyncio.gather( + hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output), + ( + agent.hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + + # TODO: don't send a screenshot every single time, use references + image_url = f"data:image/png;base64,{output}" + return ToolCallOutputItem( + agent=agent, + output=image_url, + raw_item=ComputerCallOutput( + call_id=action.tool_call.call_id, + output={ + "type": "computer_screenshot", + "image_url": image_url, + }, + type="computer_call_output", + ), + ) + + @classmethod + async def _get_screenshot_sync( + cls, + computer: Computer, + tool_call: ResponseComputerToolCall, + ) -> str: + action = tool_call.action + if isinstance(action, ActionClick): + computer.click(action.x, action.y, action.button) + elif isinstance(action, ActionDoubleClick): + computer.double_click(action.x, action.y) + elif isinstance(action, ActionDrag): + computer.drag([(p.x, p.y) for p in action.path]) + elif isinstance(action, ActionKeypress): + computer.keypress(action.keys) + elif isinstance(action, ActionMove): + computer.move(action.x, action.y) + elif isinstance(action, ActionScreenshot): + computer.screenshot() + elif isinstance(action, ActionScroll): + computer.scroll(action.x, action.y, action.scroll_x, action.scroll_y) + elif isinstance(action, ActionType): + computer.type(action.text) + elif isinstance(action, ActionWait): + computer.wait() + + return computer.screenshot() + + @classmethod + async def _get_screenshot_async( + cls, + computer: AsyncComputer, + tool_call: ResponseComputerToolCall, + ) -> str: + action = tool_call.action + if isinstance(action, ActionClick): + await computer.click(action.x, action.y, action.button) + elif isinstance(action, ActionDoubleClick): + await computer.double_click(action.x, action.y) + elif isinstance(action, ActionDrag): + await computer.drag([(p.x, p.y) for p in action.path]) + elif isinstance(action, ActionKeypress): + await computer.keypress(action.keys) + elif isinstance(action, ActionMove): + await computer.move(action.x, action.y) + elif isinstance(action, ActionScreenshot): + await computer.screenshot() + elif isinstance(action, ActionScroll): + await computer.scroll(action.x, action.y, action.scroll_x, action.scroll_y) + elif isinstance(action, ActionType): + await computer.type(action.text) + elif isinstance(action, ActionWait): + await computer.wait() + + return await computer.screenshot() diff --git a/src/agents/_utils.py b/src/agents/_utils.py new file mode 100644 index 0000000..2a0293a --- /dev/null +++ b/src/agents/_utils.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import re +from collections.abc import Awaitable +from typing import Any, Literal, Union + +from pydantic import TypeAdapter, ValidationError +from typing_extensions import TypeVar + +from .exceptions import ModelBehaviorError +from .logger import logger +from .tracing import Span, SpanError, get_current_span + +T = TypeVar("T") + +MaybeAwaitable = Union[Awaitable[T], T] + + +def transform_string_function_style(name: str) -> str: + # Replace spaces with underscores + name = name.replace(" ", "_") + + # Replace non-alphanumeric characters with underscores + name = re.sub(r"[^a-zA-Z0-9]", "_", name) + + return name.lower() + + +def validate_json(json_str: str, type_adapter: TypeAdapter[T], partial: bool) -> T: + partial_setting: bool | Literal["off", "on", "trailing-strings"] = ( + "trailing-strings" if partial else False + ) + try: + validated = type_adapter.validate_json(json_str, experimental_allow_partial=partial_setting) + return validated + except ValidationError as e: + attach_error_to_current_span( + SpanError( + message="Invalid JSON provided", + data={}, + ) + ) + raise ModelBehaviorError( + f"Invalid JSON when parsing {json_str} for {type_adapter}; {e}" + ) from e + + +def attach_error_to_span(span: Span[Any], error: SpanError) -> None: + span.set_error(error) + + +def attach_error_to_current_span(error: SpanError) -> None: + span = get_current_span() + if span: + attach_error_to_span(span, error) + else: + logger.warning(f"No span to add error {error} to") + + +async def noop_coroutine() -> None: + pass diff --git a/src/agents/agent.py b/src/agents/agent.py new file mode 100644 index 0000000..61c0a89 --- /dev/null +++ b/src/agents/agent.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import dataclasses +import inspect +from collections.abc import Awaitable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Generic, cast + +from . import _utils +from ._utils import MaybeAwaitable +from .guardrail import InputGuardrail, OutputGuardrail +from .handoffs import Handoff +from .items import ItemHelpers +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 + +if TYPE_CHECKING: + from .lifecycle import AgentHooks + from .result import RunResult + + +@dataclass +class Agent(Generic[TContext]): + """An agent is an AI model configured with instructions, tools, guardrails, handoffs and more. + + We strongly recommend passing `instructions`, which is the "system prompt" for the agent. In + addition, you can pass `description`, which is a human-readable description of the agent, used + when the agent is used inside tools/handoffs. + + Agents are generic on the context type. The context is a (mutable) object you create. It is + passed to tool functions, handoffs, guardrails, etc. + """ + + name: str + """The name of the agent.""" + + instructions: ( + str + | Callable[ + [RunContextWrapper[TContext], Agent[TContext]], + MaybeAwaitable[str], + ] + | None + ) = None + """The instructions for the agent. Will be used as the "system prompt" when this agent is + invoked. Describes what the agent should do, and how it responds. + + Can either be a string, or a function that dynamically generates instructions for the agent. If + you provide a function, it will be called with the context and the agent instance. It must + return a string. + """ + + handoff_description: str | None = None + """A description of the agent. This is used when the agent is used as a handoff, so that an + LLM knows what it does and when to invoke it. + """ + + handoffs: list[Agent[Any] | Handoff[TContext]] = field(default_factory=list) + """Handoffs are sub-agents that the agent can delegate to. You can provide a list of handoffs, + and the agent can choose to delegate to them if relevant. Allows for separation of concerns and + modularity. + """ + + model: str | Model | None = None + """The model implementation to use when invoking the LLM. + + By default, if not set, the agent will use the default model configured in + `model_settings.DEFAULT_MODEL`. + """ + + model_settings: ModelSettings = field(default_factory=ModelSettings) + """Configures model-specific tuning parameters (e.g. temperature, top_p). + """ + + tools: list[Tool] = field(default_factory=list) + """A list of tools that the agent can use.""" + + input_guardrails: list[InputGuardrail[TContext]] = field(default_factory=list) + """A list of checks that run in parallel to the agent's execution, before generating a + response. Runs only if the agent is the first agent in the chain. + """ + + output_guardrails: list[OutputGuardrail[TContext]] = field(default_factory=list) + """A list of checks that run on the final output of the agent, after generating a response. + Runs only if the agent produces a final output. + """ + + output_type: type[Any] | None = None + """The type of the output object. If not provided, the output will be `str`.""" + + hooks: AgentHooks[TContext] | None = None + """A class that receives callbacks on various lifecycle events for this agent. + """ + + def clone(self, **kwargs: Any) -> Agent[TContext]: + """Make a copy of the agent, with the given arguments changed. For example, you could do: + ``` + new_agent = agent.clone(instructions="New instructions") + ``` + """ + return dataclasses.replace(self, **kwargs) + + def as_tool( + self, + tool_name: str | None, + tool_description: str | None, + custom_output_extractor: Callable[[RunResult], Awaitable[str]] | None = None, + ) -> Tool: + """Transform this agent into a tool, callable by other agents. + + This is different from handoffs in two ways: + 1. In handoffs, the new agent receives the conversation history. In this tool, the new agent + receives generated input. + 2. In handoffs, the new agent takes over the conversation. In this tool, the new agent is + called as a tool, and the conversation is continued by the original agent. + + Args: + tool_name: The name of the tool. If not provided, the agent's name will be used. + tool_description: The description of the tool, which should indicate what it does and + when to use it. + custom_output_extractor: A function that extracts the output from the agent. If not + provided, the last message from the agent will be used. + """ + + @function_tool( + name_override=tool_name or _utils.transform_string_function_style(self.name), + description_override=tool_description or "", + ) + async def run_agent(context: RunContextWrapper, input: str) -> str: + from .run import Runner + + output = await Runner.run( + starting_agent=self, + input=input, + context=context.context, + ) + if custom_output_extractor: + return await custom_output_extractor(output) + + return ItemHelpers.text_message_outputs(output.new_items) + + return run_agent + + async def get_system_prompt(self, run_context: RunContextWrapper[TContext]) -> str | None: + """Get the system prompt for the agent.""" + if isinstance(self.instructions, str): + return self.instructions + elif callable(self.instructions): + if inspect.iscoroutinefunction(self.instructions): + return await cast(Awaitable[str], self.instructions(run_context, self)) + else: + return cast(str, self.instructions(run_context, self)) + elif self.instructions is not None: + logger.error(f"Instructions must be a string or a function, got {self.instructions}") + + return None diff --git a/src/agents/agent_output.py b/src/agents/agent_output.py new file mode 100644 index 0000000..8140d8c --- /dev/null +++ b/src/agents/agent_output.py @@ -0,0 +1,144 @@ +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel, TypeAdapter +from typing_extensions import TypedDict, get_args, get_origin + +from . import _utils +from .exceptions import ModelBehaviorError, UserError +from .strict_schema import ensure_strict_json_schema +from .tracing import SpanError + +_WRAPPER_DICT_KEY = "response" + + +@dataclass(init=False) +class AgentOutputSchema: + """An object that captures the JSON schema of the output, as well as validating/parsing JSON + produced by the LLM into the output type. + """ + + output_type: type[Any] + """The type of the output.""" + + _type_adapter: TypeAdapter[Any] + """A type adapter that wraps the output type, so that we can validate JSON.""" + + _is_wrapped: bool + """Whether the output type is wrapped in a dictionary. This is generally done if the base + output type cannot be represented as a JSON Schema object. + """ + + _output_schema: dict[str, Any] + """The JSON schema of the output.""" + + strict_json_schema: bool + """Whether the JSON schema is in strict mode. We **strongly** recommend setting this to True, + as it increases the likelihood of correct JSON input. + """ + + def __init__(self, output_type: type[Any], strict_json_schema: bool = True): + """ + Args: + output_type: The type of the output. + strict_json_schema: Whether the JSON schema is in strict mode. We **strongly** recommend + setting this to True, as it increases the likelihood of correct JSON input. + """ + self.output_type = output_type + self.strict_json_schema = strict_json_schema + + if output_type is None or output_type is str: + self._is_wrapped = False + self._type_adapter = TypeAdapter(output_type) + self._output_schema = self._type_adapter.json_schema() + return + + # We should wrap for things that are not plain text, and for things that would definitely + # not be a JSON Schema object. + self._is_wrapped = not _is_subclass_of_base_model_or_dict(output_type) + + if self._is_wrapped: + OutputType = TypedDict( + "OutputType", + { + _WRAPPER_DICT_KEY: output_type, # type: ignore + }, + ) + self._type_adapter = TypeAdapter(OutputType) + self._output_schema = self._type_adapter.json_schema() + else: + self._type_adapter = TypeAdapter(output_type) + self._output_schema = self._type_adapter.json_schema() + + if self.strict_json_schema: + self._output_schema = ensure_strict_json_schema(self._output_schema) + + def is_plain_text(self) -> bool: + """Whether the output type is plain text (versus a JSON object).""" + return self.output_type is None or self.output_type is str + + def json_schema(self) -> dict[str, Any]: + """The JSON schema of the output type.""" + if self.is_plain_text(): + raise UserError("Output type is plain text, so no JSON schema is available") + return self._output_schema + + def validate_json(self, json_str: str, partial: bool = False) -> Any: + """Validate a JSON string against the output type. Returns the validated object, or raises + a `ModelBehaviorError` if the JSON is invalid. + """ + validated = _utils.validate_json(json_str, self._type_adapter, partial) + if self._is_wrapped: + if not isinstance(validated, dict): + _utils.attach_error_to_current_span( + SpanError( + message="Invalid JSON", + data={"details": f"Expected a dict, got {type(validated)}"}, + ) + ) + raise ModelBehaviorError( + f"Expected a dict, got {type(validated)} for JSON: {json_str}" + ) + + if _WRAPPER_DICT_KEY not in validated: + _utils.attach_error_to_current_span( + SpanError( + message="Invalid JSON", + data={"details": f"Could not find key {_WRAPPER_DICT_KEY} in JSON"}, + ) + ) + raise ModelBehaviorError( + f"Could not find key {_WRAPPER_DICT_KEY} in JSON: {json_str}" + ) + return validated[_WRAPPER_DICT_KEY] + return validated + + def output_type_name(self) -> str: + """The name of the output type.""" + return _type_to_str(self.output_type) + + +def _is_subclass_of_base_model_or_dict(t: Any) -> bool: + if not isinstance(t, type): + return False + + # If it's a generic alias, 'origin' will be the actual type, e.g. 'list' + origin = get_origin(t) + + allowed_types = (BaseModel, dict) + # If it's a generic alias e.g. list[str], then we should check the origin type i.e. list + return issubclass(origin or t, allowed_types) + + +def _type_to_str(t: type[Any]) -> str: + origin = get_origin(t) + args = get_args(t) + + if origin is None: + # It's a simple type like `str`, `int`, etc. + return t.__name__ + elif args: + args_str = ', '.join(_type_to_str(arg) for arg in args) + return f"{origin.__name__}[{args_str}]" + else: + return str(t) diff --git a/src/agents/computer.py b/src/agents/computer.py new file mode 100644 index 0000000..1b9224d --- /dev/null +++ b/src/agents/computer.py @@ -0,0 +1,107 @@ +import abc +from typing import Literal + +Environment = Literal["mac", "windows", "ubuntu", "browser"] +Button = Literal["left", "right", "wheel", "back", "forward"] + + +class Computer(abc.ABC): + """A computer implemented with sync operations. The Computer interface abstracts the + operations needed to control a computer or browser.""" + + @property + @abc.abstractmethod + def environment(self) -> Environment: + pass + + @property + @abc.abstractmethod + def dimensions(self) -> tuple[int, int]: + pass + + @abc.abstractmethod + def screenshot(self) -> str: + pass + + @abc.abstractmethod + def click(self, x: int, y: int, button: Button) -> None: + pass + + @abc.abstractmethod + def double_click(self, x: int, y: int) -> None: + pass + + @abc.abstractmethod + def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + pass + + @abc.abstractmethod + def type(self, text: str) -> None: + pass + + @abc.abstractmethod + def wait(self) -> None: + pass + + @abc.abstractmethod + def move(self, x: int, y: int) -> None: + pass + + @abc.abstractmethod + def keypress(self, keys: list[str]) -> None: + pass + + @abc.abstractmethod + def drag(self, path: list[tuple[int, int]]) -> None: + pass + + +class AsyncComputer(abc.ABC): + """A computer implemented with async operations. The Computer interface abstracts the + operations needed to control a computer or browser.""" + + @property + @abc.abstractmethod + def environment(self) -> Environment: + pass + + @property + @abc.abstractmethod + def dimensions(self) -> tuple[int, int]: + pass + + @abc.abstractmethod + async def screenshot(self) -> str: + pass + + @abc.abstractmethod + async def click(self, x: int, y: int, button: Button) -> None: + pass + + @abc.abstractmethod + async def double_click(self, x: int, y: int) -> None: + pass + + @abc.abstractmethod + async def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + pass + + @abc.abstractmethod + async def type(self, text: str) -> None: + pass + + @abc.abstractmethod + async def wait(self) -> None: + pass + + @abc.abstractmethod + async def move(self, x: int, y: int) -> None: + pass + + @abc.abstractmethod + async def keypress(self, keys: list[str]) -> None: + pass + + @abc.abstractmethod + async def drag(self, path: list[tuple[int, int]]) -> None: + pass diff --git a/src/agents/exceptions.py b/src/agents/exceptions.py new file mode 100644 index 0000000..78898f0 --- /dev/null +++ b/src/agents/exceptions.py @@ -0,0 +1,63 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .guardrail import InputGuardrailResult, OutputGuardrailResult + + +class AgentsException(Exception): + """Base class for all exceptions in the Agents SDK.""" + + +class MaxTurnsExceeded(AgentsException): + """Exception raised when the maximum number of turns is exceeded.""" + + message: str + + def __init__(self, message: str): + self.message = message + + +class ModelBehaviorError(AgentsException): + """Exception raised when the model does something unexpected, e.g. calling a tool that doesn't + exist, or providing malformed JSON. + """ + + message: str + + def __init__(self, message: str): + self.message = message + + +class UserError(AgentsException): + """Exception raised when the user makes an error using the SDK.""" + + message: str + + def __init__(self, message: str): + self.message = message + + +class InputGuardrailTripwireTriggered(AgentsException): + """Exception raised when a guardrail tripwire is triggered.""" + + guardrail_result: "InputGuardrailResult" + """The result data of the guardrail that was triggered.""" + + def __init__(self, guardrail_result: "InputGuardrailResult"): + self.guardrail_result = guardrail_result + super().__init__( + f"Guardrail {guardrail_result.guardrail.__class__.__name__} triggered tripwire" + ) + + +class OutputGuardrailTripwireTriggered(AgentsException): + """Exception raised when a guardrail tripwire is triggered.""" + + guardrail_result: "OutputGuardrailResult" + """The result data of the guardrail that was triggered.""" + + def __init__(self, guardrail_result: "OutputGuardrailResult"): + self.guardrail_result = guardrail_result + super().__init__( + f"Guardrail {guardrail_result.guardrail.__class__.__name__} triggered tripwire" + ) diff --git a/src/agents/extensions/__init__.py b/src/agents/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/extensions/__pycache__/__init__.cpython-313.pyc b/src/agents/extensions/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..bda7d30 Binary files /dev/null and b/src/agents/extensions/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agents/extensions/__pycache__/__init__.cpython-39.pyc b/src/agents/extensions/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..b46e0f6 Binary files /dev/null and b/src/agents/extensions/__pycache__/__init__.cpython-39.pyc differ diff --git a/src/agents/extensions/__pycache__/handoff_filters.cpython-313.pyc b/src/agents/extensions/__pycache__/handoff_filters.cpython-313.pyc new file mode 100644 index 0000000..d2b51f4 Binary files /dev/null and b/src/agents/extensions/__pycache__/handoff_filters.cpython-313.pyc differ diff --git a/src/agents/extensions/__pycache__/handoff_filters.cpython-39.pyc b/src/agents/extensions/__pycache__/handoff_filters.cpython-39.pyc new file mode 100644 index 0000000..5befbc6 Binary files /dev/null and b/src/agents/extensions/__pycache__/handoff_filters.cpython-39.pyc differ diff --git a/src/agents/extensions/__pycache__/handoff_prompt.cpython-313.pyc b/src/agents/extensions/__pycache__/handoff_prompt.cpython-313.pyc new file mode 100644 index 0000000..1b1154b Binary files /dev/null and b/src/agents/extensions/__pycache__/handoff_prompt.cpython-313.pyc differ diff --git a/src/agents/extensions/handoff_filters.py b/src/agents/extensions/handoff_filters.py new file mode 100644 index 0000000..f4f9b8b --- /dev/null +++ b/src/agents/extensions/handoff_filters.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from ..handoffs import HandoffInputData +from ..items import ( + HandoffCallItem, + HandoffOutputItem, + RunItem, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, +) + +"""Contains common handoff input filters, for convenience. """ + + +def remove_all_tools(handoff_input_data: HandoffInputData) -> HandoffInputData: + """Filters out all tool items: file search, web search and function calls+output.""" + + history = handoff_input_data.input_history + new_items = handoff_input_data.new_items + + filtered_history = ( + _remove_tool_types_from_input(history) if isinstance(history, tuple) else history + ) + filtered_pre_handoff_items = _remove_tools_from_items(handoff_input_data.pre_handoff_items) + filtered_new_items = _remove_tools_from_items(new_items) + + return HandoffInputData( + input_history=filtered_history, + pre_handoff_items=filtered_pre_handoff_items, + new_items=filtered_new_items, + ) + + +def _remove_tools_from_items(items: tuple[RunItem, ...]) -> tuple[RunItem, ...]: + filtered_items = [] + for item in items: + if ( + isinstance(item, HandoffCallItem) + or isinstance(item, HandoffOutputItem) + or isinstance(item, ToolCallItem) + or isinstance(item, ToolCallOutputItem) + ): + continue + filtered_items.append(item) + return tuple(filtered_items) + + +def _remove_tool_types_from_input( + items: tuple[TResponseInputItem, ...], +) -> tuple[TResponseInputItem, ...]: + tool_types = [ + "function_call", + "function_call_output", + "computer_call", + "computer_call_output", + "file_search_call", + "web_search_call", + ] + + filtered_items: list[TResponseInputItem] = [] + for item in items: + itype = item.get("type") + if itype in tool_types: + continue + filtered_items.append(item) + return tuple(filtered_items) diff --git a/src/agents/extensions/handoff_prompt.py b/src/agents/extensions/handoff_prompt.py new file mode 100644 index 0000000..cfb5ca7 --- /dev/null +++ b/src/agents/extensions/handoff_prompt.py @@ -0,0 +1,19 @@ +# A recommended prompt prefix for agents that use handoffs. We recommend including this or +# similar instructions in any agents that use handoffs. +RECOMMENDED_PROMPT_PREFIX = ( + "# System context\n" + "You are part of a multi-agent system called the Agents SDK, designed to make agent " + "coordination and execution easy. Agents uses two primary abstraction: **Agents** and " + "**Handoffs**. An agent encompasses instructions and tools and can hand off a " + "conversation to another agent when appropriate. " + "Handoffs are achieved by calling a handoff function, generally named " + "`transfer_to_`. Transfers between agents are handled seamlessly in the background;" + " do not mention or draw attention to these transfers in your conversation with the user.\n" +) + + +def prompt_with_handoff_instructions(prompt: str) -> str: + """ + Add recommended instructions to the prompt for agents that use handoffs. + """ + return f"{RECOMMENDED_PROMPT_PREFIX}\n\n{prompt}" diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py new file mode 100644 index 0000000..a4b5767 --- /dev/null +++ b/src/agents/function_schema.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import contextlib +import inspect +import logging +import re +from dataclasses import dataclass +from typing import Any, Callable, Literal, get_args, get_origin, get_type_hints + +from griffe import Docstring, DocstringSectionKind +from pydantic import BaseModel, Field, create_model + +from .exceptions import UserError +from .run_context import RunContextWrapper +from .strict_schema import ensure_strict_json_schema + + +@dataclass +class FuncSchema: + """ + Captures the schema for a python function, in preparation for sending it to an LLM as a tool. + """ + + name: str + """The name of the function.""" + description: str | None + """The description of the function.""" + params_pydantic_model: type[BaseModel] + """A Pydantic model that represents the function's parameters.""" + params_json_schema: dict[str, Any] + """The JSON schema for the function's parameters, derived from the Pydantic model.""" + signature: inspect.Signature + """The signature of the function.""" + takes_context: bool = False + """Whether the function takes a RunContextWrapper argument (must be the first argument).""" + + def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: + """ + Converts validated data from the Pydantic model into (args, kwargs), suitable for calling + the original function. + """ + positional_args: list[Any] = [] + keyword_args: dict[str, Any] = {} + seen_var_positional = False + + # Use enumerate() so we can skip the first parameter if it's context. + for idx, (name, param) in enumerate(self.signature.parameters.items()): + # If the function takes a RunContextWrapper and this is the first parameter, skip it. + if self.takes_context and idx == 0: + continue + + value = getattr(data, name, None) + if param.kind == param.VAR_POSITIONAL: + # e.g. *args: extend positional args and mark that *args is now seen + positional_args.extend(value or []) + seen_var_positional = True + elif param.kind == param.VAR_KEYWORD: + # e.g. **kwargs handling + keyword_args.update(value or {}) + elif param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): + # Before *args, add to positional args. After *args, add to keyword args. + if not seen_var_positional: + positional_args.append(value) + else: + keyword_args[name] = value + else: + # For KEYWORD_ONLY parameters, always use keyword args. + keyword_args[name] = value + return positional_args, keyword_args + + +@dataclass +class FuncDocumentation: + """Contains metadata about a python function, extracted from its docstring.""" + + name: str + """The name of the function, via `__name__`.""" + description: str | None + """The description of the function, derived from the docstring.""" + param_descriptions: dict[str, str] | None + """The parameter descriptions of the function, derived from the docstring.""" + + +DocstringStyle = Literal["google", "numpy", "sphinx"] + + +# As of Feb 2025, the automatic style detection in griffe is an Insiders feature. This +# code approximates it. +def _detect_docstring_style(doc: str) -> DocstringStyle: + scores: dict[DocstringStyle, int] = {"sphinx": 0, "numpy": 0, "google": 0} + + # Sphinx style detection: look for :param, :type, :return:, and :rtype: + sphinx_patterns = [r"^:param\s", r"^:type\s", r"^:return:", r"^:rtype:"] + for pattern in sphinx_patterns: + if re.search(pattern, doc, re.MULTILINE): + scores["sphinx"] += 1 + + # Numpy style detection: look for headers like 'Parameters', 'Returns', or 'Yields' followed by + # a dashed underline + numpy_patterns = [ + r"^Parameters\s*\n\s*-{3,}", + r"^Returns\s*\n\s*-{3,}", + r"^Yields\s*\n\s*-{3,}", + ] + for pattern in numpy_patterns: + if re.search(pattern, doc, re.MULTILINE): + scores["numpy"] += 1 + + # Google style detection: look for section headers with a trailing colon + google_patterns = [r"^(Args|Arguments):", r"^(Returns):", r"^(Raises):"] + for pattern in google_patterns: + if re.search(pattern, doc, re.MULTILINE): + scores["google"] += 1 + + max_score = max(scores.values()) + if max_score == 0: + return "google" + + # Priority order: sphinx > numpy > google in case of tie + styles: list[DocstringStyle] = ["sphinx", "numpy", "google"] + + for style in styles: + if scores[style] == max_score: + return style + + return "google" + + +@contextlib.contextmanager +def _suppress_griffe_logging(): + # Supresses warnings about missing annotations for params + logger = logging.getLogger("griffe") + previous_level = logger.getEffectiveLevel() + logger.setLevel(logging.ERROR) + try: + yield + finally: + logger.setLevel(previous_level) + + +def generate_func_documentation( + func: Callable[..., Any], style: DocstringStyle | None = None +) -> FuncDocumentation: + """ + Extracts metadata from a function docstring, in preparation for sending it to an LLM as a tool. + + Args: + func: The function to extract documentation from. + style: The style of the docstring to use for parsing. If not provided, we will attempt to + auto-detect the style. + + Returns: + A FuncDocumentation object containing the function's name, description, and parameter + descriptions. + """ + name = func.__name__ + doc = inspect.getdoc(func) + if not doc: + return FuncDocumentation(name=name, description=None, param_descriptions=None) + + with _suppress_griffe_logging(): + docstring = Docstring(doc, lineno=1, parser=style or _detect_docstring_style(doc)) + parsed = docstring.parse() + + description: str | None = next( + (section.value for section in parsed if section.kind == DocstringSectionKind.text), None + ) + + param_descriptions: dict[str, str] = { + param.name: param.description + for section in parsed + if section.kind == DocstringSectionKind.parameters + for param in section.value + } + + return FuncDocumentation( + name=func.__name__, + description=description, + param_descriptions=param_descriptions or None, + ) + + +def function_schema( + func: Callable[..., Any], + docstring_style: DocstringStyle | None = None, + name_override: str | None = None, + description_override: str | None = None, + use_docstring_info: bool = True, + strict_json_schema: bool = True, +) -> FuncSchema: + """ + Given a python function, extracts a `FuncSchema` from it, capturing the name, description, + parameter descriptions, and other metadata. + + Args: + func: The function to extract the schema from. + docstring_style: The style of the docstring to use for parsing. If not provided, we will + attempt to auto-detect the style. + name_override: If provided, use this name instead of the function's `__name__`. + description_override: If provided, use this description instead of the one derived from the + docstring. + use_docstring_info: If True, uses the docstring to generate the description and parameter + descriptions. + strict_json_schema: Whether the JSON schema is in strict mode. If True, we'll ensure that + the schema adheres to the "strict" standard the OpenAI API expects. We **strongly** + recommend setting this to True, as it increases the likelihood of the LLM providing + correct JSON input. + + Returns: + A `FuncSchema` object containing the function's name, description, parameter descriptions, + and other metadata. + """ + + # 1. Grab docstring info + if use_docstring_info: + doc_info = generate_func_documentation(func, docstring_style) + param_descs = doc_info.param_descriptions or {} + else: + doc_info = None + param_descs = {} + + func_name = name_override or doc_info.name if doc_info else func.__name__ + + # 2. Inspect function signature and get type hints + sig = inspect.signature(func) + type_hints = get_type_hints(func) + params = list(sig.parameters.items()) + takes_context = False + filtered_params = [] + + if params: + first_name, first_param = params[0] + # Prefer the evaluated type hint if available + ann = type_hints.get(first_name, first_param.annotation) + if ann != inspect._empty: + origin = get_origin(ann) or ann + if origin is RunContextWrapper: + takes_context = True # Mark that the function takes context + else: + filtered_params.append((first_name, first_param)) + else: + filtered_params.append((first_name, first_param)) + + # For parameters other than the first, raise error if any use RunContextWrapper. + for name, param in params[1:]: + ann = type_hints.get(name, param.annotation) + if ann != inspect._empty: + origin = get_origin(ann) or ann + if origin is RunContextWrapper: + raise UserError( + f"RunContextWrapper param found at non-first position in function" + f" {func.__name__}" + ) + filtered_params.append((name, param)) + + # We will collect field definitions for create_model as a dict: + # field_name -> (type_annotation, default_value_or_Field(...)) + fields: dict[str, Any] = {} + + for name, param in filtered_params: + ann = type_hints.get(name, param.annotation) + default = param.default + + # If there's no type hint, assume `Any` + if ann == inspect._empty: + ann = Any + + # If a docstring param description exists, use it + field_description = param_descs.get(name, None) + + # Handle different parameter kinds + if param.kind == param.VAR_POSITIONAL: + # e.g. *args: extend positional args + if get_origin(ann) is tuple: + # e.g. def foo(*args: tuple[int, ...]) -> treat as List[int] + args_of_tuple = get_args(ann) + if len(args_of_tuple) == 2 and args_of_tuple[1] is Ellipsis: + ann = list[args_of_tuple[0]] # type: ignore + else: + ann = list[Any] + else: + # If user wrote *args: int, treat as List[int] + ann = list[ann] # type: ignore + + # Default factory to empty list + fields[name] = ( + ann, + Field(default_factory=list, description=field_description), # type: ignore + ) + + elif param.kind == param.VAR_KEYWORD: + # **kwargs handling + if get_origin(ann) is dict: + # e.g. def foo(**kwargs: dict[str, int]) + dict_args = get_args(ann) + if len(dict_args) == 2: + ann = dict[dict_args[0], dict_args[1]] # type: ignore + else: + ann = dict[str, Any] + else: + # e.g. def foo(**kwargs: int) -> Dict[str, int] + ann = dict[str, ann] # type: ignore + + fields[name] = ( + ann, + Field(default_factory=dict, description=field_description), # type: ignore + ) + + else: + # Normal parameter + if default == inspect._empty: + # Required field + fields[name] = ( + ann, + Field(..., description=field_description), + ) + else: + # Parameter with a default value + fields[name] = ( + ann, + Field(default=default, description=field_description), + ) + + # 3. Dynamically build a Pydantic model + dynamic_model = create_model(f"{func_name}_args", __base__=BaseModel, **fields) + + # 4. Build JSON schema from that model + json_schema = dynamic_model.model_json_schema() + if strict_json_schema: + json_schema = ensure_strict_json_schema(json_schema) + + # 5. Return as a FuncSchema dataclass + return FuncSchema( + name=func_name, + description=description_override or doc_info.description if doc_info else None, + params_pydantic_model=dynamic_model, + params_json_schema=json_schema, + signature=sig, + takes_context=takes_context, + ) diff --git a/src/agents/guardrail.py b/src/agents/guardrail.py new file mode 100644 index 0000000..fcae0b8 --- /dev/null +++ b/src/agents/guardrail.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import inspect +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Generic, Union, overload + +from typing_extensions import TypeVar + +from ._utils import MaybeAwaitable +from .exceptions import UserError +from .items import TResponseInputItem +from .run_context import RunContextWrapper, TContext + +if TYPE_CHECKING: + from .agent import Agent + + +@dataclass +class GuardrailFunctionOutput: + """The output of a guardrail function.""" + + output_info: Any + """ + Optional information about the guardrail's output. For example, the guardrail could include + information about the checks it performed and granular results. + """ + + tripwire_triggered: bool + """ + Whether the tripwire was triggered. If triggered, the agent's execution will be halted. + """ + + +@dataclass +class InputGuardrailResult: + """The result of a guardrail run.""" + + guardrail: InputGuardrail[Any] + """ + The guardrail that was run. + """ + + output: GuardrailFunctionOutput + """The output of the guardrail function.""" + + +@dataclass +class OutputGuardrailResult: + """The result of a guardrail run.""" + + guardrail: OutputGuardrail[Any] + """ + The guardrail that was run. + """ + + agent_output: Any + """ + The output of the agent that was checked by the guardrail. + """ + + agent: Agent[Any] + """ + The agent that was checked by the guardrail. + """ + + output: GuardrailFunctionOutput + """The output of the guardrail function.""" + + +@dataclass +class InputGuardrail(Generic[TContext]): + """Input guardrails are checks that run in parallel to the agent's execution. + They can be used to do things like: + - Check if input messages are off-topic + - Take over control of the agent's execution if an unexpected input is detected + + You can use the `@input_guardrail()` decorator to turn a function into an `InputGuardrail`, or + create an `InputGuardrail` manually. + + Guardrails return a `GuardrailResult`. If `result.tripwire_triggered` is `True`, the agent + execution will immediately stop and a `InputGuardrailTripwireTriggered` exception will be raised + """ + + guardrail_function: Callable[ + [RunContextWrapper[TContext], Agent[Any], str | list[TResponseInputItem]], + MaybeAwaitable[GuardrailFunctionOutput], + ] + """A function that receives the the agent input and the context, and returns a + `GuardrailResult`. The result marks whether the tripwire was triggered, and can optionally + include information about the guardrail's output. + """ + + name: str | None = None + """The name of the guardrail, used for tracing. If not provided, we'll use the guardrail + function's name. + """ + + def get_name(self) -> str: + if self.name: + return self.name + + return self.guardrail_function.__name__ + + async def run( + self, + agent: Agent[Any], + input: str | list[TResponseInputItem], + context: RunContextWrapper[TContext], + ) -> InputGuardrailResult: + if not callable(self.guardrail_function): + raise UserError(f"Guardrail function must be callable, got {self.guardrail_function}") + + output = self.guardrail_function(context, agent, input) + if inspect.isawaitable(output): + return InputGuardrailResult( + guardrail=self, + output=await output, + ) + + return InputGuardrailResult( + guardrail=self, + output=output, + ) + + +@dataclass +class OutputGuardrail(Generic[TContext]): + """Output guardrails are checks that run on the final output of an agent. + They can be used to do check if the output passes certain validation criteria + + You can use the `@output_guardrail()` decorator to turn a function into an `OutputGuardrail`, + or create an `OutputGuardrail` manually. + + Guardrails return a `GuardrailResult`. If `result.tripwire_triggered` is `True`, a + `OutputGuardrailTripwireTriggered` exception will be raised. + """ + + guardrail_function: Callable[ + [RunContextWrapper[TContext], Agent[Any], Any], + MaybeAwaitable[GuardrailFunctionOutput], + ] + """A function that receives the final agent, its output, and the context, and returns a + `GuardrailResult`. The result marks whether the tripwire was triggered, and can optionally + include information about the guardrail's output. + """ + + name: str | None = None + """The name of the guardrail, used for tracing. If not provided, we'll use the guardrail + function's name. + """ + + def get_name(self) -> str: + if self.name: + return self.name + + return self.guardrail_function.__name__ + + async def run( + self, context: RunContextWrapper[TContext], agent: Agent[Any], agent_output: Any + ) -> OutputGuardrailResult: + if not callable(self.guardrail_function): + raise UserError(f"Guardrail function must be callable, got {self.guardrail_function}") + + output = self.guardrail_function(context, agent, agent_output) + if inspect.isawaitable(output): + return OutputGuardrailResult( + guardrail=self, + agent=agent, + agent_output=agent_output, + output=await output, + ) + + return OutputGuardrailResult( + guardrail=self, + agent=agent, + agent_output=agent_output, + output=output, + ) + + +TContext_co = TypeVar("TContext_co", bound=Any, covariant=True) + +# For InputGuardrail +_InputGuardrailFuncSync = Callable[ + [RunContextWrapper[TContext_co], "Agent[Any]", Union[str, list[TResponseInputItem]]], + GuardrailFunctionOutput, +] +_InputGuardrailFuncAsync = Callable[ + [RunContextWrapper[TContext_co], "Agent[Any]", Union[str, list[TResponseInputItem]]], + Awaitable[GuardrailFunctionOutput], +] + + +@overload +def input_guardrail( + func: _InputGuardrailFuncSync[TContext_co], +) -> InputGuardrail[TContext_co]: ... + + +@overload +def input_guardrail( + func: _InputGuardrailFuncAsync[TContext_co], +) -> InputGuardrail[TContext_co]: ... + + +@overload +def input_guardrail( + *, + name: str | None = None, +) -> Callable[ + [_InputGuardrailFuncSync[TContext_co] | _InputGuardrailFuncAsync[TContext_co]], + InputGuardrail[TContext_co], +]: ... + + +def input_guardrail( + func: _InputGuardrailFuncSync[TContext_co] + | _InputGuardrailFuncAsync[TContext_co] + | None = None, + *, + name: str | None = None, +) -> ( + InputGuardrail[TContext_co] + | Callable[ + [_InputGuardrailFuncSync[TContext_co] | _InputGuardrailFuncAsync[TContext_co]], + InputGuardrail[TContext_co], + ] +): + """ + Decorator that transforms a sync or async function into an `InputGuardrail`. + It can be used directly (no parentheses) or with keyword args, e.g.: + + @input_guardrail + def my_sync_guardrail(...): ... + + @input_guardrail(name="guardrail_name") + async def my_async_guardrail(...): ... + """ + + def decorator( + f: _InputGuardrailFuncSync[TContext_co] | _InputGuardrailFuncAsync[TContext_co], + ) -> InputGuardrail[TContext_co]: + return InputGuardrail(guardrail_function=f, name=name) + + if func is not None: + # Decorator was used without parentheses + return decorator(func) + + # Decorator used with keyword arguments + return decorator + + +_OutputGuardrailFuncSync = Callable[ + [RunContextWrapper[TContext_co], "Agent[Any]", Any], + GuardrailFunctionOutput, +] +_OutputGuardrailFuncAsync = Callable[ + [RunContextWrapper[TContext_co], "Agent[Any]", Any], + Awaitable[GuardrailFunctionOutput], +] + + +@overload +def output_guardrail( + func: _OutputGuardrailFuncSync[TContext_co], +) -> OutputGuardrail[TContext_co]: ... + + +@overload +def output_guardrail( + func: _OutputGuardrailFuncAsync[TContext_co], +) -> OutputGuardrail[TContext_co]: ... + + +@overload +def output_guardrail( + *, + name: str | None = None, +) -> Callable[ + [_OutputGuardrailFuncSync[TContext_co] | _OutputGuardrailFuncAsync[TContext_co]], + OutputGuardrail[TContext_co], +]: ... + + +def output_guardrail( + func: _OutputGuardrailFuncSync[TContext_co] + | _OutputGuardrailFuncAsync[TContext_co] + | None = None, + *, + name: str | None = None, +) -> ( + OutputGuardrail[TContext_co] + | Callable[ + [_OutputGuardrailFuncSync[TContext_co] | _OutputGuardrailFuncAsync[TContext_co]], + OutputGuardrail[TContext_co], + ] +): + """ + Decorator that transforms a sync or async function into an `OutputGuardrail`. + It can be used directly (no parentheses) or with keyword args, e.g.: + + @output_guardrail + def my_sync_guardrail(...): ... + + @output_guardrail(name="guardrail_name") + async def my_async_guardrail(...): ... + """ + + def decorator( + f: _OutputGuardrailFuncSync[TContext_co] | _OutputGuardrailFuncAsync[TContext_co], + ) -> OutputGuardrail[TContext_co]: + return OutputGuardrail(guardrail_function=f, name=name) + + if func is not None: + # Decorator was used without parentheses + return decorator(func) + + # Decorator used with keyword arguments + return decorator diff --git a/src/agents/handoffs.py b/src/agents/handoffs.py new file mode 100644 index 0000000..ac15740 --- /dev/null +++ b/src/agents/handoffs.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import inspect +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Generic, cast, overload + +from pydantic import TypeAdapter +from typing_extensions import TypeAlias, TypeVar + +from . import _utils +from .exceptions import ModelBehaviorError, UserError +from .items import RunItem, TResponseInputItem +from .run_context import RunContextWrapper, TContext +from .strict_schema import ensure_strict_json_schema +from .tracing.spans import SpanError + +if TYPE_CHECKING: + from .agent import Agent + + +# The handoff input type is the type of data passed when the agent is called via a handoff. +THandoffInput = TypeVar("THandoffInput", default=Any) + +OnHandoffWithInput = Callable[[RunContextWrapper[Any], THandoffInput], Any] +OnHandoffWithoutInput = Callable[[RunContextWrapper[Any]], Any] + + +@dataclass(frozen=True) +class HandoffInputData: + input_history: str | tuple[TResponseInputItem, ...] + """ + The input history before `Runner.run()` was called. + """ + + pre_handoff_items: tuple[RunItem, ...] + """ + The items generated before the agent turn where the handoff was invoked. + """ + + new_items: tuple[RunItem, ...] + """ + The new items generated during the current agent turn, including the item that triggered the + handoff and the tool output message representing the response from the handoff output. + """ + + +HandoffInputFilter: TypeAlias = Callable[[HandoffInputData], HandoffInputData] +"""A function that filters the input data passed to the next agent.""" + + +@dataclass +class Handoff(Generic[TContext]): + """A handoff is when an agent delegates a task to another agent. + For example, in a customer support scenario you might have a "triage agent" that determines + which agent should handle the user's request, and sub-agents that specialize in different + areas like billing, account management, etc. + """ + + tool_name: str + """The name of the tool that represents the handoff.""" + + tool_description: str + """The description of the tool that represents the handoff.""" + + input_json_schema: dict[str, Any] + """The JSON schema for the handoff input. Can be empty if the handoff does not take an input. + """ + + on_invoke_handoff: Callable[[RunContextWrapper[Any], str], Awaitable[Agent[TContext]]] + """The function that invokes the handoff. The parameters passed are: + 1. The handoff run context + 2. The arguments from the LLM, as a JSON string. Empty string if input_json_schema is empty. + + Must return an agent. + """ + + agent_name: str + """The name of the agent that is being handed off to.""" + + input_filter: HandoffInputFilter | None = None + """A function that filters the inputs that are passed to the next agent. By default, the new + agent sees the entire conversation history. In some cases, you may want to filter inputs e.g. + to remove older inputs, or remove tools from existing inputs. + + The function will receive the entire conversation history so far, including the input item + that triggered the handoff and a tool call output item representing the handoff tool's output. + + You are free to modify the input history or new items as you see fit. The next agent that + runs will receive `handoff_input_data.all_items`. + + IMPORTANT: in streaming mode, we will not stream anything as a result of this function. The + items generated before will already have been streamed. + """ + + strict_json_schema: bool = True + """Whether the input JSON schema is in strict mode. We **strongly** recommend setting this to + True, as it increases the likelihood of correct JSON input. + """ + + def get_transfer_message(self, agent: Agent[Any]) -> str: + base = f"{{'assistant': '{agent.name}'}}" + return base + + @classmethod + def default_tool_name(cls, agent: Agent[Any]) -> str: + return _utils.transform_string_function_style(f"transfer_to_{agent.name}") + + @classmethod + def default_tool_description(cls, agent: Agent[Any]) -> str: + return ( + f"Handoff to the {agent.name} agent to handle the request. " + f"{agent.handoff_description or ''}" + ) + + +@overload +def handoff( + agent: Agent[TContext], + *, + tool_name_override: str | None = None, + tool_description_override: str | None = None, + input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None, +) -> Handoff[TContext]: ... + + +@overload +def handoff( + agent: Agent[TContext], + *, + on_handoff: OnHandoffWithInput[THandoffInput], + input_type: type[THandoffInput], + tool_description_override: str | None = None, + tool_name_override: str | None = None, + input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None, +) -> Handoff[TContext]: ... + + +@overload +def handoff( + agent: Agent[TContext], + *, + on_handoff: OnHandoffWithoutInput, + tool_description_override: str | None = None, + tool_name_override: str | None = None, + input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None, +) -> Handoff[TContext]: ... + + +def handoff( + agent: Agent[TContext], + tool_name_override: str | None = None, + tool_description_override: str | None = None, + on_handoff: OnHandoffWithInput[THandoffInput] | OnHandoffWithoutInput | None = None, + input_type: type[THandoffInput] | None = None, + input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None, +) -> Handoff[TContext]: + """Create a handoff from an agent. + + Args: + agent: The agent to handoff to, or a function that returns an agent. + tool_name_override: Optional override for the name of the tool that represents the handoff. + tool_description_override: Optional override for the description of the tool that + represents the handoff. + on_handoff: A function that runs when the handoff is invoked. + input_type: the type of the input to the handoff. If provided, the input will be validated + against this type. Only relevant if you pass a function that takes an input. + input_filter: a function that filters the inputs that are passed to the next agent. + """ + assert (on_handoff and input_type) or not (on_handoff and input_type), ( + "You must provide either both on_input and input_type, or neither" + ) + type_adapter: TypeAdapter[Any] | None + if input_type is not None: + assert callable(on_handoff), "on_handoff must be callable" + sig = inspect.signature(on_handoff) + if len(sig.parameters) != 2: + raise UserError("on_handoff must take two arguments: context and input") + + type_adapter = TypeAdapter(input_type) + input_json_schema = type_adapter.json_schema() + else: + type_adapter = None + input_json_schema = {} + if on_handoff is not None: + sig = inspect.signature(on_handoff) + if len(sig.parameters) != 1: + raise UserError("on_handoff must take one argument: context") + + async def _invoke_handoff( + ctx: RunContextWrapper[Any], input_json: str | None = None + ) -> Agent[Any]: + if input_type is not None and type_adapter is not None: + if input_json is None: + _utils.attach_error_to_current_span( + SpanError( + message="Handoff function expected non-null input, but got None", + data={"details": "input_json is None"}, + ) + ) + raise ModelBehaviorError("Handoff function expected non-null input, but got None") + + validated_input = _utils.validate_json( + json_str=input_json, + type_adapter=type_adapter, + partial=False, + ) + input_func = cast(OnHandoffWithInput[THandoffInput], on_handoff) + if inspect.iscoroutinefunction(input_func): + await input_func(ctx, validated_input) + else: + input_func(ctx, validated_input) + elif on_handoff is not None: + no_input_func = cast(OnHandoffWithoutInput, on_handoff) + if inspect.iscoroutinefunction(no_input_func): + await no_input_func(ctx) + else: + no_input_func(ctx) + + return agent + + tool_name = tool_name_override or Handoff.default_tool_name(agent) + tool_description = tool_description_override or Handoff.default_tool_description(agent) + + # Always ensure the input JSON schema is in strict mode + # If there is a need, we can make this configurable in the future + input_json_schema = ensure_strict_json_schema(input_json_schema) + + return Handoff( + tool_name=tool_name, + tool_description=tool_description, + input_json_schema=input_json_schema, + on_invoke_handoff=_invoke_handoff, + input_filter=input_filter, + agent_name=agent.name, + ) diff --git a/src/agents/items.py b/src/agents/items.py new file mode 100644 index 0000000..bbaf49d --- /dev/null +++ b/src/agents/items.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import abc +import copy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, Union + +from openai.types.responses import ( + Response, + ResponseComputerToolCall, + ResponseFileSearchToolCall, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseInputItemParam, + ResponseOutputItem, + ResponseOutputMessage, + ResponseOutputRefusal, + ResponseOutputText, + ResponseStreamEvent, +) +from openai.types.responses.response_input_item_param import ComputerCallOutput, FunctionCallOutput +from openai.types.responses.response_output_item import Reasoning +from pydantic import BaseModel +from typing_extensions import TypeAlias + +from .exceptions import AgentsException, ModelBehaviorError +from .usage import Usage + +if TYPE_CHECKING: + from .agent import Agent + +TResponse = Response +"""A type alias for the Response type from the OpenAI SDK.""" + +TResponseInputItem = ResponseInputItemParam +"""A type alias for the ResponseInputItemParam type from the OpenAI SDK.""" + +TResponseOutputItem = ResponseOutputItem +"""A type alias for the ResponseOutputItem type from the OpenAI SDK.""" + +TResponseStreamEvent = ResponseStreamEvent +"""A type alias for the ResponseStreamEvent type from the OpenAI SDK.""" + +T = TypeVar("T", bound=Union[TResponseOutputItem, TResponseInputItem]) + + +@dataclass +class RunItemBase(Generic[T], abc.ABC): + agent: Agent[Any] + """The agent whose run caused this item to be generated.""" + + raw_item: T + """The raw Responses item from the run. This will always be a either an output item (i.e. + `openai.types.responses.ResponseOutputItem` or an input item + (i.e. `openai.types.responses.ResponseInputItemParam`). + """ + + def to_input_item(self) -> TResponseInputItem: + """Converts this item into an input item suitable for passing to the model.""" + if isinstance(self.raw_item, dict): + # We know that input items are dicts, so we can ignore the type error + return self.raw_item # type: ignore + elif isinstance(self.raw_item, BaseModel): + # All output items are Pydantic models that can be converted to input items. + return self.raw_item.model_dump(exclude_unset=True) # type: ignore + else: + raise AgentsException(f"Unexpected raw item type: {type(self.raw_item)}") + + +@dataclass +class MessageOutputItem(RunItemBase[ResponseOutputMessage]): + """Represents a message from the LLM.""" + + raw_item: ResponseOutputMessage + """The raw response output message.""" + + type: Literal["message_output_item"] = "message_output_item" + + +@dataclass +class HandoffCallItem(RunItemBase[ResponseFunctionToolCall]): + """Represents a tool call for a handoff from one agent to another.""" + + raw_item: ResponseFunctionToolCall + """The raw response function tool call that represents the handoff.""" + + type: Literal["handoff_call_item"] = "handoff_call_item" + + +@dataclass +class HandoffOutputItem(RunItemBase[TResponseInputItem]): + """Represents the output of a handoff.""" + + raw_item: TResponseInputItem + """The raw input item that represents the handoff taking place.""" + + source_agent: Agent[Any] + """The agent that made the handoff.""" + + target_agent: Agent[Any] + """The agent that is being handed off to.""" + + type: Literal["handoff_output_item"] = "handoff_output_item" + + +ToolCallItemTypes: TypeAlias = Union[ + ResponseFunctionToolCall, + ResponseComputerToolCall, + ResponseFileSearchToolCall, + ResponseFunctionWebSearch, +] +"""A type that represents a tool call item.""" + + +@dataclass +class ToolCallItem(RunItemBase[ToolCallItemTypes]): + """Represents a tool call e.g. a function call or computer action call.""" + + raw_item: ToolCallItemTypes + """The raw tool call item.""" + + type: Literal["tool_call_item"] = "tool_call_item" + + +@dataclass +class ToolCallOutputItem(RunItemBase[Union[FunctionCallOutput, ComputerCallOutput]]): + """Represents the output of a tool call.""" + + raw_item: FunctionCallOutput | ComputerCallOutput + """The raw item from the model.""" + + output: str + """The output of the tool call.""" + + type: Literal["tool_call_output_item"] = "tool_call_output_item" + + +@dataclass +class ReasoningItem(RunItemBase[Reasoning]): + """Represents a reasoning item.""" + + raw_item: Reasoning + """The raw reasoning item.""" + + type: Literal["reasoning_item"] = "reasoning_item" + + +RunItem: TypeAlias = Union[ + MessageOutputItem, + HandoffCallItem, + HandoffOutputItem, + ToolCallItem, + ToolCallOutputItem, + ReasoningItem, +] +"""An item generated by an agent.""" + + +@dataclass +class ModelResponse: + output: list[TResponseOutputItem] + """A list of outputs (messages, tool calls, etc) generated by the model""" + + usage: Usage + """The usage information for the response.""" + + referenceable_id: str | None + """An ID for the response which can be used to refer to the response in subsequent calls to the + model. Not supported by all model providers. + """ + + def to_input_items(self) -> list[TResponseInputItem]: + """Convert the output into a list of input items suitable for passing to the model.""" + # We happen to know that the shape of the Pydantic output items are the same as the + # equivalent TypedDict input items, so we can just convert each one. + # This is also tested via unit tests. + return [it.model_dump(exclude_unset=True) for it in self.output] # type: ignore + + +class ItemHelpers: + @classmethod + def extract_last_content(cls, message: TResponseOutputItem) -> str: + """Extracts the last text content or refusal from a message.""" + if not isinstance(message, ResponseOutputMessage): + return "" + + last_content = message.content[-1] + if isinstance(last_content, ResponseOutputText): + return last_content.text + elif isinstance(last_content, ResponseOutputRefusal): + return last_content.refusal + else: + raise ModelBehaviorError(f"Unexpected content type: {type(last_content)}") + + @classmethod + def extract_last_text(cls, message: TResponseOutputItem) -> str | None: + """Extracts the last text content from a message, if any. Ignores refusals.""" + if isinstance(message, ResponseOutputMessage): + last_content = message.content[-1] + if isinstance(last_content, ResponseOutputText): + return last_content.text + + return None + + @classmethod + def input_to_new_input_list( + cls, input: str | list[TResponseInputItem] + ) -> list[TResponseInputItem]: + """Converts a string or list of input items into a list of input items.""" + if isinstance(input, str): + return [ + { + "content": input, + "role": "user", + } + ] + return copy.deepcopy(input) + + @classmethod + def text_message_outputs(cls, items: list[RunItem]) -> str: + """Concatenates all the text content from a list of message output items.""" + text = "" + for item in items: + if isinstance(item, MessageOutputItem): + text += cls.text_message_output(item) + return text + + @classmethod + def text_message_output(cls, message: MessageOutputItem) -> str: + """Extracts all the text content from a single message output item.""" + text = "" + for item in message.raw_item.content: + if isinstance(item, ResponseOutputText): + text += item.text + return text + + @classmethod + def tool_call_output_item( + cls, tool_call: ResponseFunctionToolCall, output: str + ) -> FunctionCallOutput: + """Creates a tool call output item from a tool call and its output.""" + return { + "call_id": tool_call.call_id, + "output": output, + "type": "function_call_output", + } diff --git a/src/agents/lifecycle.py b/src/agents/lifecycle.py new file mode 100644 index 0000000..8643248 --- /dev/null +++ b/src/agents/lifecycle.py @@ -0,0 +1,105 @@ +from typing import Any, Generic + +from .agent import Agent +from .run_context import RunContextWrapper, TContext +from .tool import Tool + + +class RunHooks(Generic[TContext]): + """A class that receives callbacks on various lifecycle events in an agent run. Subclass and + override the methods you need. + """ + + async def on_agent_start( + self, context: RunContextWrapper[TContext], agent: Agent[TContext] + ) -> None: + """Called before the agent is invoked. Called each time the current agent changes.""" + pass + + async def on_agent_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + output: Any, + ) -> None: + """Called when the agent produces a final output.""" + pass + + async def on_handoff( + self, + context: RunContextWrapper[TContext], + from_agent: Agent[TContext], + to_agent: Agent[TContext], + ) -> None: + """Called when a handoff occurs.""" + pass + + async def on_tool_start( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + ) -> None: + """Called before a tool is invoked.""" + pass + + async def on_tool_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + result: str, + ) -> None: + """Called after a tool is invoked.""" + pass + + +class AgentHooks(Generic[TContext]): + """A class that receives callbacks on various lifecycle events for a specific agent. You can + set this on `agent.hooks` to receive events for that specific agent. + + Subclass and override the methods you need. + """ + + async def on_start(self, context: RunContextWrapper[TContext], agent: Agent[TContext]) -> None: + """Called before the agent is invoked. Called each time the running agent is changed to this + agent.""" + pass + + async def on_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + output: Any, + ) -> None: + """Called when the agent produces a final output.""" + pass + + async def on_handoff( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + source: Agent[TContext], + ) -> None: + """Called when the agent is being handed off to. The `source` is the agent that is handing + off to this agent.""" + pass + + async def on_tool_start( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + ) -> None: + """Called before a tool is invoked.""" + pass + + async def on_tool_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + result: str, + ) -> None: + """Called after a tool is invoked.""" + pass diff --git a/src/agents/logger.py b/src/agents/logger.py new file mode 100644 index 0000000..bd81a82 --- /dev/null +++ b/src/agents/logger.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger("openai.agents") diff --git a/src/agents/model_settings.py b/src/agents/model_settings.py new file mode 100644 index 0000000..78cf9a8 --- /dev/null +++ b/src/agents/model_settings.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +@dataclass +class ModelSettings: + """Settings to use when calling an LLM. + + This class holds optional model configuration parameters (e.g. temperature, + top_p, penalties, truncation, etc.). + """ + temperature: float | None = None + top_p: float | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + tool_choice: Literal["auto", "required", "none"] | str | None = None + parallel_tool_calls: bool | None = False + truncation: Literal["auto", "disabled"] | None = None + + def resolve(self, override: ModelSettings | None) -> ModelSettings: + """Produce a new ModelSettings by overlaying any non-None values from the + override on top of this instance.""" + if override is None: + return self + return ModelSettings( + temperature=override.temperature or self.temperature, + top_p=override.top_p or self.top_p, + frequency_penalty=override.frequency_penalty or self.frequency_penalty, + presence_penalty=override.presence_penalty or self.presence_penalty, + tool_choice=override.tool_choice or self.tool_choice, + parallel_tool_calls=override.parallel_tool_calls or self.parallel_tool_calls, + truncation=override.truncation or self.truncation, + ) diff --git a/src/agents/models/__init__.py b/src/agents/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/models/__pycache__/__init__.cpython-311.pyc b/src/agents/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..809c19d Binary files /dev/null and b/src/agents/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/agents/models/__pycache__/__init__.cpython-313.pyc b/src/agents/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..62acacc Binary files /dev/null and b/src/agents/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agents/models/__pycache__/__init__.cpython-39.pyc b/src/agents/models/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..772e18b Binary files /dev/null and b/src/agents/models/__pycache__/__init__.cpython-39.pyc differ diff --git a/src/agents/models/__pycache__/_openai_shared.cpython-311.pyc b/src/agents/models/__pycache__/_openai_shared.cpython-311.pyc new file mode 100644 index 0000000..31d9557 Binary files /dev/null and b/src/agents/models/__pycache__/_openai_shared.cpython-311.pyc differ diff --git a/src/agents/models/__pycache__/_openai_shared.cpython-313.pyc b/src/agents/models/__pycache__/_openai_shared.cpython-313.pyc new file mode 100644 index 0000000..33dfe31 Binary files /dev/null and b/src/agents/models/__pycache__/_openai_shared.cpython-313.pyc differ diff --git a/src/agents/models/__pycache__/_openai_shared.cpython-39.pyc b/src/agents/models/__pycache__/_openai_shared.cpython-39.pyc new file mode 100644 index 0000000..9f95fb8 Binary files /dev/null and b/src/agents/models/__pycache__/_openai_shared.cpython-39.pyc differ diff --git a/src/agents/models/__pycache__/fake_id.cpython-313.pyc b/src/agents/models/__pycache__/fake_id.cpython-313.pyc new file mode 100644 index 0000000..07ae380 Binary files /dev/null and b/src/agents/models/__pycache__/fake_id.cpython-313.pyc differ diff --git a/src/agents/models/__pycache__/fake_id.cpython-39.pyc b/src/agents/models/__pycache__/fake_id.cpython-39.pyc new file mode 100644 index 0000000..66b64fe Binary files /dev/null and b/src/agents/models/__pycache__/fake_id.cpython-39.pyc differ diff --git a/src/agents/models/__pycache__/interface.cpython-313.pyc b/src/agents/models/__pycache__/interface.cpython-313.pyc new file mode 100644 index 0000000..f96c60b Binary files /dev/null and b/src/agents/models/__pycache__/interface.cpython-313.pyc differ diff --git a/src/agents/models/__pycache__/interface.cpython-39.pyc b/src/agents/models/__pycache__/interface.cpython-39.pyc new file mode 100644 index 0000000..cb247f3 Binary files /dev/null and b/src/agents/models/__pycache__/interface.cpython-39.pyc differ diff --git a/src/agents/models/__pycache__/map.cpython-313.pyc b/src/agents/models/__pycache__/map.cpython-313.pyc new file mode 100644 index 0000000..f648281 Binary files /dev/null and b/src/agents/models/__pycache__/map.cpython-313.pyc differ diff --git a/src/agents/models/__pycache__/map.cpython-39.pyc b/src/agents/models/__pycache__/map.cpython-39.pyc new file mode 100644 index 0000000..091f73a Binary files /dev/null and b/src/agents/models/__pycache__/map.cpython-39.pyc differ diff --git a/src/agents/models/__pycache__/openai_chatcompletions.cpython-313.pyc b/src/agents/models/__pycache__/openai_chatcompletions.cpython-313.pyc new file mode 100644 index 0000000..064b4f3 Binary files /dev/null and b/src/agents/models/__pycache__/openai_chatcompletions.cpython-313.pyc differ diff --git a/src/agents/models/__pycache__/openai_chatcompletions.cpython-39.pyc b/src/agents/models/__pycache__/openai_chatcompletions.cpython-39.pyc new file mode 100644 index 0000000..e7d1e60 Binary files /dev/null and b/src/agents/models/__pycache__/openai_chatcompletions.cpython-39.pyc differ diff --git a/src/agents/models/__pycache__/openai_provider.cpython-313.pyc b/src/agents/models/__pycache__/openai_provider.cpython-313.pyc new file mode 100644 index 0000000..16ada95 Binary files /dev/null and b/src/agents/models/__pycache__/openai_provider.cpython-313.pyc differ diff --git a/src/agents/models/__pycache__/openai_provider.cpython-39.pyc b/src/agents/models/__pycache__/openai_provider.cpython-39.pyc new file mode 100644 index 0000000..a6a5cbc Binary files /dev/null and b/src/agents/models/__pycache__/openai_provider.cpython-39.pyc differ diff --git a/src/agents/models/__pycache__/openai_responses.cpython-313.pyc b/src/agents/models/__pycache__/openai_responses.cpython-313.pyc new file mode 100644 index 0000000..5637c60 Binary files /dev/null and b/src/agents/models/__pycache__/openai_responses.cpython-313.pyc differ diff --git a/src/agents/models/__pycache__/openai_responses.cpython-39.pyc b/src/agents/models/__pycache__/openai_responses.cpython-39.pyc new file mode 100644 index 0000000..3944c59 Binary files /dev/null and b/src/agents/models/__pycache__/openai_responses.cpython-39.pyc differ diff --git a/src/agents/models/_openai_shared.py b/src/agents/models/_openai_shared.py new file mode 100644 index 0000000..2e14501 --- /dev/null +++ b/src/agents/models/_openai_shared.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from openai import AsyncOpenAI + +_default_openai_key: str | None = None +_default_openai_client: AsyncOpenAI | None = None +_use_responses_by_default: bool = True + + +def set_default_openai_key(key: str) -> None: + global _default_openai_key + _default_openai_key = key + + +def get_default_openai_key() -> str | None: + return _default_openai_key + + +def set_default_openai_client(client: AsyncOpenAI) -> None: + global _default_openai_client + _default_openai_client = client + + +def get_default_openai_client() -> AsyncOpenAI | None: + return _default_openai_client + + +def set_use_responses_by_default(use_responses: bool) -> None: + global _use_responses_by_default + _use_responses_by_default = use_responses + + +def get_use_responses_by_default() -> bool: + return _use_responses_by_default diff --git a/src/agents/models/fake_id.py b/src/agents/models/fake_id.py new file mode 100644 index 0000000..0565b0a --- /dev/null +++ b/src/agents/models/fake_id.py @@ -0,0 +1,5 @@ +FAKE_RESPONSES_ID = "__fake_id__" +"""This is a placeholder ID used to fill in the `id` field in Responses API related objects. It's +useful when you're creating Responses objects from non-Responses APIs, e.g. the OpenAI Chat +Completions API or other LLM providers. +""" diff --git a/src/agents/models/interface.py b/src/agents/models/interface.py new file mode 100644 index 0000000..e9a8700 --- /dev/null +++ b/src/agents/models/interface.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import abc +import enum +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING + +from ..agent_output import AgentOutputSchema +from ..handoffs import Handoff +from ..items import ModelResponse, TResponseInputItem, TResponseStreamEvent +from ..tool import Tool + +if TYPE_CHECKING: + from ..model_settings import ModelSettings + + +class ModelTracing(enum.Enum): + DISABLED = 0 + """Tracing is disabled entirely.""" + + ENABLED = 1 + """Tracing is enabled, and all data is included.""" + + ENABLED_WITHOUT_DATA = 2 + """Tracing is enabled, but inputs/outputs are not included.""" + + def is_disabled(self) -> bool: + return self == ModelTracing.DISABLED + + def include_data(self) -> bool: + return self == ModelTracing.ENABLED + + +class Model(abc.ABC): + """The base interface for calling an LLM.""" + + @abc.abstractmethod + 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: + """Get a response from the model. + + Args: + system_instructions: The system instructions to use. + input: The input items to the model, in OpenAI Responses format. + model_settings: The model settings to use. + tools: The tools available to the model. + output_schema: The output schema to use. + handoffs: The handoffs available to the model. + tracing: Tracing configuration. + + Returns: + The full model response. + """ + pass + + @abc.abstractmethod + 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]: + """Stream a response from the model. + + Args: + system_instructions: The system instructions to use. + input: The input items to the model, in OpenAI Responses format. + model_settings: The model settings to use. + tools: The tools available to the model. + output_schema: The output schema to use. + handoffs: The handoffs available to the model. + tracing: Tracing configuration. + + Returns: + An iterator of response stream events, in OpenAI Responses format. + """ + pass + + +class ModelProvider(abc.ABC): + """The base interface for a model provider. + + Model provider is responsible for looking up Models by name. + """ + + @abc.abstractmethod + def get_model(self, model_name: str | None) -> Model: + """Get a model by name. + + Args: + model_name: The name of the model to get. + + Returns: + The model. + """ diff --git a/src/agents/models/openai_chatcompletions.py b/src/agents/models/openai_chatcompletions.py new file mode 100644 index 0000000..a7340d0 --- /dev/null +++ b/src/agents/models/openai_chatcompletions.py @@ -0,0 +1,952 @@ +from __future__ import annotations + +import dataclasses +import json +import time +from collections.abc import AsyncIterator, Iterable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal, cast, overload + +from openai import NOT_GIVEN, AsyncOpenAI, AsyncStream, NotGiven +from openai.types import ChatModel +from openai.types.chat import ( + ChatCompletion, + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionContentPartImageParam, + ChatCompletionContentPartParam, + ChatCompletionContentPartTextParam, + ChatCompletionDeveloperMessageParam, + ChatCompletionMessage, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolChoiceOptionParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_tool_param import ChatCompletionToolParam +from openai.types.chat.completion_create_params import ResponseFormat +from openai.types.completion_usage import CompletionUsage +from openai.types.responses import ( + EasyInputMessageParam, + Response, + ResponseCompletedEvent, + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseCreatedEvent, + ResponseFileSearchToolCallParam, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionToolCall, + ResponseFunctionToolCallParam, + ResponseInputContentParam, + ResponseInputImageParam, + ResponseInputTextParam, + ResponseOutputItem, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputMessageParam, + ResponseOutputRefusal, + ResponseOutputText, + ResponseRefusalDeltaEvent, + ResponseTextDeltaEvent, +) +from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message + +from .. import _debug +from ..agent_output import AgentOutputSchema +from ..exceptions import AgentsException, UserError +from ..handoffs import Handoff +from ..items import ModelResponse, TResponseInputItem, TResponseOutputItem, TResponseStreamEvent +from ..logger import logger +from ..tool import FunctionTool, Tool +from ..tracing import generation_span +from ..tracing.span_data import GenerationSpanData +from ..tracing.spans import Span +from ..usage import Usage +from ..version import __version__ +from .fake_id import FAKE_RESPONSES_ID +from .interface import Model, ModelTracing + +if TYPE_CHECKING: + from ..model_settings import ModelSettings + + +_USER_AGENT = f"Agents/Python {__version__}" +_HEADERS = {"User-Agent": _USER_AGENT} + + +@dataclass +class _StreamingState: + started: bool = False + text_content_index_and_output: tuple[int, ResponseOutputText] | None = None + refusal_content_index_and_output: tuple[int, ResponseOutputRefusal] | None = None + function_calls: dict[int, ResponseFunctionToolCall] = field(default_factory=dict) + + +class OpenAIChatCompletionsModel(Model): + def __init__( + self, + model: str | ChatModel, + openai_client: AsyncOpenAI, + ) -> None: + self.model = model + self._client = openai_client + + def _non_null_or_not_given(self, value: Any) -> Any: + return value if value is not None else NOT_GIVEN + + 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: + with generation_span( + model=str(self.model), + model_config=dataclasses.asdict(model_settings) + | {"base_url": str(self._client.base_url)}, + disabled=tracing.is_disabled(), + ) as span_generation: + response = await self._fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + span_generation, + tracing, + stream=False, + ) + + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("Received model response") + else: + logger.debug( + f"LLM resp:\n{json.dumps(response.choices[0].message.model_dump(), indent=2)}\n" + ) + + usage = ( + Usage( + requests=1, + input_tokens=response.usage.prompt_tokens, + output_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + ) + if response.usage + else Usage() + ) + if tracing.include_data(): + span_generation.span_data.output = [response.choices[0].message.model_dump()] + span_generation.span_data.usage = { + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + } + + items = _Converter.message_to_output_items(response.choices[0].message) + + return ModelResponse( + output=items, + usage=usage, + referenceable_id=None, + ) + + 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]: + """ + Yields a partial message as it is generated, as well as the usage information. + """ + with generation_span( + model=str(self.model), + model_config=dataclasses.asdict(model_settings) + | {"base_url": str(self._client.base_url)}, + disabled=tracing.is_disabled(), + ) as span_generation: + response, stream = await self._fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + span_generation, + tracing, + stream=True, + ) + + usage: CompletionUsage | None = None + state = _StreamingState() + + async for chunk in stream: + if not state.started: + state.started = True + yield ResponseCreatedEvent( + response=response, + type="response.created", + ) + + # The usage is only available in the last chunk + usage = chunk.usage + + if not chunk.choices or not chunk.choices[0].delta: + continue + + delta = chunk.choices[0].delta + + # Handle text + if delta.content: + if not state.text_content_index_and_output: + # Initialize a content tracker for streaming text + state.text_content_index_and_output = ( + 0 if not state.refusal_content_index_and_output else 1, + ResponseOutputText( + text="", + type="output_text", + annotations=[], + ), + ) + # Start a new assistant message stream + assistant_item = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="in_progress", + ) + # Notify consumers of the start of a new output message + first content part + yield ResponseOutputItemAddedEvent( + item=assistant_item, + output_index=0, + type="response.output_item.added", + ) + yield ResponseContentPartAddedEvent( + content_index=state.text_content_index_and_output[0], + item_id=FAKE_RESPONSES_ID, + output_index=0, + part=ResponseOutputText( + text="", + type="output_text", + annotations=[], + ), + type="response.content_part.added", + ) + # Emit the delta for this segment of content + yield ResponseTextDeltaEvent( + content_index=state.text_content_index_and_output[0], + delta=delta.content, + item_id=FAKE_RESPONSES_ID, + output_index=0, + type="response.output_text.delta", + ) + # Accumulate the text into the response part + state.text_content_index_and_output[1].text += delta.content + + # Handle refusals (model declines to answer) + if delta.refusal: + if not state.refusal_content_index_and_output: + # Initialize a content tracker for streaming refusal text + state.refusal_content_index_and_output = ( + 0 if not state.text_content_index_and_output else 1, + ResponseOutputRefusal(refusal="", type="refusal"), + ) + # Start a new assistant message if one doesn't exist yet (in-progress) + assistant_item = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="in_progress", + ) + # Notify downstream that assistant message + first content part are starting + yield ResponseOutputItemAddedEvent( + item=assistant_item, + output_index=0, + type="response.output_item.added", + ) + yield ResponseContentPartAddedEvent( + content_index=state.refusal_content_index_and_output[0], + item_id=FAKE_RESPONSES_ID, + output_index=0, + part=ResponseOutputText( + text="", + type="output_text", + annotations=[], + ), + type="response.content_part.added", + ) + # Emit the delta for this segment of refusal + yield ResponseRefusalDeltaEvent( + content_index=state.refusal_content_index_and_output[0], + delta=delta.refusal, + item_id=FAKE_RESPONSES_ID, + output_index=0, + type="response.refusal.delta", + ) + # Accumulate the refusal string in the output part + state.refusal_content_index_and_output[1].refusal += delta.refusal + + # Handle tool calls + # Because we don't know the name of the function until the end of the stream, we'll + # save everything and yield events at the end + if delta.tool_calls: + for tc_delta in delta.tool_calls: + if tc_delta.index not in state.function_calls: + state.function_calls[tc_delta.index] = ResponseFunctionToolCall( + id=FAKE_RESPONSES_ID, + arguments="", + name="", + type="function_call", + call_id="", + ) + tc_function = tc_delta.function + + state.function_calls[tc_delta.index].arguments += ( + tc_function.arguments if tc_function else "" + ) or "" + state.function_calls[tc_delta.index].name += ( + tc_function.name if tc_function else "" + ) or "" + state.function_calls[tc_delta.index].call_id += tc_delta.id or "" + + function_call_starting_index = 0 + if state.text_content_index_and_output: + function_call_starting_index += 1 + # Send end event for this content part + yield ResponseContentPartDoneEvent( + content_index=state.text_content_index_and_output[0], + item_id=FAKE_RESPONSES_ID, + output_index=0, + part=state.text_content_index_and_output[1], + type="response.content_part.done", + ) + + if state.refusal_content_index_and_output: + function_call_starting_index += 1 + # Send end event for this content part + yield ResponseContentPartDoneEvent( + content_index=state.refusal_content_index_and_output[0], + item_id=FAKE_RESPONSES_ID, + output_index=0, + part=state.refusal_content_index_and_output[1], + type="response.content_part.done", + ) + + # Actually send events for the function calls + for function_call in state.function_calls.values(): + # First, a ResponseOutputItemAdded for the function call + yield ResponseOutputItemAddedEvent( + item=ResponseFunctionToolCall( + id=FAKE_RESPONSES_ID, + call_id=function_call.call_id, + arguments=function_call.arguments, + name=function_call.name, + type="function_call", + ), + output_index=function_call_starting_index, + type="response.output_item.added", + ) + # Then, yield the args + yield ResponseFunctionCallArgumentsDeltaEvent( + delta=function_call.arguments, + item_id=FAKE_RESPONSES_ID, + output_index=function_call_starting_index, + type="response.function_call_arguments.delta", + ) + # Finally, the ResponseOutputItemDone + yield ResponseOutputItemDoneEvent( + item=ResponseFunctionToolCall( + id=FAKE_RESPONSES_ID, + call_id=function_call.call_id, + arguments=function_call.arguments, + name=function_call.name, + type="function_call", + ), + output_index=function_call_starting_index, + type="response.output_item.done", + ) + + # Finally, send the Response completed event + outputs: list[ResponseOutputItem] = [] + if state.text_content_index_and_output or state.refusal_content_index_and_output: + assistant_msg = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="completed", + ) + if state.text_content_index_and_output: + assistant_msg.content.append(state.text_content_index_and_output[1]) + if state.refusal_content_index_and_output: + assistant_msg.content.append(state.refusal_content_index_and_output[1]) + outputs.append(assistant_msg) + + # send a ResponseOutputItemDone for the assistant message + yield ResponseOutputItemDoneEvent( + item=assistant_msg, + output_index=0, + type="response.output_item.done", + ) + + for function_call in state.function_calls.values(): + outputs.append(function_call) + + final_response = response.model_copy(update={"output": outputs, "usage": usage}) + + yield ResponseCompletedEvent( + response=final_response, + type="response.completed", + ) + if tracing.include_data(): + span_generation.span_data.output = [final_response.model_dump()] + + if usage: + span_generation.span_data.usage = { + "input_tokens": usage.prompt_tokens, + "output_tokens": usage.completion_tokens, + } + + @overload + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + span: Span[GenerationSpanData], + tracing: ModelTracing, + stream: Literal[True], + ) -> tuple[Response, AsyncStream[ChatCompletionChunk]]: ... + + @overload + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + span: Span[GenerationSpanData], + tracing: ModelTracing, + stream: Literal[False], + ) -> ChatCompletion: ... + + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + span: Span[GenerationSpanData], + tracing: ModelTracing, + stream: bool = False, + ) -> ChatCompletion | tuple[Response, AsyncStream[ChatCompletionChunk]]: + converted_messages = _Converter.items_to_messages(input) + + if system_instructions: + converted_messages.insert( + 0, + { + "content": system_instructions, + "role": "system", + }, + ) + if tracing.include_data(): + span.span_data.input = converted_messages + + parallel_tool_calls = ( + True if model_settings.parallel_tool_calls and tools and len(tools) > 0 else NOT_GIVEN + ) + tool_choice = _Converter.convert_tool_choice(model_settings.tool_choice) + response_format = _Converter.convert_response_format(output_schema) + + converted_tools = [ToolConverter.to_openai(tool) for tool in tools] if tools else [] + + for handoff in handoffs: + converted_tools.append(ToolConverter.convert_handoff_tool(handoff)) + + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("Calling LLM") + else: + logger.debug( + f"{json.dumps(converted_messages, indent=2)}\n" + f"Tools:\n{json.dumps(converted_tools, indent=2)}\n" + f"Stream: {stream}\n" + f"Tool choice: {tool_choice}\n" + f"Response format: {response_format}\n" + ) + + ret = await self._get_client().chat.completions.create( + model=self.model, + messages=converted_messages, + tools=converted_tools or NOT_GIVEN, + temperature=self._non_null_or_not_given(model_settings.temperature), + top_p=self._non_null_or_not_given(model_settings.top_p), + frequency_penalty=self._non_null_or_not_given(model_settings.frequency_penalty), + presence_penalty=self._non_null_or_not_given(model_settings.presence_penalty), + tool_choice=tool_choice, + response_format=response_format, + parallel_tool_calls=parallel_tool_calls, + stream=stream, + stream_options={"include_usage": True} if stream else NOT_GIVEN, + extra_headers=_HEADERS, + ) + + if isinstance(ret, ChatCompletion): + return ret + + response = Response( + id=FAKE_RESPONSES_ID, + created_at=time.time(), + model=self.model, + object="response", + output=[], + tool_choice=cast(Literal["auto", "required", "none"], tool_choice) + if tool_choice != NOT_GIVEN + else "auto", + top_p=model_settings.top_p, + temperature=model_settings.temperature, + tools=[], + parallel_tool_calls=parallel_tool_calls or False, + ) + return response, ret + + def _get_client(self) -> AsyncOpenAI: + if self._client is None: + self._client = AsyncOpenAI() + return self._client + + +class _Converter: + @classmethod + def convert_tool_choice( + cls, tool_choice: Literal["auto", "required", "none"] | str | None + ) -> ChatCompletionToolChoiceOptionParam | NotGiven: + if tool_choice is None: + return NOT_GIVEN + elif tool_choice == "auto": + return "auto" + elif tool_choice == "required": + return "required" + elif tool_choice == "none": + return "none" + else: + return { + "type": "function", + "function": { + "name": tool_choice, + }, + } + + @classmethod + def convert_response_format( + cls, final_output_schema: AgentOutputSchema | None + ) -> ResponseFormat | NotGiven: + if not final_output_schema or final_output_schema.is_plain_text(): + return NOT_GIVEN + + return { + "type": "json_schema", + "json_schema": { + "name": "final_output", + "strict": final_output_schema.strict_json_schema, + "schema": final_output_schema.json_schema(), + }, + } + + @classmethod + def message_to_output_items(cls, message: ChatCompletionMessage) -> list[TResponseOutputItem]: + items: list[TResponseOutputItem] = [] + + message_item = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="completed", + ) + if message.content: + message_item.content.append( + ResponseOutputText(text=message.content, type="output_text", annotations=[]) + ) + if message.refusal: + message_item.content.append( + ResponseOutputRefusal(refusal=message.refusal, type="refusal") + ) + if message.audio: + raise AgentsException("Audio is not currently supported") + + if message_item.content: + items.append(message_item) + + if message.tool_calls: + for tool_call in message.tool_calls: + items.append( + ResponseFunctionToolCall( + id=FAKE_RESPONSES_ID, + call_id=tool_call.id, + arguments=tool_call.function.arguments, + name=tool_call.function.name, + type="function_call", + ) + ) + + return items + + @classmethod + def maybe_easy_input_message(cls, item: Any) -> EasyInputMessageParam | None: + if not isinstance(item, dict): + return None + + keys = item.keys() + # EasyInputMessageParam only has these two keys + if keys != {"content", "role"}: + return None + + role = item.get("role", None) + if role not in ("user", "assistant", "system", "developer"): + return None + + if "content" not in item: + return None + + return cast(EasyInputMessageParam, item) + + @classmethod + def maybe_input_message(cls, item: Any) -> Message | None: + if ( + isinstance(item, dict) + and item.get("type") == "message" + and item.get("role") + in ( + "user", + "system", + "developer", + ) + ): + return cast(Message, item) + + return None + + @classmethod + def maybe_file_search_call(cls, item: Any) -> ResponseFileSearchToolCallParam | None: + if isinstance(item, dict) and item.get("type") == "file_search_call": + return cast(ResponseFileSearchToolCallParam, item) + return None + + @classmethod + def maybe_function_tool_call(cls, item: Any) -> ResponseFunctionToolCallParam | None: + if isinstance(item, dict) and item.get("type") == "function_call": + return cast(ResponseFunctionToolCallParam, item) + return None + + @classmethod + def maybe_function_tool_call_output( + cls, + item: Any, + ) -> FunctionCallOutput | None: + if isinstance(item, dict) and item.get("type") == "function_call_output": + return cast(FunctionCallOutput, item) + return None + + @classmethod + def maybe_item_reference(cls, item: Any) -> ItemReference | None: + if isinstance(item, dict) and item.get("type") == "item_reference": + return cast(ItemReference, item) + return None + + @classmethod + def maybe_response_output_message(cls, item: Any) -> ResponseOutputMessageParam | None: + # ResponseOutputMessage is only used for messages with role assistant + if ( + isinstance(item, dict) + and item.get("type") == "message" + and item.get("role") == "assistant" + ): + return cast(ResponseOutputMessageParam, item) + return None + + @classmethod + def extract_text_content( + cls, content: str | Iterable[ResponseInputContentParam] + ) -> str | list[ChatCompletionContentPartTextParam]: + all_content = cls.extract_all_content(content) + if isinstance(all_content, str): + return all_content + out: list[ChatCompletionContentPartTextParam] = [] + for c in all_content: + if c.get("type") == "text": + out.append(cast(ChatCompletionContentPartTextParam, c)) + return out + + @classmethod + def extract_all_content( + cls, content: str | Iterable[ResponseInputContentParam] + ) -> str | list[ChatCompletionContentPartParam]: + if isinstance(content, str): + return content + out: list[ChatCompletionContentPartParam] = [] + + for c in content: + if isinstance(c, dict) and c.get("type") == "input_text": + casted_text_param = cast(ResponseInputTextParam, c) + out.append( + ChatCompletionContentPartTextParam( + type="text", + text=casted_text_param["text"], + ) + ) + elif isinstance(c, dict) and c.get("type") == "input_image": + casted_image_param = cast(ResponseInputImageParam, c) + if "image_url" not in casted_image_param or not casted_image_param["image_url"]: + raise UserError( + f"Only image URLs are supported for input_image {casted_image_param}" + ) + out.append( + ChatCompletionContentPartImageParam( + type="image_url", + image_url={ + "url": casted_image_param["image_url"], + "detail": casted_image_param["detail"], + }, + ) + ) + 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}") + return out + + @classmethod + def items_to_messages( + cls, + items: str | Iterable[TResponseInputItem], + ) -> list[ChatCompletionMessageParam]: + """ + Convert a sequence of 'Item' objects into a list of ChatCompletionMessageParam. + + Rules: + - EasyInputMessage or InputMessage (role=user) => ChatCompletionUserMessageParam + - EasyInputMessage or InputMessage (role=system) => ChatCompletionSystemMessageParam + - EasyInputMessage or InputMessage (role=developer) => ChatCompletionDeveloperMessageParam + - InputMessage (role=assistant) => Start or flush a ChatCompletionAssistantMessageParam + - response_output_message => Also produces/flushes a ChatCompletionAssistantMessageParam + - tool calls get attached to the *current* assistant message, or create one if none. + - tool outputs => ChatCompletionToolMessageParam + """ + + if isinstance(items, str): + return [ + ChatCompletionUserMessageParam( + role="user", + content=items, + ) + ] + + result: list[ChatCompletionMessageParam] = [] + current_assistant_msg: ChatCompletionAssistantMessageParam | None = None + + def flush_assistant_message() -> None: + nonlocal current_assistant_msg + if current_assistant_msg is not None: + # The API doesn't support empty arrays for tool_calls + if not current_assistant_msg.get("tool_calls"): + del current_assistant_msg["tool_calls"] + result.append(current_assistant_msg) + current_assistant_msg = None + + def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: + nonlocal current_assistant_msg + if current_assistant_msg is None: + current_assistant_msg = ChatCompletionAssistantMessageParam(role="assistant") + current_assistant_msg["tool_calls"] = [] + return current_assistant_msg + + for item in items: + # 1) Check easy input message + if easy_msg := cls.maybe_easy_input_message(item): + role = easy_msg["role"] + content = easy_msg["content"] + + if role == "user": + flush_assistant_message() + msg_user: ChatCompletionUserMessageParam = { + "role": "user", + "content": cls.extract_all_content(content), + } + result.append(msg_user) + elif role == "system": + flush_assistant_message() + msg_system: ChatCompletionSystemMessageParam = { + "role": "system", + "content": cls.extract_text_content(content), + } + result.append(msg_system) + elif role == "developer": + flush_assistant_message() + msg_developer: ChatCompletionDeveloperMessageParam = { + "role": "developer", + "content": cls.extract_text_content(content), + } + result.append(msg_developer) + else: + raise UserError(f"Unexpected role in easy_input_message: {role}") + + # 2) Check input message + elif in_msg := cls.maybe_input_message(item): + role = in_msg["role"] + content = in_msg["content"] + flush_assistant_message() + + if role == "user": + msg_user = { + "role": "user", + "content": cls.extract_all_content(content), + } + result.append(msg_user) + elif role == "system": + msg_system = { + "role": "system", + "content": cls.extract_text_content(content), + } + result.append(msg_system) + elif role == "developer": + msg_developer = { + "role": "developer", + "content": cls.extract_text_content(content), + } + result.append(msg_developer) + else: + raise UserError(f"Unexpected role in input_message: {role}") + + # 3) response output message => assistant + elif resp_msg := cls.maybe_response_output_message(item): + flush_assistant_message() + new_asst = ChatCompletionAssistantMessageParam(role="assistant") + contents = resp_msg["content"] + + text_segments = [] + for c in contents: + if c["type"] == "output_text": + text_segments.append(c["text"]) + elif c["type"] == "refusal": + new_asst["refusal"] = c["refusal"] + elif c["type"] == "output_audio": + # Can't handle this, b/c chat completions expects an ID which we dont have + raise UserError( + f"Only audio IDs are supported for chat completions, but got: {c}" + ) + else: + raise UserError(f"Unknown content type in ResponseOutputMessage: {c}") + + if text_segments: + combined = "\n".join(text_segments) + new_asst["content"] = combined + + new_asst["tool_calls"] = [] + current_assistant_msg = new_asst + + # 4) function/file-search calls => attach to assistant + elif file_search := cls.maybe_file_search_call(item): + asst = ensure_assistant_message() + tool_calls = list(asst.get("tool_calls", [])) + new_tool_call = ChatCompletionMessageToolCallParam( + id=file_search["id"], + type="function", + function={ + "name": "file_search_call", + "arguments": json.dumps( + { + "queries": file_search.get("queries", []), + "status": file_search.get("status"), + } + ), + }, + ) + tool_calls.append(new_tool_call) + asst["tool_calls"] = tool_calls + + elif func_call := cls.maybe_function_tool_call(item): + asst = ensure_assistant_message() + tool_calls = list(asst.get("tool_calls", [])) + new_tool_call = ChatCompletionMessageToolCallParam( + id=func_call["call_id"], + type="function", + function={ + "name": func_call["name"], + "arguments": func_call["arguments"], + }, + ) + tool_calls.append(new_tool_call) + asst["tool_calls"] = tool_calls + # 5) function call output => tool message + elif func_output := cls.maybe_function_tool_call_output(item): + flush_assistant_message() + msg: ChatCompletionToolMessageParam = { + "role": "tool", + "tool_call_id": func_output["call_id"], + "content": func_output["output"], + } + result.append(msg) + + # 6) item reference => handle or raise + elif item_ref := cls.maybe_item_reference(item): + raise UserError( + f"Encountered an item_reference, which is not supported: {item_ref}" + ) + + # 7) If we haven't recognized it => fail or ignore + else: + raise UserError(f"Unhandled item type or structure: {item}") + + flush_assistant_message() + return result + + +class ToolConverter: + @classmethod + def to_openai(cls, tool: Tool) -> ChatCompletionToolParam: + if isinstance(tool, FunctionTool): + return { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description or "", + "parameters": tool.params_json_schema, + }, + } + + raise UserError( + f"Hosted tools are not supported with the ChatCompletions API. FGot tool type: " + f"{type(tool)}, tool: {tool}" + ) + + @classmethod + def convert_handoff_tool(cls, handoff: Handoff[Any]) -> ChatCompletionToolParam: + return { + "type": "function", + "function": { + "name": handoff.tool_name, + "description": handoff.tool_description, + "parameters": handoff.input_json_schema, + }, + } diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py new file mode 100644 index 0000000..5194663 --- /dev/null +++ b/src/agents/models/openai_provider.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import httpx +from openai import AsyncOpenAI, DefaultAsyncHttpxClient + +from . import _openai_shared +from .interface import Model, ModelProvider +from .openai_chatcompletions import OpenAIChatCompletionsModel +from .openai_responses import OpenAIResponsesModel + +DEFAULT_MODEL: str = "gpt-4o" + + +_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 + + +class OpenAIProvider(ModelProvider): + 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, + use_responses: bool | None = None, + ) -> None: + 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 = openai_client + else: + self._client = _openai_shared.get_default_openai_client() or AsyncOpenAI( + api_key=api_key or _openai_shared.get_default_openai_key(), + base_url=base_url, + organization=organization, + project=project, + http_client=shared_http_client(), + ) + + self._is_openai_model = self._client.base_url.host.startswith("api.openai.com") + if use_responses is not None: + self._use_responses = use_responses + else: + self._use_responses = _openai_shared.get_use_responses_by_default() + + def get_model(self, model_name: str | None) -> Model: + if model_name is None: + model_name = DEFAULT_MODEL + + return ( + OpenAIResponsesModel(model=model_name, openai_client=self._client) + if self._use_responses + else OpenAIChatCompletionsModel(model=model_name, openai_client=self._client) + ) diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py new file mode 100644 index 0000000..a10d7b9 --- /dev/null +++ b/src/agents/models/openai_responses.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal, overload + +from openai import NOT_GIVEN, AsyncOpenAI, AsyncStream, NotGiven +from openai.types import ChatModel +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseStreamEvent, + ResponseTextConfigParam, + ToolParam, + WebSearchToolParam, + response_create_params, +) + +from .. import _debug +from ..agent_output import AgentOutputSchema +from ..exceptions import UserError +from ..handoffs import Handoff +from ..items import ItemHelpers, ModelResponse, TResponseInputItem +from ..logger import logger +from ..tool import ComputerTool, FileSearchTool, FunctionTool, Tool, WebSearchTool +from ..tracing import SpanError, response_span +from ..usage import Usage +from ..version import __version__ +from .interface import Model, ModelTracing + +if TYPE_CHECKING: + from ..model_settings import ModelSettings + + +_USER_AGENT = f"Agents/Python {__version__}" +_HEADERS = {"User-Agent": _USER_AGENT} + +# From the Responses API +IncludeLiteral = Literal[ + "file_search_call.results", + "message.input_image.image_url", + "computer_call_output.output.image_url", +] + + +class OpenAIResponsesModel(Model): + """ + Implementation of `Model` that uses the OpenAI Responses API. + """ + + def __init__( + self, + model: str | ChatModel, + openai_client: AsyncOpenAI, + ) -> None: + self.model = model + self._client = openai_client + + def _non_null_or_not_given(self, value: Any) -> Any: + return value if value is not None else NOT_GIVEN + + 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: + with response_span(disabled=tracing.is_disabled()) as span_response: + try: + response = await self._fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + stream=False, + ) + + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("LLM responsed") + else: + logger.debug( + "LLM resp:\n" + f"{json.dumps([x.model_dump() for x in response.output], indent=2)}\n" + ) + + usage = ( + Usage( + requests=1, + input_tokens=response.usage.input_tokens, + output_tokens=response.usage.output_tokens, + total_tokens=response.usage.total_tokens, + ) + if response.usage + else Usage() + ) + + if tracing.include_data(): + span_response.span_data.response = response + span_response.span_data.input = input + except Exception as e: + span_response.set_error( + SpanError( + message="Error getting response", + data={ + "error": str(e) if tracing.include_data() else e.__class__.__name__, + }, + ) + ) + logger.error(f"Error getting response: {e}") + raise + + return ModelResponse( + output=response.output, + usage=usage, + referenceable_id=response.id, + ) + + 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[ResponseStreamEvent]: + """ + Yields a partial message as it is generated, as well as the usage information. + """ + with response_span(disabled=tracing.is_disabled()) as span_response: + try: + stream = await self._fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + stream=True, + ) + + final_response: Response | None = None + + async for chunk in stream: + if isinstance(chunk, ResponseCompletedEvent): + final_response = chunk.response + yield chunk + + if final_response and tracing.include_data(): + span_response.span_data.response = final_response + span_response.span_data.input = input + + except Exception as e: + span_response.set_error( + SpanError( + message="Error streaming response", + data={ + "error": str(e) if tracing.include_data() else e.__class__.__name__, + }, + ) + ) + logger.error(f"Error streaming response: {e}") + raise + + @overload + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + stream: Literal[True], + ) -> AsyncStream[ResponseStreamEvent]: ... + + @overload + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + stream: Literal[False], + ) -> Response: ... + + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + stream: Literal[True] | Literal[False] = False, + ) -> Response | AsyncStream[ResponseStreamEvent]: + 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 + ) + + tool_choice = Converter.convert_tool_choice(model_settings.tool_choice) + converted_tools = Converter.convert_tools(tools, handoffs) + response_format = Converter.get_response_format(output_schema) + + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("Calling LLM") + else: + logger.debug( + f"Calling LLM {self.model} with input:\n" + f"{json.dumps(list_input, indent=2)}\n" + f"Tools:\n{json.dumps(converted_tools.tools, indent=2)}\n" + f"Stream: {stream}\n" + f"Tool choice: {tool_choice}\n" + f"Response format: {response_format}\n" + ) + + return await self._client.responses.create( + instructions=self._non_null_or_not_given(system_instructions), + model=self.model, + input=list_input, + include=converted_tools.includes, + tools=converted_tools.tools, + temperature=self._non_null_or_not_given(model_settings.temperature), + top_p=self._non_null_or_not_given(model_settings.top_p), + truncation=self._non_null_or_not_given(model_settings.truncation), + tool_choice=tool_choice, + parallel_tool_calls=parallel_tool_calls, + stream=stream, + extra_headers=_HEADERS, + text=response_format, + ) + + def _get_client(self) -> AsyncOpenAI: + if self._client is None: + self._client = AsyncOpenAI() + return self._client + + +@dataclass +class ConvertedTools: + tools: list[ToolParam] + includes: list[IncludeLiteral] + + +class Converter: + @classmethod + def convert_tool_choice( + cls, tool_choice: Literal["auto", "required", "none"] | str | None + ) -> response_create_params.ToolChoice | NotGiven: + if tool_choice is None: + return NOT_GIVEN + elif tool_choice == "required": + return "required" + elif tool_choice == "auto": + return "auto" + elif tool_choice == "none": + return "none" + elif tool_choice == "file_search": + return { + "type": "file_search", + } + elif tool_choice == "web_search_preview": + return { + "type": "web_search_preview", + } + elif tool_choice == "computer_use_preview": + return { + "type": "computer_use_preview", + } + else: + return { + "type": "function", + "name": tool_choice, + } + + @classmethod + def get_response_format( + cls, output_schema: AgentOutputSchema | None + ) -> ResponseTextConfigParam | NotGiven: + if output_schema is None or output_schema.is_plain_text(): + return NOT_GIVEN + else: + return { + "format": { + "type": "json_schema", + "name": "final_output", + "schema": output_schema.json_schema(), + "strict": output_schema.strict_json_schema, + } + } + + @classmethod + def convert_tools( + cls, + tools: list[Tool], + handoffs: list[Handoff[Any]], + ) -> ConvertedTools: + converted_tools: list[ToolParam] = [] + includes: list[IncludeLiteral] = [] + + computer_tools = [tool for tool in tools if isinstance(tool, ComputerTool)] + if len(computer_tools) > 1: + raise UserError(f"You can only provide one computer tool. Got {len(computer_tools)}") + + for tool in tools: + converted_tool, include = cls._convert_tool(tool) + converted_tools.append(converted_tool) + if include: + includes.append(include) + + for handoff in handoffs: + converted_tools.append(cls._convert_handoff_tool(handoff)) + + return ConvertedTools(tools=converted_tools, includes=includes) + + @classmethod + def _convert_tool(cls, tool: Tool) -> tuple[ToolParam, IncludeLiteral | None]: + """Returns converted tool and includes""" + + if isinstance(tool, FunctionTool): + converted_tool: ToolParam = { + "name": tool.name, + "parameters": tool.params_json_schema, + "strict": tool.strict_json_schema, + "type": "function", + "description": tool.description, + } + includes: IncludeLiteral | None = None + elif isinstance(tool, WebSearchTool): + ws: WebSearchToolParam = { + "type": "web_search_preview", + "user_location": tool.user_location, + "search_context_size": tool.search_context_size, + } + converted_tool = ws + includes = None + elif isinstance(tool, FileSearchTool): + converted_tool = { + "type": "file_search", + "vector_store_ids": tool.vector_store_ids, + } + if tool.max_num_results: + converted_tool["max_num_results"] = tool.max_num_results + if tool.ranking_options: + converted_tool["ranking_options"] = tool.ranking_options + if tool.filters: + converted_tool["filters"] = tool.filters + + includes = "file_search_call.results" if tool.include_search_results else None + elif isinstance(tool, ComputerTool): + converted_tool = { + "type": "computer-preview", + "environment": tool.computer.environment, + "display_width": tool.computer.dimensions[0], + "display_height": tool.computer.dimensions[1], + } + includes = None + + else: + raise UserError(f"Unknown tool type: {type(tool)}, tool") + + return converted_tool, includes + + @classmethod + def _convert_handoff_tool(cls, handoff: Handoff) -> ToolParam: + return { + "name": handoff.tool_name, + "parameters": handoff.input_json_schema, + "strict": handoff.strict_json_schema, + "type": "function", + "description": handoff.tool_description, + } diff --git a/src/agents/result.py b/src/agents/result.py new file mode 100644 index 0000000..5683827 --- /dev/null +++ b/src/agents/result.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import abc +import asyncio +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, cast + +from typing_extensions import TypeVar + +from ._run_impl import QueueCompleteSentinel +from .agent import Agent +from .agent_output import AgentOutputSchema +from .exceptions import InputGuardrailTripwireTriggered, MaxTurnsExceeded +from .guardrail import InputGuardrailResult, OutputGuardrailResult +from .items import ItemHelpers, ModelResponse, RunItem, TResponseInputItem +from .logger import logger +from .stream_events import StreamEvent +from .tracing import Trace + +if TYPE_CHECKING: + from ._run_impl import QueueCompleteSentinel + from .agent import Agent + +T = TypeVar("T") + + +@dataclass +class RunResultBase(abc.ABC): + input: str | list[TResponseInputItem] + """The original input items i.e. the items before run() was called. This may be a mutated + version of the input, if there are handoff input filters that mutate the input. + """ + + new_items: list[RunItem] + """The new items generated during the agent run. These include things like new messages, tool + calls and their outputs, etc. + """ + + raw_responses: list[ModelResponse] + """The raw LLM responses generated by the model during the agent run.""" + + final_output: Any + """The output of the last agent.""" + + input_guardrail_results: list[InputGuardrailResult] + """Guardrail results for the input messages.""" + + output_guardrail_results: list[OutputGuardrailResult] + """Guardrail results for the final output of the agent.""" + + @property + @abc.abstractmethod + def last_agent(self) -> Agent[Any]: + """The last agent that was run.""" + + def final_output_as(self, cls: type[T], raise_if_incorrect_type: bool = False) -> T: + """A convenience method to cast the final output to a specific type. By default, the cast + is only for the typechecker. If you set `raise_if_incorrect_type` to True, we'll raise a + TypeError if the final output is not of the given type. + + Args: + cls: The type to cast the final output to. + raise_if_incorrect_type: If True, we'll raise a TypeError if the final output is not of + the given type. + + Returns: + The final output casted to the given type. + """ + if raise_if_incorrect_type and not isinstance(self.final_output, cls): + raise TypeError(f"Final output is not of type {cls.__name__}") + + return cast(T, self.final_output) + + def to_input_list(self) -> list[TResponseInputItem]: + """Creates a new input list, merging the original input with all the new items generated.""" + original_items: list[TResponseInputItem] = ItemHelpers.input_to_new_input_list(self.input) + new_items = [item.to_input_item() for item in self.new_items] + + return original_items + new_items + + +@dataclass +class RunResult(RunResultBase): + _last_agent: Agent[Any] + + @property + def last_agent(self) -> Agent[Any]: + """The last agent that was run.""" + return self._last_agent + + +@dataclass +class RunResultStreaming(RunResultBase): + """The result of an agent run in streaming mode. You can use the `stream_events` method to + receive semantic events as they are generated. + + The streaming method will raise: + - A MaxTurnsExceeded exception if the agent exceeds the max_turns limit. + - A GuardrailTripwireTriggered exception if a guardrail is tripped. + """ + + current_agent: Agent[Any] + """The current agent that is running.""" + + current_turn: int + """The current turn number.""" + + max_turns: int + """The maximum number of turns the agent can run for.""" + + final_output: Any + """The final output of the agent. This is None until the agent has finished running.""" + + _current_agent_output_schema: AgentOutputSchema | None = field(repr=False) + + _trace: Trace | None = field(repr=False) + + is_complete: bool = False + """Whether the agent has finished running.""" + + # Queues that the background run_loop writes to + _event_queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel] = field( + default_factory=asyncio.Queue, repr=False + ) + _input_guardrail_queue: asyncio.Queue[InputGuardrailResult] = field( + default_factory=asyncio.Queue, repr=False + ) + + # Store the asyncio tasks that we're waiting on + _run_impl_task: asyncio.Task[Any] | None = field(default=None, repr=False) + _input_guardrails_task: asyncio.Task[Any] | None = field(default=None, repr=False) + _output_guardrails_task: asyncio.Task[Any] | None = field(default=None, repr=False) + _stored_exception: Exception | None = field(default=None, repr=False) + + @property + def last_agent(self) -> Agent[Any]: + """The last agent that was run. Updates as the agent run progresses, so the true last agent + is only available after the agent run is complete. + """ + return self.current_agent + + async def stream_events(self) -> AsyncIterator[StreamEvent]: + """Stream deltas for new items as they are generated. We're using the types from the + OpenAI Responses API, so these are semantic events: each event has a `type` field that + describes the type of the event, along with the data for that event. + + This will raise: + - A MaxTurnsExceeded exception if the agent exceeds the max_turns limit. + - A GuardrailTripwireTriggered exception if a guardrail is tripped. + """ + while True: + self._check_errors() + if self._stored_exception: + logger.debug("Breaking due to stored exception") + self.is_complete = True + break + + if self.is_complete and self._event_queue.empty(): + break + + try: + item = await self._event_queue.get() + except asyncio.CancelledError: + break + + if isinstance(item, QueueCompleteSentinel): + self._event_queue.task_done() + # Check for errors, in case the queue was completed due to an exception + self._check_errors() + break + + yield item + self._event_queue.task_done() + + if self._trace: + self._trace.finish(reset_current=True) + + self._cleanup_tasks() + + if self._stored_exception: + raise self._stored_exception + + def _check_errors(self): + if self.current_turn > self.max_turns: + self._stored_exception = MaxTurnsExceeded(f"Max turns ({self.max_turns}) exceeded") + + # Fetch all the completed guardrail results from the queue and raise if needed + while not self._input_guardrail_queue.empty(): + guardrail_result = self._input_guardrail_queue.get_nowait() + if guardrail_result.output.tripwire_triggered: + self._stored_exception = InputGuardrailTripwireTriggered(guardrail_result) + + # Check the tasks for any exceptions + if self._run_impl_task and self._run_impl_task.done(): + exc = self._run_impl_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + if self._input_guardrails_task and self._input_guardrails_task.done(): + exc = self._input_guardrails_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + if self._output_guardrails_task and self._output_guardrails_task.done(): + exc = self._output_guardrails_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + def _cleanup_tasks(self): + if self._run_impl_task and not self._run_impl_task.done(): + self._run_impl_task.cancel() + + if self._input_guardrails_task and not self._input_guardrails_task.done(): + self._input_guardrails_task.cancel() + + if self._output_guardrails_task and not self._output_guardrails_task.done(): + self._output_guardrails_task.cancel() + self._output_guardrails_task.cancel() + self._output_guardrails_task.cancel() diff --git a/src/agents/run.py b/src/agents/run.py new file mode 100644 index 0000000..dfff7e3 --- /dev/null +++ b/src/agents/run.py @@ -0,0 +1,904 @@ +from __future__ import annotations + +import asyncio +import copy +from dataclasses import dataclass, field +from typing import Any, cast + +from openai.types.responses import ResponseCompletedEvent + +from . import Model, _utils +from ._run_impl import ( + NextStepFinalOutput, + NextStepHandoff, + NextStepRunAgain, + QueueCompleteSentinel, + RunImpl, + SingleStepResult, + TraceCtxManager, + get_model_tracing_impl, +) +from .agent import Agent +from .agent_output import AgentOutputSchema +from .exceptions import ( + AgentsException, + InputGuardrailTripwireTriggered, + MaxTurnsExceeded, + ModelBehaviorError, + OutputGuardrailTripwireTriggered, +) +from .guardrail import InputGuardrail, InputGuardrailResult, OutputGuardrail, OutputGuardrailResult +from .handoffs import Handoff, HandoffInputFilter, handoff +from .items import ItemHelpers, ModelResponse, RunItem, TResponseInputItem +from .lifecycle import RunHooks +from .logger import logger +from .model_settings import ModelSettings +from .models.interface import ModelProvider +from .models.openai_provider import OpenAIProvider +from .result import RunResult, RunResultStreaming +from .run_context import RunContextWrapper, TContext +from .stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent +from .tracing import Span, SpanError, agent_span, get_current_trace, trace +from .tracing.span_data import AgentSpanData +from .usage import Usage + +DEFAULT_MAX_TURNS = 10 + + +@dataclass +class RunConfig: + """Configures settings for the entire agent run.""" + + model: str | Model | None = None + """The model to use for the entire agent run. If set, will override the model set on every + agent. The model_provider passed in below must be able to resolve this model name. + """ + + model_provider: ModelProvider = field(default_factory=OpenAIProvider) + """The model provider to use when looking up string model names. Defaults to OpenAI.""" + + model_settings: ModelSettings | None = None + """Configure global model settings. Any non-null values will override the agent-specific model + settings. + """ + + handoff_input_filter: HandoffInputFilter | None = None + """A global input filter to apply to all handoffs. If `Handoff.input_filter` is set, then that + will take precedence. The input filter allows you to edit the inputs that are sent to the new + agent. See the documentation in `Handoff.input_filter` for more details. + """ + + input_guardrails: list[InputGuardrail[Any]] | None = None + """A list of input guardrails to run on the initial run input.""" + + output_guardrails: list[OutputGuardrail[Any]] | None = None + """A list of output guardrails to run on the final output of the run.""" + + tracing_disabled: bool = False + """Whether tracing is disabled for the agent run. If disabled, we will not trace the agent run. + """ + + trace_include_sensitive_data: bool = True + """Whether we include potentially sensitive data (for example: inputs/outputs of tool calls or + LLM generations) in traces. If False, we'll still create spans for these events, but the + sensitive data will not be included. + """ + + workflow_name: str = "Agent workflow" + """The name of the run, used for tracing. Should be a logical name for the run, like + "Code generation workflow" or "Customer support agent". + """ + + trace_id: str | None = None + """A custom trace ID to use for tracing. If not provided, we will generate a new trace ID.""" + + group_id: str | None = None + """ + A grouping identifier to use for tracing, to link multiple traces from the same conversation + or process. For example, you might use a chat thread ID. + """ + + trace_metadata: dict[str, Any] | None = None + """ + An optional dictionary of additional metadata to include with the trace. + """ + + +class Runner: + @classmethod + async def run( + cls, + starting_agent: Agent[TContext], + input: str | list[TResponseInputItem], + *, + context: TContext | None = None, + max_turns: int = DEFAULT_MAX_TURNS, + hooks: RunHooks[TContext] | None = None, + run_config: RunConfig | None = None, + ) -> RunResult: + """Run a workflow starting at the given agent. The agent will run in a loop until a final + output is generated. The loop runs like so: + 1. The agent is invoked with the given input. + 2. If there is a final output (i.e. the agent produces something of type + `agent.output_type`, the loop terminates. + 3. If there's a handoff, we run the loop again, with the new agent. + 4. Else, we run tool calls (if any), and re-run the loop. + + In two cases, the agent may raise an exception: + 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised. + 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised. + + Note that only the first agent's input guardrails are run. + + Args: + starting_agent: The starting agent to run. + input: The initial input to the agent. You can pass a single string for a user message, + or a list of input items. + context: The context to run the agent with. + max_turns: The maximum number of turns to run the agent for. A turn is defined as one + AI invocation (including any tool calls that might occur). + hooks: An object that receives callbacks on various lifecycle events. + run_config: Global settings for the entire agent run. + + Returns: + A run result containing all the inputs, guardrail results and the output of the last + agent. Agents may perform handoffs, so we don't know the specific type of the output. + """ + if hooks is None: + hooks = RunHooks[Any]() + if run_config is None: + run_config = RunConfig() + + with TraceCtxManager( + workflow_name=run_config.workflow_name, + trace_id=run_config.trace_id, + group_id=run_config.group_id, + metadata=run_config.trace_metadata, + disabled=run_config.tracing_disabled, + ): + current_turn = 0 + original_input: str | list[TResponseInputItem] = copy.deepcopy(input) + generated_items: list[RunItem] = [] + model_responses: list[ModelResponse] = [] + + context_wrapper: RunContextWrapper[TContext] = RunContextWrapper( + context=context, # type: ignore + ) + + input_guardrail_results: list[InputGuardrailResult] = [] + + current_span: Span[AgentSpanData] | None = None + current_agent = starting_agent + should_run_agent_start_hooks = True + + try: + while True: + # Start an agent span if we don't have one. This span is ended if the current + # agent changes, or if the agent loop ends. + if current_span is None: + handoff_names = [h.agent_name for h in cls._get_handoffs(current_agent)] + tool_names = [t.name for t in current_agent.tools] + if output_schema := cls._get_output_schema(current_agent): + output_type_name = output_schema.output_type_name() + else: + output_type_name = "str" + + current_span = agent_span( + name=current_agent.name, + handoffs=handoff_names, + tools=tool_names, + output_type=output_type_name, + ) + current_span.start(mark_as_current=True) + + current_turn += 1 + if current_turn > max_turns: + _utils.attach_error_to_span( + current_span, + SpanError( + message="Max turns exceeded", + data={"max_turns": max_turns}, + ), + ) + raise MaxTurnsExceeded(f"Max turns ({max_turns}) exceeded") + + logger.debug( + f"Running agent {current_agent.name} (turn {current_turn})", + ) + + if current_turn == 1: + input_guardrail_results, turn_result = await asyncio.gather( + cls._run_input_guardrails( + starting_agent, + starting_agent.input_guardrails + + (run_config.input_guardrails or []), + copy.deepcopy(input), + context_wrapper, + ), + cls._run_single_turn( + agent=current_agent, + original_input=original_input, + generated_items=generated_items, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + should_run_agent_start_hooks=should_run_agent_start_hooks, + ), + ) + else: + turn_result = await cls._run_single_turn( + agent=current_agent, + original_input=original_input, + generated_items=generated_items, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + should_run_agent_start_hooks=should_run_agent_start_hooks, + ) + should_run_agent_start_hooks = False + + model_responses.append(turn_result.model_response) + original_input = turn_result.original_input + generated_items = turn_result.generated_items + + if isinstance(turn_result.next_step, NextStepFinalOutput): + output_guardrail_results = await cls._run_output_guardrails( + current_agent.output_guardrails + (run_config.output_guardrails or []), + current_agent, + turn_result.next_step.output, + context_wrapper, + ) + return RunResult( + input=original_input, + new_items=generated_items, + raw_responses=model_responses, + final_output=turn_result.next_step.output, + _last_agent=current_agent, + input_guardrail_results=input_guardrail_results, + output_guardrail_results=output_guardrail_results, + ) + elif isinstance(turn_result.next_step, NextStepHandoff): + current_agent = cast(Agent[TContext], turn_result.next_step.new_agent) + current_span.finish(reset_current=True) + current_span = None + should_run_agent_start_hooks = True + elif isinstance(turn_result.next_step, NextStepRunAgain): + pass + else: + raise AgentsException( + f"Unknown next step type: {type(turn_result.next_step)}" + ) + finally: + if current_span: + current_span.finish(reset_current=True) + + @classmethod + def run_sync( + cls, + starting_agent: Agent[TContext], + input: str | list[TResponseInputItem], + *, + context: TContext | None = None, + max_turns: int = DEFAULT_MAX_TURNS, + hooks: RunHooks[TContext] | None = None, + run_config: RunConfig | None = None, + ) -> RunResult: + """Run a workflow synchronously, starting at the given agent. Note that this just wraps the + `run` method, so it will not work if there's already an event loop (e.g. inside an async + function, or in a Jupyter notebook or async context like FastAPI). For those cases, use + the `run` method instead. + + The agent will run in a loop until a final output is generated. The loop runs like so: + 1. The agent is invoked with the given input. + 2. If there is a final output (i.e. the agent produces something of type + `agent.output_type`, the loop terminates. + 3. If there's a handoff, we run the loop again, with the new agent. + 4. Else, we run tool calls (if any), and re-run the loop. + + In two cases, the agent may raise an exception: + 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised. + 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised. + + Note that only the first agent's input guardrails are run. + + Args: + starting_agent: The starting agent to run. + input: The initial input to the agent. You can pass a single string for a user message, + or a list of input items. + context: The context to run the agent with. + max_turns: The maximum number of turns to run the agent for. A turn is defined as one + AI invocation (including any tool calls that might occur). + hooks: An object that receives callbacks on various lifecycle events. + run_config: Global settings for the entire agent run. + + Returns: + A run result containing all the inputs, guardrail results and the output of the last + agent. Agents may perform handoffs, so we don't know the specific type of the output. + """ + return asyncio.get_event_loop().run_until_complete( + cls.run( + starting_agent, + input, + context=context, + max_turns=max_turns, + hooks=hooks, + run_config=run_config, + ) + ) + + @classmethod + def run_streamed( + cls, + starting_agent: Agent[TContext], + input: str | list[TResponseInputItem], + context: TContext | None = None, + max_turns: int = DEFAULT_MAX_TURNS, + hooks: RunHooks[TContext] | None = None, + run_config: RunConfig | None = None, + ) -> RunResultStreaming: + """Run a workflow starting at the given agent in streaming mode. The returned result object + contains a method you can use to stream semantic events as they are generated. + + The agent will run in a loop until a final output is generated. The loop runs like so: + 1. The agent is invoked with the given input. + 2. If there is a final output (i.e. the agent produces something of type + `agent.output_type`, the loop terminates. + 3. If there's a handoff, we run the loop again, with the new agent. + 4. Else, we run tool calls (if any), and re-run the loop. + + In two cases, the agent may raise an exception: + 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised. + 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised. + + Note that only the first agent's input guardrails are run. + + Args: + starting_agent: The starting agent to run. + input: The initial input to the agent. You can pass a single string for a user message, + or a list of input items. + context: The context to run the agent with. + max_turns: The maximum number of turns to run the agent for. A turn is defined as one + AI invocation (including any tool calls that might occur). + hooks: An object that receives callbacks on various lifecycle events. + run_config: Global settings for the entire agent run. + + Returns: + A result object that contains data about the run, as well as a method to stream events. + """ + if hooks is None: + hooks = RunHooks[Any]() + if run_config is None: + run_config = RunConfig() + + # If there's already a trace, we don't create a new one. In addition, we can't end the + # trace here, because the actual work is done in `stream_events` and this method ends + # before that. + new_trace = ( + None + if get_current_trace() + else trace( + workflow_name=run_config.workflow_name, + trace_id=run_config.trace_id, + group_id=run_config.group_id, + metadata=run_config.trace_metadata, + disabled=run_config.tracing_disabled, + ) + ) + # Need to start the trace here, because the current trace contextvar is captured at + # asyncio.create_task time + if new_trace: + new_trace.start(mark_as_current=True) + + output_schema = cls._get_output_schema(starting_agent) + context_wrapper: RunContextWrapper[TContext] = RunContextWrapper( + context=context # type: ignore + ) + + streamed_result = RunResultStreaming( + input=copy.deepcopy(input), + new_items=[], + current_agent=starting_agent, + raw_responses=[], + final_output=None, + is_complete=False, + current_turn=0, + max_turns=max_turns, + input_guardrail_results=[], + output_guardrail_results=[], + _current_agent_output_schema=output_schema, + _trace=new_trace, + ) + + # Kick off the actual agent loop in the background and return the streamed result object. + streamed_result._run_impl_task = asyncio.create_task( + cls._run_streamed_impl( + starting_input=input, + streamed_result=streamed_result, + starting_agent=starting_agent, + max_turns=max_turns, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + ) + return streamed_result + + @classmethod + async def _run_input_guardrails_with_queue( + cls, + agent: Agent[Any], + guardrails: list[InputGuardrail[TContext]], + input: str | list[TResponseInputItem], + context: RunContextWrapper[TContext], + streamed_result: RunResultStreaming, + parent_span: Span[Any], + ): + queue = streamed_result._input_guardrail_queue + + # We'll run the guardrails and push them onto the queue as they complete + guardrail_tasks = [ + asyncio.create_task( + RunImpl.run_single_input_guardrail(agent, guardrail, input, context) + ) + for guardrail in guardrails + ] + guardrail_results = [] + try: + for done in asyncio.as_completed(guardrail_tasks): + result = await done + if result.output.tripwire_triggered: + _utils.attach_error_to_span( + parent_span, + SpanError( + message="Guardrail tripwire triggered", + data={ + "guardrail": result.guardrail.get_name(), + "type": "input_guardrail", + }, + ), + ) + queue.put_nowait(result) + guardrail_results.append(result) + except Exception: + for t in guardrail_tasks: + t.cancel() + raise + + streamed_result.input_guardrail_results = guardrail_results + + @classmethod + async def _run_streamed_impl( + cls, + starting_input: str | list[TResponseInputItem], + streamed_result: RunResultStreaming, + starting_agent: Agent[TContext], + max_turns: int, + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ): + current_span: Span[AgentSpanData] | None = None + current_agent = starting_agent + current_turn = 0 + should_run_agent_start_hooks = True + + streamed_result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=current_agent)) + + try: + while True: + if streamed_result.is_complete: + break + + # Start an agent span if we don't have one. This span is ended if the current + # agent changes, or if the agent loop ends. + if current_span is None: + handoff_names = [h.agent_name for h in cls._get_handoffs(current_agent)] + tool_names = [t.name for t in current_agent.tools] + if output_schema := cls._get_output_schema(current_agent): + output_type_name = output_schema.output_type_name() + else: + output_type_name = "str" + + current_span = agent_span( + name=current_agent.name, + handoffs=handoff_names, + tools=tool_names, + output_type=output_type_name, + ) + current_span.start(mark_as_current=True) + + current_turn += 1 + streamed_result.current_turn = current_turn + + if current_turn > max_turns: + _utils.attach_error_to_span( + current_span, + SpanError( + message="Max turns exceeded", + data={"max_turns": max_turns}, + ), + ) + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + break + + if current_turn == 1: + # Run the input guardrails in the background and put the results on the queue + streamed_result._input_guardrails_task = asyncio.create_task( + cls._run_input_guardrails_with_queue( + starting_agent, + starting_agent.input_guardrails + (run_config.input_guardrails or []), + copy.deepcopy(ItemHelpers.input_to_new_input_list(starting_input)), + context_wrapper, + streamed_result, + current_span, + ) + ) + try: + turn_result = await cls._run_single_turn_streamed( + streamed_result, + current_agent, + hooks, + context_wrapper, + run_config, + should_run_agent_start_hooks, + ) + should_run_agent_start_hooks = False + + streamed_result.raw_responses = streamed_result.raw_responses + [ + turn_result.model_response + ] + streamed_result.input = turn_result.original_input + streamed_result.new_items = turn_result.generated_items + + if isinstance(turn_result.next_step, NextStepHandoff): + current_agent = turn_result.next_step.new_agent + current_span.finish(reset_current=True) + current_span = None + should_run_agent_start_hooks = True + streamed_result._event_queue.put_nowait( + AgentUpdatedStreamEvent(new_agent=current_agent) + ) + elif isinstance(turn_result.next_step, NextStepFinalOutput): + streamed_result._output_guardrails_task = asyncio.create_task( + cls._run_output_guardrails( + current_agent.output_guardrails + + (run_config.output_guardrails or []), + current_agent, + turn_result.next_step.output, + context_wrapper, + ) + ) + + try: + output_guardrail_results = await streamed_result._output_guardrails_task + except Exception: + # Exceptions will be checked in the stream_events loop + output_guardrail_results = [] + + streamed_result.output_guardrail_results = output_guardrail_results + streamed_result.final_output = turn_result.next_step.output + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + elif isinstance(turn_result.next_step, NextStepRunAgain): + pass + except Exception as e: + if current_span: + _utils.attach_error_to_span( + current_span, + SpanError( + message="Error in agent run", + data={"error": str(e)}, + ), + ) + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + raise + + streamed_result.is_complete = True + finally: + if current_span: + current_span.finish(reset_current=True) + + @classmethod + async def _run_single_turn_streamed( + cls, + streamed_result: RunResultStreaming, + agent: Agent[TContext], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + should_run_agent_start_hooks: bool, + ) -> SingleStepResult: + if should_run_agent_start_hooks: + await asyncio.gather( + hooks.on_agent_start(context_wrapper, agent), + ( + agent.hooks.on_start(context_wrapper, agent) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + + output_schema = cls._get_output_schema(agent) + + streamed_result.current_agent = agent + streamed_result._current_agent_output_schema = output_schema + + system_prompt = await agent.get_system_prompt(context_wrapper) + + handoffs = cls._get_handoffs(agent) + + model = cls._get_model(agent, run_config) + model_settings = agent.model_settings.resolve(run_config.model_settings) + final_response: ModelResponse | None = None + + input = ItemHelpers.input_to_new_input_list(streamed_result.input) + input.extend([item.to_input_item() for item in streamed_result.new_items]) + + # 1. Stream the output events + async for event in model.stream_response( + system_prompt, + input, + model_settings, + agent.tools, + output_schema, + handoffs, + get_model_tracing_impl( + run_config.tracing_disabled, run_config.trace_include_sensitive_data + ), + ): + if isinstance(event, ResponseCompletedEvent): + usage = ( + Usage( + requests=1, + input_tokens=event.response.usage.input_tokens, + output_tokens=event.response.usage.output_tokens, + total_tokens=event.response.usage.total_tokens, + ) + if event.response.usage + else Usage() + ) + final_response = ModelResponse( + output=event.response.output, + usage=usage, + referenceable_id=event.response.id, + ) + + streamed_result._event_queue.put_nowait(RawResponsesStreamEvent(data=event)) + + # 2. At this point, the streaming is complete for this turn of the agent loop. + if not final_response: + raise ModelBehaviorError("Model did not produce a final response!") + + # 3. Now, we can process the turn as we do in the non-streaming case + single_step_result = await cls._get_single_step_result_from_response( + agent=agent, + original_input=streamed_result.input, + pre_step_items=streamed_result.new_items, + new_response=final_response, + output_schema=output_schema, + handoffs=handoffs, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + + RunImpl.stream_step_result_to_queue(single_step_result, streamed_result._event_queue) + return single_step_result + + @classmethod + async def _run_single_turn( + cls, + *, + agent: Agent[TContext], + original_input: str | list[TResponseInputItem], + generated_items: list[RunItem], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + should_run_agent_start_hooks: bool, + ) -> SingleStepResult: + # Ensure we run the hooks before anything else + if should_run_agent_start_hooks: + await asyncio.gather( + hooks.on_agent_start(context_wrapper, agent), + ( + agent.hooks.on_start(context_wrapper, agent) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + + system_prompt = await agent.get_system_prompt(context_wrapper) + + output_schema = cls._get_output_schema(agent) + handoffs = cls._get_handoffs(agent) + input = ItemHelpers.input_to_new_input_list(original_input) + input.extend([generated_item.to_input_item() for generated_item in generated_items]) + + new_response = await cls._get_new_response( + agent, + system_prompt, + input, + output_schema, + handoffs, + context_wrapper, + run_config, + ) + + return await cls._get_single_step_result_from_response( + agent=agent, + original_input=original_input, + pre_step_items=generated_items, + new_response=new_response, + output_schema=output_schema, + handoffs=handoffs, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + + @classmethod + async def _get_single_step_result_from_response( + cls, + *, + agent: Agent[TContext], + original_input: str | list[TResponseInputItem], + pre_step_items: list[RunItem], + new_response: ModelResponse, + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ) -> SingleStepResult: + processed_response = RunImpl.process_model_response( + agent=agent, + response=new_response, + output_schema=output_schema, + handoffs=handoffs, + ) + return await RunImpl.execute_tools_and_side_effects( + agent=agent, + original_input=original_input, + pre_step_items=pre_step_items, + new_response=new_response, + processed_response=processed_response, + output_schema=output_schema, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + + @classmethod + async def _run_input_guardrails( + cls, + agent: Agent[Any], + guardrails: list[InputGuardrail[TContext]], + input: str | list[TResponseInputItem], + context: RunContextWrapper[TContext], + ) -> list[InputGuardrailResult]: + if not guardrails: + return [] + + guardrail_tasks = [ + asyncio.create_task( + RunImpl.run_single_input_guardrail(agent, guardrail, input, context) + ) + for guardrail in guardrails + ] + + guardrail_results = [] + + for done in asyncio.as_completed(guardrail_tasks): + result = await done + if result.output.tripwire_triggered: + # Cancel all guardrail tasks if a tripwire is triggered. + for t in guardrail_tasks: + t.cancel() + _utils.attach_error_to_current_span( + SpanError( + message="Guardrail tripwire triggered", + data={"guardrail": result.guardrail.get_name()}, + ) + ) + raise InputGuardrailTripwireTriggered(result) + else: + guardrail_results.append(result) + + return guardrail_results + + @classmethod + async def _run_output_guardrails( + cls, + guardrails: list[OutputGuardrail[TContext]], + agent: Agent[TContext], + agent_output: Any, + context: RunContextWrapper[TContext], + ) -> list[OutputGuardrailResult]: + if not guardrails: + return [] + + guardrail_tasks = [ + asyncio.create_task( + RunImpl.run_single_output_guardrail(guardrail, agent, agent_output, context) + ) + for guardrail in guardrails + ] + + guardrail_results = [] + + for done in asyncio.as_completed(guardrail_tasks): + result = await done + if result.output.tripwire_triggered: + # Cancel all guardrail tasks if a tripwire is triggered. + for t in guardrail_tasks: + t.cancel() + _utils.attach_error_to_current_span( + SpanError( + message="Guardrail tripwire triggered", + data={"guardrail": result.guardrail.get_name()}, + ) + ) + raise OutputGuardrailTripwireTriggered(result) + else: + guardrail_results.append(result) + + return guardrail_results + + @classmethod + async def _get_new_response( + cls, + agent: Agent[TContext], + system_prompt: str | None, + input: list[TResponseInputItem], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ) -> ModelResponse: + model = cls._get_model(agent, run_config) + model_settings = agent.model_settings.resolve(run_config.model_settings) + new_response = await model.get_response( + system_instructions=system_prompt, + input=input, + model_settings=model_settings, + tools=agent.tools, + output_schema=output_schema, + handoffs=handoffs, + tracing=get_model_tracing_impl( + run_config.tracing_disabled, run_config.trace_include_sensitive_data + ), + ) + + context_wrapper.usage.add(new_response.usage) + + return new_response + + @classmethod + def _get_output_schema(cls, agent: Agent[Any]) -> AgentOutputSchema | None: + if agent.output_type is None or agent.output_type is str: + return None + + return AgentOutputSchema(agent.output_type) + + @classmethod + def _get_handoffs(cls, agent: Agent[Any]) -> list[Handoff]: + handoffs = [] + for handoff_item in agent.handoffs: + if isinstance(handoff_item, Handoff): + handoffs.append(handoff_item) + elif isinstance(handoff_item, Agent): + handoffs.append(handoff(handoff_item)) + return handoffs + + @classmethod + def _get_model(cls, agent: Agent[Any], run_config: RunConfig) -> Model: + if isinstance(run_config.model, Model): + return run_config.model + elif isinstance(run_config.model, str): + return run_config.model_provider.get_model(run_config.model) + elif isinstance(agent.model, Model): + return agent.model + + return run_config.model_provider.get_model(agent.model) diff --git a/src/agents/run_context.py b/src/agents/run_context.py new file mode 100644 index 0000000..579a215 --- /dev/null +++ b/src/agents/run_context.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field +from typing import Any, Generic + +from typing_extensions import TypeVar + +from .usage import Usage + +TContext = TypeVar("TContext", default=Any) + + +@dataclass +class RunContextWrapper(Generic[TContext]): + """This wraps the context object that you passed to `Runner.run()`. It also contains + information about the usage of the agent run so far. + + NOTE: Contexts are not passed to the LLM. They're a way to pass dependencies and data to code + you implement, like tool functions, callbacks, hooks, etc. + """ + + context: TContext + """The context object (or None), passed by you to `Runner.run()`""" + + usage: Usage = field(default_factory=Usage) + """The usage of the agent run so far. For streamed responses, the usage will be stale until the + last chunk of the stream is processed. + """ diff --git a/src/agents/stream_events.py b/src/agents/stream_events.py new file mode 100644 index 0000000..bd37d11 --- /dev/null +++ b/src/agents/stream_events.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, Union + +from typing_extensions import TypeAlias + +from .agent import Agent +from .items import RunItem, TResponseStreamEvent + + +@dataclass +class RawResponsesStreamEvent: + """Streaming event from the LLM. These are 'raw' events, i.e. they are directly passed through + from the LLM. + """ + + data: TResponseStreamEvent + """The raw responses streaming event from the LLM.""" + + type: Literal["raw_response_event"] = "raw_response_event" + """The type of the event.""" + + +@dataclass +class RunItemStreamEvent: + """Streaming events that wrap a `RunItem`. As the agent processes the LLM response, it will + generate these events for new messages, tool calls, tool outputs, handoffs, etc. + """ + + name: Literal[ + "message_output_created", + "handoff_requested", + "handoff_occured", + "tool_called", + "tool_output", + "reasoning_item_created", + ] + """The name of the event.""" + + item: RunItem + """The item that was created.""" + + type: Literal["run_item_stream_event"] = "run_item_stream_event" + + +@dataclass +class AgentUpdatedStreamEvent: + """Event that notifies that there is a new agent running.""" + + new_agent: Agent[Any] + """The new agent.""" + + type: Literal["agent_updated_stream_event"] = "agent_updated_stream_event" + + +StreamEvent: TypeAlias = Union[RawResponsesStreamEvent, RunItemStreamEvent, AgentUpdatedStreamEvent] +"""A streaming event from an agent.""" diff --git a/src/agents/strict_schema.py b/src/agents/strict_schema.py new file mode 100644 index 0000000..910ad85 --- /dev/null +++ b/src/agents/strict_schema.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from typing import Any + +from openai import NOT_GIVEN +from typing_extensions import TypeGuard + +from .exceptions import UserError + +_EMPTY_SCHEMA = { + "additionalProperties": False, + "type": "object", + "properties": {}, + "required": [], +} + + +def ensure_strict_json_schema( + schema: dict[str, Any], +) -> dict[str, Any]: + """Mutates the given JSON schema to ensure it conforms to the `strict` standard + that the OpenAI API expects. + """ + if schema == {}: + return _EMPTY_SCHEMA + return _ensure_strict_json_schema(schema, path=(), root=schema) + + +# Adapted from https://github.com/openai/openai-python/blob/main/src/openai/lib/_pydantic.py +def _ensure_strict_json_schema( + json_schema: object, + *, + path: tuple[str, ...], + root: dict[str, object], +) -> dict[str, Any]: + if not is_dict(json_schema): + raise TypeError(f"Expected {json_schema} to be a dictionary; path={path}") + + defs = json_schema.get("$defs") + if is_dict(defs): + for def_name, def_schema in defs.items(): + _ensure_strict_json_schema(def_schema, path=(*path, "$defs", def_name), root=root) + + definitions = json_schema.get("definitions") + if is_dict(definitions): + for definition_name, definition_schema in definitions.items(): + _ensure_strict_json_schema( + definition_schema, path=(*path, "definitions", definition_name), root=root + ) + + typ = json_schema.get("type") + if typ == "object" and "additionalProperties" not in json_schema: + json_schema["additionalProperties"] = False + elif ( + typ == "object" + and "additionalProperties" in json_schema + and json_schema["additionalProperties"] is True + ): + raise UserError( + "additionalProperties should not be set for object types. This could be because " + "you're using an older version of Pydantic, or because you configured additional " + "properties to be allowed. If you really need this, update the function or output tool " + "to not use a strict schema." + ) + + # object types + # { 'type': 'object', 'properties': { 'a': {...} } } + properties = json_schema.get("properties") + if is_dict(properties): + json_schema["required"] = list(properties.keys()) + json_schema["properties"] = { + key: _ensure_strict_json_schema(prop_schema, path=(*path, "properties", key), root=root) + for key, prop_schema in properties.items() + } + + # arrays + # { 'type': 'array', 'items': {...} } + items = json_schema.get("items") + if is_dict(items): + json_schema["items"] = _ensure_strict_json_schema(items, path=(*path, "items"), root=root) + + # unions + any_of = json_schema.get("anyOf") + if is_list(any_of): + json_schema["anyOf"] = [ + _ensure_strict_json_schema(variant, path=(*path, "anyOf", str(i)), root=root) + for i, variant in enumerate(any_of) + ] + + # intersections + all_of = json_schema.get("allOf") + if is_list(all_of): + if len(all_of) == 1: + json_schema.update( + _ensure_strict_json_schema(all_of[0], path=(*path, "allOf", "0"), root=root) + ) + json_schema.pop("allOf") + else: + json_schema["allOf"] = [ + _ensure_strict_json_schema(entry, path=(*path, "allOf", str(i)), root=root) + for i, entry in enumerate(all_of) + ] + + # strip `None` defaults as there's no meaningful distinction here + # the schema will still be `nullable` and the model will default + # to using `None` anyway + if json_schema.get("default", NOT_GIVEN) is None: + json_schema.pop("default") + + # we can't use `$ref`s if there are also other properties defined, e.g. + # `{"$ref": "...", "description": "my description"}` + # + # so we unravel the ref + # `{"type": "string", "description": "my description"}` + ref = json_schema.get("$ref") + if ref and has_more_than_n_keys(json_schema, 1): + assert isinstance(ref, str), f"Received non-string $ref - {ref}" + + resolved = resolve_ref(root=root, ref=ref) + if not is_dict(resolved): + raise ValueError( + f"Expected `$ref: {ref}` to resolved to a dictionary but got {resolved}" + ) + + # properties from the json schema take priority over the ones on the `$ref` + json_schema.update({**resolved, **json_schema}) + json_schema.pop("$ref") + # Since the schema expanded from `$ref` might not have `additionalProperties: false` applied + # we call `_ensure_strict_json_schema` again to fix the inlined schema and ensure it's valid + return _ensure_strict_json_schema(json_schema, path=path, root=root) + + return json_schema + + +def resolve_ref(*, root: dict[str, object], ref: str) -> object: + if not ref.startswith("#/"): + raise ValueError(f"Unexpected $ref format {ref!r}; Does not start with #/") + + path = ref[2:].split("/") + resolved = root + for key in path: + value = resolved[key] + assert is_dict(value), ( + f"encountered non-dictionary entry while resolving {ref} - {resolved}" + ) + resolved = value + + return resolved + + +def is_dict(obj: object) -> TypeGuard[dict[str, object]]: + # just pretend that we know there are only `str` keys + # as that check is not worth the performance cost + return isinstance(obj, dict) + + +def is_list(obj: object) -> TypeGuard[list[object]]: + return isinstance(obj, list) + + +def has_more_than_n_keys(obj: dict[str, object], n: int) -> bool: + i = 0 + for _ in obj.keys(): + i += 1 + if i > n: + return True + return False diff --git a/src/agents/tool.py b/src/agents/tool.py new file mode 100644 index 0000000..7587268 --- /dev/null +++ b/src/agents/tool.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import inspect +import json +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import Any, Callable, Literal, Union, overload + +from openai.types.responses.file_search_tool_param import Filters, RankingOptions +from openai.types.responses.web_search_tool_param import UserLocation +from pydantic import ValidationError +from typing_extensions import Concatenate, ParamSpec + +from . import _debug, _utils +from ._utils import MaybeAwaitable +from .computer import AsyncComputer, Computer +from .exceptions import ModelBehaviorError +from .function_schema import DocstringStyle, function_schema +from .logger import logger +from .run_context import RunContextWrapper +from .tracing import SpanError + +ToolParams = ParamSpec("ToolParams") + +ToolFunctionWithoutContext = Callable[ToolParams, Any] +ToolFunctionWithContext = Callable[Concatenate[RunContextWrapper[Any], ToolParams], Any] + +ToolFunction = Union[ToolFunctionWithoutContext[ToolParams], ToolFunctionWithContext[ToolParams]] + + +@dataclass +class FunctionTool: + """A tool that wraps a function. In most cases, you should use the `function_tool` helpers to + create a FunctionTool, as they let you easily wrap a Python function. + """ + + name: str + """The name of the tool, as shown to the LLM. Generally the name of the function.""" + + description: str + """A description of the tool, as shown to the LLM.""" + + params_json_schema: dict[str, Any] + """The JSON schema for the tool's parameters.""" + + on_invoke_tool: Callable[[RunContextWrapper[Any], str], Awaitable[str]] + """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). + """ + + strict_json_schema: bool = True + """Whether the JSON schema is in strict mode. We **strongly** recommend setting this to True, + as it increases the likelihood of correct JSON input.""" + + +@dataclass +class FileSearchTool: + """A hosted tool that lets the LLM search through a vector store. Currently only supported with + OpenAI models, using the Responses API. + """ + + vector_store_ids: list[str] + """The IDs of the vector stores to search.""" + + max_num_results: int | None = None + """The maximum number of results to return.""" + + include_search_results: bool = False + """Whether to include the search results in the output produced by the LLM.""" + + ranking_options: RankingOptions | None = None + """Ranking options for search.""" + + filters: Filters | None = None + """A filter to apply based on file attributes.""" + + @property + def name(self): + return "file_search" + + +@dataclass +class WebSearchTool: + """A hosted tool that lets the LLM search the web. Currently only supported with OpenAI models, + using the Responses API. + """ + + user_location: UserLocation | None = None + """Optional location for the search. Lets you customize results to be relevant to a location.""" + + search_context_size: Literal["low", "medium", "high"] = "medium" + """The amount of context to use for the search.""" + + @property + def name(self): + return "web_search_preview" + + +@dataclass +class ComputerTool: + """A hosted tool that lets the LLM control a computer.""" + + computer: Computer | AsyncComputer + """The computer implementation, which describes the environment and dimensions of the computer, + as well as implements the computer actions like click, screenshot, etc. + """ + + @property + def name(self): + return "computer_use_preview" + + +Tool = Union[FunctionTool, FileSearchTool, WebSearchTool, ComputerTool] +"""A tool that can be used in an agent.""" + + +def default_tool_error_function(ctx: RunContextWrapper[Any], error: Exception) -> str: + """The default tool error function, which just returns a generic error message.""" + return f"An error occurred while running the tool. Please try again. Error: {str(error)}" + + +ToolErrorFunction = Callable[[RunContextWrapper[Any], Exception], MaybeAwaitable[str]] + + +@overload +def function_tool( + func: ToolFunction[...], + *, + name_override: str | None = None, + description_override: str | None = None, + docstring_style: DocstringStyle | None = None, + use_docstring_info: bool = True, + failure_error_function: ToolErrorFunction | None = None, +) -> FunctionTool: + """Overload for usage as @function_tool (no parentheses).""" + ... + + +@overload +def function_tool( + *, + name_override: str | None = None, + description_override: str | None = None, + docstring_style: DocstringStyle | None = None, + use_docstring_info: bool = True, + failure_error_function: ToolErrorFunction | None = None, +) -> Callable[[ToolFunction[...]], FunctionTool]: + """Overload for usage as @function_tool(...).""" + ... + + +def function_tool( + func: ToolFunction[...] | None = None, + *, + name_override: str | None = None, + description_override: str | None = None, + docstring_style: DocstringStyle | None = None, + use_docstring_info: bool = True, + failure_error_function: ToolErrorFunction | None = default_tool_error_function, +) -> FunctionTool | Callable[[ToolFunction[...]], FunctionTool]: + """ + Decorator to create a FunctionTool from a function. By default, we will: + 1. Parse the function signature to create a JSON schema for the tool's parameters. + 2. Use the function's docstring to populate the tool's description. + 3. Use the function's docstring to populate argument descriptions. + The docstring style is detected automatically, but you can override it. + + If the function takes a `RunContextWrapper` as the first argument, it *must* match the + context type of the agent that uses the tool. + + Args: + func: The function to wrap. + name_override: If provided, use this name for the tool instead of the function's name. + description_override: If provided, use this description for the tool instead of the + function's docstring. + docstring_style: If provided, use this style for the tool's docstring. If not provided, + we will attempt to auto-detect the style. + use_docstring_info: If True, use the function's docstring to populate the tool's + description and argument descriptions. + 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. + """ + + def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: + schema = function_schema( + func=the_func, + name_override=name_override, + description_override=description_override, + docstring_style=docstring_style, + use_docstring_info=use_docstring_info, + ) + + async def _on_invoke_tool_impl(ctx: RunContextWrapper[Any], input: str) -> str: + try: + json_data: dict[str, Any] = json.loads(input) if input else {} + except Exception as e: + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invalid JSON input for tool {schema.name}") + else: + logger.debug(f"Invalid JSON input for tool {schema.name}: {input}") + raise ModelBehaviorError( + f"Invalid JSON input for tool {schema.name}: {input}" + ) from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invoking tool {schema.name}") + else: + logger.debug(f"Invoking tool {schema.name} with input {input}") + + try: + parsed = ( + schema.params_pydantic_model(**json_data) + if json_data + else schema.params_pydantic_model() + ) + except ValidationError as e: + raise ModelBehaviorError(f"Invalid JSON input for tool {schema.name}: {e}") from e + + args, kwargs_dict = schema.to_call_args(parsed) + + if not _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Tool call args: {args}, kwargs: {kwargs_dict}") + + if inspect.iscoroutinefunction(the_func): + if schema.takes_context: + result = await the_func(ctx, *args, **kwargs_dict) + else: + result = await the_func(*args, **kwargs_dict) + else: + if schema.takes_context: + result = the_func(ctx, *args, **kwargs_dict) + else: + result = the_func(*args, **kwargs_dict) + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Tool {schema.name} completed.") + else: + logger.debug(f"Tool {schema.name} returned {result}") + + return str(result) + + async def _on_invoke_tool(ctx: RunContextWrapper[Any], input: str) -> str: + try: + return await _on_invoke_tool_impl(ctx, input) + except Exception as e: + if failure_error_function is None: + raise + + result = failure_error_function(ctx, e) + if inspect.isawaitable(result): + return await result + + _utils.attach_error_to_current_span( + SpanError( + message="Error running tool (non-fatal)", + data={ + "tool_name": schema.name, + "error": str(e), + }, + ) + ) + return result + + return FunctionTool( + name=schema.name, + description=schema.description or "", + params_json_schema=schema.params_json_schema, + on_invoke_tool=_on_invoke_tool, + ) + + # If func is actually a callable, we were used as @function_tool with no parentheses + if callable(func): + return _create_function_tool(func) + + # Otherwise, we were used as @function_tool(...), so return a decorator + def decorator(real_func: ToolFunction[...]) -> FunctionTool: + return _create_function_tool(real_func) + + return decorator diff --git a/src/agents/tracing/__init__.py b/src/agents/tracing/__init__.py new file mode 100644 index 0000000..8e80201 --- /dev/null +++ b/src/agents/tracing/__init__.py @@ -0,0 +1,97 @@ +import atexit + +from .create import ( + agent_span, + custom_span, + function_span, + generation_span, + get_current_span, + get_current_trace, + guardrail_span, + handoff_span, + response_span, + trace, +) +from .processor_interface import TracingProcessor +from .processors import default_exporter, default_processor +from .setup import GLOBAL_TRACE_PROVIDER +from .span_data import ( + AgentSpanData, + CustomSpanData, + FunctionSpanData, + GenerationSpanData, + GuardrailSpanData, + HandoffSpanData, + ResponseSpanData, + SpanData, +) +from .spans import Span, SpanError +from .traces import Trace +from .util import gen_span_id, gen_trace_id + +__all__ = [ + "add_trace_processor", + "agent_span", + "custom_span", + "function_span", + "generation_span", + "get_current_span", + "get_current_trace", + "guardrail_span", + "handoff_span", + "response_span", + "set_trace_processors", + "set_tracing_disabled", + "trace", + "Trace", + "SpanError", + "Span", + "SpanData", + "AgentSpanData", + "CustomSpanData", + "FunctionSpanData", + "GenerationSpanData", + "GuardrailSpanData", + "HandoffSpanData", + "ResponseSpanData", + "TracingProcessor", + "gen_trace_id", + "gen_span_id", +] + + +def add_trace_processor(span_processor: TracingProcessor) -> None: + """ + Adds a new trace processor. This processor will receive all traces/spans. + """ + GLOBAL_TRACE_PROVIDER.register_processor(span_processor) + + +def set_trace_processors(processors: list[TracingProcessor]) -> None: + """ + Set the list of trace processors. This will replace the current list of processors. + """ + GLOBAL_TRACE_PROVIDER.set_processors(processors) + + +def set_tracing_disabled(disabled: bool) -> None: + """ + Set whether tracing is globally disabled. + """ + GLOBAL_TRACE_PROVIDER.set_disabled(disabled) + + +def set_tracing_export_api_key(api_key: str) -> None: + """ + Set the OpenAI API key for the backend exporter. + """ + default_exporter().set_api_key(api_key) + + +# Add the default processor, which exports traces and spans to the backend in batches. You can +# change the default behavior by either: +# 1. calling add_trace_processor(), which adds additional processors, or +# 2. calling set_trace_processors(), which replaces the default processor. +add_trace_processor(default_processor()) + +atexit.register(GLOBAL_TRACE_PROVIDER.shutdown) diff --git a/src/agents/tracing/__pycache__/__init__.cpython-311.pyc b/src/agents/tracing/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..8d78e76 Binary files /dev/null and b/src/agents/tracing/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/__init__.cpython-313.pyc b/src/agents/tracing/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..96425e4 Binary files /dev/null and b/src/agents/tracing/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/__init__.cpython-39.pyc b/src/agents/tracing/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..63b233a Binary files /dev/null and b/src/agents/tracing/__pycache__/__init__.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/_strict_schema.cpython-313.pyc b/src/agents/tracing/__pycache__/_strict_schema.cpython-313.pyc new file mode 100644 index 0000000..79c5357 Binary files /dev/null and b/src/agents/tracing/__pycache__/_strict_schema.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/create.cpython-311.pyc b/src/agents/tracing/__pycache__/create.cpython-311.pyc new file mode 100644 index 0000000..62b33cf Binary files /dev/null and b/src/agents/tracing/__pycache__/create.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/create.cpython-313.pyc b/src/agents/tracing/__pycache__/create.cpython-313.pyc new file mode 100644 index 0000000..efa27d1 Binary files /dev/null and b/src/agents/tracing/__pycache__/create.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/create.cpython-39.pyc b/src/agents/tracing/__pycache__/create.cpython-39.pyc new file mode 100644 index 0000000..027a1e6 Binary files /dev/null and b/src/agents/tracing/__pycache__/create.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/logger.cpython-311.pyc b/src/agents/tracing/__pycache__/logger.cpython-311.pyc new file mode 100644 index 0000000..07d2503 Binary files /dev/null and b/src/agents/tracing/__pycache__/logger.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/logger.cpython-313.pyc b/src/agents/tracing/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000..17e3c74 Binary files /dev/null and b/src/agents/tracing/__pycache__/logger.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/logger.cpython-39.pyc b/src/agents/tracing/__pycache__/logger.cpython-39.pyc new file mode 100644 index 0000000..756e590 Binary files /dev/null and b/src/agents/tracing/__pycache__/logger.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/processor_interface.cpython-311.pyc b/src/agents/tracing/__pycache__/processor_interface.cpython-311.pyc new file mode 100644 index 0000000..5ea4cfd Binary files /dev/null and b/src/agents/tracing/__pycache__/processor_interface.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/processor_interface.cpython-313.pyc b/src/agents/tracing/__pycache__/processor_interface.cpython-313.pyc new file mode 100644 index 0000000..7f216bc Binary files /dev/null and b/src/agents/tracing/__pycache__/processor_interface.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/processor_interface.cpython-39.pyc b/src/agents/tracing/__pycache__/processor_interface.cpython-39.pyc new file mode 100644 index 0000000..9360e43 Binary files /dev/null and b/src/agents/tracing/__pycache__/processor_interface.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/processors.cpython-311.pyc b/src/agents/tracing/__pycache__/processors.cpython-311.pyc new file mode 100644 index 0000000..719f468 Binary files /dev/null and b/src/agents/tracing/__pycache__/processors.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/processors.cpython-313.pyc b/src/agents/tracing/__pycache__/processors.cpython-313.pyc new file mode 100644 index 0000000..da8a566 Binary files /dev/null and b/src/agents/tracing/__pycache__/processors.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/processors.cpython-39.pyc b/src/agents/tracing/__pycache__/processors.cpython-39.pyc new file mode 100644 index 0000000..ce8f3fa Binary files /dev/null and b/src/agents/tracing/__pycache__/processors.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/scope.cpython-311.pyc b/src/agents/tracing/__pycache__/scope.cpython-311.pyc new file mode 100644 index 0000000..ca52084 Binary files /dev/null and b/src/agents/tracing/__pycache__/scope.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/scope.cpython-313.pyc b/src/agents/tracing/__pycache__/scope.cpython-313.pyc new file mode 100644 index 0000000..017851d Binary files /dev/null and b/src/agents/tracing/__pycache__/scope.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/scope.cpython-39.pyc b/src/agents/tracing/__pycache__/scope.cpython-39.pyc new file mode 100644 index 0000000..1f28b19 Binary files /dev/null and b/src/agents/tracing/__pycache__/scope.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/setup.cpython-311.pyc b/src/agents/tracing/__pycache__/setup.cpython-311.pyc new file mode 100644 index 0000000..3aed1e6 Binary files /dev/null and b/src/agents/tracing/__pycache__/setup.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/setup.cpython-313.pyc b/src/agents/tracing/__pycache__/setup.cpython-313.pyc new file mode 100644 index 0000000..1d3aac1 Binary files /dev/null and b/src/agents/tracing/__pycache__/setup.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/setup.cpython-39.pyc b/src/agents/tracing/__pycache__/setup.cpython-39.pyc new file mode 100644 index 0000000..baf1700 Binary files /dev/null and b/src/agents/tracing/__pycache__/setup.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/span_data.cpython-311.pyc b/src/agents/tracing/__pycache__/span_data.cpython-311.pyc new file mode 100644 index 0000000..0cc7384 Binary files /dev/null and b/src/agents/tracing/__pycache__/span_data.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/span_data.cpython-313.pyc b/src/agents/tracing/__pycache__/span_data.cpython-313.pyc new file mode 100644 index 0000000..127efae Binary files /dev/null and b/src/agents/tracing/__pycache__/span_data.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/span_data.cpython-39.pyc b/src/agents/tracing/__pycache__/span_data.cpython-39.pyc new file mode 100644 index 0000000..6032252 Binary files /dev/null and b/src/agents/tracing/__pycache__/span_data.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/spans.cpython-311.pyc b/src/agents/tracing/__pycache__/spans.cpython-311.pyc new file mode 100644 index 0000000..156ba23 Binary files /dev/null and b/src/agents/tracing/__pycache__/spans.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/spans.cpython-313.pyc b/src/agents/tracing/__pycache__/spans.cpython-313.pyc new file mode 100644 index 0000000..3a565c0 Binary files /dev/null and b/src/agents/tracing/__pycache__/spans.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/spans.cpython-39.pyc b/src/agents/tracing/__pycache__/spans.cpython-39.pyc new file mode 100644 index 0000000..fdf2f6e Binary files /dev/null and b/src/agents/tracing/__pycache__/spans.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/traces.cpython-311.pyc b/src/agents/tracing/__pycache__/traces.cpython-311.pyc new file mode 100644 index 0000000..3f45b35 Binary files /dev/null and b/src/agents/tracing/__pycache__/traces.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/traces.cpython-313.pyc b/src/agents/tracing/__pycache__/traces.cpython-313.pyc new file mode 100644 index 0000000..84f817d Binary files /dev/null and b/src/agents/tracing/__pycache__/traces.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/traces.cpython-39.pyc b/src/agents/tracing/__pycache__/traces.cpython-39.pyc new file mode 100644 index 0000000..7aae52b Binary files /dev/null and b/src/agents/tracing/__pycache__/traces.cpython-39.pyc differ diff --git a/src/agents/tracing/__pycache__/util.cpython-311.pyc b/src/agents/tracing/__pycache__/util.cpython-311.pyc new file mode 100644 index 0000000..70a74e9 Binary files /dev/null and b/src/agents/tracing/__pycache__/util.cpython-311.pyc differ diff --git a/src/agents/tracing/__pycache__/util.cpython-313.pyc b/src/agents/tracing/__pycache__/util.cpython-313.pyc new file mode 100644 index 0000000..ebc9166 Binary files /dev/null and b/src/agents/tracing/__pycache__/util.cpython-313.pyc differ diff --git a/src/agents/tracing/__pycache__/util.cpython-39.pyc b/src/agents/tracing/__pycache__/util.cpython-39.pyc new file mode 100644 index 0000000..a81c2d1 Binary files /dev/null and b/src/agents/tracing/__pycache__/util.cpython-39.pyc differ diff --git a/src/agents/tracing/create.py b/src/agents/tracing/create.py new file mode 100644 index 0000000..8d7fc49 --- /dev/null +++ b/src/agents/tracing/create.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any + +from .logger import logger +from .setup import GLOBAL_TRACE_PROVIDER +from .span_data import ( + AgentSpanData, + CustomSpanData, + FunctionSpanData, + GenerationSpanData, + GuardrailSpanData, + HandoffSpanData, + ResponseSpanData, +) +from .spans import Span +from .traces import Trace + +if TYPE_CHECKING: + from openai.types.responses import Response + + +def trace( + workflow_name: str, + trace_id: str | None = None, + group_id: str | None = None, + metadata: dict[str, Any] | None = None, + disabled: bool = False, +) -> Trace: + """ + Create a new trace. The trace will not be started automatically; you should either use + it as a context manager (`with trace(...):`) or call `trace.start()` + `trace.finish()` + manually. + + In addition to the workflow name and optional grouping identifier, you can provide + an arbitrary metadata dictionary to attach additional user-defined information to + the trace. + + Args: + workflow_name: The name of the logical app or workflow. For example, you might provide + "code_bot" for a coding agent, or "customer_support_agent" for a customer support agent. + trace_id: The ID of the trace. Optional. If not provided, we will generate an ID. We + recommend using `util.gen_trace_id()` to generate a trace ID, to guarantee that IDs are + correctly formatted. + group_id: Optional grouping identifier to link multiple traces from the same conversation + or process. For instance, you might use a chat thread ID. + metadata: Optional dictionary of additional metadata to attach to the trace. + disabled: If True, we will return a Trace but the Trace will not be recorded. This will + not be checked if there's an existing trace and `even_if_trace_running` is True. + + Returns: + The newly created trace object. + """ + current_trace = GLOBAL_TRACE_PROVIDER.get_current_trace() + if current_trace: + logger.warning( + "Trace already exists. Creating a new trace, but this is probably a mistake." + ) + + return GLOBAL_TRACE_PROVIDER.create_trace( + name=workflow_name, + trace_id=trace_id, + group_id=group_id, + metadata=metadata, + disabled=disabled, + ) + + +def get_current_trace() -> Trace | None: + """Returns the currently active trace, if present.""" + return GLOBAL_TRACE_PROVIDER.get_current_trace() + + +def get_current_span() -> Span[Any] | None: + """Returns the currently active span, if present.""" + return GLOBAL_TRACE_PROVIDER.get_current_span() + + +def agent_span( + name: str, + handoffs: list[str] | None = None, + tools: list[str] | None = None, + output_type: str | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[AgentSpanData]: + """Create a new agent span. The span will not be started automatically, you should either do + `with agent_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + name: The name of the agent. + handoffs: Optional list of agent names to which this agent could hand off control. + tools: Optional list of tool names available to this agent. + output_type: Optional name of the output type produced by the agent. + 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 agent span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=AgentSpanData(name=name, handoffs=handoffs, tools=tools, output_type=output_type), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def function_span( + name: str, + input: str | None = None, + output: str | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[FunctionSpanData]: + """Create a new function span. The span will not be started automatically, you should either do + `with function_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + name: The name of the function. + input: The input to the function. + output: The output of the function. + 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 function span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=FunctionSpanData(name=name, input=input, output=output), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def generation_span( + input: Sequence[Mapping[str, Any]] | None = None, + output: Sequence[Mapping[str, Any]] | None = None, + model: str | None = None, + model_config: Mapping[str, Any] | None = None, + usage: dict[str, Any] | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[GenerationSpanData]: + """Create a new generation span. The span will not be started automatically, you should either + do `with generation_span() ...` or call `span.start()` + `span.finish()` manually. + + This span captures the details of a model generation, including the + input message sequence, any generated outputs, the model name and + configuration, and usage data. If you only need to capture a model + response identifier, use `response_span()` instead. + + Args: + input: The sequence of input messages sent to the model. + output: The sequence of output messages received from the model. + model: The model identifier used for the generation. + model_config: The model configuration (hyperparameters) used. + usage: A dictionary of usage information (input tokens, output tokens, etc.). + 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 generation span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=GenerationSpanData( + input=input, output=output, model=model, model_config=model_config, usage=usage + ), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def response_span( + response: Response | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[ResponseSpanData]: + """Create a new response span. The span will not be started automatically, you should either do + `with response_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + response: The OpenAI Response object. + 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=ResponseSpanData(response=response), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def handoff_span( + from_agent: str | None = None, + to_agent: str | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[HandoffSpanData]: + """Create a new handoff span. The span will not be started automatically, you should either do + `with handoff_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + from_agent: The name of the agent that is handing off. + to_agent: The name of the agent that is receiving the handoff. + 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 handoff span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=HandoffSpanData(from_agent=from_agent, to_agent=to_agent), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def custom_span( + name: str, + data: dict[str, Any] | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[CustomSpanData]: + """Create a new custom span, to which you can add your own metadata. The span will not be + started automatically, you should either do `with custom_span() ...` or call + `span.start()` + `span.finish()` manually. + + Args: + name: The name of the custom span. + data: Arbitrary structured data to associate with the span. + 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 custom span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=CustomSpanData(name=name, data=data or {}), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def guardrail_span( + name: str, + triggered: bool = False, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[GuardrailSpanData]: + """Create a new guardrail span. The span will not be started automatically, you should either + do `with guardrail_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + name: The name of the guardrail. + triggered: Whether the guardrail was triggered. + 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=GuardrailSpanData(name=name, triggered=triggered), + span_id=span_id, + parent=parent, + disabled=disabled, + ) diff --git a/src/agents/tracing/logger.py b/src/agents/tracing/logger.py new file mode 100644 index 0000000..661d09b --- /dev/null +++ b/src/agents/tracing/logger.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger("openai.agents.tracing") diff --git a/src/agents/tracing/processor_interface.py b/src/agents/tracing/processor_interface.py new file mode 100644 index 0000000..4dcd897 --- /dev/null +++ b/src/agents/tracing/processor_interface.py @@ -0,0 +1,69 @@ +import abc +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .spans import Span + from .traces import Trace + + +class TracingProcessor(abc.ABC): + """Interface for processing spans.""" + + @abc.abstractmethod + def on_trace_start(self, trace: "Trace") -> None: + """Called when a trace is started. + + Args: + trace: The trace that started. + """ + pass + + @abc.abstractmethod + def on_trace_end(self, trace: "Trace") -> None: + """Called when a trace is finished. + + Args: + trace: The trace that started. + """ + pass + + @abc.abstractmethod + def on_span_start(self, span: "Span[Any]") -> None: + """Called when a span is started. + + Args: + span: The span that started. + """ + pass + + @abc.abstractmethod + def on_span_end(self, span: "Span[Any]") -> None: + """Called when a span is finished. Should not block or raise exceptions. + + Args: + span: The span that finished. + """ + pass + + @abc.abstractmethod + def shutdown(self) -> None: + """Called when the application stops.""" + pass + + @abc.abstractmethod + def force_flush(self) -> None: + """Forces an immediate flush of all queued spans/traces.""" + pass + + +class TracingExporter(abc.ABC): + """Exports traces and spans. For example, could log them or send them to a backend.""" + + @abc.abstractmethod + def export(self, items: list["Trace | Span[Any]"]) -> None: + """Exports a list of traces and spans. + + Args: + items: The items to export. + """ + pass diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py new file mode 100644 index 0000000..282bc23 --- /dev/null +++ b/src/agents/tracing/processors.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import os +import queue +import random +import threading +import time +from typing import Any + +import httpx + +from .logger import logger +from .processor_interface import TracingExporter, TracingProcessor +from .spans import Span +from .traces import Trace + + +class ConsoleSpanExporter(TracingExporter): + """Prints the traces and spans to the console.""" + + def export(self, items: list[Trace | Span[Any]]) -> None: + for item in items: + if isinstance(item, Trace): + print(f"[Exporter] Export trace_id={item.trace_id}, name={item.name}, ") + else: + print(f"[Exporter] Export span: {item.export()}") + + +class BackendSpanExporter(TracingExporter): + def __init__( + self, + api_key: str | None = None, + organization: str | None = None, + project: str | None = None, + endpoint: str = "https://api.openai.com/v1/traces/ingest", + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 30.0, + ): + """ + Args: + api_key: The API key for the "Authorization" header. Defaults to + `os.environ["OPENAI_TRACE_API_KEY"]` if not provided. + organization: The OpenAI organization to use. Defaults to + `os.environ["OPENAI_ORG_ID"]` if not provided. + project: The OpenAI project to use. Defaults to + `os.environ["OPENAI_PROJECT_ID"]` if not provided. + endpoint: The HTTP endpoint to which traces/spans are posted. + max_retries: Maximum number of retries upon failures. + 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.endpoint = endpoint + self.max_retries = max_retries + self.base_delay = base_delay + self.max_delay = max_delay + + # Keep a client open for connection pooling across multiple export calls + self._client = httpx.Client(timeout=httpx.Timeout(timeout=60, connect=5.0)) + + def set_api_key(self, api_key: str): + """Set the OpenAI API key for the exporter. + + Args: + api_key: The OpenAI API key to use. This is the same key used by the OpenAI Python + client. + """ + self.api_key = api_key + + def export(self, items: list[Trace | Span[Any]]) -> None: + if not items: + return + + if not self.api_key: + logger.warning("OPENAI_API_KEY is not set, skipping trace export") + return + + traces: list[dict[str, Any]] = [] + spans: list[dict[str, Any]] = [] + + data = [item.export() for item in items if item.export()] + payload = {"data": data} + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "OpenAI-Beta": "traces=v1", + } + + # Exponential backoff loop + attempt = 0 + delay = self.base_delay + while True: + attempt += 1 + try: + response = self._client.post(url=self.endpoint, headers=headers, json=payload) + + # If the response is successful, break out of the loop + if response.status_code < 300: + logger.debug(f"Exported {len(traces)} traces, {len(spans)} spans") + return + + # 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}") + return + + # For 5xx or other unexpected codes, treat it as transient and retry + logger.warning(f"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}") + + # 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.") + return + + # Exponential backoff + jitter + sleep_time = delay + random.uniform(0, 0.1 * delay) # 10% jitter + time.sleep(sleep_time) + delay = min(delay * 2, self.max_delay) + + def close(self): + """Close the underlying HTTP client.""" + self._client.close() + + +class BatchTraceProcessor(TracingProcessor): + """Some implementation notes: + 1. Using Queue, which is thread-safe. + 2. Using a background thread to export spans, to minimize any performance issues. + 3. Spans are stored in memory until they are exported. + """ + + def __init__( + self, + exporter: TracingExporter, + max_queue_size: int = 8192, + max_batch_size: int = 128, + schedule_delay: float = 5.0, + export_trigger_ratio: float = 0.7, + ): + """ + Args: + exporter: The exporter to use. + max_queue_size: The maximum number of spans to store in the queue. After this, we will + start dropping spans. + max_batch_size: The maximum number of spans to export in a single batch. + schedule_delay: The delay between checks for new spans to export. + export_trigger_ratio: The ratio of the queue size at which we will trigger an export. + """ + self._exporter = exporter + self._queue: queue.Queue[Trace | Span[Any]] = queue.Queue(maxsize=max_queue_size) + self._max_queue_size = max_queue_size + self._max_batch_size = max_batch_size + self._schedule_delay = schedule_delay + self._shutdown_event = threading.Event() + + # The queue size threshold at which we export immediately. + self._export_trigger_size = int(max_queue_size * export_trigger_ratio) + + # Track when we next *must* perform a scheduled export + self._next_export_time = time.time() + self._schedule_delay + + self._shutdown_event = threading.Event() + self._worker_thread = threading.Thread(target=self._run, daemon=True) + self._worker_thread.start() + + def on_trace_start(self, trace: Trace) -> None: + try: + self._queue.put_nowait(trace) + except queue.Full: + logger.warning("Queue is full, dropping trace.") + + def on_trace_end(self, trace: Trace) -> None: + # We send traces via on_trace_start, so we don't need to do anything here. + pass + + def on_span_start(self, span: Span[Any]) -> None: + # We send spans via on_span_end, so we don't need to do anything here. + pass + + def on_span_end(self, span: Span[Any]) -> None: + try: + self._queue.put_nowait(span) + except queue.Full: + logger.warning("Queue is full, dropping span.") + + def shutdown(self, timeout: float | None = None): + """ + Called when the application stops. We signal our thread to stop, then join it. + """ + self._shutdown_event.set() + self._worker_thread.join(timeout=timeout) + + def force_flush(self): + """ + Forces an immediate flush of all queued spans. + """ + self._export_batches(force=True) + + def _run(self): + while not self._shutdown_event.is_set(): + current_time = time.time() + queue_size = self._queue.qsize() + + # If it's time for a scheduled flush or queue is above the trigger threshold + if current_time >= self._next_export_time or queue_size >= self._export_trigger_size: + self._export_batches(force=False) + # Reset the next scheduled flush time + self._next_export_time = time.time() + self._schedule_delay + else: + # Sleep a short interval so we don't busy-wait. + time.sleep(0.2) + + # Final drain after shutdown + self._export_batches(force=True) + + def _export_batches(self, force: bool = False): + """Drains the queue and exports in batches. If force=True, export everything. + Otherwise, export up to `max_batch_size` repeatedly until the queue is empty or below a + certain threshold. + """ + while True: + items_to_export: list[Span[Any] | Trace] = [] + + # Gather a batch of spans up to max_batch_size + while not self._queue.empty() and ( + force or len(items_to_export) < self._max_batch_size + ): + try: + items_to_export.append(self._queue.get_nowait()) + except queue.Empty: + # Another thread might have emptied the queue between checks + break + + # If we collected nothing, we're done + if not items_to_export: + break + + # Export the batch + self._exporter.export(items_to_export) + + +# Create a shared global instance: +_global_exporter = BackendSpanExporter() +_global_processor = BatchTraceProcessor(_global_exporter) + + +def default_exporter() -> BackendSpanExporter: + """The default exporter, which exports traces and spans to the backend in batches.""" + return _global_exporter + + +def default_processor() -> BatchTraceProcessor: + """The default processor, which exports traces and spans to the backend in batches.""" + return _global_processor diff --git a/src/agents/tracing/scope.py b/src/agents/tracing/scope.py new file mode 100644 index 0000000..9ccd9f8 --- /dev/null +++ b/src/agents/tracing/scope.py @@ -0,0 +1,45 @@ +# Holds the current active span +import contextvars +from typing import TYPE_CHECKING, Any + +from .logger import logger + +if TYPE_CHECKING: + from .spans import Span + from .traces import Trace + +_current_span: contextvars.ContextVar["Span[Any] | None"] = contextvars.ContextVar( + "current_span", default=None +) + +_current_trace: contextvars.ContextVar["Trace | None"] = contextvars.ContextVar( + "current_trace", default=None +) + + +class Scope: + @classmethod + def get_current_span(cls) -> "Span[Any] | None": + return _current_span.get() + + @classmethod + def set_current_span(cls, span: "Span[Any] | None") -> "contextvars.Token[Span[Any] | None]": + return _current_span.set(span) + + @classmethod + def reset_current_span(cls, token: "contextvars.Token[Span[Any] | None]") -> None: + _current_span.reset(token) + + @classmethod + def get_current_trace(cls) -> "Trace | None": + return _current_trace.get() + + @classmethod + def set_current_trace(cls, trace: "Trace | None") -> "contextvars.Token[Trace | None]": + logger.debug(f"Setting current trace: {trace.trace_id if trace else None}") + return _current_trace.set(trace) + + @classmethod + def reset_current_trace(cls, token: "contextvars.Token[Trace | None]") -> None: + logger.debug("Resetting current trace") + _current_trace.reset(token) diff --git a/src/agents/tracing/setup.py b/src/agents/tracing/setup.py new file mode 100644 index 0000000..bc340c9 --- /dev/null +++ b/src/agents/tracing/setup.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import os +import threading +from typing import Any + +from . import util +from .logger import logger +from .processor_interface import TracingProcessor +from .scope import Scope +from .spans import NoOpSpan, Span, SpanImpl, TSpanData +from .traces import NoOpTrace, Trace, TraceImpl + + +class SynchronousMultiTracingProcessor(TracingProcessor): + """ + Forwards all calls to a list of TracingProcessors, in order of registration. + """ + + def __init__(self): + # Using a tuple to avoid race conditions when iterating over processors + self._processors: tuple[TracingProcessor, ...] = () + self._lock = threading.Lock() + + def add_tracing_processor(self, tracing_processor: TracingProcessor): + """ + Add a processor to the list of processors. Each processor will receive all traces/spans. + """ + with self._lock: + self._processors += (tracing_processor,) + + def set_processors(self, processors: list[TracingProcessor]): + """ + Set the list of processors. This will replace the current list of processors. + """ + with self._lock: + self._processors = tuple(processors) + + def on_trace_start(self, trace: Trace) -> None: + """ + Called when a trace is started. + """ + for processor in self._processors: + processor.on_trace_start(trace) + + def on_trace_end(self, trace: Trace) -> None: + """ + Called when a trace is finished. + """ + for processor in self._processors: + processor.on_trace_end(trace) + + def on_span_start(self, span: Span[Any]) -> None: + """ + Called when a span is started. + """ + for processor in self._processors: + processor.on_span_start(span) + + def on_span_end(self, span: Span[Any]) -> None: + """ + Called when a span is finished. + """ + for processor in self._processors: + processor.on_span_end(span) + + def shutdown(self) -> None: + """ + Called when the application stops. + """ + for processor in self._processors: + logger.debug(f"Shutting down trace processor {processor}") + processor.shutdown() + + def force_flush(self): + """ + Force the processors to flush their buffers. + """ + for processor in self._processors: + processor.force_flush() + + +class TraceProvider: + def __init__(self): + self._multi_processor = SynchronousMultiTracingProcessor() + self._disabled = os.environ.get("OPENAI_AGENTS_DISABLE_TRACING", "false").lower() in ( + "true", + "1", + ) + + def register_processor(self, processor: TracingProcessor): + """ + Add a processor to the list of processors. Each processor will receive all traces/spans. + """ + self._multi_processor.add_tracing_processor(processor) + + def set_processors(self, processors: list[TracingProcessor]): + """ + Set the list of processors. This will replace the current list of processors. + """ + self._multi_processor.set_processors(processors) + + def get_current_trace(self) -> Trace | None: + """ + Returns the currently active trace, if any. + """ + return Scope.get_current_trace() + + def get_current_span(self) -> Span[Any] | None: + """ + Returns the currently active span, if any. + """ + return Scope.get_current_span() + + def set_disabled(self, disabled: bool) -> None: + """ + Set whether tracing is disabled. + """ + self._disabled = disabled + + def create_trace( + self, + name: str, + trace_id: str | None = None, + group_id: str | None = None, + metadata: dict[str, Any] | None = None, + disabled: bool = False, + ) -> Trace: + """ + Create a new trace. + """ + if self._disabled or disabled: + logger.debug(f"Tracing is disabled. Not creating trace {name}") + return NoOpTrace() + + trace_id = trace_id or util.gen_trace_id() + + logger.debug(f"Creating trace {name} with id {trace_id}") + + return TraceImpl( + name=name, + trace_id=trace_id, + group_id=group_id, + metadata=metadata, + processor=self._multi_processor, + ) + + def create_span( + self, + span_data: TSpanData, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, + ) -> Span[TSpanData]: + """ + Create a new span. + """ + if self._disabled or disabled: + logger.debug(f"Tracing is disabled. Not creating span {span_data}") + return NoOpSpan(span_data) + + if not parent: + current_span = Scope.get_current_span() + current_trace = Scope.get_current_trace() + if current_trace is None: + logger.error( + "No active trace. Make sure to start a trace with `trace()` first" + "Returning NoOpSpan." + ) + return NoOpSpan(span_data) + elif isinstance(current_trace, NoOpTrace) or isinstance(current_span, NoOpSpan): + logger.debug( + f"Parent {current_span} or {current_trace} is no-op, returning NoOpSpan" + ) + return NoOpSpan(span_data) + + parent_id = current_span.span_id if current_span else None + trace_id = current_trace.trace_id + + elif isinstance(parent, Trace): + if isinstance(parent, NoOpTrace): + logger.debug(f"Parent {parent} is no-op, returning NoOpSpan") + return NoOpSpan(span_data) + trace_id = parent.trace_id + parent_id = None + elif isinstance(parent, Span): + if isinstance(parent, NoOpSpan): + logger.debug(f"Parent {parent} is no-op, returning NoOpSpan") + return NoOpSpan(span_data) + parent_id = parent.span_id + trace_id = parent.trace_id + + logger.debug(f"Creating span {span_data} with id {span_id}") + + return SpanImpl( + trace_id=trace_id, + span_id=span_id, + parent_id=parent_id, + processor=self._multi_processor, + span_data=span_data, + ) + + def shutdown(self) -> None: + try: + logger.debug("Shutting down trace provider") + self._multi_processor.shutdown() + except Exception as e: + logger.error(f"Error shutting down trace provider: {e}") + + +GLOBAL_TRACE_PROVIDER = TraceProvider() diff --git a/src/agents/tracing/span_data.py b/src/agents/tracing/span_data.py new file mode 100644 index 0000000..5e5d38c --- /dev/null +++ b/src/agents/tracing/span_data.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import abc +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from openai.types.responses import Response, ResponseInputItemParam + + +class SpanData(abc.ABC): + @abc.abstractmethod + def export(self) -> dict[str, Any]: + pass + + @property + @abc.abstractmethod + def type(self) -> str: + pass + + +class AgentSpanData(SpanData): + __slots__ = ("name", "handoffs", "tools", "output_type") + + def __init__( + self, + name: str, + handoffs: list[str] | None = None, + tools: list[str] | None = None, + output_type: str | None = None, + ): + self.name = name + self.handoffs: list[str] | None = handoffs + self.tools: list[str] | None = tools + self.output_type: str | None = output_type + + @property + def type(self) -> str: + return "agent" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "name": self.name, + "handoffs": self.handoffs, + "tools": self.tools, + "output_type": self.output_type, + } + + +class FunctionSpanData(SpanData): + __slots__ = ("name", "input", "output") + + def __init__(self, name: str, input: str | None, output: str | None): + self.name = name + self.input = input + self.output = output + + @property + def type(self) -> str: + return "function" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "name": self.name, + "input": self.input, + "output": self.output, + } + + +class GenerationSpanData(SpanData): + __slots__ = ( + "input", + "output", + "model", + "model_config", + "usage", + ) + + def __init__( + self, + input: Sequence[Mapping[str, Any]] | None = None, + output: Sequence[Mapping[str, Any]] | None = None, + model: str | None = None, + model_config: Mapping[str, Any] | None = None, + usage: dict[str, Any] | None = None, + ): + self.input = input + self.output = output + self.model = model + self.model_config = model_config + self.usage = usage + + @property + def type(self) -> str: + return "generation" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "input": self.input, + "output": self.output, + "model": self.model, + "model_config": self.model_config, + "usage": self.usage, + } + + +class ResponseSpanData(SpanData): + __slots__ = ("response", "input") + + def __init__( + self, + response: Response | None = None, + input: str | list[ResponseInputItemParam] | None = None, + ) -> None: + self.response = response + # This is not used by the OpenAI trace processors, but is useful for other tracing + # processor implementations + self.input = input + + @property + def type(self) -> str: + return "response" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "response_id": self.response.id if self.response else None, + } + + +class HandoffSpanData(SpanData): + __slots__ = ("from_agent", "to_agent") + + def __init__(self, from_agent: str | None, to_agent: str | None): + self.from_agent = from_agent + self.to_agent = to_agent + + @property + def type(self) -> str: + return "handoff" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "from_agent": self.from_agent, + "to_agent": self.to_agent, + } + + +class CustomSpanData(SpanData): + __slots__ = ("name", "data") + + def __init__(self, name: str, data: dict[str, Any]): + self.name = name + self.data = data + + @property + def type(self) -> str: + return "custom" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "name": self.name, + "data": self.data, + } + + +class GuardrailSpanData(SpanData): + __slots__ = ("name", "triggered") + + def __init__(self, name: str, triggered: bool = False): + self.name = name + self.triggered = triggered + + @property + def type(self) -> str: + return "guardrail" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "name": self.name, + "triggered": self.triggered, + } diff --git a/src/agents/tracing/spans.py b/src/agents/tracing/spans.py new file mode 100644 index 0000000..d682a9a --- /dev/null +++ b/src/agents/tracing/spans.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import abc +import contextvars +from typing import Any, Generic, TypeVar + +from typing_extensions import TypedDict + +from . import util +from .logger import logger +from .processor_interface import TracingProcessor +from .scope import Scope +from .span_data import SpanData + +TSpanData = TypeVar("TSpanData", bound=SpanData) + + +class SpanError(TypedDict): + message: str + data: dict[str, Any] | None + + +class Span(abc.ABC, Generic[TSpanData]): + @property + @abc.abstractmethod + def trace_id(self) -> str: + pass + + @property + @abc.abstractmethod + def span_id(self) -> str: + pass + + @property + @abc.abstractmethod + def span_data(self) -> TSpanData: + pass + + @abc.abstractmethod + def start(self, mark_as_current: bool = False): + """ + Start the span. + + Args: + mark_as_current: If true, the span will be marked as the current span. + """ + pass + + @abc.abstractmethod + def finish(self, reset_current: bool = False) -> None: + """ + Finish the span. + + Args: + reset_current: If true, the span will be reset as the current span. + """ + pass + + @abc.abstractmethod + def __enter__(self) -> Span[TSpanData]: + pass + + @abc.abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + @property + @abc.abstractmethod + def parent_id(self) -> str | None: + pass + + @abc.abstractmethod + def set_error(self, error: SpanError) -> None: + pass + + @property + @abc.abstractmethod + def error(self) -> SpanError | None: + pass + + @abc.abstractmethod + def export(self) -> dict[str, Any] | None: + pass + + @property + @abc.abstractmethod + def started_at(self) -> str | None: + pass + + @property + @abc.abstractmethod + def ended_at(self) -> str | None: + pass + + +class NoOpSpan(Span[TSpanData]): + __slots__ = ("_span_data", "_prev_span_token") + + def __init__(self, span_data: TSpanData): + self._span_data = span_data + self._prev_span_token: contextvars.Token[Span[TSpanData] | None] | None = None + + @property + def trace_id(self) -> str: + return "no-op" + + @property + def span_id(self) -> str: + return "no-op" + + @property + def span_data(self) -> TSpanData: + return self._span_data + + @property + def parent_id(self) -> str | None: + return None + + def start(self, mark_as_current: bool = False): + if mark_as_current: + self._prev_span_token = Scope.set_current_span(self) + + def finish(self, reset_current: bool = False) -> None: + if reset_current and self._prev_span_token is not None: + Scope.reset_current_span(self._prev_span_token) + self._prev_span_token = None + + def __enter__(self) -> Span[TSpanData]: + self.start(mark_as_current=True) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + reset_current = True + if exc_type is GeneratorExit: + logger.debug("GeneratorExit, skipping span reset") + reset_current = False + + self.finish(reset_current=reset_current) + + def set_error(self, error: SpanError) -> None: + pass + + @property + def error(self) -> SpanError | None: + return None + + def export(self) -> dict[str, Any] | None: + return None + + @property + def started_at(self) -> str | None: + return None + + @property + def ended_at(self) -> str | None: + return None + + +class SpanImpl(Span[TSpanData]): + __slots__ = ( + "_trace_id", + "_span_id", + "_parent_id", + "_started_at", + "_ended_at", + "_error", + "_prev_span_token", + "_processor", + "_span_data", + ) + + def __init__( + self, + trace_id: str, + span_id: str | None, + parent_id: str | None, + processor: TracingProcessor, + span_data: TSpanData, + ): + self._trace_id = trace_id + self._span_id = span_id or util.gen_span_id() + self._parent_id = parent_id + self._started_at: str | None = None + self._ended_at: str | None = None + self._processor = processor + self._error: SpanError | None = None + self._prev_span_token: contextvars.Token[Span[TSpanData] | None] | None = None + self._span_data = span_data + + @property + def trace_id(self) -> str: + return self._trace_id + + @property + def span_id(self) -> str: + return self._span_id + + @property + def span_data(self) -> TSpanData: + return self._span_data + + @property + def parent_id(self) -> str | None: + return self._parent_id + + def start(self, mark_as_current: bool = False): + if self.started_at is not None: + logger.warning("Span already started") + return + + self._started_at = util.time_iso() + self._processor.on_span_start(self) + if mark_as_current: + self._prev_span_token = Scope.set_current_span(self) + + def finish(self, reset_current: bool = False) -> None: + if self.ended_at is not None: + logger.warning("Span already finished") + return + + self._ended_at = util.time_iso() + self._processor.on_span_end(self) + if reset_current and self._prev_span_token is not None: + Scope.reset_current_span(self._prev_span_token) + self._prev_span_token = None + + def __enter__(self) -> Span[TSpanData]: + self.start(mark_as_current=True) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + reset_current = True + if exc_type is GeneratorExit: + logger.debug("GeneratorExit, skipping span reset") + reset_current = False + + self.finish(reset_current=reset_current) + + def set_error(self, error: SpanError) -> None: + self._error = error + + @property + def error(self) -> SpanError | None: + return self._error + + @property + def started_at(self) -> str | None: + return self._started_at + + @property + def ended_at(self) -> str | None: + return self._ended_at + + def export(self) -> dict[str, Any] | None: + return { + "object": "trace.span", + "id": self.span_id, + "trace_id": self.trace_id, + "parent_id": self._parent_id, + "started_at": self._started_at, + "ended_at": self._ended_at, + "span_data": self.span_data.export(), + "error": self._error, + } diff --git a/src/agents/tracing/traces.py b/src/agents/tracing/traces.py new file mode 100644 index 0000000..bf3b43d --- /dev/null +++ b/src/agents/tracing/traces.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import abc +import contextvars +from typing import Any + +from . import util +from .logger import logger +from .processor_interface import TracingProcessor +from .scope import Scope + + +class Trace: + """ + A trace is the root level object that tracing creates. It represents a logical "workflow". + """ + + @abc.abstractmethod + def __enter__(self) -> Trace: + pass + + @abc.abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + @abc.abstractmethod + def start(self, mark_as_current: bool = False): + """ + Start the trace. + + Args: + mark_as_current: If true, the trace will be marked as the current trace. + """ + pass + + @abc.abstractmethod + def finish(self, reset_current: bool = False): + """ + Finish the trace. + + Args: + reset_current: If true, the trace will be reset as the current trace. + """ + pass + + @property + @abc.abstractmethod + def trace_id(self) -> str: + """ + The trace ID. + """ + pass + + @property + @abc.abstractmethod + def name(self) -> str: + """ + The name of the workflow being traced. + """ + pass + + @abc.abstractmethod + def export(self) -> dict[str, Any] | None: + """ + Export the trace as a dictionary. + """ + pass + + +class NoOpTrace(Trace): + """ + A no-op trace that will not be recorded. + """ + + def __init__(self): + self._started = False + self._prev_context_token: contextvars.Token[Trace | None] | None = None + + def __enter__(self) -> Trace: + if self._started: + if not self._prev_context_token: + logger.error("Trace already started but no context token set") + return self + + self._started = True + self.start(mark_as_current=True) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.finish(reset_current=True) + + def start(self, mark_as_current: bool = False): + if mark_as_current: + self._prev_context_token = Scope.set_current_trace(self) + + def finish(self, reset_current: bool = False): + if reset_current and self._prev_context_token is not None: + Scope.reset_current_trace(self._prev_context_token) + self._prev_context_token = None + + @property + def trace_id(self) -> str: + return "no-op" + + @property + def name(self) -> str: + return "no-op" + + def export(self) -> dict[str, Any] | None: + return None + + +NO_OP_TRACE = NoOpTrace() + + +class TraceImpl(Trace): + """ + A trace that will be recorded by the tracing library. + """ + + __slots__ = ( + "_name", + "_trace_id", + "group_id", + "metadata", + "_prev_context_token", + "_processor", + "_started", + ) + + def __init__( + self, + name: str, + trace_id: str | None, + group_id: str | None, + metadata: dict[str, Any] | None, + processor: TracingProcessor, + ): + self._name = name + self._trace_id = trace_id or util.gen_trace_id() + self.group_id = group_id + self.metadata = metadata + self._prev_context_token: contextvars.Token[Trace | None] | None = None + self._processor = processor + self._started = False + + @property + def trace_id(self) -> str: + return self._trace_id + + @property + def name(self) -> str: + return self._name + + def start(self, mark_as_current: bool = False): + if self._started: + return + + self._started = True + self._processor.on_trace_start(self) + + if mark_as_current: + self._prev_context_token = Scope.set_current_trace(self) + + def finish(self, reset_current: bool = False): + if not self._started: + return + + self._processor.on_trace_end(self) + + if reset_current and self._prev_context_token is not None: + Scope.reset_current_trace(self._prev_context_token) + self._prev_context_token = None + + def __enter__(self) -> Trace: + if self._started: + if not self._prev_context_token: + logger.error("Trace already started but no context token set") + return self + + self.start(mark_as_current=True) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.finish(reset_current=exc_type is not GeneratorExit) + + def export(self) -> dict[str, Any] | None: + return { + "object": "trace", + "id": self.trace_id, + "workflow_name": self.name, + "group_id": self.group_id, + "metadata": self.metadata, + } diff --git a/src/agents/tracing/util.py b/src/agents/tracing/util.py new file mode 100644 index 0000000..3e5cad9 --- /dev/null +++ b/src/agents/tracing/util.py @@ -0,0 +1,17 @@ +import uuid +from datetime import datetime, timezone + + +def time_iso() -> str: + """Returns the current time in ISO 8601 format.""" + return datetime.now(timezone.utc).isoformat() + + +def gen_trace_id() -> str: + """Generates a new trace ID.""" + return f"trace_{uuid.uuid4().hex}" + + +def gen_span_id() -> str: + """Generates a new span ID.""" + return f"span_{uuid.uuid4().hex[:24]}" diff --git a/src/agents/usage.py b/src/agents/usage.py new file mode 100644 index 0000000..23d989b --- /dev/null +++ b/src/agents/usage.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass +class Usage: + requests: int = 0 + """Total requests made to the LLM API.""" + + input_tokens: int = 0 + """Total input tokens sent, across all requests.""" + + output_tokens: int = 0 + """Total output tokens received, across all requests.""" + + total_tokens: int = 0 + """Total tokens sent and received, across all requests.""" + + def add(self, other: "Usage") -> None: + self.requests += other.requests if other.requests else 0 + self.input_tokens += other.input_tokens if other.input_tokens else 0 + self.output_tokens += other.output_tokens if other.output_tokens else 0 + self.total_tokens += other.total_tokens if other.total_tokens else 0 diff --git a/src/agents/version.py b/src/agents/version.py new file mode 100644 index 0000000..a0b7e9b --- /dev/null +++ b/src/agents/version.py @@ -0,0 +1,7 @@ +import importlib.metadata + +try: + __version__ = importlib.metadata.version("agents") +except importlib.metadata.PackageNotFoundError: + # Fallback if running from source without being installed + __version__ = "0.0.0" diff --git a/src/openai_agents.egg-info/PKG-INFO b/src/openai_agents.egg-info/PKG-INFO new file mode 100644 index 0000000..ebf2d7c --- /dev/null +++ b/src/openai_agents.egg-info/PKG-INFO @@ -0,0 +1,217 @@ +Metadata-Version: 2.2 +Name: openai-agents +Version: 0.0.1 +Summary: OpenAI Agents SDK +Author-email: OpenAI +Project-URL: Homepage, https://github.com/openai/openai-agents-python +Project-URL: Repository, https://github.com/openai/openai-agents-python +Classifier: Typing :: Typed +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Intended Audience :: Developers +Classifier: Intended Audience :: Information Technology +Classifier: Operating System :: OS Independent +Classifier: Operating System :: POSIX +Classifier: Operating System :: MacOS +Classifier: Operating System :: POSIX :: Linux +Classifier: Operating System :: Microsoft :: Windows +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.9 +Description-Content-Type: text/markdown +Requires-Dist: openai@ {root:parent:uri}/openai-1.30.1-py3-none-any.whl +Requires-Dist: pydantic<3,>=2.10 +Requires-Dist: griffe<2,>=1.5.6 +Requires-Dist: typing-extensions<5,>=4.12.2 +Requires-Dist: requests<3,>=2.0 +Requires-Dist: types-requests<3,>=2.0 + +# OpenAI Agents SDK + +The OpenAI Agents SDK is a lightweight yet powerful framework for building multi-agent workflows. + +### Core concepts: +1. [**Agents,**](docs/agents.md) which are LLMs configured with instructions, tools, guardrails, and handoffs +2. [**Handoffs,**](docs/handoffs.md) which allow agents to transfer control to other agents for specific tasks +3. [**Guardrails,**](docs/guardrails.md) which makes it easy to watch an agent execution and validate inputs/outputs +4. [**Tracing,**](docs/tracing.md) which automatically captures the entire agentic run, allowing you to view, debug and optimize your workflows + +Explore examples of the SDK in action in the [examples](examples) directory. + +## Using the SDK + +1. Set up python env + +``` +python -m venv env +source env/bin/activate +``` + +2. Install Agents SDK + +``` +pip install git+ssh://git@github.com/openai/agentsdk_prototype.git#subdirectory=agents +``` + +## Development (only needed if you need to edit the SDK/examples) + +0. Ensure you have [`uv`](https://docs.astral.sh/uv/) installed. + +```bash +uv --version +``` + +1. Install dependencies/setup virtual environment + +```bash +uv sync +``` + +2. Install the dependencies + +```bash +uv sync --all-extras --all-packages +``` + +3. Activate the virtual environment + +```bash +source .venv/bin/activate +``` + +## Tests + +Make sure the virtual environment is activated first. + +```bash +pytest +``` + +## Hello world example + +```py +from agents.agent import Agent +from agents.run import Runner +import asyncio + +agent = Agent( + name="Hello world", + instructions="You are a helpful agent." +) + +async def main(): + out = await Runner.run(agent, input="Hola, ¿cómo estás?") + print(out) + + +if __name__ == "__main__": + asyncio.run(main()) + +# The capital of the United States is Washington, D.C. +``` + +## Handoffs example + +```py +from agents.agent import Agent +from agents.run import Runner +import asyncio + +spanish_agent = Agent( + name="spanish_agent", + instructions="You only speak Spanish.", +) + +english_agent = Agent( + name="english_agent", + instructions="You only speak English", +) + +triage_agent = Agent( + name="triage_agent", + instructions="Handoff to the appropriate agent based on the language of the request.", + handoffs=[spanish_agent, english_agent], +) + + +async def main(): + out = await Runner.run(triage_agent, input="Hola, ¿cómo estás?") + print(out) + + +if __name__ == "__main__": + asyncio.run(main()) + +# ¡Hola! Estoy bien, gracias por preguntar. ¿Y tú, cómo estás? +``` + +## Functions example + +```python +from agents.agent import Agent +from agents.run import Runner +import asyncio +from agents.tool import function_tool + + +@function_tool +def get_weather(city: str) -> str: + print(f"Getting weather for {city}") + return f"The weather in {city} is sunny." + + +agent = Agent( + name="Hello world", + instructions="You are a helpful agent.", + tools=[get_weather], +) + + +async def main(): + out = await Runner.run(agent, input="What's the weather in Tokyo?") + print(out.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +For more complex systems, we recommend including detailed instructions about handoffs. We have a recommendation in `handoff.RECOMMENDED_PROMPT_PREFIX` that can be used to add these instructions to an agent. + +```py +agent = Agent( + ..., + instructions=f"{handoff.RECOMMENDED_PROMPT_PREFIX}\n\n{instructions}" +) +``` + +## The agent loop + +When you call `Runner.run()`, we run a loop until we get a final output. + +1. We call the LLM, using the model and settings on the agent, and the message history. +2. The LLM returns a response, which may include tool calls. +3. If the response has a final output (see below for the more on this), we return it and end the loop. +4. If the response has a handoff, we set the agent to the new agent and go back to step 1. +5. We process the tool calls (if any) and append the tool responses messsages. Then we go to step 1. + +There is a `max_turns` parameter that you can use to limit the number of times the loop executes. + +### Final output + +There are two ways to get a **final output**: + +1. If you set an `output_type` on the agent, the LLM is given a special tool called `final_output`. If it uses this tool, the output of the tool is the final output. +2. If there's no `output_type`, then we assume the final output is a string. As soon as the LLM produces a message without any tool calls, that is considered the final output. + +As a result, the mental model for the agent loop is: + +1. If the current agent has an `output_type`, the loop runs until the agent uses that tool to return the final output. +2. If the current agent does not have an `output_type`, the loop runs until the current agent produces a message without any tool calls. + +## Common agent patterns + +There are a number of useful patterns in agentic apps. There are a number of examples in [`examples/agent_patterns`](examples/agent_patterns), and we recommend reading them. diff --git a/src/openai_agents.egg-info/SOURCES.txt b/src/openai_agents.egg-info/SOURCES.txt new file mode 100644 index 0000000..695ad1f --- /dev/null +++ b/src/openai_agents.egg-info/SOURCES.txt @@ -0,0 +1,81 @@ +README.md +pyproject.toml +src/agents/__init__.py +src/agents/_config.py +src/agents/_debug.py +src/agents/_run_impl.py +src/agents/_utils.py +src/agents/agent.py +src/agents/agent_output.py +src/agents/call_agent_tool.py +src/agents/computer.py +src/agents/exceptions.py +src/agents/function_schema.py +src/agents/guardrail.py +src/agents/handoffs.py +src/agents/items.py +src/agents/lifecycle.py +src/agents/logger.py +src/agents/model_settings.py +src/agents/result.py +src/agents/run.py +src/agents/run_context.py +src/agents/strict_schema.py +src/agents/tool.py +src/agents/usage.py +src/agents/version.py +src/agents/extensions/__init__.py +src/agents/extensions/handoff_filters.py +src/agents/extensions/handoff_prompt.py +src/agents/models/__init__.py +src/agents/models/_openai_shared.py +src/agents/models/fake_id.py +src/agents/models/interface.py +src/agents/models/map.py +src/agents/models/openai_chatcompletions.py +src/agents/models/openai_responses.py +src/agents/tracing/__init__.py +src/agents/tracing/create.py +src/agents/tracing/logger.py +src/agents/tracing/processor_interface.py +src/agents/tracing/processors.py +src/agents/tracing/scope.py +src/agents/tracing/setup.py +src/agents/tracing/span_data.py +src/agents/tracing/spans.py +src/agents/tracing/traces.py +src/agents/tracing/util.py +src/openai_agents.egg-info/PKG-INFO +src/openai_agents.egg-info/SOURCES.txt +src/openai_agents.egg-info/dependency_links.txt +src/openai_agents.egg-info/requires.txt +src/openai_agents.egg-info/top_level.txt +tests/test_agent_config.py +tests/test_agent_hooks.py +tests/test_agent_runner.py +tests/test_agent_runner_streamed.py +tests/test_agent_tracing.py +tests/test_config.py +tests/test_doc_parsing.py +tests/test_function_schema.py +tests/test_function_tool.py +tests/test_function_tool_decorator.py +tests/test_global_hooks.py +tests/test_guardrails.py +tests/test_handoff_tool.py +tests/test_items_helpers.py +tests/test_max_turns.py +tests/test_model_mapper.py +tests/test_openai_chatcompletions_converter.py +tests/test_openai_responses_converter.py +tests/test_output_tool.py +tests/test_responses.py +tests/test_run_config.py +tests/test_run_step_execution.py +tests/test_run_step_processing.py +tests/test_tool_converter.py +tests/test_trace_processor.py +tests/test_tracing.py +tests/test_tracing_errors.py +tests/test_tracing_errors_streamed.py +tests/testing_processor.py \ No newline at end of file diff --git a/src/openai_agents.egg-info/dependency_links.txt b/src/openai_agents.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/openai_agents.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/openai_agents.egg-info/requires.txt b/src/openai_agents.egg-info/requires.txt new file mode 100644 index 0000000..3dbad2b --- /dev/null +++ b/src/openai_agents.egg-info/requires.txt @@ -0,0 +1,6 @@ +openai@ {root:parent:uri}/openai-1.30.1-py3-none-any.whl +pydantic<3,>=2.10 +griffe<2,>=1.5.6 +typing-extensions<5,>=4.12.2 +requests<3,>=2.0 +types-requests<3,>=2.0 diff --git a/src/openai_agents.egg-info/top_level.txt b/src/openai_agents.egg-info/top_level.txt new file mode 100644 index 0000000..4a33ff6 --- /dev/null +++ b/src/openai_agents.egg-info/top_level.txt @@ -0,0 +1 @@ +agents diff --git a/tests/LICENSE b/tests/LICENSE new file mode 100644 index 0000000..e5ad2c5 --- /dev/null +++ b/tests/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 0000000..7dd9bbd --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,37 @@ +.PHONY: sync +sync: + uv sync --all-extras --all-packages --group dev + +.PHONY: format +format: + uv run ruff format + +.PHONY: lint +lint: + uv run ruff check + +.PHONY: mypy +mypy: + uv run mypy . + +.PHONY: tests +tests: + uv run pytest + +.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: + uv run mkdocs build + +.PHONY: serve-docs +serve-docs: + uv run mkdocs serve + +.PHONY: deploy-docs +deploy-docs: + uv run mkdocs gh-deploy --force --verbose + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..8acd13c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,174 @@ +# OpenAI Agents SDK + +The OpenAI Agents SDK is a lightweight yet powerful framework for building multi-agent workflows. + +Image of the Agents Tracing UI + +### Core concepts: + +1. [**Agents**](docs/agents.md): LLMs configured with instructions, tools, guardrails, and handoffs +2. [**Handoffs**](docs/handoffs.md): Allow agents to transfer control to other agents for specific tasks +3. [**Guardrails**](docs/guardrails.md): Configurable safety checks for input and output validation +4. [**Tracing**](docs/tracing.md): Built-in tracking of agent runs, allowing you to view, debug and optimize your workflows + +Explore the [examples](examples) directory to see the SDK in action. + +## Get started + +1. Set up your Python environment + +``` +python -m venv env +source env/bin/activate +``` + +2. Install Agents SDK + +``` +pip install openai-agents +``` + +## Hello world example + +```python +from agents import Agent, Runner + +agent = Agent(name="Assistant", instructions="You are a helpful assistant") + +result = Runner.run_sync(agent, "Write a haiku about recursion in programming.") +print(result.final_output) + +# Code within the code, +# Functions calling themselves, +# Infinite loop's dance. +``` + +(_If running this, ensure you set the `OPENAI_API_KEY` environment variable_) + +## Handoffs example + +```py +from agents import Agent, Runner +import asyncio + +spanish_agent = Agent( + name="Spanish agent", + instructions="You only speak Spanish.", +) + +english_agent = Agent( + name="English agent", + instructions="You only speak English", +) + +triage_agent = Agent( + name="Triage agent", + instructions="Handoff to the appropriate agent based on the language of the request.", + handoffs=[spanish_agent, english_agent], +) + + +async def main(): + result = await Runner.run(triage_agent, input="Hola, ¿cómo estás?") + print(result.final_output) + # ¡Hola! Estoy bien, gracias por preguntar. ¿Y tú, cómo estás? + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Functions example + +```python +import asyncio + +from agents import Agent, Runner, function_tool + + +@function_tool +def get_weather(city: str) -> str: + return f"The weather in {city} is sunny." + + +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()) +``` + +## The agent loop + +When you call `Runner.run()`, we run a loop until we get a final output. + +1. We call the LLM, using the model and settings on the agent, and the message history. +2. The LLM returns a response, which may include tool calls. +3. If the response has a final output (see below for the more on this), we return it and end the loop. +4. If the response has a handoff, we set the agent to the new agent and go back to step 1. +5. We process the tool calls (if any) and append the tool responses messsages. Then we go to step 1. + +There is a `max_turns` parameter that you can use to limit the number of times the loop executes. + +### Final output + +Final output is the last thing the agent produces in the loop. + +1. If you set an `output_type` on the agent, the final output is when the LLM returns something of that type. We use [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) for this. +2. If there's no `output_type` (i.e. plain text responses), then the first LLM response without any tool calls or handoffs is considered as the final output. + +As a result, the mental model for the agent loop is: + +1. If the current agent has an `output_type`, the loop runs until the agent produces structured output matching that type. +2. If the current agent does not have an `output_type`, the loop runs until the current agent produces a message without any tool calls/handoffs. + +## Common agent patterns + +The Agents SDK is designed to be highly flexible, allowing you to model a wide range of LLM workflows including deterministic flows, iterative loops, and more. See examples in [`examples/agent_patterns`](examples/agent_patterns). + +## Tracing + +The Agents SDK includes built-in tracing, 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). See [Tracing](http://openai.github.io/openai-agents-python/tracing.md) for more details. + +## Development (only needed if you need to edit the SDK/examples) + +0. Ensure you have [`uv`](https://docs.astral.sh/uv/) installed. + +```bash +uv --version +``` + +1. Install dependencies + +```bash +make sync +``` + +2. (After making changes) lint/test + +``` +make tests # run tests +make mypy # run typechecker +make lint # run linter +``` + +## Acknowledgements + +We'd like to acknowledge the excellent work of the open-source community, especially: + +- [Pydantic](https://docs.pydantic.dev/latest/) (data validation) and [PydanticAI](https://ai.pydantic.dev/) (advanced agent framework) +- [MkDocs](https://github.com/squidfunk/mkdocs-material) +- [Griffe](https://github.com/mkdocstrings/griffe) +- [uv](https://github.com/astral-sh/uv) and [ruff](https://github.com/astral-sh/ruff) + +We're committed to continuing to build the Agents SDK as an open source framework so others in the community can expand on our approach. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-311.pyc b/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..6ebfe47 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-311.pyc differ diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..63635d7 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/__pycache__/__init__.cpython-39.pyc b/tests/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..8ca2739 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-39.pyc differ diff --git a/tests/__pycache__/conftest.cpython-311-pytest-8.2.0.pyc b/tests/__pycache__/conftest.cpython-311-pytest-8.2.0.pyc new file mode 100644 index 0000000..124055e Binary files /dev/null and b/tests/__pycache__/conftest.cpython-311-pytest-8.2.0.pyc differ diff --git a/tests/__pycache__/conftest.cpython-311-pytest-8.3.4.pyc b/tests/__pycache__/conftest.cpython-311-pytest-8.3.4.pyc new file mode 100644 index 0000000..f4936b4 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-311-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/conftest.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/conftest.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..4cdf4fb Binary files /dev/null and b/tests/__pycache__/conftest.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/conftest.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/conftest.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..5d41f1d Binary files /dev/null and b/tests/__pycache__/conftest.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/conftest.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/conftest.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..96b6d26 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/conftest.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/conftest.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..758ab7c Binary files /dev/null and b/tests/__pycache__/conftest.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/fake_model.cpython-313.pyc b/tests/__pycache__/fake_model.cpython-313.pyc new file mode 100644 index 0000000..db9a727 Binary files /dev/null and b/tests/__pycache__/fake_model.cpython-313.pyc differ diff --git a/tests/__pycache__/fake_model.cpython-39.pyc b/tests/__pycache__/fake_model.cpython-39.pyc new file mode 100644 index 0000000..09d5140 Binary files /dev/null and b/tests/__pycache__/fake_model.cpython-39.pyc differ diff --git a/tests/__pycache__/test_agent_config.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_config.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..0f9b823 Binary files /dev/null and b/tests/__pycache__/test_agent_config.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_config.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_config.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..3a27929 Binary files /dev/null and b/tests/__pycache__/test_agent_config.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_agent_config.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_config.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..7208114 Binary files /dev/null and b/tests/__pycache__/test_agent_config.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_config.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_config.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..4c8cce4 Binary files /dev/null and b/tests/__pycache__/test_agent_config.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_agent_hooks.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_hooks.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..680275d Binary files /dev/null and b/tests/__pycache__/test_agent_hooks.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_hooks.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_hooks.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..8abba76 Binary files /dev/null and b/tests/__pycache__/test_agent_hooks.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_agent_hooks.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_hooks.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..a7a415b Binary files /dev/null and b/tests/__pycache__/test_agent_hooks.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_hooks.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_hooks.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..37d3bdf Binary files /dev/null and b/tests/__pycache__/test_agent_hooks.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_agent_runner.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_runner.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..ab8091c Binary files /dev/null and b/tests/__pycache__/test_agent_runner.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_runner.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_runner.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..355e69b Binary files /dev/null and b/tests/__pycache__/test_agent_runner.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_agent_runner.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_runner.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..a88334a Binary files /dev/null and b/tests/__pycache__/test_agent_runner.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_runner.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_runner.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..53bf89a Binary files /dev/null and b/tests/__pycache__/test_agent_runner.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_agent_runner_streamed.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_runner_streamed.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..f894581 Binary files /dev/null and b/tests/__pycache__/test_agent_runner_streamed.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_runner_streamed.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_runner_streamed.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..60ae269 Binary files /dev/null and b/tests/__pycache__/test_agent_runner_streamed.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_agent_runner_streamed.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_runner_streamed.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..63c58a5 Binary files /dev/null and b/tests/__pycache__/test_agent_runner_streamed.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_runner_streamed.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_runner_streamed.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..a83fc93 Binary files /dev/null and b/tests/__pycache__/test_agent_runner_streamed.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_agent_runner_streamed_warnings.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_runner_streamed_warnings.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..322e4dd Binary files /dev/null and b/tests/__pycache__/test_agent_runner_streamed_warnings.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_tracing.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_tracing.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..39ac0c2 Binary files /dev/null and b/tests/__pycache__/test_agent_tracing.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_tracing.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_tracing.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..699761f Binary files /dev/null and b/tests/__pycache__/test_agent_tracing.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_agent_tracing.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_agent_tracing.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..11647b7 Binary files /dev/null and b/tests/__pycache__/test_agent_tracing.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_agent_tracing.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_agent_tracing.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..8752149 Binary files /dev/null and b/tests/__pycache__/test_agent_tracing.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_computer_action.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_computer_action.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..1ae95a8 Binary files /dev/null and b/tests/__pycache__/test_computer_action.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_computer_action.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_computer_action.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..a99b7e8 Binary files /dev/null and b/tests/__pycache__/test_computer_action.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_config.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_config.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..4683d9e Binary files /dev/null and b/tests/__pycache__/test_config.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_config.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_config.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..6c7c90e Binary files /dev/null and b/tests/__pycache__/test_config.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_doc_parsing.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_doc_parsing.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..27b300a Binary files /dev/null and b/tests/__pycache__/test_doc_parsing.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_doc_parsing.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_doc_parsing.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..3cc666f Binary files /dev/null and b/tests/__pycache__/test_doc_parsing.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_doc_parsing.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_doc_parsing.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..e759461 Binary files /dev/null and b/tests/__pycache__/test_doc_parsing.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_doc_parsing.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_doc_parsing.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..9826a1e Binary files /dev/null and b/tests/__pycache__/test_doc_parsing.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_extension_filters.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_extension_filters.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..a0bc4e2 Binary files /dev/null and b/tests/__pycache__/test_extension_filters.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_extension_filters.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_extension_filters.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..b06885b Binary files /dev/null and b/tests/__pycache__/test_extension_filters.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_function_schema.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_function_schema.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..73e2e51 Binary files /dev/null and b/tests/__pycache__/test_function_schema.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_function_schema.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_function_schema.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..1e044c3 Binary files /dev/null and b/tests/__pycache__/test_function_schema.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_function_schema.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_function_schema.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..043ac5d Binary files /dev/null and b/tests/__pycache__/test_function_schema.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_function_schema.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_function_schema.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..fd1e869 Binary files /dev/null and b/tests/__pycache__/test_function_schema.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_function_tool.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_function_tool.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..1078b42 Binary files /dev/null and b/tests/__pycache__/test_function_tool.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_function_tool.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_function_tool.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..d20e59a Binary files /dev/null and b/tests/__pycache__/test_function_tool.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_function_tool.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_function_tool.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..2bd74c7 Binary files /dev/null and b/tests/__pycache__/test_function_tool.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_function_tool.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_function_tool.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..95d02f2 Binary files /dev/null and b/tests/__pycache__/test_function_tool.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_function_tool_decorator.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_function_tool_decorator.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..d13db5d Binary files /dev/null and b/tests/__pycache__/test_function_tool_decorator.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_function_tool_decorator.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_function_tool_decorator.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..27c8553 Binary files /dev/null and b/tests/__pycache__/test_function_tool_decorator.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_global_hooks.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_global_hooks.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..5130ea0 Binary files /dev/null and b/tests/__pycache__/test_global_hooks.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_global_hooks.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_global_hooks.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..d8ef217 Binary files /dev/null and b/tests/__pycache__/test_global_hooks.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_global_hooks.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_global_hooks.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..a566457 Binary files /dev/null and b/tests/__pycache__/test_global_hooks.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_global_hooks.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_global_hooks.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..9ed2fd4 Binary files /dev/null and b/tests/__pycache__/test_global_hooks.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_guardrails.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_guardrails.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..1b10047 Binary files /dev/null and b/tests/__pycache__/test_guardrails.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_guardrails.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_guardrails.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..a3d50b4 Binary files /dev/null and b/tests/__pycache__/test_guardrails.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_guardrails.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_guardrails.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..6ab86d9 Binary files /dev/null and b/tests/__pycache__/test_guardrails.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_guardrails.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_guardrails.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..ec9cb41 Binary files /dev/null and b/tests/__pycache__/test_guardrails.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_handoff_tool.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_handoff_tool.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..c8f1650 Binary files /dev/null and b/tests/__pycache__/test_handoff_tool.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_handoff_tool.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_handoff_tool.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..036bdfb Binary files /dev/null and b/tests/__pycache__/test_handoff_tool.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_handoff_tool.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_handoff_tool.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..0dc8b12 Binary files /dev/null and b/tests/__pycache__/test_handoff_tool.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_handoff_tool.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_handoff_tool.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..e4130b0 Binary files /dev/null and b/tests/__pycache__/test_handoff_tool.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_items_helpers.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_items_helpers.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..2cf86c0 Binary files /dev/null and b/tests/__pycache__/test_items_helpers.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_items_helpers.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_items_helpers.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..2db1092 Binary files /dev/null and b/tests/__pycache__/test_items_helpers.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_lifecycle.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_lifecycle.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..c305861 Binary files /dev/null and b/tests/__pycache__/test_lifecycle.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_max_turns.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_max_turns.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..55703be Binary files /dev/null and b/tests/__pycache__/test_max_turns.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_max_turns.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_max_turns.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..9029b77 Binary files /dev/null and b/tests/__pycache__/test_max_turns.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_max_turns.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_max_turns.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..86c7081 Binary files /dev/null and b/tests/__pycache__/test_max_turns.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_max_turns.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_max_turns.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..75d930e Binary files /dev/null and b/tests/__pycache__/test_max_turns.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_model_mapper.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_model_mapper.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..c3f1c0e Binary files /dev/null and b/tests/__pycache__/test_model_mapper.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_model_mapper.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_model_mapper.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..39b502b Binary files /dev/null and b/tests/__pycache__/test_model_mapper.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_model_mapper.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_model_mapper.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..87a8e2c Binary files /dev/null and b/tests/__pycache__/test_model_mapper.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_model_mapper.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_model_mapper.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..16b5c5f Binary files /dev/null and b/tests/__pycache__/test_model_mapper.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_openai_chatcompletions.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_openai_chatcompletions.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..a035941 Binary files /dev/null and b/tests/__pycache__/test_openai_chatcompletions.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_openai_chatcompletions.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_openai_chatcompletions.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..165b9bf Binary files /dev/null and b/tests/__pycache__/test_openai_chatcompletions.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_openai_chatcompletions_converter.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_openai_chatcompletions_converter.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..d96f094 Binary files /dev/null and b/tests/__pycache__/test_openai_chatcompletions_converter.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_openai_chatcompletions_converter.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_openai_chatcompletions_converter.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..5c88328 Binary files /dev/null and b/tests/__pycache__/test_openai_chatcompletions_converter.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_openai_chatcompletions_get_response.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_openai_chatcompletions_get_response.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..0c1a130 Binary files /dev/null and b/tests/__pycache__/test_openai_chatcompletions_get_response.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_openai_chatcompletions_stream.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_openai_chatcompletions_stream.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..95d3cb3 Binary files /dev/null and b/tests/__pycache__/test_openai_chatcompletions_stream.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_openai_chatcompletions_stream.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_openai_chatcompletions_stream.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..c57882e Binary files /dev/null and b/tests/__pycache__/test_openai_chatcompletions_stream.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_openai_responses_converter.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_openai_responses_converter.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..ede5c41 Binary files /dev/null and b/tests/__pycache__/test_openai_responses_converter.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_openai_responses_converter.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_openai_responses_converter.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..de9f045 Binary files /dev/null and b/tests/__pycache__/test_openai_responses_converter.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_output_tool.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_output_tool.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..b669852 Binary files /dev/null and b/tests/__pycache__/test_output_tool.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_output_tool.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_output_tool.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..8fb7794 Binary files /dev/null and b/tests/__pycache__/test_output_tool.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_output_tool.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_output_tool.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..bac2bde Binary files /dev/null and b/tests/__pycache__/test_output_tool.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_output_tool.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_output_tool.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..3d81891 Binary files /dev/null and b/tests/__pycache__/test_output_tool.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_responses.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_responses.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..d88ce6c Binary files /dev/null and b/tests/__pycache__/test_responses.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_responses.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_responses.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..4729f3c Binary files /dev/null and b/tests/__pycache__/test_responses.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_responses.cpython-313.pyc b/tests/__pycache__/test_responses.cpython-313.pyc new file mode 100644 index 0000000..5a64d58 Binary files /dev/null and b/tests/__pycache__/test_responses.cpython-313.pyc differ diff --git a/tests/__pycache__/test_responses.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_responses.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..f14356d Binary files /dev/null and b/tests/__pycache__/test_responses.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_responses.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_responses.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..c8fba38 Binary files /dev/null and b/tests/__pycache__/test_responses.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_responses_tracing.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_responses_tracing.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..e83286c Binary files /dev/null and b/tests/__pycache__/test_responses_tracing.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_responses_tracing.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_responses_tracing.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..1f2f412 Binary files /dev/null and b/tests/__pycache__/test_responses_tracing.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_result_cast.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_result_cast.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..694aa15 Binary files /dev/null and b/tests/__pycache__/test_result_cast.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_result_cast.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_result_cast.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..30a15d5 Binary files /dev/null and b/tests/__pycache__/test_result_cast.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_run_config.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_run_config.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..f5802ed Binary files /dev/null and b/tests/__pycache__/test_run_config.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_run_config.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_run_config.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..38ac10c Binary files /dev/null and b/tests/__pycache__/test_run_config.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_run_config.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_run_config.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..174b66e Binary files /dev/null and b/tests/__pycache__/test_run_config.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_run_config.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_run_config.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..af9dfb7 Binary files /dev/null and b/tests/__pycache__/test_run_config.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_run_step_execution.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_run_step_execution.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..8df2dc0 Binary files /dev/null and b/tests/__pycache__/test_run_step_execution.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_run_step_execution.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_run_step_execution.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..345762b Binary files /dev/null and b/tests/__pycache__/test_run_step_execution.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_run_step_execution.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_run_step_execution.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..4fae6dc Binary files /dev/null and b/tests/__pycache__/test_run_step_execution.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_run_step_execution.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_run_step_execution.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..ca24aea Binary files /dev/null and b/tests/__pycache__/test_run_step_execution.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_run_step_processing.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_run_step_processing.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..3e2e846 Binary files /dev/null and b/tests/__pycache__/test_run_step_processing.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_run_step_processing.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_run_step_processing.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..ee7266a Binary files /dev/null and b/tests/__pycache__/test_run_step_processing.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_run_step_processing.cpython-313.pyc b/tests/__pycache__/test_run_step_processing.cpython-313.pyc new file mode 100644 index 0000000..c5f79ff Binary files /dev/null and b/tests/__pycache__/test_run_step_processing.cpython-313.pyc differ diff --git a/tests/__pycache__/test_run_step_processing.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_run_step_processing.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..6ea87e7 Binary files /dev/null and b/tests/__pycache__/test_run_step_processing.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_run_step_processing.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_run_step_processing.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..602fdce Binary files /dev/null and b/tests/__pycache__/test_run_step_processing.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_strict_schema.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_strict_schema.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..f0e7e7c Binary files /dev/null and b/tests/__pycache__/test_strict_schema.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_strict_schema.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_strict_schema.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..28d5086 Binary files /dev/null and b/tests/__pycache__/test_strict_schema.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_tool_converter.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_tool_converter.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..8213181 Binary files /dev/null and b/tests/__pycache__/test_tool_converter.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_tool_converter.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_tool_converter.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..f1783da Binary files /dev/null and b/tests/__pycache__/test_tool_converter.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_tool_converter.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_tool_converter.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..9826760 Binary files /dev/null and b/tests/__pycache__/test_tool_converter.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_tool_converter.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_tool_converter.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..ca0f0ed Binary files /dev/null and b/tests/__pycache__/test_tool_converter.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_trace_processor.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_trace_processor.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..d6cdc5a Binary files /dev/null and b/tests/__pycache__/test_trace_processor.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_trace_processor.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_trace_processor.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..1160168 Binary files /dev/null and b/tests/__pycache__/test_trace_processor.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_trace_processor.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_trace_processor.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..e8d3e12 Binary files /dev/null and b/tests/__pycache__/test_trace_processor.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_trace_processor.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_trace_processor.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..2925c40 Binary files /dev/null and b/tests/__pycache__/test_trace_processor.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_tracing.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_tracing.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..379aa7f Binary files /dev/null and b/tests/__pycache__/test_tracing.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_tracing.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_tracing.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..40a5895 Binary files /dev/null and b/tests/__pycache__/test_tracing.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_tracing.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_tracing.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..999e389 Binary files /dev/null and b/tests/__pycache__/test_tracing.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_tracing.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_tracing.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..dc92ec4 Binary files /dev/null and b/tests/__pycache__/test_tracing.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_tracing_errors.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_tracing_errors.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..bedc4f5 Binary files /dev/null and b/tests/__pycache__/test_tracing_errors.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_tracing_errors.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_tracing_errors.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..d6fc80e Binary files /dev/null and b/tests/__pycache__/test_tracing_errors.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_tracing_errors.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_tracing_errors.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..b7b9429 Binary files /dev/null and b/tests/__pycache__/test_tracing_errors.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_tracing_errors.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_tracing_errors.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..16af2c3 Binary files /dev/null and b/tests/__pycache__/test_tracing_errors.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_tracing_errors_streamed.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/test_tracing_errors_streamed.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..b4ecdf7 Binary files /dev/null and b/tests/__pycache__/test_tracing_errors_streamed.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_tracing_errors_streamed.cpython-313-pytest-8.3.5.pyc b/tests/__pycache__/test_tracing_errors_streamed.cpython-313-pytest-8.3.5.pyc new file mode 100644 index 0000000..385eb1c Binary files /dev/null and b/tests/__pycache__/test_tracing_errors_streamed.cpython-313-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/test_tracing_errors_streamed.cpython-39-pytest-8.3.4.pyc b/tests/__pycache__/test_tracing_errors_streamed.cpython-39-pytest-8.3.4.pyc new file mode 100644 index 0000000..61d6378 Binary files /dev/null and b/tests/__pycache__/test_tracing_errors_streamed.cpython-39-pytest-8.3.4.pyc differ diff --git a/tests/__pycache__/test_tracing_errors_streamed.cpython-39-pytest-8.3.5.pyc b/tests/__pycache__/test_tracing_errors_streamed.cpython-39-pytest-8.3.5.pyc new file mode 100644 index 0000000..f536717 Binary files /dev/null and b/tests/__pycache__/test_tracing_errors_streamed.cpython-39-pytest-8.3.5.pyc differ diff --git a/tests/__pycache__/testing_processor.cpython-313.pyc b/tests/__pycache__/testing_processor.cpython-313.pyc new file mode 100644 index 0000000..dd65629 Binary files /dev/null and b/tests/__pycache__/testing_processor.cpython-313.pyc differ diff --git a/tests/__pycache__/testing_processor.cpython-39.pyc b/tests/__pycache__/testing_processor.cpython-39.pyc new file mode 100644 index 0000000..dac08ba Binary files /dev/null and b/tests/__pycache__/testing_processor.cpython-39.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ba0d882 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import pytest + +from agents.models import _openai_shared +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from agents.models.openai_responses import OpenAIResponsesModel +from agents.tracing import set_trace_processors +from agents.tracing.setup import GLOBAL_TRACE_PROVIDER + +from .testing_processor import SPAN_PROCESSOR_TESTING + + +# This fixture will run once before any tests are executed +@pytest.fixture(scope="session", autouse=True) +def setup_span_processor(): + set_trace_processors([SPAN_PROCESSOR_TESTING]) + + +# This fixture will run before each test +@pytest.fixture(autouse=True) +def clear_span_processor(): + SPAN_PROCESSOR_TESTING.force_flush() + SPAN_PROCESSOR_TESTING.shutdown() + SPAN_PROCESSOR_TESTING.clear() + + +# This fixture will run before each test +@pytest.fixture(autouse=True) +def clear_openai_settings(): + _openai_shared._default_openai_key = None + _openai_shared._default_openai_client = None + _openai_shared._use_responses_by_default = True + + +# This fixture will run after all tests end +@pytest.fixture(autouse=True, scope="session") +def shutdown_trace_provider(): + yield + GLOBAL_TRACE_PROVIDER.shutdown() + + +@pytest.fixture(autouse=True) +def disable_real_model_clients(monkeypatch, request): + # If the test is marked to allow the method call, don't override it. + if request.node.get_closest_marker("allow_call_model_methods"): + return + + def failing_version(*args, **kwargs): + pytest.fail("Real models should not be used in tests!") + + monkeypatch.setattr(OpenAIResponsesModel, "get_response", failing_version) + monkeypatch.setattr(OpenAIResponsesModel, "stream_response", failing_version) + monkeypatch.setattr(OpenAIChatCompletionsModel, "get_response", failing_version) + monkeypatch.setattr(OpenAIChatCompletionsModel, "stream_response", failing_version) diff --git a/tests/docs/agents.md b/tests/docs/agents.md new file mode 100644 index 0000000..9b6264b --- /dev/null +++ b/tests/docs/agents.md @@ -0,0 +1,131 @@ +# Agents + +Agents are the core building block in your apps. An agent is a large language model (LLM), configured with instructions and tools. + +## Basic configuration + +The most common properties of an agent you'll configure are: + +- `instructions`: also known as a developer message or system prompt. +- `model`: which LLM to use, and optional `model_settings` to configure model tuning parameters like temperature, top_p, etc. +- `tools`: Tools that the agent can use to achieve its tasks. + +```python +from agents import Agent, ModelSettings, function_tool + +def get_weather(city: str) -> str: + return f"The weather in {city} is sunny" + +agent = Agent( + name="Haiku agent", + instructions="Always respond in haiku form", + model="o3-mini", + tools=[function_tool(get_weather)], +) +``` + +## Context + +Agents are generic on their `context` type. Context is a dependency-injection tool: it's an object you create and pass to `Runner.run()`, that is passed to every agent, tool, handoff etc, and it serves as a grab bag of dependencies and state for the agent run. You can provide any Python object as the context. + +```python +@dataclass +class UserContext: + uid: str + is_pro_user: bool + + async def fetch_purchases() -> list[Purchase]: + return ... + +agent = Agent[UserContext]( + ..., +) +``` + +## Output types + +By default, agents produce plain text (i.e. `str`) outputs. If you want the agent to produce a particular type of output, you can use the `output_type` parameter. A common choice is to use [Pydantic](https://docs.pydantic.dev/) objects, but we support any type that can be wrapped in a Pydantic [TypeAdapter](https://docs.pydantic.dev/latest/api/type_adapter/) - dataclasses, lists, TypedDict, etc. + +```python +from pydantic import BaseModel +from agents import Agent + + +class CalendarEvent(BaseModel): + name: str + date: str + participants: list[str] + +agent = Agent( + name="Calendar extractor", + instructions="Extract calendar events from text", + output_type=CalendarEvent, +) +``` + +!!! note + + When you pass an `output_type`, that tells the model to use [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) instead of regular plain text responses. + +## Handoffs + +Handoffs are sub-agents that the agent can delegate to. You provide a list of handoffs, and the agent can choose to delegate to them if relevant. This is a powerful pattern that allows orchestrating modular, specialized agents that excel at a single task. Read more in the [handoffs](handoffs.md) documentation. + +```python +from agents import Agent + +booking_agent = Agent(...) +refund_agent = Agent(...) + +triage_agent = Agent( + name="Triage agent", + instructions=( + "Help the user with their questions." + "If they ask about booking, handoff to the booking agent." + "If they ask about refunds, handoff to the refund agent." + ), + handoffs=[booking_agent, refund_agent], +) +``` + +## Dynamic instructions + +In most cases, you can provide instructions when you create the agent. However, you can also provide dynamic instructions via a function. The function will receive the agent and context, and must return the prompt. Both regular and `async` functions are accepted. + +```python +def dynamic_instructions( + context: RunContextWrapper[UserContext], agent: Agent[UserContext] +) -> str: + return f"The user's name is {context.context.name}. Help them with their questions." + + +agent = Agent[UserContext]( + name="Triage agent", + instructions=dynamic_instructions, +) +``` + +## Lifecycle events (hooks) + +Sometimes, you want to observe the lifecycle of an agent. For example, you may want to log events, or pre-fetch data when certain events occur. You can hook into the agent lifecycle with the `hooks` property. Subclass the [`AgentHooks`][agents.lifecycle.AgentHooks] class, and override the methods you're interested in. + +## Guardrails + +Guardrails allow you to run checks/validations on user input, in parallel to the agent running. For example, you could screen the user's input for relevance. Read more in the [guardrails](guardrails.md) documentation. + +## Cloning/copying agents + +By using the `clone()` method on an agent, you can duplicate an Agent, and optionally change any properties you like. + +```python +pirate_agent = Agent( + name="Pirate", + instructions="Write like a pirate", + model="o3-mini", +) + +robot_agent = pirate_agent.clone( + name="Robot", + instructions="Write like a robot", +) +``` diff --git a/tests/docs/assets/images/favicon-platform.svg b/tests/docs/assets/images/favicon-platform.svg new file mode 100644 index 0000000..91ef0ae --- /dev/null +++ b/tests/docs/assets/images/favicon-platform.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/docs/assets/images/orchestration.png b/tests/docs/assets/images/orchestration.png new file mode 100644 index 0000000..621a833 Binary files /dev/null and b/tests/docs/assets/images/orchestration.png differ diff --git a/tests/docs/assets/logo.svg b/tests/docs/assets/logo.svg new file mode 100644 index 0000000..ba36fc2 --- /dev/null +++ b/tests/docs/assets/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tests/docs/config.md b/tests/docs/config.md new file mode 100644 index 0000000..198d7b7 --- /dev/null +++ b/tests/docs/config.md @@ -0,0 +1,94 @@ +# Configuring the SDK + +## API keys and clients + +By default, the SDK looks for the `OPENAI_API_KEY` environment variable for LLM requests and tracing, as soon as it is imported. If you are unable to set that environment variable before your app starts, you can use the [set_default_openai_key()][agents.set_default_openai_key] function to set the key. + +```python +from agents import set_default_openai_key + +set_default_openai_key("sk-...") +``` + +Alternatively, you can also configure an OpenAI client to be used. By default, the SDK creates an `AsyncOpenAI` instance, using the API key from the environment variable or the default key set above. You can chnage this by using the [set_default_openai_client()][agents.set_default_openai_client] function. + +```python +from openai import AsyncOpenAI +from agents import set_default_openai_client + +custom_client = AsyncOpenAI(base_url="...", api_key="...") +set_default_openai_client(client) +``` + +Finally, you can also customize the OpenAI API that is used. By default, we use the OpenAI Responses API. You can override this to use the Chat Completions API by using the [set_default_openai_api()][agents.set_default_openai_api] function. + +```python +from agents import set_default_openai_api + +set_default_openai_api("chat_completions") +``` + +## Tracing + +Tracing is enabled by default. It uses the OpenAI API keys from the section above by default (i.e. the environment variable or the default key you set). You can specifically set the API key used for tracing by using the [`set_tracing_export_api_key`][agents.set_tracing_export_api_key] function. + +```python +from agents import set_tracing_export_api_key + +set_tracing_export_api_key("sk-...") +``` + +You can also disable tracing entirely by using the [`set_tracing_disabled()`][agents.set_tracing_disabled] function. + +```python +from agents import set_tracing_disabled + +set_tracing_disabled(True) +``` + +## Debug logging + +The SDK has two Python loggers without any handlers set. By default, this means that warnings and errors are sent to `stdout`, but other logs are suppressed. + +To enable verbose logging, use the [`enable_verbose_stdout_logging()`][agents.enable_verbose_stdout_logging] function. + +```python +from agents import enable_verbose_stdout_logging + +enable_verbose_stdout_logging() +``` + +Alternatively, you can customize the logs by adding handlers, filters, formatters, etc. You can read more in the [Python logging guide](https://docs.python.org/3/howto/logging.html). + +```python +import logging + +logger = logging.getLogger("openai.agents") # or openai.agents.tracing for the Tracing logger + +# To make all logs show up +logger.setLevel(logging.DEBUG) +# To make info and above show up +logger.setLevel(logging.INFO) +# To make warning and above show up +logger.setLevel(logging.WARNING) +# etc + +# You can customize this as needed, but this will output to `stderr` by default +logger.addHandler(logging.StreamHandler()) +``` + +### Sensitive data in logs + +Certain logs may contain sensitive data (for example, user data). If you want to disable this data from being logged, set the following environment variables. + +To disable logging LLM inputs and outputs: + +```bash +export OPENAI_AGENTS_DONT_LOG_MODEL_DATA=1 +``` + +To disable logging tool inputs and outputs: + +```bash +export OPENAI_AGENTS_DONT_LOG_TOOL_DATA=1 +``` diff --git a/tests/docs/context.md b/tests/docs/context.md new file mode 100644 index 0000000..5dcaceb --- /dev/null +++ b/tests/docs/context.md @@ -0,0 +1,76 @@ +# Context management + +Context is an overloaded term. There are two main classes of context you might care about: + +1. Context available locally to your code: this is data and dependencies you might need when tool functions run, during callbacks like `on_handoff`, in lifecycle hooks, etc. +2. Context available to LLMs: this is data the LLM sees when generating a response. + +## Local context + +This is represented via the [`RunContextWrapper`][agents.run_context.RunContextWrapper] class and the [`context`][agents.run_context.RunContextWrapper.context] property within it. The way this works is: + +1. You create any Python object you want. A common pattern is to use a dataclass or a Pydantic object. +2. You pass that object to the various run methods (e.g. `Runner.run(..., **context=whatever**))`. +3. All your tool calls, lifecycle hooks etc will be passed a wrapper object, `RunContextWrapper[T]`, where `T` represents your context object type which you can access via `wrapper.context`. + +The **most important** thing to be aware of: every agent, tool function, lifecycle etc for a given agent run must use the same _type_ of context. + +You can use the context for things like: + +- Contextual data for your run (e.g. things like a username/uid or other information about the user) +- Dependencies (e.g. logger objects, data fetchers, etc) +- Helper functions + +!!! danger "Note" + + The context object is **not** sent to the LLM. It is purely a local object that you can read from, write to and call methods on it. + +```python +import asyncio +from dataclasses import dataclass + +from agents import Agent, RunContextWrapper, Runner, function_tool + +@dataclass +class UserInfo: # (1)! + name: str + uid: int + +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)! + + agent = Agent[UserInfo]( # (4)! + name="Assistant", + tools=[function_tool(fetch_user_age)], + ) + + result = await Runner.run( + starting_agent=agent, + input="What is the age of the user?", + context=user_info, + ) + + print(result.final_output) # (5)! + # The user John is 47 years old. + +if __name__ == "__main__": + asyncio.run(main()) +``` + +1. This is the context object. We've used a dataclass here, but you can use any type. +2. This is a tool. You can see it takes a `RunContextWrapper[UserInfo]`. The tool implementation reads from the context. +3. We mark the agent with the generic `UserInfo`, so that the typechecker can catch errors (for example, if we tried to pass a tool that took a different context type). +4. The context is passed to the `run` function. +5. The agent correctly calls the tool and gets the age. + +## Agent/LLM context + +When an LLM is called, the **only** data it can see is from the conversation history. This means that if you want to make some new data available to the LLM, you must do it in a way that makes it available in that history. There are a few ways to do this: + +1. You can add it to the Agent `instructions`. This is also known as a "system prompt" or "developer message". System prompts can be static strings, or they can be dynamic functions that receive the context and output a string. This is a common tactic for information that is always useful (for example, the user's name or the current date). +2. Add it to the `input` when calling the `Runner.run` functions. This is similar to the `instructions` tactic, but allows you to have messages that are lower in the [chain of command](https://cdn.openai.com/spec/model-spec-2024-05-08.html#follow-the-chain-of-command). +3. Expose it via function tools. This is useful for _on-demand_ context - the LLM decides when it needs some data, and can call the tool to fetch that data. +4. Use retrieval or web search. These are special tools that are able to fetch relevant data from files or databases (retrieval), or from the web (web search). This is useful for "grounding" the response in relevant contextual data. diff --git a/tests/docs/guardrails.md b/tests/docs/guardrails.md new file mode 100644 index 0000000..2b7369c --- /dev/null +++ b/tests/docs/guardrails.md @@ -0,0 +1,154 @@ +# Guardrails + +Guardrails run _in parallel_ to your agents, enabling you to do checks and validations of user input. For example, imagine you have an agent that uses a very smart (and hence slow/expensive) model to help with customer requests. You wouldn't want malicious users to ask the model to help them with their math homework. So, you can run a guardrail with a fast/cheap model. If the guardrail detects malicious usage, it can immediately raise an error, which stops the expensive model from running and saves you time/money. + +There are two kinds of guardrails: + +1. Input guardrails run on the initial user input +2. Output guardrails run on the final agent output + +## Input guardrails + +Input guardrails run in 3 steps: + +1. First, the guardrail receives the same input passed to the agent. +2. Next, the guardrail function runs to produce a [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput], which is then wrapped in an [`InputGuardrailResult`][agents.guardrail.InputGuardrailResult] +3. Finally, we check if [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] is true. If true, an [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered] exception is raised, so you can appropriately respond to the user or handle the exception. + +!!! Note + + Input guardrails are intended to run on user input, so an agent's guardrails only run if the agent is the *first* agent. You might wonder, why is the `guardrails` property on the agent instead of passed to `Runner.run`? It's 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 + +Output guardrailas run in 3 steps: + +1. First, the guardrail receives the same input passed to the agent. +2. Next, the guardrail function runs to produce a [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput], which is then wrapped in an [`OutputGuardrailResult`][agents.guardrail.OutputGuardrailResult] +3. Finally, we check if [`.tripwire_triggered`][agents.guardrail.GuardrailFunctionOutput.tripwire_triggered] is true. If true, an [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] exception is raised, so you can appropriately respond to the user or handle the exception. + +!!! 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. + +## Tripwires + +If the input or output fails the guardrail, the Guardrail can signal this with a tripwire. As soon as we see a guardail that has triggered the tripwires, we immediately raise a `{Input,Output}GuardrailTripwireTriggered` exception and halt the Agent execution. + +## Implementing a guardrail + +You need to provide a function that receives input, and returns a [`GuardrailFunctionOutput`][agents.guardrail.GuardrailFunctionOutput]. In this example, we'll do this by running an Agent under the hood. + +```python +from pydantic import BaseModel +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + TResponseInputItem, + input_guardrail, +) + +class MathHomeworkOutput(BaseModel): + is_math_homework: bool + reasoning: str + +guardrail_agent = Agent( # (1)! + name="Guardrail check", + instructions="Check if the user is asking you to do their math homework.", + output_type=MathHomeworkOutput, +) + + +@input_guardrail +async def math_guardrail( # (2)! + ctx: RunContextWrapper[None], agent: Agent, input: str | list[TResponseInputItem] +) -> GuardrailFunctionOutput: + result = await Runner.run(guardrail_agent, input, context=ctx.context) + + return GuardrailFunctionOutput( + output_info=result.final_output, # (3)! + tripwire_triggered=result.final_output.is_math_homework, + ) + + +agent = Agent( # (4)! + name="Customer support agent", + instructions="You are a customer support agent. You help customers with their questions.", + input_guardrails=[math_guardrail], +) + +async def main(): + # This should trip the guardrail + try: + await Runner.run(agent, "Hello, can you help me solve for x: 2x + 3 = 11?") + print("Guardrail didn't trip - this is unexpected") + + except InputGuardrailTripwireTriggered: + print("Math homework guardrail tripped") +``` + +1. We'll use this agent in our guardrail function. +2. This is the guardrail function that receives the agent's input/context, and returns the result. +3. We can include extra information in the guardrail result. +4. This is the actual agent that defines the workflow. + +Output guardrails are similar. + +```python +from pydantic import BaseModel +from agents import ( + Agent, + GuardrailFunctionOutput, + OutputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + output_guardrail, +) +class MessageOutput(BaseModel): # (1)! + response: str + +class MathOutput(BaseModel): # (2)! + is_math: bool + reasoning: str + +guardrail_agent = Agent( + name="Guardrail check", + instructions="Check if the output includes any math.", + output_type=MathOutput, +) + +@output_guardrail +async def math_guardrail( # (3)! + ctx: RunContextWrapper, agent: Agent, output: MessageOutput +) -> GuardrailFunctionOutput: + result = await Runner.run(guardrail_agent, output.response, context=ctx.context) + + return GuardrailFunctionOutput( + output_info=result.final_output, + tripwire_triggered=result.final_output.is_math, + ) + +agent = Agent( # (4)! + name="Customer support agent", + instructions="You are a customer support agent. You help customers with their questions.", + output_guardrails=[math_guardrail], + output_type=MessageOutput, +) + +async def main(): + # This should trip the guardrail + try: + await Runner.run(agent, "Hello, can you help me solve for x: 2x + 3 = 11?") + print("Guardrail didn't trip - this is unexpected") + + except OutputGuardrailTripwireTriggered: + print("Math output guardrail tripped") +``` + +1. This is the actual agent's output type. +2. This is the guardrail's output type. +3. This is the guardrail function that receives the agent's output, and returns the result. +4. This is the actual agent that defines the workflow. diff --git a/tests/docs/handoffs.md b/tests/docs/handoffs.md new file mode 100644 index 0000000..0b868c4 --- /dev/null +++ b/tests/docs/handoffs.md @@ -0,0 +1,113 @@ +# Handoffs + +Handoffs allow an agent to delegate tasks to another agent. This is particularly useful in scenarios where different agents specialize in distinct areas. For example, a customer support app might have agents that each specifically handle tasks like order status, refunds, FAQs, etc. + +Handoffs are represented as tools to the LLM. So if there's a handoff to an agent named `Refund Agent`, the tool would be called `transfer_to_refund_agent`. + +## Creating a handoff + +All agents have a [`handoffs`][agents.agent.Agent.handoffs] param, which can either take an `Agent` directly, or a `Handoff` object that customizes the Handoff. + +You can create a handoff using the [`handoff()`][agents.handoffs.handoff] function provided by the Agents SDK. This function allows you to specify the agent to hand off to, along with optional overrides and input filters. + +### Basic Usage + +Here's how you can create a simple handoff: + +```python +from agents import Agent, handoff + +billing_agent = Agent(name="Billing agent") +refund_agent = Agent(name="Refund agent") + +# (1)! +triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refund_agent)]) +``` + +1. You can use the agent directly (as in `billing_agent`), or you can use the `handoff()` function. + +### Customizing handoffs via the `handoff()` function + +The [`handoff()`][agents.handoffs.handoff] function lets you customize things. + +- `agent`: This is the agent to which things will be handed off. +- `tool_name_override`: By default, the `Handoff.default_tool_name()` function is used, which resolves to `transfer_to_`. You can override this. +- `tool_description_override`: Override the default tool description from `Handoff.default_tool_description()` +- `on_handoff`: A callback function executed when the handoff is invoked. This is useful for things like kicking off some data fetching as soon as you know a handoff is being invoked. This function receives the agent context, and can optionally also receive LLM generated input. The input data is controlled by the `input_type` param. +- `input_type`: The type of input expected by the handoff (optional). +- `input_filter`: This lets you filter the input received by the next agent. See below for more. + +```python +from agents import Agent, handoff, RunContextWrapper + +def on_handoff(ctx: RunContextWrapper[None]): + print("Handoff called") + +agent = Agent(name="My agent") + +handoff_obj = handoff( + agent=agent, + on_handoff=on_handoff, + tool_name_override="custom_handoff_tool", + tool_description_override="Custom description", +) +``` + +## Handoff inputs + +In certain situations, you want the LLM to provide some data when it calls a handoff. For example, imagine a handoff to an "Escalation agent". You might want a reason to be provided, so you can log it. + +```python +from pydantic import BaseModel + +from agents import Agent, handoff, RunContextWrapper + +class EscalationData(BaseModel): + reason: str + +async def on_handoff(ctx: RunContextWrapper[None], input_data: EscalationData): + print(f"Escalation agent called with reason: {input_data.reason}") + +agent = Agent(name="Escalation agent") + +handoff_obj = handoff( + agent=agent, + on_handoff=on_handoff, + input_type=EscalationData, +) +``` + +## Input filters + +When a handoff occurs, it's as though the new agent takes over the conversation, and gets to see the entire previous conversation history. If you want to change this, you can set an [`input_filter`][agents.handoffs.Handoff.input_filter]. An input filter is a function that receives the existing input via a [`HandoffInputData`][agents.handoffs.HandoffInputData], and must return a new `HandoffInputData`. + +There are some common patterns (for example removing all tool calls from the history), which are implemented for you in [`agents.extensions.handoff_filters`][] + +```python +from agents import Agent, handoff +from agents.extensions import handoff_filters + +agent = Agent(name="FAQ agent") + +handoff_obj = handoff( + agent=agent, + input_filter=handoff_filters.remove_all_tools, # (1)! +) +``` + +1. This will automatically remove all tools from the history when `FAQ agent` is called. + +## Recommended prompts + +To make sure that LLMs understand handoffs properly, we recommend including information about handoffs in your agents. We have a suggested prefix in [`agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX`][], or you can call [`agents.extensions.handoff_prompt.prompt_with_handoff_instructions`][] to automatically add recommended data to your prompts. + +```python +from agents import Agent +from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX + +billing_agent = Agent( + name="Billing agent", + instructions=f"""{RECOMMENDED_PROMPT_PREFIX} + .""", +) +``` diff --git a/tests/docs/index.md b/tests/docs/index.md new file mode 100644 index 0000000..28c6870 --- /dev/null +++ b/tests/docs/index.md @@ -0,0 +1,52 @@ +# OpenAI Agents SDK + +The OpenAI Agents SDK enables you to build agentic AI apps in a lightweight, easy to use package with very few abstractions. It's a production-ready upgrade of our previous experimentation for agents, [Swarm](https://github.com/openai/swarm/tree/main). The Agents SDK has a very small set of primitives: + +- **Agents**, which are LLMs equipped with instructions and tools +- **Handoffs**, which allow agents to delegate to other agents for specific tasks +- **Guardrails**, which enable the inputs to agents to be validated + +In combination with Python, these primitives are powerful enough to express complex relationships between tools and agents, and allow you to build real world applications without a steep learning curve. In addition, the SDK comes with built-in **tracing** that lets you visualize and debug your agentic flows, as well as evaluate them and even fine-tune models for your application. + +## Why use the Agents SDK + +The SDK has two driving design principles: + +1. Enough features to be worth using, but few enough primitives to make it quick to learn. +2. Works great out of the box, but you can customize exactly what happens. + +Here are the main features of the SDK: + +- Agent loop: Built-in agent loop that handles calling tools, sending results to the LLM, and looping until the LLM is done. +- Python-first: Use built-in language features to orchestrate and chain agents, rather than needing to learn new abstractions. +- Handoffs: A powerful feature to coordinate and delegate between multiple agents. +- Guardrails: Run input validations and checks in parallel to your agents, breaking early if the checks fail. +- Function tools: Turn any Python function into a tool, with automatic schema generation and Pydantic-powered validation. +- Tracing: Built-in tracing that lets you visualize, debug and monitor your workflows, as well as use the OpenAI suite of evaluation, fine-tuning and distillation tools. + +## Installation + +```bash +pip install openai-agents +``` + +## Hello world example + +```python +from agents import Agent, Runner + +agent = Agent(name="Assistant", instructions="You are a helpful assistant") + +result = Runner.run_sync(agent, "Write a haiku about recursion in programming.") +print(result.final_output) + +# Code within the code, +# Functions calling themselves, +# Infinite loop's dance. +``` + +(_If running this, ensure you set the `OPENAI_API_KEY` environment variable_) + +```bash +export OPENAI_API_KEY=sk-... +``` diff --git a/tests/docs/models.md b/tests/docs/models.md new file mode 100644 index 0000000..7d2ff1f --- /dev/null +++ b/tests/docs/models.md @@ -0,0 +1,73 @@ +# Models + +The Agents SDK comes with out of the box support for OpenAI models in two flavors: + +- **Recommended**: the [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel], which calls OpenAI APIs using the new [Responses API](https://platform.openai.com/docs/api-reference/responses). +- The [`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel], which calls OpenAI APIs using the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). + +## Mixing and matching models + +Within a single workflow, you may want to use different models for each agent. For example, you could use a smaller, faster model for triage, while using a larger, more capable model for complex tasks. When configuring an [`Agent`][agents.Agent], you can select a specific model by either: + +1. Passing the name of an OpenAI model. +2. Passing any model name + a [`ModelProvider`][agents.models.interface.ModelProvider] that can map that name to a Model instance. +3. Directly providing a [`Model`][agents.models.interface.Model] implementation. + +!!!note + + While our SDK supports both the [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel] and the[`OpenAIChatCompletionsModel`][agents.models.openai_chatcompletions.OpenAIChatCompletionsModel] shapes, we recommend using a single model shape for each workflow because the two shapes support a different set of features and tools. If your workflow requires mixing and matching model shapes, make sure that all the features you're using are available on both. + +```python +from agents import Agent, Runner, AsyncOpenAI, OpenAIChatCompletionsModel +import asyncio + +spanish_agent = Agent( + name="Spanish agent", + instructions="You only speak Spanish.", + model="o3-mini", # (1)! +) + +english_agent = Agent( + name="English agent", + instructions="You only speak English", + model=OpenAIChatCompletionsModel( # (2)! + model="gpt-4o", + openai_client=AsyncOpenAI() + ), +) + +triage_agent = Agent( + name="Triage agent", + instructions="Handoff to the appropriate agent based on the language of the request.", + handoffs=[spanish_agent, english_agent], + model="gpt-3.5-turbo", +) + +async def main(): + result = await Runner.run(triage_agent, input="Hola, ¿cómo estás?") + print(result.final_output) +``` + +1. Sets the the name of an OpenAI model directly. +2. Provides a [`Model`][agents.models.interface.Model] implementation. + +## Using other LLM providers + +Many providers also support the OpenAI API format, which means you can pass a `base_url` to the existing OpenAI model implementations and use them easily. `ModelSettings` is used to configure tuning parameters (e.g., temperature, top_p) for the model you select. + +```python +external_client = AsyncOpenAI( + api_key="EXTERNAL_API_KEY", + base_url="https://api.external.com/v1/", +) + +spanish_agent = Agent( + name="Spanish agent", + instructions="You only speak Spanish.", + model=OpenAIChatCompletionsModel( + model="EXTERNAL_MODEL_NAME", + openai_client=external_client, + ), + model_settings=ModelSettings(temperature=0.5), +) +``` diff --git a/tests/docs/multi_agent.md b/tests/docs/multi_agent.md new file mode 100644 index 0000000..c118249 --- /dev/null +++ b/tests/docs/multi_agent.md @@ -0,0 +1,37 @@ +# Orchestrating multiple agents + +Orchestration refers to the flow of agents in your app. Which agents run, in what order, and how do they decide what happens next? There are two main ways to orchestrate agents: + +1. Allowing the LLM to make decisions: this uses the intelligence of an LLM to plan, reason, and decide on what steps to take based on that. +2. Orchestrating via code: determining the flow of agents via your code. + +You can mix and match these patterns. Each has their own tradeoffs, described below. + +## Orchestrating via LLM + +An agent is an LLM equipped with instructions, tools and handoffs. This means that given an open-ended task, the LLM can autonomously plan how it will tackle the task, using tools to take actions and acquire data, and using handoffs to delegate tasks to sub-agents. For example, a research agent could be equipped with tools like: + +- Web search to find information online +- File search and retrieval to search through proprietary data and connections +- Computer use to take actions on a computer +- Code execution to do data analysis +- Handoffs to specialized agents that are great at planning, report writing and more. + +This pattern is great when the task is open-ended and you want to rely on the intelligence of an LLM. The most important tactics here are: + +1. Invest in good prompts. Make it clear what tools are available, how to use them, and what parameters it must operate within. +2. Monitor your app and iterate on it. See where things go wrong, and iterate on your prompts. +3. Allow the agent to introspect and improve. For example, run it in a loop, and let it critique itself; or, provide error messages and let it improve. +4. Have specialized agents that excel in one task, rather than having a general purpose agent that is expected to be good at anything. +5. Invest in [evals](https://platform.openai.com/docs/guides/evals). This lets you train your agents to improve and get better at tasks. + +## Orchestrating via code + +While orchestrating via LLM is powerful, orchestrating via LLM makes tasks more deterministic and predictable, in terms of speed, cost and performance. Common patterns here are: + +- Using [structured outputs](https://platform.openai.com/docs/guides/structured-outputs) to generate well formed data that you can inspect with your code. For example, you might ask an agent to classify the task into a few categories, and then pick the next agent based on the category. +- Chaining multiple agents by transforming the output of one into the input of the next. You can decompose a task like writing a blog post into a series of steps - do research, write an outline, write the blog post, critique it, and then improve it. +- Running the agent that performs the task in a `while` loop with an agent that evaluates and provides feedback, until the evaluator says the output passes certain criteria. +- Running multiple agents in parallel, e.g. via Python primitives like `asyncio.gather`. This is useful for speed when you have multiple tasks that don't depend on each other. + +We have a number of examples in [`examples/agent_patterns`](https://github.com/openai/openai-agents-python/examples/agent_patterns). diff --git a/tests/docs/quickstart.md b/tests/docs/quickstart.md new file mode 100644 index 0000000..19051f4 --- /dev/null +++ b/tests/docs/quickstart.md @@ -0,0 +1,186 @@ +# Quickstart + +## Create a project and virtual environment + +You'll only need to do this once. + +```bash +mkdir my_project +cd my_project +python -m venv .venv +``` + +### Activate the virtual environment + +Do this every time you start a new terminal session. + +```bash +source .venv/bin/activate +``` + +### Install the Agents SDK + +```bash +pip install openai-agents # or `uv add openai-agents`, etc +``` + +### Set an OpenAI API key + +If you don't have one, follow [these instructions](https://platform.openai.com/docs/quickstart#create-and-export-an-api-key) to create an OpenAI API key. + +```bash +export OPENAI_API_KEY=sk-... +``` + +## Create your first agent + +Agents are defined with instructions, a name, and optional config (such as `model_config`) + +```python +from agents import Agent + +agent = Agent( + name="Math Tutor", + instructions="You provide help with math problems. Explain your reasoning at each step and include examples", +) +``` + +## Add a few more agents + +Additional agents can be defined in the same way. `handoff_descriptions` provide additional context for determining handoff routing + +```python +from agents import Agent + +history_tutor_agent = Agent( + name="History Tutor", + handoff_description="Specialist agent for historical questions", + instructions="You provide assistance with historical queries. Explain important events and context clearly.", +) + +math_tutor_agent = Agent( + name="Math Tutor", + handoff_description="Specialist agent for math questions", + instructions="You provide help with math problems. Explain your reasoning at each step and include examples", +) +``` + +## Define your handoffs + +On each agent, you can define an inventory of outgoing handoff options that the agent can choose from to decide how to make progress on their task. + +```python +triage_agent = Agent( + name="Triage Agent", + instructions="You determine which agent to use based on the user's homework question", + handoffs=[history_tutor_agent, math_tutor_agent] +) +``` + +## Run the agent orchestration + +Let's check that the workflow runs and the triage agent correctly routes between the two specialist agents. + +```python +from agents import Runner + +async def main(): + result = await Runner.run(triage_agent, "What is the capital of France?") + print(result.final_output) +``` + +## Add a guardrail + +You can define custom guardrails to run on the input or output. + +```python +from agents import GuardrailFunctionOutput, Agent, Runner +from pydantic import BaseModel + +class HomeworkOutput(BaseModel): + is_homework: bool + reasoning: str + +guardrail_agent = Agent( + name="Guardrail check", + instructions="Check if the user is asking about homework.", + output_type=HomeworkOutput, +) + +async def homework_guardrail(ctx, agent, input_data): + result = await Runner.run(guardrail_agent, input_data, context=ctx.context) + final_output = result.final_output_as(HomeworkOutput) + return GuardrailFunctionOutput( + output_info=final_output, + tripwire_triggered=not final_output.is_homework, + ) +``` + +## Put it all together + +Let's put it all together and run the entire workflow, using handoffs and the input guardrail. + +```python +from agents import Agent, InputGuardrail,GuardrailFunctionOutput, Runner +from pydantic import BaseModel +import asyncio + +class HomeworkOutput(BaseModel): + is_homework: bool + reasoning: str + +guardrail_agent = Agent( + name="Guardrail check", + instructions="Check if the user is asking about homework.", + output_type=HomeworkOutput, +) + +math_tutor_agent = Agent( + name="Math Tutor", + handoff_description="Specialist agent for math questions", + instructions="You provide help with math problems. Explain your reasoning at each step and include examples", +) + +history_tutor_agent = Agent( + name="History Tutor", + handoff_description="Specialist agent for historical questions", + instructions="You provide assistance with historical queries. Explain important events and context clearly.", +) + + +async def homework_guardrail(ctx, agent, input_data): + result = await Runner.run(guardrail_agent, input_data, context=ctx.context) + final_output = result.final_output_as(HomeworkOutput) + return GuardrailFunctionOutput( + output_info=final_output, + tripwire_triggered=not final_output.is_homework, + ) + +triage_agent = Agent( + name="Triage Agent", + instructions="You determine which agent to use based on the user's homework question", + handoffs=[history_tutor_agent, math_tutor_agent], + input_guardrails=[ + InputGuardrail(guardrail_function=homework_guardrail), + ], +) + +async def main(): + result = await Runner.run(triage_agent, "what is life") + print(result.final_output) + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## View your traces + +To review what happened during your agent run, navigate to the [Trace viewer in the OpenAI Dashboard](https://platform.openai.com/traces) to view traces of your agent runs. + +## Next steps + +Learn how to build more complex agentic flows: + +- Learn about how to configure [Agents](agents.md). +- Learn about [running agents](running_agents.md). +- Learn about [tools](tools.md), [guardrails](guardrails.md) and [models](models.md). diff --git a/tests/docs/ref/agent.md b/tests/docs/ref/agent.md new file mode 100644 index 0000000..9f8b10d --- /dev/null +++ b/tests/docs/ref/agent.md @@ -0,0 +1,3 @@ +# `Agents` + +::: agents.agent diff --git a/tests/docs/ref/agent_output.md b/tests/docs/ref/agent_output.md new file mode 100644 index 0000000..e453de0 --- /dev/null +++ b/tests/docs/ref/agent_output.md @@ -0,0 +1,3 @@ +# `Agent output` + +::: agents.agent_output diff --git a/tests/docs/ref/exceptions.md b/tests/docs/ref/exceptions.md new file mode 100644 index 0000000..7c1a254 --- /dev/null +++ b/tests/docs/ref/exceptions.md @@ -0,0 +1,3 @@ +# `Exceptions` + +::: agents.exceptions diff --git a/tests/docs/ref/extensions/handoff_filters.md b/tests/docs/ref/extensions/handoff_filters.md new file mode 100644 index 0000000..0ffcb13 --- /dev/null +++ b/tests/docs/ref/extensions/handoff_filters.md @@ -0,0 +1,3 @@ +# `Handoff filters` + +::: agents.extensions.handoff_filters diff --git a/tests/docs/ref/extensions/handoff_prompt.md b/tests/docs/ref/extensions/handoff_prompt.md new file mode 100644 index 0000000..ca80076 --- /dev/null +++ b/tests/docs/ref/extensions/handoff_prompt.md @@ -0,0 +1,8 @@ +# `Handoff prompt` + +::: agents.extensions.handoff_prompt + + options: + members: + - RECOMMENDED_PROMPT_PREFIX + - prompt_with_handoff_instructions diff --git a/tests/docs/ref/function_schema.md b/tests/docs/ref/function_schema.md new file mode 100644 index 0000000..06aac2a --- /dev/null +++ b/tests/docs/ref/function_schema.md @@ -0,0 +1,3 @@ +# `Function schema` + +::: agents.function_schema diff --git a/tests/docs/ref/guardrail.md b/tests/docs/ref/guardrail.md new file mode 100644 index 0000000..17ec929 --- /dev/null +++ b/tests/docs/ref/guardrail.md @@ -0,0 +1,3 @@ +# `Guardrails` + +::: agents.guardrail diff --git a/tests/docs/ref/handoffs.md b/tests/docs/ref/handoffs.md new file mode 100644 index 0000000..717a918 --- /dev/null +++ b/tests/docs/ref/handoffs.md @@ -0,0 +1,3 @@ +# `Handoffs` + +::: agents.handoffs diff --git a/tests/docs/ref/index.md b/tests/docs/ref/index.md new file mode 100644 index 0000000..1b8439f --- /dev/null +++ b/tests/docs/ref/index.md @@ -0,0 +1,13 @@ +# Agents module + +::: agents + + options: + members: + - set_default_openai_key + - set_default_openai_client + - set_default_openai_api + - set_tracing_export_api_key + - set_tracing_disabled + - set_trace_processors + - enable_verbose_stdout_logging diff --git a/tests/docs/ref/items.md b/tests/docs/ref/items.md new file mode 100644 index 0000000..29279e1 --- /dev/null +++ b/tests/docs/ref/items.md @@ -0,0 +1,3 @@ +# `Items` + +::: agents.items diff --git a/tests/docs/ref/lifecycle.md b/tests/docs/ref/lifecycle.md new file mode 100644 index 0000000..432af14 --- /dev/null +++ b/tests/docs/ref/lifecycle.md @@ -0,0 +1,6 @@ +# `Lifecycle` + +::: agents.lifecycle + + options: + show_source: false diff --git a/tests/docs/ref/model_settings.md b/tests/docs/ref/model_settings.md new file mode 100644 index 0000000..f7f411f --- /dev/null +++ b/tests/docs/ref/model_settings.md @@ -0,0 +1,3 @@ +# `Model settings` + +::: agents.model_settings diff --git a/tests/docs/ref/models/interface.md b/tests/docs/ref/models/interface.md new file mode 100644 index 0000000..e7bd89a --- /dev/null +++ b/tests/docs/ref/models/interface.md @@ -0,0 +1,3 @@ +# `Model interface` + +::: agents.models.interface diff --git a/tests/docs/ref/models/openai_chatcompletions.md b/tests/docs/ref/models/openai_chatcompletions.md new file mode 100644 index 0000000..76cf563 --- /dev/null +++ b/tests/docs/ref/models/openai_chatcompletions.md @@ -0,0 +1,3 @@ +# `OpenAI Chat Completions model` + +::: agents.models.openai_chatcompletions diff --git a/tests/docs/ref/models/openai_responses.md b/tests/docs/ref/models/openai_responses.md new file mode 100644 index 0000000..e1794ba --- /dev/null +++ b/tests/docs/ref/models/openai_responses.md @@ -0,0 +1,3 @@ +# `OpenAI Responses model` + +::: agents.models.openai_responses diff --git a/tests/docs/ref/result.md b/tests/docs/ref/result.md new file mode 100644 index 0000000..3a9e4a9 --- /dev/null +++ b/tests/docs/ref/result.md @@ -0,0 +1,3 @@ +# `Results` + +::: agents.result diff --git a/tests/docs/ref/run.md b/tests/docs/ref/run.md new file mode 100644 index 0000000..ddf4475 --- /dev/null +++ b/tests/docs/ref/run.md @@ -0,0 +1,8 @@ +# `Runner` + +::: agents.run + + options: + members: + - Runner + - RunConfig diff --git a/tests/docs/ref/run_context.md b/tests/docs/ref/run_context.md new file mode 100644 index 0000000..49e8730 --- /dev/null +++ b/tests/docs/ref/run_context.md @@ -0,0 +1,3 @@ +# `Run context` + +::: agents.run_context diff --git a/tests/docs/ref/stream_events.md b/tests/docs/ref/stream_events.md new file mode 100644 index 0000000..ea48431 --- /dev/null +++ b/tests/docs/ref/stream_events.md @@ -0,0 +1,3 @@ +# `Streaming events` + +::: agents.stream_events diff --git a/tests/docs/ref/tool.md b/tests/docs/ref/tool.md new file mode 100644 index 0000000..887bef7 --- /dev/null +++ b/tests/docs/ref/tool.md @@ -0,0 +1,3 @@ +# `Tools` + +::: agents.tool diff --git a/tests/docs/ref/tracing/create.md b/tests/docs/ref/tracing/create.md new file mode 100644 index 0000000..c983e33 --- /dev/null +++ b/tests/docs/ref/tracing/create.md @@ -0,0 +1,3 @@ +# `Creating traces/spans` + +::: agents.tracing.create diff --git a/tests/docs/ref/tracing/index.md b/tests/docs/ref/tracing/index.md new file mode 100644 index 0000000..88a0fe6 --- /dev/null +++ b/tests/docs/ref/tracing/index.md @@ -0,0 +1,3 @@ +# Tracing module + +::: agents.tracing diff --git a/tests/docs/ref/tracing/processor_interface.md b/tests/docs/ref/tracing/processor_interface.md new file mode 100644 index 0000000..9fb04e8 --- /dev/null +++ b/tests/docs/ref/tracing/processor_interface.md @@ -0,0 +1,3 @@ +# `Processor interface` + +::: agents.tracing.processor_interface diff --git a/tests/docs/ref/tracing/processors.md b/tests/docs/ref/tracing/processors.md new file mode 100644 index 0000000..d7ac4af --- /dev/null +++ b/tests/docs/ref/tracing/processors.md @@ -0,0 +1,3 @@ +# `Processors` + +::: agents.tracing.processors diff --git a/tests/docs/ref/tracing/scope.md b/tests/docs/ref/tracing/scope.md new file mode 100644 index 0000000..7b5b9fd --- /dev/null +++ b/tests/docs/ref/tracing/scope.md @@ -0,0 +1,3 @@ +# `Scope` + +::: agents.tracing.scope diff --git a/tests/docs/ref/tracing/setup.md b/tests/docs/ref/tracing/setup.md new file mode 100644 index 0000000..1dc6a0f --- /dev/null +++ b/tests/docs/ref/tracing/setup.md @@ -0,0 +1,3 @@ +# `Setup` + +::: agents.tracing.setup diff --git a/tests/docs/ref/tracing/span_data.md b/tests/docs/ref/tracing/span_data.md new file mode 100644 index 0000000..6ace7a8 --- /dev/null +++ b/tests/docs/ref/tracing/span_data.md @@ -0,0 +1,3 @@ +# `Span data` + +::: agents.tracing.span_data diff --git a/tests/docs/ref/tracing/spans.md b/tests/docs/ref/tracing/spans.md new file mode 100644 index 0000000..9071707 --- /dev/null +++ b/tests/docs/ref/tracing/spans.md @@ -0,0 +1,9 @@ +# `Spans` + +::: agents.tracing.spans + + options: + members: + - Span + - NoOpSpan + - SpanImpl diff --git a/tests/docs/ref/tracing/traces.md b/tests/docs/ref/tracing/traces.md new file mode 100644 index 0000000..0b7377f --- /dev/null +++ b/tests/docs/ref/tracing/traces.md @@ -0,0 +1,3 @@ +# `Traces` + +::: agents.tracing.traces diff --git a/tests/docs/ref/tracing/util.md b/tests/docs/ref/tracing/util.md new file mode 100644 index 0000000..2be3d58 --- /dev/null +++ b/tests/docs/ref/tracing/util.md @@ -0,0 +1,3 @@ +# `Util` + +::: agents.tracing.util diff --git a/tests/docs/ref/usage.md b/tests/docs/ref/usage.md new file mode 100644 index 0000000..b8b29db --- /dev/null +++ b/tests/docs/ref/usage.md @@ -0,0 +1,3 @@ +# `Usage` + +::: agents.usage diff --git a/tests/docs/results.md b/tests/docs/results.md new file mode 100644 index 0000000..d1864fa --- /dev/null +++ b/tests/docs/results.md @@ -0,0 +1,52 @@ +# Results + +When you call the `Runner.run` methods, you either get a: + +- [`RunResult`][agents.result.RunResult] if you call `run` or `run_sync` +- [`RunResultStreaming`][agents.result.RunResultStreaming] if you call `run_streamed` + +Both of these inherit from [`RunResultBase`][agents.result.RunResultBase], which is where most useful information is present. + +## Final output + +The [`final_output`][agents.result.RunResultBase.final_output] property contains the final output of the last agent that ran. This is either: + +- a `str`, if the last agent didn't have an `output_type` defined +- an object of type `last_agent.output_type`, if the agent had an output type defined. + +!!! note + + `final_output` is of type `Any`. We can't statically type this, because of handoffs. If handoffs occur, that means any Agent might be the last agent, so we don't statically know the set of possible output types. + +## Inputs for the next turn + +You can use [`result.to_input_list()`][agents.result.RunResultBase.to_input_list] to turn the result into an input list that concatenates the original input you provided, to the items generated during the agent run. This makes it convenient to take the outputs of one agent run and pass them into another run, or to run it in a loop and append new user inputs each time. + +## Last agent + +The [`last_agent`][agents.result.RunResultBase.last_agent] property contains the last agent that ran. Depending on your application, this is often useful for the next time the user inputs something. For example, if you have a frontline triage agent that hands off to a language-specific agent, you can store the last agent, and re-use it the next time the user messages the agent. + +## New items + +The [`new_items`][agents.result.RunResultBase.new_items] property contains the new items generated during the run. The items are [`RunItem`][agents.items.RunItem]s. A run item wraps the raw item generated by the LLM. + +- [`MessageOutputItem`][agents.items.MessageOutputItem] indicates a message from the LLM. The raw item is the message generated. +- [`HandoffCallItem`][agents.items.HandoffCallItem] indicates that the LLM called the handoff tool. The raw item is the tool call item from the LLM. +- [`HandoffOutputItem`][agents.items.HandoffOutputItem] indicates that a handoff occured. The raw item is the tool response to the handoff tool call. You can also access the source/target agents from the item. +- [`ToolCallItem`][agents.items.ToolCallItem] indicates that the LLM invoked a tool. +- [`ToolCallOutputItem`][agents.items.ToolCallOutputItem] indicates that a tool was called. The raw item is the tool response. You can also access the tool output from the item. +- [`ReasoningItem`][agents.items.ReasoningItem] indicates a reasoning item from the LLM. The raw item is the reasoning generated. + +## Other information + +### Guardrail results + +The [`input_guardrail_results`][agents.result.RunResultBase.input_guardrail_results] and [`output_guardrail_results`][agents.result.RunResultBase.output_guardrail_results] properties contain the results of the guardrails, if any. Guardrail results can sometimes contain useful information you want to log or store, so we make these available to you. + +### Raw responses + +The [`raw_responses`][agents.result.RunResultBase.raw_responses] property contains the [`ModelResponse`][agents.items.ModelResponse]s generated by the LLM. + +### Original input + +The [`input`][agents.result.RunResultBase.input] property contains the original input you provided to the `run` method. In most cases you won't need this, but it's available in case you do. diff --git a/tests/docs/running_agents.md b/tests/docs/running_agents.md new file mode 100644 index 0000000..a2f137c --- /dev/null +++ b/tests/docs/running_agents.md @@ -0,0 +1,95 @@ +# Running agents + +You can run agents via the [`Runner`][agents.run.Runner] class. You have 3 options: + +1. [`Runner.run()`][agents.run.Runner.run], which runs async and returns a [`RunResult`][agents.result.RunResult]. +2. [`Runner.run_sync()`][agents.run.Runner.run_sync], which is a sync method and just runs `.run()` under the hood. +3. [`Runner.run_streamed()`][agents.run.Runner.run_streamed], which runs async and returns a [`RunResultStreaming`][agents.result.RunResultStreaming]. It calls the LLM in streaming mode, and streams those events to you as they are received. + +```python +from agents import Agent, Runner + +async def main(): + agent = Agent(name="Assistant", instructions="You are a helpful assistant") + + result = await Runner.run(agent, "Write a haiku about recursion in programming.") + print(result.final_output) + # Code within the code, + # Functions calling themselves, + # Infinite loop's dance. +``` + +Read more in the [results guide](results.md). + +## The agent loop + +When you use the run method in `Runner`, you pass in a starting agent and input. The input can either be a string (which is considered a user message), or a list of input items, which are the items in the OpenAI Responses API. + +The runner then runs a loop: + +1. We call the LLM for the current agent, with the current input. +2. The LLM produces its output. + 1. If the LLM returns a `final_output`, the loop ends and we return the result. + 2. If the LLM does a handoff, we update the current agent and input, and re-run the loop. + 3. If the LLM produces tool calls, we run those tool calls, append the results, and re-run the loop. +3. If we exceed the `max_turns` passed, we raise a [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] exception. + +!!! note + + The rule for whether the LLM output is considered as a "final output" is that it produces text output with the desired type, and there are no tool calls. + +## Streaming + +Streaming allows you to additionally receive streaming events as the LLM runs. Once the stream is done, the [`RunResultStreaming`][agents.result.RunResultStreaming] will contain the complete information about the run, including all the new outputs produces. You can call `.stream_events()` for the streaming events. Read more in the [streaming guide](streaming.md). + +## Run config + +The `run_config` parameter lets you configure some global settings for the agent run: + +- [`model`][agents.run.RunConfig.model]: Allows setting a global LLM model to use, irrespective of what `model` each Agent has. +- [`model_provider`][agents.run.RunConfig.model_provider]: A model provider for looking up model names, which defaults to OpenAI. +- [`model_settings`][agents.run.RunConfig.model_settings]: Overrides agent-specific settings. For example, you can set a global `temperature` or `top_p`. +- [`input_guardrails`][agents.run.RunConfig.input_guardrails], [`output_guardrails`][agents.run.RunConfig.output_guardrails]: A list of input or output guardrails to include on all runs. +- [`handoff_input_filter`][agents.run.RunConfig.handoff_input_filter]: A global input filter to apply to all handoffs, if the handoff doesn't already have one. The input filter allows you to edit the inputs that are sent to the new agent. See the documentation in [`Handoff.input_filter`][agents.handoffs.Handoff.input_filter] for more details. +- [`tracing_disabled`][agents.run.RunConfig.tracing_disabled]: Allows you to disable [tracing](tracing.md) for the entire run. +- [`trace_include_sensitive_data`][agents.run.RunConfig.trace_include_sensitive_data]: Configures whether traces will include potentially sensitive data, such as LLM and tool call inputs/outputs. +- [`workflow_name`][agents.run.RunConfig.workflow_name], [`trace_id`][agents.run.RunConfig.trace_id], [`group_id`][agents.run.RunConfig.group_id]: Sets the tracing workflow name, trace ID and trace group ID for the run. We recommend at least setting `workflow_name`. The session ID is an optional field that lets you link traces across multiple runs. +- [`trace_metadata`][agents.run.RunConfig.trace_metadata]: Metadata to include on all traces. + +## Conversations/chat threads + +Calling any of the run methods can result in one or more agents running (and hence one or more LLM calls), but it represents a single logical turn in a chat conversation. For example: + +1. User turn: user enter text +2. Runner run: first agent calls LLM, runs tools, does a handoff to a second agent, second agent runs more tools, and then produces an output. + +At the end of the agent run, you can choose what to show to the user. For example, you might show the user every new item generated by the agents, or just the final output. Either way, the user might then ask a followup question, in which case you can call the run method again. + +You can use the base [`RunResultBase.to_input_list()`][agents.result.RunResultBase.to_input_list] method to get the inputs for the next turn. + +```python +async def main(): + agent = Agent(name="Assistant", instructions="Reply very concisely.") + + with trace(workflow_name="Conversation", group_id=thread_id): + # First turn + result = await Runner.run(agent, "What city is the Golden Gate Bridge in?") + print(result.final_output) + # San Francisco + + # Second turn + new_input = output.to_input_list() + [{"role": "user", "content": "What state is it in?"}] + result = await Runner.run(agent, new_input) + print(result.final_output) + # California +``` + +## Exceptions + +The SDK raises exceptions in certain cases. The full list is in [`agents.exceptions`][]. As an overview: + +- [`AgentsException`][agents.exceptions.AgentsException] is the base class for all exceptions raised in the SDK. +- [`MaxTurnsExceeded`][agents.exceptions.MaxTurnsExceeded] is raised when the run exceeds the `max_turns` passed to the run methods. +- [`ModelBehaviorError`][agents.exceptions.ModelBehaviorError] is raised when the model produces invalid outputs, e.g. malformed JSON or using non-existent tools. +- [`UserError`][agents.exceptions.UserError] is raised when you (the person writing code using the SDK) make an error using the SDK. +- [`InputGuardrailTripwireTriggered`][agents.exceptions.InputGuardrailTripwireTriggered], [`OutputGuardrailTripwireTriggered`][agents.exceptions.OutputGuardrailTripwireTriggered] is raised when a [guardrail](guardrails.md) is tripped. diff --git a/tests/docs/streaming.md b/tests/docs/streaming.md new file mode 100644 index 0000000..b2c7c09 --- /dev/null +++ b/tests/docs/streaming.md @@ -0,0 +1,87 @@ +# Streaming + +Streaming lets you subscribe to updates of the agent run as it proceeds. This can be useful for showing the end-user progress updates and partial responses. + +To stream, you can call [`Runner.run_streamed()`][agents.run.Runner.run_streamed], which will give you a [`RunResultStreaming`][agents.result.RunResultStreaming]. Calling `result.stream_events()` gives you an async stream of [`StreamEvent`][agents.stream_events.StreamEvent] objects, which are described below. + +## Raw response events + +[`RawResponsesStreamEvent`][agents.stream_events.RawResponsesStreamEvent] are raw events passed directly from the LLM. They are in OpenAI Responses API format, which means each event has a type (like `response.created`, `response.output_text.delta`, etc) and data. These events are useful if you want to stream response messages to the user as soon as they are generated. + +For example, this will output the text generated by the LLM token-by-token. + +```python +import asyncio +from openai.types.responses import ResponseTextDeltaEvent +from agents import Agent, Runner + +async def main(): + agent = Agent( + name="Joker", + instructions="You are a helpful assistant.", + ) + + result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") + async for event in result.stream_events(): + if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent): + print(event.data.delta, end="", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Run item events and agent events + +[`RunItemStreamEvent`][agents.stream_events.RunItemStreamEvent]s are higher level events. They inform you when an item has been fully generated. This allows you to push progress updates at the level of "message generated", "tool ran", etc, instead of each token. Similarly, [`AgentUpdatedStreamEvent`][agents.stream_events.AgentUpdatedStreamEvent] gives you updates when the current agent changes (e.g. as the result of a handoff). + +For example, this will ignore raw events and stream updates to the user. + +```python +import asyncio +import random +from agents import Agent, ItemHelpers, Runner, function_tool + +@function_tool +def how_many_jokes() -> int: + return random.randint(1, 10) + + +async def main(): + agent = Agent( + name="Joker", + instructions="First call the `how_many_jokes` tool, then tell that many jokes.", + tools=[how_many_jokes], + ) + + result = Runner.run_streamed( + agent, + input="Hello", + ) + print("=== Run starting ===") + + async for event in result.stream_events(): + # We'll ignore the raw responses event deltas + if event.type == "raw_response_event": + continue + # When the agent updates, print that + elif event.type == "agent_updated_stream_event": + print(f"Agent updated: {event.new_agent.name}") + continue + # When items are generated, print them + elif event.type == "run_item_stream_event": + if event.item.type == "tool_call_item": + print("-- Tool was called") + elif event.item.type == "tool_call_output_item": + print(f"-- Tool output: {event.item.output}") + elif event.item.type == "message_output_item": + print(f"-- Message output:\n {ItemHelpers.text_message_output(event.item)}") + else: + pass # Ignore other event types + + print("=== Run complete ===") + + +if __name__ == "__main__": + asyncio.run(main()) +``` diff --git a/tests/docs/stylesheets/extra.css b/tests/docs/stylesheets/extra.css new file mode 100644 index 0000000..89cf164 --- /dev/null +++ b/tests/docs/stylesheets/extra.css @@ -0,0 +1,194 @@ +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: normal; + font-weight: 400; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-Regular.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: italic; + font-weight: 400; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-RegularItalic.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: normal; + font-weight: 500; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-Medium.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: italic; + font-weight: 500; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-MediumItalic.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: normal; + font-weight: 600; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-Semibold.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: italic; + font-weight: 600; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-SemiboldItalic.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: normal; + font-weight: 700; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-Bold.woff2") + format("woff2"); +} + +@font-face { + font-display: swap; + font-family: "OpenAI Sans"; + font-style: italic; + font-weight: 700; + src: url("https://cdn.openai.com/common/fonts/openai-sans/OpenAISans-BoldItalic.woff2") + format("woff2"); +} + +/* + Root variables that apply to all color schemes. + Material for MkDocs automatically switches data-md-color-scheme + between "default" (light) and "slate" (dark) when you use the toggles. +*/ +:root { + /* Font families */ + --md-text-font: "OpenAI Sans", -apple-system, system-ui, Helvetica, Arial, + sans-serif; + --md-typeface-heading: "OpenAI Sans", -apple-system, system-ui, Helvetica, + Arial, sans-serif; + + /* Global color variables */ + --md-default-fg-color: #212121; + --md-default-bg-color: #ffffff; + --md-primary-fg-color: #000; + --md-accent-fg-color: #000; + + /* Code block theming */ + --md-code-fg-color: red; + --md-code-bg-color: #f5f5f5; + + /* Tables, blockquotes, etc. */ + --md-table-row-border-color: #e0e0e0; + --md-admonition-bg-color: #f8f8f8; + --md-admonition-title-fg-color: #373737; + --md-default-fg-color--light: #000; + + --md-typeset-a-color: #000; + --md-accent-fg-color: #000; + + --md-code-fg-color: #000; +} + +/* Header styling */ +.md-header { + background-color: #000; +} + +.md-header--shadow { + box-shadow: none; +} + +.md-content .md-typeset h1 { + color: #000; +} + +.md-typeset p, +.md-typeset li { + font-size: 16px; +} + +.md-typeset__table p { + line-height: 1em; +} + +.md-nav { + font-size: 14px; +} +.md-nav__title { + color: #000; + font-weight: 600; +} + +.md-typeset h1, +.md-typeset h2, +.md-typeset h3, +.md-typeset h4 { + font-weight: 600; +} + +.md-typeset h1 code { + color: #000; + padding: 0; + background-color: transparent; +} +.md-footer { + display: none; +} + +.md-header__title { + margin-left: 0 !important; +} + +.md-typeset .admonition, +.md-typeset details { + border: none; + outline: none; + border-radius: 8px; + overflow: hidden; +} + +.md-typeset pre > code { + font-size: 14px; +} + +.md-typeset__table code { + font-size: 14px; +} + +/* Custom link styling */ +.md-content a { + text-decoration: none; +} + +.md-content a:hover { + text-decoration: underline; +} + +/* Code block styling */ +.md-content .md-code__content { + border-radius: 8px; +} + +.md-clipboard.md-icon { + color: #9e9e9e; +} + +/* Reset scrollbar styling to browser default with high priority */ +.md-sidebar__scrollwrap { + scrollbar-color: auto !important; +} diff --git a/tests/docs/tools.md b/tests/docs/tools.md new file mode 100644 index 0000000..f7a8869 --- /dev/null +++ b/tests/docs/tools.md @@ -0,0 +1,270 @@ +# Tools + +Tools let agents take actions: things like fetching data, running code, calling external APIs, and even using a computer. There are three classes of tools in the Agent SDK: + +- Hosted tools: these run on LLM servers alongside the AI models. OpenAI offers retrieval, web search and computer use as hosted tools. +- Function calling: these allow you to use any Python function as a tool. +- Agents as tools: this allows you to use an agent as a tool, allowing Agents to call other agents without handing off to them. + +## Hosted tools + +OpenAI offers a few built-in tools when using the [`OpenAIResponsesModel`][agents.models.openai_responses.OpenAIResponsesModel]: + +- The [`WebSearchTool`][agents.tool.WebSearchTool] lets an agent search the web. +- The [`FileSearchTool`][agents.tool.FileSearchTool] allows retrieving information from your OpenAI Vector Stores. +- The [`ComputerTool`][agents.tool.ComputerTool] allows automating computer use tasks. + +```python +from agents import Agent, FileSearchTool, Runner, WebSearchTool + +agent = Agent( + name="Assistant", + tools=[ + WebSearchTool(), + FileSearchTool( + max_num_results=3, + vector_store_ids=["VECTOR_STORE_ID"], + ), + ], +) + +async def main(): + result = await Runner.run(agent, "Which coffee shop should I go to, taking into account my preferences and the weather today in SF?") + print(result.final_output) +``` + +## Function tools + +You can use any Python function as a tool. The Agents SDK will setup the tool automatically: + +- The name of the tool will be the name of the Python function (or you can provide a name) +- Tool description will be taken from the docstring of the function (or you can provide a description) +- The schema for the function inputs is automatically created from the function's arguments +- Descriptions for each input are taken from the docstring of the function, unless disabled + +We use Python's `inspect` module to extract the function signature, along with [`griffe`](https://mkdocstrings.github.io/griffe/) to parse docstrings and `pydantic` for schema creation. + +```python +import json + +from typing_extensions import TypedDict, Any + +from agents import Agent, FunctionTool, RunContextWrapper, function_tool + + +class Location(TypedDict): + lat: float + long: float + +@function_tool # (1)! +async def fetch_weather(location: Location) -> str: + # (2)! + """Fetch the weather for a given location. + + Args: + location: The location to fetch the weather for. + """ + # In real life, we'd fetch the weather from a weather API + return "sunny" + + +@function_tool(name_override="fetch_data") # (3)! +def read_file(ctx: RunContextWrapper[Any], path: str, directory: str | None = None) -> str: + """Read the contents of a file. + + Args: + path: The path to the file to read. + directory: The directory to read the file from. + """ + # In real life, we'd read the file from the file system + return "" + + +agent = Agent( + name="Assistant", + tools=[fetch_weather, read_file], # (4)! +) + +for tool in agent.tools: + if isinstance(tool, FunctionTool): + print(tool.name) + print(tool.description) + print(json.dumps(tool.params_json_schema, indent=2)) + print() + +``` + +1. You can use any Python types as arguments to your functions, and the function can be sync or async. +2. Docstrings, if present, are used to capture descriptions and argument descriptions +3. Functions can optionally take the `context` (must be the first argument). You can also set overrides, like the name of the tool, description, which docstring style to use, etc. +4. You can pass the decorated functions to the list of tools. + +??? note "Expand to see output" + + ``` + fetch_weather + Fetch the weather for a given location. + { + "$defs": { + "Location": { + "properties": { + "lat": { + "title": "Lat", + "type": "number" + }, + "long": { + "title": "Long", + "type": "number" + } + }, + "required": [ + "lat", + "long" + ], + "title": "Location", + "type": "object" + } + }, + "properties": { + "location": { + "$ref": "#/$defs/Location", + "description": "The location to fetch the weather for." + } + }, + "required": [ + "location" + ], + "title": "fetch_weather_args", + "type": "object" + } + + fetch_data + Read the contents of a file. + { + "properties": { + "path": { + "description": "The path to the file to read.", + "title": "Path", + "type": "string" + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The directory to read the file from.", + "title": "Directory" + } + }, + "required": [ + "path" + ], + "title": "fetch_data_args", + "type": "object" + } + ``` + +### Custom function tools + +Sometimes, you don't want to use a Python function as a tool. You can directly create a [`FunctionTool`][agents.tool.FunctionTool] if you prefer. You'll need to provide: + +- `name` +- `description` +- `params_json_schema`, which is the JSON schema for the arguments +- `on_invoke_tool`, which is an async function that receives the context and the arguments as a JSON string, and must return the tool output as a string. + +```python +from typing import Any + +from pydantic import BaseModel + +from agents import RunContextWrapper, FunctionTool + + + +def do_some_work(data: str) -> str: + return "done" + + +class FunctionArgs(BaseModel): + username: str + age: int + + +async def run_function(ctx: RunContextWrapper[Any], args: str) -> str: + parsed = FunctionArgs.model_validate_json(args) + return do_some_work(data=f"{parsed.username} is {parsed.age} years old") + + +tool = FunctionTool( + name="process_user", + description="Processes extracted user data", + params_json_schema=FunctionArgs.model_json_schema(), + on_invoke_tool=run_function, +) +``` + +### Automatic argument and docstring parsing + +As mentioned before, we automatically parse the function signature to extract the schema for the tool, and we parse the docstring to extract descriptions for the tool and for individual arguments. Some notes on that: + +1. The signature parsing is done via the `inspect` module. We use type annotations to understand the types for the arguments, and dynamically build a Pydantic model to represent the overall schema. It supports most types, including Python primitives, Pydantic models, TypedDicts, and more. +2. We use `griffe` to parse docstrings. Supported docstring formats are `google`, `sphinx` and `numpy`. We attempt to automatically detect the docstring format, but this is best-effort and you can explicitly set it when calling `function_tool`. You can also disable docstring parsing by setting `use_docstring_info` to `False`. + +The code for the schema extraction lives in [`agents.function_schema`][]. + +## Agents as tools + +In some workflows, you may want a central agent to orchestrate a network of specialized agents, instead of handing off control. You can do this by modeling agents as tools. + +```python +from agents import Agent, Runner +import asyncio + +spanish_agent = Agent( + name="Spanish agent", + instructions="You translate the user's message to Spanish", +) + +french_agent = Agent( + name="French agent", + instructions="You translate the user's message to French", +) + +orchestrator_agent = Agent( + name="orchestrator_agent", + instructions=( + "You are a translation agent. You use the tools given to you to translate." + "If asked for multiple translations, you call the relevant tools." + ), + tools=[ + spanish_agent.as_tool( + tool_name="translate_to_spanish", + tool_description="Translate the user's message to Spanish", + ), + french_agent.as_tool( + tool_name="translate_to_french", + tool_description="Translate the user's message to French", + ), + ], +) + +async def main(): + result = await Runner.run(orchestrator_agent, input="Say 'Hello, how are you?' in Spanish.") + print(result.final_output) +``` + +## Handling errors in function tools + +When you create a function tool via `@function_tool`, you can pass a `failure_error_function`. This is a function that provides an error response to the LLM in case the tool call crashes. + +- By default (i.e. if you don't pass anything), it runs a `default_tool_error_function` which tells the LLM an error occurred. +- If you pass your own error function, it runs that instead, and sends the response to the LLM. +- If you explicitly pass `None`, then any tool call errors will be re-raised for you to handle. This could be a `ModelBehaviorError` if the model produced invalid JSON, or a `UserError` if your code crashed, etc. + +If you are manually creating a `FunctionTool` object, then you must handle errors inside the `on_invoke_tool` function. diff --git a/tests/docs/tracing.md b/tests/docs/tracing.md new file mode 100644 index 0000000..fbf2ae4 --- /dev/null +++ b/tests/docs/tracing.md @@ -0,0 +1,95 @@ +# Tracing + +The Agents SDK includes built-in tracing, collecting a comprehensive record of events during an agent run: LLM generations, tool calls, handoffs, guardrails, and even custom events that occur. Using the [Traces dashboard](https://platform.openai.com/traces), you can debug, visualize, and monitor your workflows during development and in production. + +!!!note + + Tracing is enabled by default. There are two ways to disable tracing: + + 1. You can globally disable tracing by setting the env var `OPENAI_AGENTS_DISABLE_TRACING=1` + 2. You can disable tracing for a single run by setting [`agents.run.RunConfig.tracing_disabled`][] to `True` + +## Traces and spans + +- **Traces** represent a single end-to-end operation of a "workflow". They're composed of Spans. Traces have the following properties: + - `workflow_name`: This is the logical workflow or app. For example "Code generation" or "Customer service". + - `trace_id`: A unique ID for the trace. Automatically generated if you don't pass one. Must have the format `trace_<32_alphanumeric>`. + - `group_id`: Optional group ID, to link multiple traces from the same conversation. For example, you might use a chat thread ID. + - `disabled`: If True, the trace will not be recorded. + - `metadata`: Optiona metadata for the trace. +- **Spans** represent operations that have a start and end time. Spans have: + - `started_at` and `ended_at` timestamps. + - `trace_id`, to represent the trace they belong to + - `parent_id`, which points to the parent Span of this Span (if any) + - `span_data`, which is information about the Span. For example, `AgentSpanData` contains information about the Agent, `GenerationSpanData` contains information about the LLM generation, etc. + +## Default tracing + +By default, the SDK traces the following: + +- The entire `Runner.{run, run_sync, run_streamed}()` is wrapped in a `trace()`. +- Each time an agent runs, it is wrapped in `agent_span()` +- LLM generations are wrapped in `generation_span()` +- Function tool calls are each wrapped in `function_span()` +- Guardrails are wrapped in `guardrail_span()` +- Handoffs are wrapped in `handoff_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]. + +In addition, you can set up [custom trace processors](#custom-tracing-processors) to push traces to other destinations (as a replacement, or secondary destination). + +## Higher level traces + +Sometimes, you might want multiple calls to `run()` to be part of a single trace. You can do this by wrapping the entire code in a `trace()`. + +```python +from agents import Agent, Runner, trace + +async def main(): + agent = Agent(name="Joke generator", instructions="Tell funny jokes.") + + with trace("Joke workflow"): # (1)! + first_result = await Runner.run(agent, "Tell me a joke") + second_result = await Runner.run(agent, f"Rate this joke: {first_output.final_output}") + print(f"Joke: {first_result.final_output}") + print(f"Rating: {second_result.final_output}") +``` + +1. Because the two calls to `Runner.run` are wrapped in a `with trace()`, the individual runs will be part of the overall trace rather than creating two traces. + +## Creating traces + +You can use the [`trace()`][agents.tracing.trace] function to create a trace. Traces need to be started and finished. You have two options to do so: + +1. **Recommended**: use the trace as a context manager, i.e. `with trace(...) as my_trace`. This will automatically start and end the trace at the right time. +2. You can also manually call [`trace.start()`][agents.tracing.Trace.start] and [`trace.finish()`][agents.tracing.Trace.finish]. + +The current trace is tracked via a Python [`contextvar`](https://docs.python.org/3/library/contextvars.html). This means that it works with concurrency automatically. If you manually start/end a trace, you'll need to pass `mark_as_current` and `reset_current` to `start()`/`finish()` to update the current trace. + +## Creating spans + +You can use the various [`*_span()`][agents.tracing.create] methods to create a span. In general, you don't need to manually create spans. A [`custom_span()`][agents.tracing.custom_span] function is available for tracking custom span information. + +Spans are automatically part of the current trace, and are nested under the nearest current span, which is tracked via a Python [`contextvar`](https://docs.python.org/3/library/contextvars.html). + +## 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]. + +## Custom tracing processors + +The high level architecture for tracing is: + +- At initialization, we create a global [`TraceProvider`][agents.tracing.setup.TraceProvider], which is responsible for creating traces. +- We configure the `TraceProvider` with a [`BatchTraceProcessor`][agents.tracing.processors.BatchTraceProcessor] that sends traces/spans in batches to a [`BackendSpanExporter`][agents.tracing.processors.BackendSpanExporter], which exports the spans and traces to the OpenAI backend in batches. + +To customize this default setup, to send traces to alternative or additional backends or modifying exporter behavior, you have two options: + +1. [`add_trace_processor()`][agents.tracing.add_trace_processor] lets you add an **additional** trace processor that will receive traces and spans as they are ready. This lets you do your own processing in addition to sending traces to OpenAI's backend. +2. [`set_trace_processors()`][agents.tracing.set_trace_processors] lets you **replace** the default processors with your own trace processors. This means traces will not be sent to the OpenAI backend unless you include a `TracingProcessor` that does so. + +External trace processors include: + +- [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) diff --git a/tests/examples/__init__.py b/tests/examples/__init__.py new file mode 100644 index 0000000..e333a2e --- /dev/null +++ b/tests/examples/__init__.py @@ -0,0 +1,3 @@ +# Make the examples directory into a package to avoid top-level module name collisions. +# This is needed so that mypy treats files like examples/customer_service/main.py and +# examples/researcher_app/main.py as distinct modules rather than both named "main". diff --git a/tests/examples/__pycache__/__init__.cpython-313.pyc b/tests/examples/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b690047 Binary files /dev/null and b/tests/examples/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/examples/agent_patterns/README.md b/tests/examples/agent_patterns/README.md new file mode 100644 index 0000000..4599b00 --- /dev/null +++ b/tests/examples/agent_patterns/README.md @@ -0,0 +1,54 @@ +# Common agentic patterns + +This folder contains examples of different common patterns for agents. + +## Deterministic flows + +A common tactic is to break down a task into a series of smaller steps. Each task can be performed by an agent, and the output of one agent is used as input to the next. For example, if your task was to generate a story, you could break it down into the following steps: + +1. Generate an outline +2. Generate the story +3. Generate the ending + +Each of these steps can be performed by an agent. The output of one agent is used as input to the next. + +See the [`deterministic.py`](./deterministic.py) file for an example of this. + +## Handoffs and routing + +In many situations, you have specialized sub-agents that handle specific tasks. You can use handoffs to route the task to the right agent. + +For example, you might have a frontline agent that receives a request, and then hands off to a specialized agent based on the language of the request. +See the [`routing.py`](./routing.py) file for an example of this. + +## Agents as tools + +The mental model for handoffs is that the new agent "takes over". It sees the previous conversation history, and owns the conversation from that point onwards. However, this is not the only way to use agents. You can also use agents as a tool - the tool agent goes off and runs on its own, and then returns the result to the original agent. + +For example, you could model the translation task above as tool calls instead: rather than handing over to the language-specific agent, you could call the agent as a tool, and then use the result in the next step. This enables things like translating multiple languages at once. + +See the [`agents_as_tools.py`](./agents_as_tools.py) file for an example of this. + +## LLM-as-a-judge + +LLMs can often improve the quality of their output if given feedback. A common pattern is to generate a response using a model, and then use a second model to provide feedback. You can even use a small model for the initial generation and a larger model for the feedback, to optimize cost. + +For example, you could use an LLM to generate an outline for a story, and then use a second LLM to evaluate the outline and provide feedback. You can then use the feedback to improve the outline, and repeat until the LLM is satisfied with the outline. + +See the [`llm_as_a_judge.py`](./llm_as_a_judge.py) file for an example of this. + +## Parallelization + +Running multiple agents in parallel is a common pattern. This can be useful for both latency (e.g. if you have multiple steps that don't depend on each other) and also for other reasons e.g. generating multiple responses and picking the best one. + +See the [`parallelization.py`](./parallelization.py) file for an example of this. It runs a translation agent multiple times in parallel, and then picks the best translation. + +## Guardrails + +Related to parallelization, you often want to run input guardrails to make sure the inputs to your agents are valid. For example, if you have a customer support agent, you might want to make sure that the user isn't trying to ask for help with a math problem. + +You can definitely do this without any special Agents SDK features by using parallelization, but we support a special guardrail primitive. Guardrails can have a "tripwire" - if the tripwire is triggered, the agent execution will immediately stop and a `GuardrailTripwireTriggered` exception will be raised. + +This is really useful for latency: for example, you might have a very fast model that runs the guardrail and a slow model that runs the actual agent. You wouldn't want to wait for the slow model to finish, so guardrails let you quickly reject invalid inputs. + +See the [`guardrails.py`](./guardrails.py) file for an example of this. diff --git a/tests/examples/agent_patterns/agents_as_tools.py b/tests/examples/agent_patterns/agents_as_tools.py new file mode 100644 index 0000000..9fd118e --- /dev/null +++ b/tests/examples/agent_patterns/agents_as_tools.py @@ -0,0 +1,79 @@ +import asyncio + +from agents import Agent, ItemHelpers, MessageOutputItem, Runner, trace + +""" +This example shows the agents-as-tools pattern. The frontline agent receives a user message and +then picks which agents to call, as tools. In this case, it picks from a set of translation +agents. +""" + +spanish_agent = Agent( + name="spanish_agent", + instructions="You translate the user's message to Spanish", + handoff_description="An english to spanish translator", +) + +french_agent = Agent( + name="french_agent", + instructions="You translate the user's message to French", + handoff_description="An english to french translator", +) + +italian_agent = Agent( + name="italian_agent", + instructions="You translate the user's message to Italian", + handoff_description="An english to italian translator", +) + +orchestrator_agent = Agent( + name="orchestrator_agent", + instructions=( + "You are a translation agent. You use the tools given to you to translate." + "If asked for multiple translations, you call the relevant tools in order." + "You never translate on your own, you always use the provided tools." + ), + tools=[ + spanish_agent.as_tool( + tool_name="translate_to_spanish", + tool_description="Translate the user's message to Spanish", + ), + french_agent.as_tool( + tool_name="translate_to_french", + tool_description="Translate the user's message to French", + ), + italian_agent.as_tool( + tool_name="translate_to_italian", + tool_description="Translate the user's message to Italian", + ), + ], +) + +synthesizer_agent = Agent( + name="synthesizer_agent", + instructions="You inspect translations, correct them if needed, and produce a final concatenated response.", +) + + +async def main(): + msg = input("Hi! What would you like translated, and to which languages? ") + + # Run the entire orchestration in a single trace + with trace("Orchestrator evaluator"): + orchestrator_result = await Runner.run(orchestrator_agent, msg) + + for item in orchestrator_result.new_items: + if isinstance(item, MessageOutputItem): + text = ItemHelpers.text_message_output(item) + if text: + print(f" - Translation step: {text}") + + synthesizer_result = await Runner.run( + synthesizer_agent, orchestrator_result.to_input_list() + ) + + print(f"\n\nFinal response:\n{synthesizer_result.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/agent_patterns/deterministic.py b/tests/examples/agent_patterns/deterministic.py new file mode 100644 index 0000000..0c163af --- /dev/null +++ b/tests/examples/agent_patterns/deterministic.py @@ -0,0 +1,80 @@ +import asyncio + +from pydantic import BaseModel + +from agents import Agent, Runner, trace + +""" +This example demonstrates a deterministic flow, where each step is performed by an agent. +1. The first agent generates a story outline +2. We feed the outline into the second agent +3. The second agent checks if the outline is good quality and if it is a scifi story +4. If the outline is not good quality or not a scifi story, we stop here +5. If the outline is good quality and a scifi story, we feed the outline into the third agent +6. The third agent writes the story +""" + +story_outline_agent = Agent( + name="story_outline_agent", + instructions="Generate a very short story outline based on the user's input.", +) + + +class OutlineCheckerOutput(BaseModel): + good_quality: bool + is_scifi: bool + + +outline_checker_agent = Agent( + name="outline_checker_agent", + instructions="Read the given story outline, and judge the quality. Also, determine if it is a scifi story.", + output_type=OutlineCheckerOutput, +) + +story_agent = Agent( + name="story_agent", + instructions="Write a short story based on the given outline.", + output_type=str, +) + + +async def main(): + input_prompt = input("What kind of story do you want? ") + + # Ensure the entire workflow is a single trace + with trace("Deterministic story flow"): + # 1. Generate an outline + outline_result = await Runner.run( + story_outline_agent, + input_prompt, + ) + print("Outline generated") + + # 2. Check the outline + outline_checker_result = await Runner.run( + outline_checker_agent, + outline_result.final_output, + ) + + # 3. Add a gate to stop if the outline is not good quality or not a scifi story + assert isinstance(outline_checker_result.final_output, OutlineCheckerOutput) + if not outline_checker_result.final_output.good_quality: + print("Outline is not good quality, so we stop here.") + exit(0) + + if not outline_checker_result.final_output.is_scifi: + print("Outline is not a scifi story, so we stop here.") + exit(0) + + print("Outline is good quality and a scifi story, so we continue to write the story.") + + # 4. Write the story + story_result = await Runner.run( + story_agent, + outline_result.final_output, + ) + print(f"Story: {story_result.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/agent_patterns/input_guardrails.py b/tests/examples/agent_patterns/input_guardrails.py new file mode 100644 index 0000000..6259188 --- /dev/null +++ b/tests/examples/agent_patterns/input_guardrails.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio + +from pydantic import BaseModel + +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + TResponseInputItem, + input_guardrail, +) + +""" +This example shows how to use guardrails. + +Guardrails are checks that run in parallel to the agent's execution. +They can be used to do things like: +- Check if input messages are off-topic +- Check that output messages don't violate any policies +- Take over control of the agent's execution if an unexpected input is detected + +In this example, we'll setup an input guardrail that trips if the user is asking to do math homework. +If the guardrail trips, we'll respond with a refusal message. +""" + + +### 1. An agent-based guardrail that is triggered if the user is asking to do math homework +class MathHomeworkOutput(BaseModel): + is_math_homework: bool + reasoning: str + + +guardrail_agent = Agent( + name="Guardrail check", + instructions="Check if the user is asking you to do their math homework.", + output_type=MathHomeworkOutput, +) + + +@input_guardrail +async def math_guardrail( + context: RunContextWrapper[None], agent: Agent, input: str | list[TResponseInputItem] +) -> GuardrailFunctionOutput: + """This is an input guardrail function, which happens to call an agent to check if the input + is a math homework question. + """ + result = await Runner.run(guardrail_agent, input, context=context.context) + final_output = result.final_output_as(MathHomeworkOutput) + + return GuardrailFunctionOutput( + output_info=final_output, + tripwire_triggered=not final_output.is_math_homework, + ) + + +### 2. The run loop + + +async def main(): + agent = Agent( + name="Customer support agent", + instructions="You are a customer support agent. You help customers with their questions.", + input_guardrails=[math_guardrail], + ) + + input_data: list[TResponseInputItem] = [] + + while True: + user_input = input("Enter a message: ") + input_data.append( + { + "role": "user", + "content": user_input, + } + ) + + try: + result = await Runner.run(agent, input_data) + print(result.final_output) + # If the guardrail didn't trigger, we use the result as the input for the next run + input_data = result.to_input_list() + except InputGuardrailTripwireTriggered: + # If the guardrail triggered, we instead add a refusal message to the input + message = "Sorry, I can't help you with your math homework." + print(message) + input_data.append( + { + "role": "assistant", + "content": message, + } + ) + + # Sample run: + # Enter a message: What's the capital of California? + # The capital of California is Sacramento. + # Enter a message: Can you help me solve for x: 2x + 5 = 11 + # Sorry, I can't help you with your math homework. + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/agent_patterns/llm_as_a_judge.py b/tests/examples/agent_patterns/llm_as_a_judge.py new file mode 100644 index 0000000..d13a67c --- /dev/null +++ b/tests/examples/agent_patterns/llm_as_a_judge.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Literal + +from agents import Agent, ItemHelpers, Runner, TResponseInputItem, trace + +""" +This example shows the LLM as a judge pattern. The first agent generates an outline for a story. +The second agent judges the outline and provides feedback. We loop until the judge is satisfied +with the outline. +""" + +story_outline_generator = Agent( + name="story_outline_generator", + instructions=( + "You generate a very short story outline based on the user's input." + "If there is any feedback provided, use it to improve the outline." + ), +) + + +@dataclass +class EvaluationFeedback: + score: Literal["pass", "needs_improvement", "fail"] + feedback: str + + +evaluator = Agent[None]( + name="evaluator", + instructions=( + "You evaluate a story outline and decide if it's good enough." + "If it's not good enough, you provide feedback on what needs to be improved." + "Never give it a pass on the first try." + ), + output_type=EvaluationFeedback, +) + + +async def main() -> None: + msg = input("What kind of story would you like to hear? ") + input_items: list[TResponseInputItem] = [{"content": msg, "role": "user"}] + + latest_outline: str | None = None + + # We'll run the entire workflow in a single trace + with trace("LLM as a judge"): + while True: + story_outline_result = await Runner.run( + story_outline_generator, + input_items, + ) + + input_items = story_outline_result.to_input_list() + latest_outline = ItemHelpers.text_message_outputs(story_outline_result.new_items) + print("Story outline generated") + + evaluator_result = await Runner.run(evaluator, input_items) + result: EvaluationFeedback = evaluator_result.final_output + + print(f"Evaluator score: {result.score}") + + if result.score == "pass": + print("Story outline is good enough, exiting.") + break + + print("Re-running with feedback") + + input_items.append({"content": f"Feedback: {result.feedback}", "role": "user"}) + + print(f"Final story outline: {latest_outline}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/agent_patterns/output_guardrails.py b/tests/examples/agent_patterns/output_guardrails.py new file mode 100644 index 0000000..526a085 --- /dev/null +++ b/tests/examples/agent_patterns/output_guardrails.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import asyncio +import json + +from pydantic import BaseModel, Field + +from agents import ( + Agent, + GuardrailFunctionOutput, + OutputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + output_guardrail, +) + +""" +This example shows how to use output guardrails. + +Output guardrails are checks that run on the final output of an agent. +They can be used to do things like: +- Check if the output contains sensitive data +- Check if the output is a valid response to the user's message + +In this example, we'll use a (contrived) example where we check if the agent's response contains +a phone number. +""" + + +# The agent's output type +class MessageOutput(BaseModel): + reasoning: str = Field(description="Thoughts on how to respond to the user's message") + response: str = Field(description="The response to the user's message") + user_name: str | None = Field(description="The name of the user who sent the message, if known") + + +@output_guardrail +async def sensitive_data_check( + context: RunContextWrapper, agent: Agent, output: MessageOutput +) -> GuardrailFunctionOutput: + phone_number_in_response = "650" in output.response + phone_number_in_reasoning = "650" in output.reasoning + + return GuardrailFunctionOutput( + output_info={ + "phone_number_in_response": phone_number_in_response, + "phone_number_in_reasoning": phone_number_in_reasoning, + }, + tripwire_triggered=phone_number_in_response or phone_number_in_reasoning, + ) + + +agent = Agent( + name="Assistant", + instructions="You are a helpful assistant.", + output_type=MessageOutput, + output_guardrails=[sensitive_data_check], +) + + +async def main(): + # This should be ok + await Runner.run(agent, "What's the capital of California?") + print("First message passed") + + # This should trip the guardrail + try: + result = await Runner.run( + agent, "My phone number is 650-123-4567. Where do you think I live?" + ) + print( + f"Guardrail didn't trip - this is unexpected. Output: {json.dumps(result.final_output.model_dump(), indent=2)}" + ) + + except OutputGuardrailTripwireTriggered as e: + print(f"Guardrail tripped. Info: {e.guardrail_result.output.output_info}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/agent_patterns/parallelization.py b/tests/examples/agent_patterns/parallelization.py new file mode 100644 index 0000000..fe2a8ec --- /dev/null +++ b/tests/examples/agent_patterns/parallelization.py @@ -0,0 +1,61 @@ +import asyncio + +from agents import Agent, ItemHelpers, Runner, trace + +""" +This example shows the parallelization pattern. We run the agent three times in parallel, and pick +the best result. +""" + +spanish_agent = Agent( + name="spanish_agent", + instructions="You translate the user's message to Spanish", +) + +translation_picker = Agent( + name="translation_picker", + instructions="You pick the best Spanish translation from the given options.", +) + + +async def main(): + msg = input("Hi! Enter a message, and we'll translate it to Spanish.\n\n") + + # Ensure the entire workflow is a single trace + with trace("Parallel translation"): + res_1, res_2, res_3 = await asyncio.gather( + Runner.run( + spanish_agent, + msg, + ), + Runner.run( + spanish_agent, + msg, + ), + Runner.run( + spanish_agent, + msg, + ), + ) + + outputs = [ + ItemHelpers.text_message_outputs(res_1.new_items), + ItemHelpers.text_message_outputs(res_2.new_items), + ItemHelpers.text_message_outputs(res_3.new_items), + ] + + translations = "\n\n".join(outputs) + print(f"\n\nTranslations:\n\n{translations}") + + best_translation = await Runner.run( + translation_picker, + f"Input: {msg}\n\nTranslations:\n{translations}", + ) + + print("\n\n-----") + + print(f"Best translation: {best_translation.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/agent_patterns/routing.py b/tests/examples/agent_patterns/routing.py new file mode 100644 index 0000000..3dcaefa --- /dev/null +++ b/tests/examples/agent_patterns/routing.py @@ -0,0 +1,70 @@ +import asyncio +import uuid + +from openai.types.responses import ResponseContentPartDoneEvent, ResponseTextDeltaEvent + +from agents import Agent, RawResponsesStreamEvent, Runner, TResponseInputItem, trace + +""" +This example shows the handoffs/routing pattern. The triage agent receives the first message, and +then hands off to the appropriate agent based on the language of the request. Responses are +streamed to the user. +""" + +french_agent = Agent( + name="french_agent", + instructions="You only speak French", +) + +spanish_agent = Agent( + name="spanish_agent", + instructions="You only speak Spanish", +) + +english_agent = Agent( + name="english_agent", + instructions="You only speak English", +) + +triage_agent = Agent( + name="triage_agent", + instructions="Handoff to the appropriate agent based on the language of the request.", + handoffs=[french_agent, spanish_agent, english_agent], +) + + +async def main(): + # We'll create an ID for this conversation, so we can link each trace + conversation_id = str(uuid.uuid4().hex[:16]) + + msg = input("Hi! We speak French, Spanish and English. How can I help? ") + agent = triage_agent + inputs: list[TResponseInputItem] = [{"content": msg, "role": "user"}] + + while True: + # Each conversation turn is a single trace. Normally, each input from the user would be an + # API request to your app, and you can wrap the request in a trace() + with trace("Routing example", group_id=conversation_id): + result = Runner.run_streamed( + agent, + input=inputs, + ) + async for event in result.stream_events(): + if not isinstance(event, RawResponsesStreamEvent): + continue + data = event.data + if isinstance(data, ResponseTextDeltaEvent): + print(data.delta, end="", flush=True) + elif isinstance(data, ResponseContentPartDoneEvent): + print("\n") + + inputs = result.to_input_list() + print("\n") + + user_msg = input("Enter a message: ") + inputs.append({"content": user_msg, "role": "user"}) + agent = result.current_agent + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/basic/agent_lifecycle_example.py b/tests/examples/basic/agent_lifecycle_example.py new file mode 100644 index 0000000..bc0bbe4 --- /dev/null +++ b/tests/examples/basic/agent_lifecycle_example.py @@ -0,0 +1,112 @@ +import asyncio +import random +from typing import Any + +from pydantic import BaseModel + +from agents import Agent, AgentHooks, RunContextWrapper, Runner, Tool, function_tool + + +class CustomAgentHooks(AgentHooks): + def __init__(self, display_name: str): + self.event_counter = 0 + self.display_name = display_name + + async def on_start(self, context: RunContextWrapper, agent: Agent) -> None: + self.event_counter += 1 + print(f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started") + + async def on_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} ended with output {output}" + ) + + async def on_handoff(self, context: RunContextWrapper, agent: Agent, source: Agent) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {source.name} handed off to {agent.name}" + ) + + async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started tool {tool.name}" + ) + + async def on_tool_end( + self, context: RunContextWrapper, agent: Agent, tool: Tool, result: str + ) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} ended tool {tool.name} with result {result}" + ) + + +### + + +@function_tool +def random_number(max: int) -> int: + """ + Generate a random number up to the provided maximum. + """ + return random.randint(0, max) + + +@function_tool +def multiply_by_two(x: int) -> int: + """Simple multiplication by two.""" + return x * 2 + + +class FinalResult(BaseModel): + number: int + + +multiply_agent = Agent( + name="Multiply Agent", + instructions="Multiply the number by 2 and then return the final result.", + tools=[multiply_by_two], + output_type=FinalResult, + hooks=CustomAgentHooks(display_name="Multiply 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.", + tools=[random_number], + output_type=FinalResult, + handoffs=[multiply_agent], + hooks=CustomAgentHooks(display_name="Start Agent"), +) + + +async def main() -> None: + user_input = input("Enter a max number: ") + await Runner.run( + start_agent, + input=f"Generate a random number between 0 and {user_input}.", + ) + + print("Done!") + + +if __name__ == "__main__": + asyncio.run(main()) +""" +$ python examples/basic/agent_lifecycle_example.py + +Enter a max number: 250 +### (Start Agent) 1: Agent Start Agent started +### (Start Agent) 2: Agent Start Agent started tool random_number +### (Start Agent) 3: Agent Start Agent ended tool random_number with result 37 +### (Start Agent) 4: Agent Start Agent started +### (Start Agent) 5: Agent Start Agent handed off to Multiply Agent +### (Multiply Agent) 1: Agent Multiply Agent started +### (Multiply Agent) 2: Agent Multiply Agent started tool multiply_by_two +### (Multiply Agent) 3: Agent Multiply Agent ended tool multiply_by_two with result 74 +### (Multiply Agent) 4: Agent Multiply Agent started +### (Multiply Agent) 5: Agent Multiply Agent ended with output number=74 +Done! +""" diff --git a/tests/examples/basic/dynamic_system_prompt.py b/tests/examples/basic/dynamic_system_prompt.py new file mode 100644 index 0000000..7bcf90c --- /dev/null +++ b/tests/examples/basic/dynamic_system_prompt.py @@ -0,0 +1,69 @@ +import asyncio +import random +from typing import Literal + +from agents import Agent, RunContextWrapper, Runner + + +class CustomContext: + def __init__(self, style: Literal["haiku", "pirate", "robot"]): + self.style = style + + +def custom_instructions( + run_context: RunContextWrapper[CustomContext], agent: Agent[CustomContext] +) -> str: + context = run_context.context + if context.style == "haiku": + return "Only respond in haikus." + elif context.style == "pirate": + return "Respond as a pirate." + else: + return "Respond as a robot and say 'beep boop' a lot." + + +agent = Agent( + name="Chat agent", + instructions=custom_instructions, +) + + +async def main(): + choice: Literal["haiku", "pirate", "robot"] = random.choice(["haiku", "pirate", "robot"]) + context = CustomContext(style=choice) + print(f"Using style: {choice}\n") + + user_message = "Tell me a joke." + print(f"User: {user_message}") + result = await Runner.run(agent, user_message, context=context) + + print(f"Assistant: {result.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +$ python examples/basic/dynamic_system_prompt.py + +Using style: haiku + +User: Tell me a joke. +Assistant: Why don't eggs tell jokes? +They might crack each other's shells, +leaving yolk on face. + +$ python examples/basic/dynamic_system_prompt.py +Using style: robot + +User: Tell me a joke. +Assistant: Beep boop! Why was the robot so bad at soccer? Beep boop... because it kept kicking up a debug! Beep boop! + +$ python examples/basic/dynamic_system_prompt.py +Using style: pirate + +User: Tell me a joke. +Assistant: Why did the pirate go to school? + +To improve his arrr-ticulation! Har har har! 🏴‍☠️ +""" diff --git a/tests/examples/basic/hello_world.py b/tests/examples/basic/hello_world.py new file mode 100644 index 0000000..169290d --- /dev/null +++ b/tests/examples/basic/hello_world.py @@ -0,0 +1,20 @@ +import asyncio + +from agents import Agent, Runner + + +async def main(): + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + ) + + result = await Runner.run(agent, "Tell me about recursion in programming.") + print(result.final_output) + # Function calls itself, + # Looping in smaller pieces, + # Endless by design. + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/basic/lifecycle_example.py b/tests/examples/basic/lifecycle_example.py new file mode 100644 index 0000000..9b36510 --- /dev/null +++ b/tests/examples/basic/lifecycle_example.py @@ -0,0 +1,118 @@ +import asyncio +import random +from typing import Any + +from pydantic import BaseModel + +from agents import Agent, RunContextWrapper, RunHooks, Runner, Tool, Usage, function_tool + + +class ExampleHooks(RunHooks): + def __init__(self): + self.event_counter = 0 + + def _usage_to_str(self, usage: Usage) -> str: + return f"{usage.requests} requests, {usage.input_tokens} input tokens, {usage.output_tokens} output tokens, {usage.total_tokens} total tokens" + + async def on_agent_start(self, context: RunContextWrapper, agent: Agent) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Agent {agent.name} started. Usage: {self._usage_to_str(context.usage)}" + ) + + async def on_agent_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Agent {agent.name} ended with output {output}. Usage: {self._usage_to_str(context.usage)}" + ) + + async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Tool {tool.name} started. Usage: {self._usage_to_str(context.usage)}" + ) + + async def on_tool_end( + self, context: RunContextWrapper, agent: Agent, tool: Tool, result: str + ) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Tool {tool.name} ended with result {result}. Usage: {self._usage_to_str(context.usage)}" + ) + + async def on_handoff( + self, context: RunContextWrapper, from_agent: Agent, to_agent: Agent + ) -> None: + self.event_counter += 1 + print( + f"### {self.event_counter}: Handoff from {from_agent.name} to {to_agent.name}. Usage: {self._usage_to_str(context.usage)}" + ) + + +hooks = ExampleHooks() + +### + + +@function_tool +def random_number(max: int) -> int: + """Generate a random number up to the provided max.""" + return random.randint(0, max) + + +@function_tool +def multiply_by_two(x: int) -> int: + """Return x times two.""" + return x * 2 + + +class FinalResult(BaseModel): + number: int + + +multiply_agent = Agent( + name="Multiply Agent", + instructions="Multiply the number by 2 and then return the final result.", + tools=[multiply_by_two], + output_type=FinalResult, +) + +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.", + tools=[random_number], + output_type=FinalResult, + handoffs=[multiply_agent], +) + + +async def main() -> None: + user_input = input("Enter a max number: ") + await Runner.run( + start_agent, + hooks=hooks, + input=f"Generate a random number between 0 and {user_input}.", + ) + + print("Done!") + + +if __name__ == "__main__": + asyncio.run(main()) +""" +$ python examples/basic/lifecycle_example.py + +Enter a max number: 250 +### 1: Agent Start Agent started. Usage: 0 requests, 0 input tokens, 0 output tokens, 0 total tokens +### 2: Tool random_number started. Usage: 1 requests, 148 input tokens, 15 output tokens, 163 total tokens +### 3: Tool random_number ended with result 101. Usage: 1 requests, 148 input tokens, 15 output tokens, 163 total tokens +### 4: Agent Start Agent started. Usage: 1 requests, 148 input tokens, 15 output tokens, 163 total tokens +### 5: Handoff from Start Agent to Multiply Agent. Usage: 2 requests, 323 input tokens, 30 output tokens, 353 total tokens +### 6: Agent Multiply Agent started. Usage: 2 requests, 323 input tokens, 30 output tokens, 353 total tokens +### 7: Tool multiply_by_two started. Usage: 3 requests, 504 input tokens, 46 output tokens, 550 total tokens +### 8: Tool multiply_by_two ended with result 202. Usage: 3 requests, 504 input tokens, 46 output tokens, 550 total tokens +### 9: Agent Multiply Agent started. Usage: 3 requests, 504 input tokens, 46 output tokens, 550 total tokens +### 10: Agent Multiply Agent ended with output number=202. Usage: 4 requests, 714 input tokens, 63 output tokens, 777 total tokens +Done! + +""" diff --git a/tests/examples/basic/stream_items.py b/tests/examples/basic/stream_items.py new file mode 100644 index 0000000..c1f2257 --- /dev/null +++ b/tests/examples/basic/stream_items.py @@ -0,0 +1,65 @@ +import asyncio +import random + +from agents import Agent, ItemHelpers, Runner, function_tool + + +@function_tool +def how_many_jokes() -> int: + return random.randint(1, 10) + + +async def main(): + agent = Agent( + name="Joker", + instructions="First call the `how_many_jokes` tool, then tell that many jokes.", + tools=[how_many_jokes], + ) + + result = Runner.run_streamed( + agent, + input="Hello", + ) + print("=== Run starting ===") + async for event in result.stream_events(): + # We'll ignore the raw responses event deltas + if event.type == "raw_response_event": + continue + elif event.type == "agent_updated_stream_event": + print(f"Agent updated: {event.new_agent.name}") + continue + elif event.type == "run_item_stream_event": + if event.item.type == "tool_call_item": + print("-- Tool was called") + elif event.item.type == "tool_call_output_item": + print(f"-- Tool output: {event.item.output}") + elif event.item.type == "message_output_item": + print(f"-- Message output:\n {ItemHelpers.text_message_output(event.item)}") + else: + pass # Ignore other event types + + print("=== Run complete ===") + + +if __name__ == "__main__": + asyncio.run(main()) + + # === Run starting === + # Agent updated: Joker + # -- Tool was called + # -- Tool output: 4 + # -- Message output: + # Sure, here are four jokes for you: + + # 1. **Why don't skeletons fight each other?** + # They don't have the guts! + + # 2. **What do you call fake spaghetti?** + # An impasta! + + # 3. **Why did the scarecrow win an award?** + # Because he was outstanding in his field! + + # 4. **Why did the bicycle fall over?** + # Because it was two-tired! + # === Run complete === diff --git a/tests/examples/basic/stream_text.py b/tests/examples/basic/stream_text.py new file mode 100644 index 0000000..a73c1fe --- /dev/null +++ b/tests/examples/basic/stream_text.py @@ -0,0 +1,21 @@ +import asyncio + +from openai.types.responses import ResponseTextDeltaEvent + +from agents import Agent, Runner + + +async def main(): + agent = Agent( + name="Joker", + instructions="You are a helpful assistant.", + ) + + result = Runner.run_streamed(agent, input="Please tell me 5 jokes.") + async for event in result.stream_events(): + if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent): + print(event.data.delta, end="", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/customer_service/main.py b/tests/examples/customer_service/main.py new file mode 100644 index 0000000..bd802e2 --- /dev/null +++ b/tests/examples/customer_service/main.py @@ -0,0 +1,169 @@ +from __future__ import annotations as _annotations + +import asyncio +import random +import uuid + +from pydantic import BaseModel + +from agents import ( + Agent, + HandoffOutputItem, + ItemHelpers, + MessageOutputItem, + RunContextWrapper, + Runner, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, + function_tool, + handoff, + trace, +) +from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX + +### CONTEXT + + +class AirlineAgentContext(BaseModel): + passenger_name: str | None = None + confirmation_number: str | None = None + seat_number: str | None = None + flight_number: str | None = None + + +### TOOLS + + +@function_tool( + name_override="faq_lookup_tool", description_override="Lookup frequently asked questions." +) +async def faq_lookup_tool(question: str) -> str: + if "bag" in question or "baggage" in question: + return ( + "You are allowed to bring one bag on the plane. " + "It must be under 50 pounds and 22 inches x 14 inches x 9 inches." + ) + elif "seats" in question or "plane" in question: + return ( + "There are 120 seats on the plane. " + "There are 22 business class seats and 98 economy seats. " + "Exit rows are rows 4 and 16. " + "Rows 5-8 are Economy Plus, with extra legroom. " + ) + elif "wifi" in question: + return "We have free wifi on the plane, join Airline-Wifi" + return "I'm sorry, I don't know the answer to that question." + + +@function_tool +async def update_seat( + context: RunContextWrapper[AirlineAgentContext], confirmation_number: str, new_seat: str +) -> str: + """ + Update the seat for a given confirmation number. + + Args: + confirmation_number: The confirmation number for the flight. + new_seat: The new seat to update to. + """ + # Update the context based on the customer's input + context.context.confirmation_number = confirmation_number + context.context.seat_number = new_seat + # Ensure that the flight number has been set by the incoming handoff + assert context.context.flight_number is not None, "Flight number is required" + return f"Updated seat to {new_seat} for confirmation number {confirmation_number}" + + +### HOOKS + + +async def on_seat_booking_handoff(context: RunContextWrapper[AirlineAgentContext]) -> None: + flight_number = f"FLT-{random.randint(100, 999)}" + context.context.flight_number = flight_number + + +### AGENTS + +faq_agent = Agent[AirlineAgentContext]( + name="FAQ Agent", + handoff_description="A helpful agent that can answer questions about the airline.", + instructions=f"""{RECOMMENDED_PROMPT_PREFIX} + You are an FAQ agent. If you are speaking to a customer, you probably were transferred to from the triage agent. + Use the following routine to support the customer. + # Routine + 1. Identify the last question asked by the customer. + 2. Use the faq lookup tool to answer the question. Do not rely on your own knowledge. + 3. If you cannot answer the question, transfer back to the triage agent.""", + tools=[faq_lookup_tool], +) + +seat_booking_agent = Agent[AirlineAgentContext]( + name="Seat Booking Agent", + handoff_description="A helpful agent that can update a seat on a flight.", + instructions=f"""{RECOMMENDED_PROMPT_PREFIX} + You are a seat booking agent. If you are speaking to a customer, you probably were transferred to from the triage agent. + Use the following routine to support the customer. + # Routine + 1. Ask for their confirmation number. + 2. Ask the customer what their desired seat number is. + 3. Use the update seat tool to update the seat on the flight. + If the customer asks a question that is not related to the routine, transfer back to the triage agent. """, + tools=[update_seat], +) + +triage_agent = Agent[AirlineAgentContext]( + name="Triage Agent", + handoff_description="A triage agent that can delegate a customer's request to the appropriate agent.", + instructions=( + f"{RECOMMENDED_PROMPT_PREFIX} " + "You are a helpful triaging agent. You can use your tools to delegate questions to other appropriate agents." + ), + handoffs=[ + faq_agent, + handoff(agent=seat_booking_agent, on_handoff=on_seat_booking_handoff), + ], +) + +faq_agent.handoffs.append(triage_agent) +seat_booking_agent.handoffs.append(triage_agent) + + +### RUN + + +async def main(): + current_agent: Agent[AirlineAgentContext] = triage_agent + input_items: list[TResponseInputItem] = [] + context = AirlineAgentContext() + + # Normally, each input from the user would be an API request to your app, and you can wrap the request in a trace() + # Here, we'll just use a random UUID for the conversation ID + conversation_id = uuid.uuid4().hex[:16] + + while True: + user_input = input("Enter your message: ") + with trace("Customer service", group_id=conversation_id): + input_items.append({"content": user_input, "role": "user"}) + result = await Runner.run(current_agent, input_items, context=context) + + for new_item in result.new_items: + agent_name = new_item.agent.name + if isinstance(new_item, MessageOutputItem): + print(f"{agent_name}: {ItemHelpers.text_message_output(new_item)}") + elif isinstance(new_item, HandoffOutputItem): + print( + f"Handed off from {new_item.source_agent.name} to {new_item.target_agent.name}" + ) + elif isinstance(new_item, ToolCallItem): + print(f"{agent_name}: Calling a tool") + elif isinstance(new_item, ToolCallOutputItem): + print(f"{agent_name}: Tool call output: {new_item.output}") + else: + print(f"{agent_name}: Skipping item: {new_item.__class__.__name__}") + input_items = result.to_input_list() + current_agent = result.last_agent + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/handoffs/message_filter.py b/tests/examples/handoffs/message_filter.py new file mode 100644 index 0000000..9dd56ef --- /dev/null +++ b/tests/examples/handoffs/message_filter.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +import random + +from agents import Agent, HandoffInputData, Runner, function_tool, handoff, trace +from agents.extensions import handoff_filters + + +@function_tool +def random_number_tool(max: int) -> int: + """Return a random integer between 0 and the given maximum.""" + return random.randint(0, max) + + +def spanish_handoff_message_filter(handoff_message_data: HandoffInputData) -> HandoffInputData: + # First, we'll remove any tool-related messages from the message history + handoff_message_data = handoff_filters.remove_all_tools(handoff_message_data) + + # Second, we'll also remove the first two items from the history, just for demonstration + history = ( + tuple(handoff_message_data.input_history[2:]) + if isinstance(handoff_message_data.input_history, tuple) + else handoff_message_data.input_history + ) + + return HandoffInputData( + input_history=history, + pre_handoff_items=tuple(handoff_message_data.pre_handoff_items), + new_items=tuple(handoff_message_data.new_items), + ) + + +first_agent = Agent( + name="Assistant", + instructions="Be extremely concise.", + tools=[random_number_tool], +) + +spanish_agent = Agent( + name="Spanish Assistant", + instructions="You only speak Spanish and are extremely concise.", + handoff_description="A Spanish-speaking assistant.", +) + +second_agent = Agent( + name="Assistant", + instructions=( + "Be a helpful assistant. If the user speaks Spanish, handoff to the Spanish assistant." + ), + handoffs=[handoff(spanish_agent, input_filter=spanish_handoff_message_filter)], +) + + +async def main(): + # Trace the entire run as a single workflow + with trace(workflow_name="Message filtering"): + # 1. Send a regular message to the first agent + result = await Runner.run(first_agent, input="Hi, my name is Sora.") + + print("Step 1 done") + + # 2. Ask it to square a number + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [{"content": "Can you generate a random number between 0 and 100?", "role": "user"}], + ) + + print("Step 2 done") + + # 3. Call the second agent + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [ + { + "content": "I live in New York City. Whats the population of the city?", + "role": "user", + } + ], + ) + + print("Step 3 done") + + # 4. Cause a handoff to occur + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [ + { + "content": "Por favor habla en español. ¿Cuál es mi nombre y dónde vivo?", + "role": "user", + } + ], + ) + + print("Step 4 done") + + print("\n===Final messages===\n") + + # 5. That should have caused spanish_handoff_message_filter to be called, which means the + # output should be missing the first two messages, and have no tool calls. + # Let's print the messages to see what happened + for message in result.to_input_list(): + print(json.dumps(message, indent=2)) + # tool_calls = message.tool_calls if isinstance(message, AssistantMessage) else None + + # print(f"{message.role}: {message.content}\n - Tool calls: {tool_calls or 'None'}") + """ + $python examples/handoffs/message_filter.py + Step 1 done + Step 2 done + Step 3 done + Step 4 done + + ===Final messages=== + + { + "content": "Can you generate a random number between 0 and 100?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "Sure! Here's a random number between 0 and 100: **42**.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + { + "content": "I live in New York City. Whats the population of the city?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "As of the most recent estimates, the population of New York City is approximately 8.6 million people. However, this number is constantly changing due to various factors such as migration and birth rates. For the latest and most accurate information, it's always a good idea to check the official data from sources like the U.S. Census Bureau.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + { + "content": "Por favor habla en espa\u00f1ol. \u00bfCu\u00e1l es mi nombre y d\u00f3nde vivo?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "No tengo acceso a esa informaci\u00f3n personal, solo s\u00e9 lo que me has contado: vives en Nueva York.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + """ + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/tests/examples/handoffs/message_filter_streaming.py b/tests/examples/handoffs/message_filter_streaming.py new file mode 100644 index 0000000..8d1b420 --- /dev/null +++ b/tests/examples/handoffs/message_filter_streaming.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import json +import random + +from agents import Agent, HandoffInputData, Runner, function_tool, handoff, trace +from agents.extensions import handoff_filters + + +@function_tool +def random_number_tool(max: int) -> int: + """Return a random integer between 0 and the given maximum.""" + return random.randint(0, max) + + +def spanish_handoff_message_filter(handoff_message_data: HandoffInputData) -> HandoffInputData: + # First, we'll remove any tool-related messages from the message history + handoff_message_data = handoff_filters.remove_all_tools(handoff_message_data) + + # Second, we'll also remove the first two items from the history, just for demonstration + history = ( + tuple(handoff_message_data.input_history[2:]) + if isinstance(handoff_message_data.input_history, tuple) + else handoff_message_data.input_history + ) + + return HandoffInputData( + input_history=history, + pre_handoff_items=tuple(handoff_message_data.pre_handoff_items), + new_items=tuple(handoff_message_data.new_items), + ) + + +first_agent = Agent( + name="Assistant", + instructions="Be extremely concise.", + tools=[random_number_tool], +) + +spanish_agent = Agent( + name="Spanish Assistant", + instructions="You only speak Spanish and are extremely concise.", + handoff_description="A Spanish-speaking assistant.", +) + +second_agent = Agent( + name="Assistant", + instructions=( + "Be a helpful assistant. If the user speaks Spanish, handoff to the Spanish assistant." + ), + handoffs=[handoff(spanish_agent, input_filter=spanish_handoff_message_filter)], +) + + +async def main(): + # Trace the entire run as a single workflow + with trace(workflow_name="Streaming message filter"): + # 1. Send a regular message to the first agent + result = await Runner.run(first_agent, input="Hi, my name is Sora.") + + print("Step 1 done") + + # 2. Ask it to square a number + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [{"content": "Can you generate a random number between 0 and 100?", "role": "user"}], + ) + + print("Step 2 done") + + # 3. Call the second agent + result = await Runner.run( + second_agent, + input=result.to_input_list() + + [ + { + "content": "I live in New York City. Whats the population of the city?", + "role": "user", + } + ], + ) + + print("Step 3 done") + + # 4. Cause a handoff to occur + stream_result = Runner.run_streamed( + second_agent, + input=result.to_input_list() + + [ + { + "content": "Por favor habla en español. ¿Cuál es mi nombre y dónde vivo?", + "role": "user", + } + ], + ) + async for _ in stream_result.stream_events(): + pass + + print("Step 4 done") + + print("\n===Final messages===\n") + + # 5. That should have caused spanish_handoff_message_filter to be called, which means the + # output should be missing the first two messages, and have no tool calls. + # Let's print the messages to see what happened + for item in stream_result.to_input_list(): + print(json.dumps(item, indent=2)) + """ + $python examples/handoffs/message_filter_streaming.py + Step 1 done + Step 2 done + Step 3 done + Tu nombre y lugar de residencia no los tengo disponibles. Solo sé que mencionaste vivir en la ciudad de Nueva York. + Step 4 done + + ===Final messages=== + + { + "content": "Can you generate a random number between 0 and 100?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "Sure! Here's a random number between 0 and 100: **37**.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + { + "content": "I live in New York City. Whats the population of the city?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "As of the latest estimates, New York City's population is approximately 8.5 million people. Would you like more information about the city?", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + { + "content": "Por favor habla en espa\u00f1ol. \u00bfCu\u00e1l es mi nombre y d\u00f3nde vivo?", + "role": "user" + } + { + "id": "...", + "content": [ + { + "annotations": [], + "text": "No s\u00e9 tu nombre, pero me dijiste que vives en Nueva York.", + "type": "output_text" + } + ], + "role": "assistant", + "status": "completed", + "type": "message" + } + """ + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/tests/examples/research_bot/README.md b/tests/examples/research_bot/README.md new file mode 100644 index 0000000..4060983 --- /dev/null +++ b/tests/examples/research_bot/README.md @@ -0,0 +1,25 @@ +# Research bot + +This is a simple example of a multi-agent research bot. To run it: + +```bash +python -m examples.research_bot.main +``` + +## Architecture + +The flow is: + +1. User enters their research topic +2. `planner_agent` comes up with a plan to search the web for information. The plan is a list of search queries, with a search term and a reason for each query. +3. For each search item, we run a `search_agent`, which uses the Web Search tool to search for that term and summarize the results. These all run in parallel. +4. Finally, the `writer_agent` receives the search summaries, and creates a written report. + +## Suggested improvements + +If you're building your own research bot, some ideas to add to this are: + +1. Retrieval: Add support for fetching relevant information from a vector store. You could use the File Search tool for this. +2. Image and file upload: Allow users to attach PDFs or other files, as baseline context for the research. +3. More planning and thinking: Models often produce better results given more time to think. Improve the planning process to come up with a better plan, and add an evaluation step so that the model can choose to improve it's results, search for more stuff, etc. +4. Code execution: Allow running code, which is useful for data analysis. diff --git a/tests/examples/research_bot/__init__.py b/tests/examples/research_bot/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/examples/research_bot/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/examples/research_bot/__pycache__/__init__.cpython-313.pyc b/tests/examples/research_bot/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3d1acc9 Binary files /dev/null and b/tests/examples/research_bot/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/examples/research_bot/__pycache__/main.cpython-313.pyc b/tests/examples/research_bot/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..394e462 Binary files /dev/null and b/tests/examples/research_bot/__pycache__/main.cpython-313.pyc differ diff --git a/tests/examples/research_bot/__pycache__/manager.cpython-313.pyc b/tests/examples/research_bot/__pycache__/manager.cpython-313.pyc new file mode 100644 index 0000000..aaef5de Binary files /dev/null and b/tests/examples/research_bot/__pycache__/manager.cpython-313.pyc differ diff --git a/tests/examples/research_bot/__pycache__/printer.cpython-313.pyc b/tests/examples/research_bot/__pycache__/printer.cpython-313.pyc new file mode 100644 index 0000000..3e9ac76 Binary files /dev/null and b/tests/examples/research_bot/__pycache__/printer.cpython-313.pyc differ diff --git a/tests/examples/research_bot/agents/__init__.py b/tests/examples/research_bot/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/examples/research_bot/agents/__pycache__/__init__.cpython-313.pyc b/tests/examples/research_bot/agents/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a094b5a Binary files /dev/null and b/tests/examples/research_bot/agents/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/examples/research_bot/agents/__pycache__/base_agent.cpython-313.pyc b/tests/examples/research_bot/agents/__pycache__/base_agent.cpython-313.pyc new file mode 100644 index 0000000..f33d418 Binary files /dev/null and b/tests/examples/research_bot/agents/__pycache__/base_agent.cpython-313.pyc differ diff --git a/tests/examples/research_bot/agents/__pycache__/planner_agent.cpython-313.pyc b/tests/examples/research_bot/agents/__pycache__/planner_agent.cpython-313.pyc new file mode 100644 index 0000000..b836aac Binary files /dev/null and b/tests/examples/research_bot/agents/__pycache__/planner_agent.cpython-313.pyc differ diff --git a/tests/examples/research_bot/agents/__pycache__/research_manager_agent.cpython-313.pyc b/tests/examples/research_bot/agents/__pycache__/research_manager_agent.cpython-313.pyc new file mode 100644 index 0000000..edc3f5f Binary files /dev/null and b/tests/examples/research_bot/agents/__pycache__/research_manager_agent.cpython-313.pyc differ diff --git a/tests/examples/research_bot/agents/__pycache__/search_agent.cpython-313.pyc b/tests/examples/research_bot/agents/__pycache__/search_agent.cpython-313.pyc new file mode 100644 index 0000000..b328124 Binary files /dev/null and b/tests/examples/research_bot/agents/__pycache__/search_agent.cpython-313.pyc differ diff --git a/tests/examples/research_bot/agents/__pycache__/summarization_agent.cpython-313.pyc b/tests/examples/research_bot/agents/__pycache__/summarization_agent.cpython-313.pyc new file mode 100644 index 0000000..b809d7c Binary files /dev/null and b/tests/examples/research_bot/agents/__pycache__/summarization_agent.cpython-313.pyc differ diff --git a/tests/examples/research_bot/agents/__pycache__/writer_agent.cpython-313.pyc b/tests/examples/research_bot/agents/__pycache__/writer_agent.cpython-313.pyc new file mode 100644 index 0000000..be550b1 Binary files /dev/null and b/tests/examples/research_bot/agents/__pycache__/writer_agent.cpython-313.pyc differ diff --git a/tests/examples/research_bot/agents/planner_agent.py b/tests/examples/research_bot/agents/planner_agent.py new file mode 100644 index 0000000..e80a8e6 --- /dev/null +++ b/tests/examples/research_bot/agents/planner_agent.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel + +from agents import Agent + +PROMPT = ( + "You are a helpful research assistant. Given a query, come up with a set of web searches " + "to perform to best answer the query. Output between 5 and 20 terms to query for." +) + + +class WebSearchItem(BaseModel): + reason: str + "Your reasoning for why this search is important to the query." + + query: str + "The search term to use for the web search." + + +class WebSearchPlan(BaseModel): + searches: list[WebSearchItem] + """A list of web searches to perform to best answer the query.""" + + +planner_agent = Agent( + name="PlannerAgent", + instructions=PROMPT, + model="gpt-4o", + output_type=WebSearchPlan, +) diff --git a/tests/examples/research_bot/agents/search_agent.py b/tests/examples/research_bot/agents/search_agent.py new file mode 100644 index 0000000..72cbc8e --- /dev/null +++ b/tests/examples/research_bot/agents/search_agent.py @@ -0,0 +1,18 @@ +from agents import Agent, WebSearchTool +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" + "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." +) + +search_agent = Agent( + name="Search agent", + instructions=INSTRUCTIONS, + tools=[WebSearchTool()], + model_settings=ModelSettings(tool_choice="required"), +) diff --git a/tests/examples/research_bot/agents/writer_agent.py b/tests/examples/research_bot/agents/writer_agent.py new file mode 100644 index 0000000..7b7d01a --- /dev/null +++ b/tests/examples/research_bot/agents/writer_agent.py @@ -0,0 +1,33 @@ +# Agent used to synthesize a final report from the individual summaries. +from pydantic import BaseModel + +from agents import Agent + +PROMPT = ( + "You are a senior researcher tasked with writing a cohesive report for a research query. " + "You will be provided with the original query, and some initial research done by a research " + "assistant.\n" + "You should first come up with an outline for the report that describes the structure and " + "flow of the report. Then, generate the report and return that as your final output.\n" + "The final output should be in markdown format, and it should be lengthy and detailed. Aim " + "for 5-10 pages of content, at least 1000 words." +) + + +class ReportData(BaseModel): + short_summary: str + """A short 2-3 sentence summary of the findings.""" + + markdown_report: str + """The final report""" + + follow_up_questions: list[str] + """Suggested topics to research further""" + + +writer_agent = Agent( + name="WriterAgent", + instructions=PROMPT, + model="o3-mini", + output_type=ReportData, +) diff --git a/tests/examples/research_bot/main.py b/tests/examples/research_bot/main.py new file mode 100644 index 0000000..a0fd43d --- /dev/null +++ b/tests/examples/research_bot/main.py @@ -0,0 +1,12 @@ +import asyncio + +from .manager import ResearchManager + + +async def main() -> None: + query = input("What would you like to research? ") + await ResearchManager().run(query) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/research_bot/manager.py b/tests/examples/research_bot/manager.py new file mode 100644 index 0000000..47306f1 --- /dev/null +++ b/tests/examples/research_bot/manager.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +import time + +from rich.console import Console + +from agents import Runner, custom_span, gen_trace_id, trace + +from .agents.planner_agent import WebSearchItem, WebSearchPlan, planner_agent +from .agents.search_agent import search_agent +from .agents.writer_agent import ReportData, writer_agent +from .printer import Printer + + +class ResearchManager: + def __init__(self): + self.console = Console() + self.printer = Printer(self.console) + + async def run(self, query: str) -> None: + trace_id = gen_trace_id() + with trace("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( + "starting", + "Starting research...", + is_done=True, + hide_checkmark=True, + ) + search_plan = await self._plan_searches(query) + search_results = await self._perform_searches(search_plan) + report = await self._write_report(query, search_results) + + 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("\n\n=====REPORT=====\n\n") + print(f"Report: {report.markdown_report}") + print("\n\n=====FOLLOW UP QUESTIONS=====\n\n") + follow_up_questions = "\n".join(report.follow_up_questions) + print(f"Follow up questions: {follow_up_questions}") + + async def _plan_searches(self, query: str) -> WebSearchPlan: + 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(WebSearchPlan) + + async def _perform_searches(self, search_plan: WebSearchPlan) -> list[str]: + with custom_span("Search the web"): + self.printer.update_item("searching", "Searching...") + num_completed = 0 + tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches] + results = [] + 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: WebSearchItem) -> str | None: + input = f"Search term: {item.query}\nReason for searching: {item.reason}" + try: + result = await Runner.run( + search_agent, + input, + ) + return str(result.final_output) + except Exception: + return None + + async def _write_report(self, query: str, search_results: list[str]) -> ReportData: + self.printer.update_item("writing", "Thinking about report...") + input = f"Original query: {query}\nSummarized search results: {search_results}" + result = Runner.run_streamed( + writer_agent, + input, + ) + update_messages = [ + "Thinking about report...", + "Planning report structure...", + "Writing outline...", + "Creating sections...", + "Cleaning up formatting...", + "Finalizing report...", + "Finishing 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(ReportData) diff --git a/tests/examples/research_bot/printer.py b/tests/examples/research_bot/printer.py new file mode 100644 index 0000000..e820c75 --- /dev/null +++ b/tests/examples/research_bot/printer.py @@ -0,0 +1,41 @@ +from typing import Any + +from rich.console import Console, Group +from rich.live import Live +from rich.spinner import Spinner + + +class Printer: + def __init__(self, console: Console): + 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)) diff --git a/tests/examples/research_bot/sample_outputs/product_recs.md b/tests/examples/research_bot/sample_outputs/product_recs.md new file mode 100644 index 0000000..70789eb --- /dev/null +++ b/tests/examples/research_bot/sample_outputs/product_recs.md @@ -0,0 +1,180 @@ +# Comprehensive Guide on Best Surfboards for Beginners: Transitioning, Features, and Budget Options + +Surfing is not only a sport but a lifestyle that hooks its enthusiasts with the allure of riding waves and connecting with nature. For beginners, selecting the right surfboard is critical to safety, learning, and performance. This comprehensive guide has been crafted to walk through the essential aspects of choosing the ideal surfboard for beginners, especially those looking to transition from an 11-foot longboard to a shorter, more dynamic board. We discuss various board types, materials, design elements, and budget ranges, providing a detailed road map for both new surfers and those in the process of progression. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Board Types and Design Considerations](#board-types-and-design-considerations) +3. [Key Board Dimensions and Features](#key-board-dimensions-and-features) +4. [Materials: Soft-Top vs. Hard-Top Boards](#materials-soft-top-vs-hard-top-boards) +5. [Tips for Transitioning from Longboards to Shorter Boards](#tips-for-transitioning-from-longboards-to-shorter-boards) +6. [Budget and Pricing Options](#budget-and-pricing-options) +7. [Recommended Models and Buying Options](#recommended-models-and-buying-options) +8. [Conclusion](#conclusion) +9. [Follow-up Questions](#follow-up-questions) + +--- + +## Introduction + +Surfing is a dynamic sport that requires not only skill and technique but also the proper equipment. For beginners, the right surfboard can make the difference between a frustrating experience and one that builds confidence and enthusiasm. Many newcomers start with longboards due to their stability and ease of paddling; however, as skills develop, transitioning to a shorter board might be desirable for enhancing maneuverability and performance. This guide is designed for surfers who can already catch waves on an 11-foot board and are now considering stepping down to a more versatile option. + +The overarching goal of this document is to help beginners identify which surfboard characteristics are most important, including board length, width, thickness, volume, and materials, while also considering factors like weight distribution, buoyancy, and control. We will also take a look at board types that are particularly welcoming for beginners and discuss gradual transitioning strategies. + +--- + +## Board Types and Design Considerations + +Choosing a board involves understanding the variety of designs available. Below are the main types of surfboards that cater to beginners and transitional surfers: + +### Longboards and Mini-Mals + +Longboards, typically 8 to 11 feet in length, provide ample stability, smoother paddling, and are well-suited for wave-catching. Their generous volume and width allow beginners to build confidence when standing up and riding waves. Mini-mal or mini-malibus (often around 8 to 9 feet) are a popular bridge between the longboard and the more agile shortboard, offering both stability and moderate maneuverability, which makes them excellent for gradual progress. + +### Funboards and Hybrids + +Funboards and hybrid boards blend the benefits of longboards and shortboards. They typically range from 6’6" to 8’0" in length, with extra volume and width that help preserve stability while introducing elements of sharper turning and improved agility. Hybrids are particularly helpful for surfers transitioning from longboards, as they maintain some of the buoyancy and ease of catching waves, yet offer a taste of the performance found in smaller boards. + +### Shortboards + +Shortboards emphasize performance, maneuverability, and a more responsive ride. However, they have less volume and require stronger paddling, quicker pop-up techniques, and more refined balance. For beginners, moving to a traditional shortboard immediately can be challenging. It is generally advised to make a gradual transition, potentially starting with a funboard or hybrid before making a direct leap to a performance shortboard. + +--- + +## Key Board Dimensions and Features + +When selecting a beginner surfboard, several key dimensions and features drastically affect performance, ease of learning, and safety: + +### Length and Width + +- **Length**: Starting with an 8 to 9-foot board is ideal. Longer boards offer enhanced stability and improved paddling capabilities. Gradual downsizing is recommended if you plan to move from an 11-foot board. +- **Width**: A board with a width over 20 inches provides greater stability and facilitates balance, especially vital for beginners. + +### Thickness and Volume + +- **Thickness**: Typically around 2.5 to 3 inches. Thicker decks increase buoyancy, allowing the surfer to paddle easier while catching waves. +- **Volume**: Measured in liters, volume is critical in understanding a board's flotation capacity. Higher volumes (e.g., 60-100 liters) are essential for beginners as they make the board more forgiving and stable. Suitable volumes might vary according to the surfer’s weight and experience level. + +### Nose and Tail Shape + +- **Nose Shape**: A wide, rounded nose expands the board’s planing surface, which can help in catching waves sooner and maintaining stability as you ride. +- **Tail Design**: Square or rounded tails are generally recommended as they enhance stability and allow for controlled turns, essential during the learning phase. + +### Rocker + +- **Rocker**: This is the curvature of the board from nose to tail. For beginners, a minimal or relaxed rocker provides better stability and ease during paddling. A steeper rocker might be introduced progressively as the surfer’s skills improve. + +--- + +## Materials: Soft-Top vs. Hard-Top Boards + +The material composition of a surfboard is a crucial factor in determining its performance, durability, and safety. Beginners have two primary choices: + +### Soft-Top (Foam) Boards + +Soft-top boards are constructed almost entirely from foam. Their attributes include: + +- **Safety and Forgiveness**: The foam construction minimizes injury upon impact which is advantageous for beginners who might fall frequently. +- **Stability and Buoyancy**: These boards typically offer greater buoyancy due to their softer material and thicker construction, easing the initial learning process. +- **Maintenance**: They often require less maintenance—there is typically no need for waxing and they are more resistant to dings and scratches. + +However, as a surfer’s skills progress, a soft-top might limit maneuverability and overall performance. + +### Hard-Top Boards + +Hard-tops, in contrast, offer a more traditional surfboard feel. They generally rely on a foam core encased in resin, with two prevalent combinations: + +- **PU (Polyurethane) Core with Polyester Resin**: This combination gives a classic feel and is relatively economical; however, these boards can be heavier and, as they age, more prone to damage. +- **EPS (Expanded Polystyrene) Core with Epoxy Resin**: Lightweight and durable, EPS boards are often more buoyant and resistant to damage, although they usually carry a higher price tag and may be less forgiving. + +Deciding between soft-top and hard-top boards often depends on a beginner’s progression goals, overall comfort, and budget constraints. + +--- + +## Tips for Transitioning from Longboards to Shorter Boards + +For surfers who have mastered the basics on an 11-foot board, the transition to a shorter board requires careful consideration, patience, and incremental changes. Here are some key tips: + +### Gradual Downsizing + +Experts recommend reducing the board length gradually—by about a foot at a time—to allow the body to adjust slowly to a board with less buoyancy and more responsiveness. This process helps maintain wave-catching ability and reduces the shock of transitioning to a very different board feel. + +### Strengthening Core Skills + +Before transitioning, make sure your surfing fundamentals are solid. Focus on practicing: + +- **Steep Take-offs**: Ensure that your pop-up is swift and robust to keep pace with shorter boards that demand a rapid transition from paddling to standing. +- **Angling and Paddling Techniques**: Learn to angle your takeoffs properly to compensate for the lower buoyancy and increased maneuverability of shorter boards. + +### Experimenting with Rentals or Borrowed Boards + +If possible, try out a friend’s shorter board or rent one for a day to experience firsthand the differences in performance. This practical trial can provide valuable insights and inform your decision before making a purchase. + +--- + +## Budget and Pricing Options + +Surfboards are available across a range of prices to match different budgets. Whether you are looking for an affordable beginner board or a more expensive model that grows with your skills, it’s important to understand what features you can expect at different price points. + +### Budget-Friendly Options + +For those on a tight budget, several entry-level models offer excellent value. Examples include: + +- **Wavestorm 8' Classic Pinline Surfboard**: Priced affordably, this board is popular for its ease of use, ample volume, and forgiving nature. Despite its low cost, it delivers the stability needed to get started. +- **Liquid Shredder EZ Slider Foamie**: A smaller board catering to younger or lighter surfers, this budget option provides easy paddling and a minimal risk of injury due to its soft construction. + +### Moderate Price Range + +As you move into the intermediate range, boards typically become slightly more specialized in their design, offering features such as improved stringer systems or versatile fin setups. These are excellent for surfers who wish to continue progressing their skills without compromising stability. Many surfboard packages from retailers also bundle a board with essential accessories like board bags, leashes, and wax for additional savings. + +### Higher-End Models and Transitional Packages + +For surfers looking for durability, performance, and advanced design features, investing in an EPS/epoxy board might be ideal. Although they come at a premium, these boards are lightweight, strong, and customizable with various fin configurations. Some options include boards from brands like South Bay Board Co. and ISLE, which combine high-quality construction with beginner-friendly features that help mediate the transition from longboard to shortboard performance. + +--- + +## Recommended Models and Buying Options + +Based on extensive research and community recommendations, here are some standout models and tips on where to buy: + +### Recommended Models + +- **South Bay Board Co. 8'8" Heritage**: Combining foam and resin construction, this board is ideal for beginners who need stability and a forgiving surface. Its 86-liter volume suits both lightweight and somewhat heavier surfers. +- **Rock-It 8' Big Softy**: With a high volume and an easy paddling profile, this board is designed for beginners, offering ample buoyancy to smooth out the learning curve. +- **Wave Bandit EZ Rider Series**: Available in multiple lengths (7', 8', 9'), these boards offer versatility, with construction features that balance the stability of longboards and the agility required for shorter boards. +- **Hybrid/Funboards Like the Poacher Funboard**: Perfect for transitioning surfers, these boards blend the ease of catching waves with the capability for more dynamic maneuvers. + +### Buying Options + +- **Surf Shops and Local Retailers**: Traditional surf shops allow you to test different boards, which is ideal for assessing the board feel and condition—especially if you are considering a used board. +- **Online Retailers and Marketplaces**: Websites like Evo, Surfboards Direct, and even local online marketplaces like Craigslist and Facebook Marketplace provide options that range from new to gently used boards. Always inspect reviews and verify seller policies before purchase. +- **Package Deals and Bundles**: Many retailers offer bundled packages that include not just the board, but also essentials like a leash, wax, fins, and board bags. These packages can be more cost-effective and are great for beginners who need a complete surf kit. + +--- + +## Conclusion + +Selecting the right surfboard as a beginner is about balancing various factors: stability, buoyancy, maneuverability, and budget. + +For those who have honed the basics using an 11-foot longboard, the transition to a shorter board should be gradual. Start by focusing on boards that preserve stability—such as funboards and hybrids—before moving to the more performance-oriented shortboards. Key characteristics like board length, width, thickness, volume, and material profoundly influence your surfing experience. Soft-top boards provide a forgiving entry point, while hard-top boards, especially those with EPS cores and epoxy resin, offer benefits for more advanced progression despite the increased learning curve. + +Emphasizing fundamentals like proper pop-up technique and effective paddle work will ease the transition and ensure that the new board complements your evolving skills. Additionally, understanding the pricing spectrum—from budget-friendly models to premium options—allows you to make an informed purchase that suits both your financial and performance needs. + +With a thoughtful approach to board selection, you can enhance your learning curve, enjoy safer sessions in the water, and ultimately develop the skills necessary to master the diverse challenges surfing presents. Whether your goal is to ride gentle waves or eventually experiment with sharper turns and dynamic maneuvers, choosing the right board is your first step towards a rewarding and sustainable surfing journey. + +--- + +## Follow-up Questions + +1. What is your current budget range for a new surfboard, or are you considering buying used? +2. How frequently do you plan to surf, and in what type of wave conditions? +3. Are you interested in a board that you can grow into as your skills progress, or do you prefer one that is more specialized for certain conditions? +4. Would you be interested in additional equipment bundles (like fins, leashes, boards bags) offered by local retailers or online shops? +5. Have you had the opportunity to test ride any boards before, and what feedback did you gather from that experience? + +--- + +With this detailed guide, beginners should now have a comprehensive understanding of the surfboard market and the key factors influencing board performance, safety, and ease of progression. Happy surfing, and may you find the perfect board that rides the waves as beautifully as your passion for the sport! diff --git a/tests/examples/research_bot/sample_outputs/product_recs.txt b/tests/examples/research_bot/sample_outputs/product_recs.txt new file mode 100644 index 0000000..78865f2 --- /dev/null +++ b/tests/examples/research_bot/sample_outputs/product_recs.txt @@ -0,0 +1,212 @@ +# Terminal output for a product recommendation related query. See product_recs.md for final report. + +$ uv run python -m examples.research_bot.main + +What would you like to research? Best surfboards for beginners. I can catch my own waves, but previously used an 11ft board. What should I look for, what are my options? Various budget ranges. +View trace: https://platform.openai.com/traces/trace_... +Starting research... +✅ Will perform 15 searches +✅ Searching... 15/15 completed +✅ Finishing report... +✅ Report summary + +This report provides a detailed guide on selecting the best surfboards for beginners, especially for those transitioning from an 11-foot longboard to a +shorter board. It covers design considerations such as board dimensions, shape, materials, and volume, while comparing soft-top and hard-top boards. In +addition, the report discusses various budget ranges, recommended board models, buying options (both new and used), and techniques to ease the transition to +more maneuverable boards. By understanding these factors, beginner surfers can select a board that not only enhances their skills but also suits their +individual needs. + + +=====REPORT===== + + +Report: # Comprehensive Guide on Best Surfboards for Beginners: Transitioning, Features, and Budget Options + +Surfing is not only a sport but a lifestyle that hooks its enthusiasts with the allure of riding waves and connecting with nature. For beginners, selecting the right surfboard is critical to safety, learning, and performance. This comprehensive guide has been crafted to walk through the essential aspects of choosing the ideal surfboard for beginners, especially those looking to transition from an 11-foot longboard to a shorter, more dynamic board. We discuss various board types, materials, design elements, and budget ranges, providing a detailed road map for both new surfers and those in the process of progression. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Board Types and Design Considerations](#board-types-and-design-considerations) +3. [Key Board Dimensions and Features](#key-board-dimensions-and-features) +4. [Materials: Soft-Top vs. Hard-Top Boards](#materials-soft-top-vs-hard-top-boards) +5. [Tips for Transitioning from Longboards to Shorter Boards](#tips-for-transitioning-from-longboards-to-shorter-boards) +6. [Budget and Pricing Options](#budget-and-pricing-options) +7. [Recommended Models and Buying Options](#recommended-models-and-buying-options) +8. [Conclusion](#conclusion) +9. [Follow-up Questions](#follow-up-questions) + +--- + +## Introduction + +Surfing is a dynamic sport that requires not only skill and technique but also the proper equipment. For beginners, the right surfboard can make the difference between a frustrating experience and one that builds confidence and enthusiasm. Many newcomers start with longboards due to their stability and ease of paddling; however, as skills develop, transitioning to a shorter board might be desirable for enhancing maneuverability and performance. This guide is designed for surfers who can already catch waves on an 11-foot board and are now considering stepping down to a more versatile option. + +The overarching goal of this document is to help beginners identify which surfboard characteristics are most important, including board length, width, thickness, volume, and materials, while also considering factors like weight distribution, buoyancy, and control. We will also take a look at board types that are particularly welcoming for beginners and discuss gradual transitioning strategies. + +--- + +## Board Types and Design Considerations + +Choosing a board involves understanding the variety of designs available. Below are the main types of surfboards that cater to beginners and transitional surfers: + +### Longboards and Mini-Mals + +Longboards, typically 8 to 11 feet in length, provide ample stability, smoother paddling, and are well-suited for wave-catching. Their generous volume and width allow beginners to build confidence when standing up and riding waves. Mini-mal or mini-malibus (often around 8 to 9 feet) are a popular bridge between the longboard and the more agile shortboard, offering both stability and moderate maneuverability, which makes them excellent for gradual progress. + +### Funboards and Hybrids + +Funboards and hybrid boards blend the benefits of longboards and shortboards. They typically range from 6’6" to 8’0" in length, with extra volume and width that help preserve stability while introducing elements of sharper turning and improved agility. Hybrids are particularly helpful for surfers transitioning from longboards, as they maintain some of the buoyancy and ease of catching waves, yet offer a taste of the performance found in smaller boards. + +### Shortboards + +Shortboards emphasize performance, maneuverability, and a more responsive ride. However, they have less volume and require stronger paddling, quicker pop-up techniques, and more refined balance. For beginners, moving to a traditional shortboard immediately can be challenging. It is generally advised to make a gradual transition, potentially starting with a funboard or hybrid before making a direct leap to a performance shortboard. + +--- + +## Key Board Dimensions and Features + +When selecting a beginner surfboard, several key dimensions and features drastically affect performance, ease of learning, and safety: + +### Length and Width + +- **Length**: Starting with an 8 to 9-foot board is ideal. Longer boards offer enhanced stability and improved paddling capabilities. Gradual downsizing is recommended if you plan to move from an 11-foot board. +- **Width**: A board with a width over 20 inches provides greater stability and facilitates balance, especially vital for beginners. + +### Thickness and Volume + +- **Thickness**: Typically around 2.5 to 3 inches. Thicker decks increase buoyancy, allowing the surfer to paddle easier while catching waves. +- **Volume**: Measured in liters, volume is critical in understanding a board's flotation capacity. Higher volumes (e.g., 60-100 liters) are essential for beginners as they make the board more forgiving and stable. Suitable volumes might vary according to the surfer’s weight and experience level. + +### Nose and Tail Shape + +- **Nose Shape**: A wide, rounded nose expands the board’s planing surface, which can help in catching waves sooner and maintaining stability as you ride. +- **Tail Design**: Square or rounded tails are generally recommended as they enhance stability and allow for controlled turns, essential during the learning phase. + +### Rocker + +- **Rocker**: This is the curvature of the board from nose to tail. For beginners, a minimal or relaxed rocker provides better stability and ease during paddling. A steeper rocker might be introduced progressively as the surfer’s skills improve. + +--- + +## Materials: Soft-Top vs. Hard-Top Boards + +The material composition of a surfboard is a crucial factor in determining its performance, durability, and safety. Beginners have two primary choices: + +### Soft-Top (Foam) Boards + +Soft-top boards are constructed almost entirely from foam. Their attributes include: + +- **Safety and Forgiveness**: The foam construction minimizes injury upon impact which is advantageous for beginners who might fall frequently. +- **Stability and Buoyancy**: These boards typically offer greater buoyancy due to their softer material and thicker construction, easing the initial learning process. +- **Maintenance**: They often require less maintenance—there is typically no need for waxing and they are more resistant to dings and scratches. + +However, as a surfer’s skills progress, a soft-top might limit maneuverability and overall performance. + +### Hard-Top Boards + +Hard-tops, in contrast, offer a more traditional surfboard feel. They generally rely on a foam core encased in resin, with two prevalent combinations: + +- **PU (Polyurethane) Core with Polyester Resin**: This combination gives a classic feel and is relatively economical; however, these boards can be heavier and, as they age, more prone to damage. +- **EPS (Expanded Polystyrene) Core with Epoxy Resin**: Lightweight and durable, EPS boards are often more buoyant and resistant to damage, although they usually carry a higher price tag and may be less forgiving. + +Deciding between soft-top and hard-top boards often depends on a beginner’s progression goals, overall comfort, and budget constraints. + +--- + +## Tips for Transitioning from Longboards to Shorter Boards + +For surfers who have mastered the basics on an 11-foot board, the transition to a shorter board requires careful consideration, patience, and incremental changes. Here are some key tips: + +### Gradual Downsizing + +Experts recommend reducing the board length gradually—by about a foot at a time—to allow the body to adjust slowly to a board with less buoyancy and more responsiveness. This process helps maintain wave-catching ability and reduces the shock of transitioning to a very different board feel. + +### Strengthening Core Skills + +Before transitioning, make sure your surfing fundamentals are solid. Focus on practicing: + +- **Steep Take-offs**: Ensure that your pop-up is swift and robust to keep pace with shorter boards that demand a rapid transition from paddling to standing. +- **Angling and Paddling Techniques**: Learn to angle your takeoffs properly to compensate for the lower buoyancy and increased maneuverability of shorter boards. + +### Experimenting with Rentals or Borrowed Boards + +If possible, try out a friend’s shorter board or rent one for a day to experience firsthand the differences in performance. This practical trial can provide valuable insights and inform your decision before making a purchase. + +--- + +## Budget and Pricing Options + +Surfboards are available across a range of prices to match different budgets. Whether you are looking for an affordable beginner board or a more expensive model that grows with your skills, it’s important to understand what features you can expect at different price points. + +### Budget-Friendly Options + +For those on a tight budget, several entry-level models offer excellent value. Examples include: + +- **Wavestorm 8' Classic Pinline Surfboard**: Priced affordably, this board is popular for its ease of use, ample volume, and forgiving nature. Despite its low cost, it delivers the stability needed to get started. +- **Liquid Shredder EZ Slider Foamie**: A smaller board catering to younger or lighter surfers, this budget option provides easy paddling and a minimal risk of injury due to its soft construction. + +### Moderate Price Range + +As you move into the intermediate range, boards typically become slightly more specialized in their design, offering features such as improved stringer systems or versatile fin setups. These are excellent for surfers who wish to continue progressing their skills without compromising stability. Many surfboard packages from retailers also bundle a board with essential accessories like board bags, leashes, and wax for additional savings. + +### Higher-End Models and Transitional Packages + +For surfers looking for durability, performance, and advanced design features, investing in an EPS/epoxy board might be ideal. Although they come at a premium, these boards are lightweight, strong, and customizable with various fin configurations. Some options include boards from brands like South Bay Board Co. and ISLE, which combine high-quality construction with beginner-friendly features that help mediate the transition from longboard to shortboard performance. + +--- + +## Recommended Models and Buying Options + +Based on extensive research and community recommendations, here are some standout models and tips on where to buy: + +### Recommended Models + +- **South Bay Board Co. 8'8" Heritage**: Combining foam and resin construction, this board is ideal for beginners who need stability and a forgiving surface. Its 86-liter volume suits both lightweight and somewhat heavier surfers. +- **Rock-It 8' Big Softy**: With a high volume and an easy paddling profile, this board is designed for beginners, offering ample buoyancy to smooth out the learning curve. +- **Wave Bandit EZ Rider Series**: Available in multiple lengths (7', 8', 9'), these boards offer versatility, with construction features that balance the stability of longboards and the agility required for shorter boards. +- **Hybrid/Funboards Like the Poacher Funboard**: Perfect for transitioning surfers, these boards blend the ease of catching waves with the capability for more dynamic maneuvers. + +### Buying Options + +- **Surf Shops and Local Retailers**: Traditional surf shops allow you to test different boards, which is ideal for assessing the board feel and condition—especially if you are considering a used board. +- **Online Retailers and Marketplaces**: Websites like Evo, Surfboards Direct, and even local online marketplaces like Craigslist and Facebook Marketplace provide options that range from new to gently used boards. Always inspect reviews and verify seller policies before purchase. +- **Package Deals and Bundles**: Many retailers offer bundled packages that include not just the board, but also essentials like a leash, wax, fins, and board bags. These packages can be more cost-effective and are great for beginners who need a complete surf kit. + +--- + +## Conclusion + +Selecting the right surfboard as a beginner is about balancing various factors: stability, buoyancy, maneuverability, and budget. + +For those who have honed the basics using an 11-foot longboard, the transition to a shorter board should be gradual. Start by focusing on boards that preserve stability—such as funboards and hybrids—before moving to the more performance-oriented shortboards. Key characteristics like board length, width, thickness, volume, and material profoundly influence your surfing experience. Soft-top boards provide a forgiving entry point, while hard-top boards, especially those with EPS cores and epoxy resin, offer benefits for more advanced progression despite the increased learning curve. + +Emphasizing fundamentals like proper pop-up technique and effective paddle work will ease the transition and ensure that the new board complements your evolving skills. Additionally, understanding the pricing spectrum—from budget-friendly models to premium options—allows you to make an informed purchase that suits both your financial and performance needs. + +With a thoughtful approach to board selection, you can enhance your learning curve, enjoy safer sessions in the water, and ultimately develop the skills necessary to master the diverse challenges surfing presents. Whether your goal is to ride gentle waves or eventually experiment with sharper turns and dynamic maneuvers, choosing the right board is your first step towards a rewarding and sustainable surfing journey. + +--- + +## Follow-up Questions + +1. What is your current budget range for a new surfboard, or are you considering buying used? +2. How frequently do you plan to surf, and in what type of wave conditions? +3. Are you interested in a board that you can grow into as your skills progress, or do you prefer one that is more specialized for certain conditions? +4. Would you be interested in additional equipment bundles (like fins, leashes, boards bags) offered by local retailers or online shops? +5. Have you had the opportunity to test ride any boards before, and what feedback did you gather from that experience? + +--- + +With this detailed guide, beginners should now have a comprehensive understanding of the surfboard market and the key factors influencing board performance, safety, and ease of progression. Happy surfing, and may you find the perfect board that rides the waves as beautifully as your passion for the sport! + + +=====FOLLOW UP QUESTIONS===== + + +Follow up questions: What is your current budget range for a new surfboard, or are you considering a used board? +What types of waves do you typically surf, and how might that affect your board choice? +Would you be interested in a transitional board that grows with your skills, or are you looking for a more specialized design? +Have you had experience with renting or borrowing boards to try different sizes before making a purchase? +Do you require additional equipment bundles (like fins, leash, or wax), or do you already have those? diff --git a/tests/examples/research_bot/sample_outputs/vacation.md b/tests/examples/research_bot/sample_outputs/vacation.md new file mode 100644 index 0000000..82c137a --- /dev/null +++ b/tests/examples/research_bot/sample_outputs/vacation.md @@ -0,0 +1,177 @@ +Report: # Caribbean Adventure in April: Surfing, Hiking, and Water Sports Exploration + +The Caribbean is renowned for its crystal-clear waters, vibrant culture, and diverse outdoor activities. April is an especially attractive month for visitors: warm temperatures, clear skies, and the promise of abundant activities. This report explores the best Caribbean destinations in April, with a focus on optimizing your vacation for surfing, hiking, and water sports. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Why April is the Perfect Time in the Caribbean](#why-april-is-the-perfect-time-in-the-caribbean) +3. [Surfing in the Caribbean](#surfing-in-the-caribbean) + - 3.1 [Barbados: The Tale of Two Coasts](#barbados-the-tale-of-two-coasts) + - 3.2 [Puerto Rico: Rincón and Beyond](#puerto-rico-rinc%C3%B3n-and-beyond) + - 3.3 [Dominican Republic and Other Hotspots](#dominican-republic-and-other-hotspots) +4. [Hiking Adventures Across the Caribbean](#hiking-adventures-across-the-caribbean) + - 4.1 [Trekking Through Tropical Rainforests](#trekking-through-tropical-rainforests) + - 4.2 [Volcanic Peaks and Rugged Landscapes](#volcanic-peaks-and-rugged-landscapes) +5. [Diverse Water Sports Experiences](#diverse-water-sports-experiences) + - 5.1 [Snorkeling, Diving, and Jet Skiing](#snorkeling-diving-and-jet-skiing) + - 5.2 [Kiteboarding and Windsurfing](#kiteboarding-and-windsurfing) +6. [Combining Adventures: Multi-Activity Destinations](#combining-adventures-multi-activity-destinations) +7. [Practical Advice and Travel Tips](#practical-advice-and-travel-tips) +8. [Conclusion](#conclusion) + +--- + +## Introduction + +Caribbean vacations are much more than just beach relaxation; they offer adventure, exploration, and a lively cultural tapestry waiting to be discovered. For travelers seeking an adrenaline-filled getaway, April provides optimal conditions. This report synthesizes diverse research findings and travel insights to help you create an itinerary that combines the thrill of surfing, the challenge of hiking, and the excitement of water sports. + +Whether you're standing on the edge of a powerful reef break or trekking through lush tropical landscapes, the Caribbean in April invites you to dive into nature, adventure, and culture. The following sections break down the best destinations and activities, ensuring that every aspect of your trip is meticulously planned for an unforgettable experience. + +--- + +## Why April is the Perfect Time in the Caribbean + +April stands at the crossroads of seasons in many Caribbean destinations. It marks the tail end of the dry season, ensuring: + +- **Consistent Warm Temperatures:** Average daytime highs around 29°C (84°F) foster comfortable conditions for both land and water activities. +- **Pleasant Sea Temperatures:** With sea temperatures near 26°C (79°F), swimmers, surfers, and divers are treated to inviting waters. +- **Clear Skies and Minimal Rainfall:** Crisp, blue skies make for excellent visibility during snorkeling and diving, as well as clear panoramic views while hiking. +- **Festivals and Cultural Events:** Many islands host seasonal festivals such as Barbados' Fish Festival and Antigua's Sailing Week, adding a cultural layer to your vacation. + +These factors create an ideal backdrop for balancing your outdoor pursuits, whether you’re catching epic waves, trekking rugged trails, or partaking in water sports. + +--- + +## Surfing in the Caribbean + +Surfing in the Caribbean offers diverse wave experiences, ranging from gentle, beginner-friendly rollers to powerful reef breaks that challenge even seasoned surfers. April, in particular, provides excellent conditions for those looking to ride its picturesque waves. + +### Barbados: The Tale of Two Coasts + +Barbados is a prime destination: + +- **Soup Bowl in Bathsheba:** On the east coast, the Soup Bowl is famous for its consistent, powerful waves. This spot attracts experienced surfers who appreciate its challenging right-hand reef break with steep drops, providing the kind of performance wave rarely found elsewhere. +- **Freights Bay:** On the south coast, visitors find more forgiving, gentle wave conditions. Ideal for beginners and longboarders, this spot offers the perfect balance for those still mastering their craft. + +Barbados not only excels in its surfing credentials but also complements the experience with a rich local culture and events in April, making it a well-rounded destination. + +### Puerto Rico: Rincón and Beyond + +Rincón in Puerto Rico is hailed as the Caribbean’s surfing capital: + +- **Diverse Breaks:** With spots ranging from challenging reef breaks such as Tres Palmas and Dogman's to more inviting waves at Domes and Maria's, Puerto Rico offers a spectrum for all surfing skill levels. +- **Local Culture:** Aside from its surf culture, the island boasts vibrant local food scenes, historic sites, and exciting nightlife, enriching your overall travel experience. + +In addition, Puerto Rico’s coasts often feature opportunities for hiking and other outdoor adventures, making it an attractive option for multi-activity travelers. + +### Dominican Republic and Other Hotspots + +Other islands such as the Dominican Republic, with Playa Encuentro on its north coast, provide consistent surf year-round. Highlights include: + +- **Playa Encuentro:** A hotspot known for its dependable breaks, ideal for both intermediate and advanced surfers during the cooler months of October to April. +- **Jamaica and The Bahamas:** Jamaica’s Boston Bay offers a mix of beginner and intermediate waves, and The Bahamas’ Surfer’s Beach on Eleuthera draws parallels to the legendary surf spots of Hawaii, especially during the winter months. + +These destinations not only spotlight surfing but also serve as gateways to additional outdoor activities, ensuring there's never a dull moment whether you're balancing waves with hikes or cultural exploration. + +--- + +## Hiking Adventures Across the Caribbean + +The Caribbean's topography is as varied as it is beautiful. Its network of hiking trails traverses volcanic peaks, ancient rainforests, and dramatic coastal cliffs, offering breathtaking vistas to intrepid explorers. + +### Trekking Through Tropical Rainforests + +For nature enthusiasts, the lush forests of the Caribbean present an immersive encounter with biodiversity: + +- **El Yunque National Forest, Puerto Rico:** The only tropical rainforest within the U.S. National Forest System, El Yunque is rich in endemic species such as the Puerto Rican parrot and the famous coquí frog. Trails like the El Yunque Peak Trail and La Mina Falls Trail provide both challenging hikes and scenic rewards. +- **Virgin Islands National Park, St. John:** With over 20 well-defined trails, this park offers hikes that reveal historical petroglyphs, colonial ruins, and stunning coastal views along the Reef Bay Trail. + +### Volcanic Peaks and Rugged Landscapes + +For those seeking more rugged challenges, several destinations offer unforgettable adventures: + +- **Morne Trois Pitons National Park, Dominica:** A UNESCO World Heritage Site showcasing volcanic landscapes, hot springs, the famed Boiling Lake, and lush trails that lead to hidden waterfalls. +- **Gros Piton, Saint Lucia:** The iconic hike up Gros Piton provides a moderately challenging trek that ends with panoramic views of the Caribbean Sea, a truly rewarding experience for hikers. +- **La Soufrière, St. Vincent:** This active volcano not only offers a dynamic hiking environment but also the opportunity to observe the ongoing geological transformations up close. + +Other noteworthy hiking spots include the Blue Mountains in Jamaica for coffee plantation tours and expansive views, as well as trails in Martinique around Montagne Pelée, which combine historical context with natural beauty. + +--- + +## Diverse Water Sports Experiences + +While surfing and hiking attract a broad range of adventurers, the Caribbean also scores high on other water sports. Whether you're drawn to snorkeling, jet skiing, or wind- and kiteboarding, the islands offer a plethora of aquatic activities. + +### Snorkeling, Diving, and Jet Skiing + +Caribbean waters teem with life and color, making them ideal for underwater exploration: + +- **Bonaire:** Its protected marine parks serve as a magnet for divers and snorkelers. With vibrant coral reefs and diverse marine species, Bonaire is a top destination for those who appreciate the underwater world. +- **Cayman Islands:** Unique attractions such as Stingray City provide opportunities to interact with friendly stingrays in clear, calm waters. Additionally, the Underwater Sculpture Park is an innovative blend of art and nature. +- **The Bahamas:** In places like Eleuthera, excursions often cater to families and thrill-seekers alike. Options include jet ski rentals, where groups can explore hidden beaches and pristine coves while enjoying the vibrant marine life. + +### Kiteboarding and Windsurfing + +Harnessing the steady trade winds and warm Caribbean waters, several islands have become hubs for kiteboarding and windsurfing: + +- **Aruba:** Known as "One Happy Island," Aruba’s Fisherman's Huts area provides consistent winds, perfect for enthusiasts of windsurfing and kiteboarding alike. +- **Cabarete, Dominican Republic and Silver Rock, Barbados:** Both destinations benefit from reliable trade winds, making them popular among kitesurfers. These spots often combine water sports with a lively beach culture, ensuring that the fun continues on land as well. + +Local operators provide equipment rental and lessons, ensuring that even first-time adventurers can safely and confidently enjoy these exciting sports. + +--- + +## Combining Adventures: Multi-Activity Destinations + +For travelers seeking a comprehensive vacation where surfing, hiking, and water sports converge, several Caribbean destinations offer the best of all worlds. + +- **Puerto Rico:** With its robust surf scene in Rincón, world-class hiking in El Yunque, and opportunities for snorkeling and jet skiing in San Juan Bay, Puerto Rico is a true multi-adventure destination. +- **Barbados:** In addition to the surf breaks along its coasts, Barbados offers a mix of cultural events, local cuisine, and even hiking excursions to scenic rural areas, making for a well-rounded experience. +- **Dominican Republic and Jamaica:** Both are renowned not only for their consistent surf conditions but also for expansive hiking trails and water sports. From the rugged landscapes of the Dominican Republic to Jamaica’s blend of cultural history and natural exploration, these islands allow travelers to mix and match activities seamlessly. + +Group tours and local guides further enhance these experiences, providing insider tips, safe excursions, and personalized itineraries that cater to multiple interests within one trip. + +--- + +## Practical Advice and Travel Tips + +### Weather and Timing + +- **Optimal Climate:** April offers ideal weather conditions across the Caribbean. With minimal rainfall and warm temperatures, it is a great time to schedule outdoor activities. +- **Surfing Seasons:** While April marks the end of the prime surf season in some areas (like Rincón in Puerto Rico), many destinations maintain consistent conditions during this month. + +### Booking and Costs + +- **Surfing Lessons:** Expect to pay between $40 and $110 per session depending on the location. For instance, Puerto Rico typically charges around $75 for beginner lessons, while group lessons in the Dominican Republic average approximately $95. +- **Equipment Rentals:** Pricing for jet ski, surfboard, and snorkeling equipment may vary. In the Bahamas, an hour-long jet ski tour might cost about $120 per group, whereas a similar experience might be available at a lower cost in other regions. +- **Accommodations:** Prices also vary by island. Many travelers find that even affordable stays do not skimp on amenities, allowing you to invest more in guided excursions and local experiences. + +### Cultural Considerations + +- **Festivals and Events:** Check local event calendars. Destinations like Barbados and Antigua host festivals in April that combine cultural heritage with festive outdoor activities. +- **Local Cuisine:** Incorporate food tours into your itinerary. Caribbean cuisine—with its fusion of flavors—can be as adventurous as the outdoor activities. + +### Health and Safety + +- **Staying Hydrated:** The warm temperatures demand that you stay properly hydrated. Always carry water, especially during long hikes. +- **Sun Protection:** Use sunscreen, hats, and sunglasses to protect yourself during extended periods outdoors on both land and water. +- **Local Guides:** Utilize local tour operators for both hiking and water sports. Their expertise not only enriches your experience but also ensures safety in unfamiliar terrain or water bodies. + +--- + +## Conclusion + +The Caribbean in April is a haven for adventure seekers. With its pristine beaches, diverse ecosystems, and rich cultural tapestry, it offers something for every type of traveler. Whether you're chasing the perfect wave along the shores of Barbados and Puerto Rico, trekking through the lush landscapes of El Yunque or Morne Trois Pitons, or engaging in an array of water sports from snorkeling to kiteboarding, your ideal vacation is only a booking away. + +This report has outlined the best destinations and provided practical advice to optimize your vacation for surfing, hiking, and water sports. By considering the diverse offerings—from epic surf breaks and challenging hiking trails to vibrant water sports—the Caribbean stands out as a multi-adventure destination where every day brings a new experience. + +Plan carefully, pack wisely, and get ready to explore the vibrant mosaic of landscapes and activities that make the Caribbean in April a truly unforgettable adventure. + +Happy travels! + +--- + +_References available upon request. Many insights were drawn from trusted sources including Lonely Planet, TravelPug, and various Caribbean-centric exploration sites, ensuring a well-rounded and practical guide for your vacation planning._ diff --git a/tests/examples/research_bot/sample_outputs/vacation.txt b/tests/examples/research_bot/sample_outputs/vacation.txt new file mode 100644 index 0000000..b264998 --- /dev/null +++ b/tests/examples/research_bot/sample_outputs/vacation.txt @@ -0,0 +1,206 @@ +# Terminal output for a vacation related query. See vacation.md for final report. + +$ uv run python -m examples.research_bot.main +What would you like to research? Caribbean vacation spots in April, optimizing for surfing, hiking and water sports +View trace: https://platform.openai.com/traces/trace_.... +Starting research... +✅ Will perform 15 searches +✅ Searching... 15/15 completed +✅ Finishing report... +✅ Report summary + +This report provides an in-depth exploration of selected Caribbean vacation spots in April that are ideal for surfing, hiking, and water sports. Covering +destinations from Barbados and Puerto Rico to the Bahamas and Jamaica, it examines favorable weather conditions, recommended surf breaks, scenic hiking +trails, and various water sports activities. Detailed destination profiles, activity highlights, and travel tips are integrated to help travelers design a +multi-adventure itinerary in the Caribbean during April. + + +=====REPORT===== + + +Report: # Caribbean Adventure in April: Surfing, Hiking, and Water Sports Exploration + +The Caribbean is renowned for its crystal-clear waters, vibrant culture, and diverse outdoor activities. April is an especially attractive month for visitors: warm temperatures, clear skies, and the promise of abundant activities. This report explores the best Caribbean destinations in April, with a focus on optimizing your vacation for surfing, hiking, and water sports. + +--- + +## Table of Contents + +1. [Introduction](#introduction) +2. [Why April is the Perfect Time in the Caribbean](#why-april-is-the-perfect-time-in-the-caribbean) +3. [Surfing in the Caribbean](#surfing-in-the-caribbean) + - 3.1 [Barbados: The Tale of Two Coasts](#barbados-the-tale-of-two-coasts) + - 3.2 [Puerto Rico: Rincón and Beyond](#puerto-rico-rinc%C3%B3n-and-beyond) + - 3.3 [Dominican Republic and Other Hotspots](#dominican-republic-and-other-hotspots) +4. [Hiking Adventures Across the Caribbean](#hiking-adventures-across-the-caribbean) + - 4.1 [Trekking Through Tropical Rainforests](#trekking-through-tropical-rainforests) + - 4.2 [Volcanic Peaks and Rugged Landscapes](#volcanic-peaks-and-rugged-landscapes) +5. [Diverse Water Sports Experiences](#diverse-water-sports-experiences) + - 5.1 [Snorkeling, Diving, and Jet Skiing](#snorkeling-diving-and-jet-skiing) + - 5.2 [Kiteboarding and Windsurfing](#kiteboarding-and-windsurfing) +6. [Combining Adventures: Multi-Activity Destinations](#combining-adventures-multi-activity-destinations) +7. [Practical Advice and Travel Tips](#practical-advice-and-travel-tips) +8. [Conclusion](#conclusion) + +--- + +## Introduction + +Caribbean vacations are much more than just beach relaxation; they offer adventure, exploration, and a lively cultural tapestry waiting to be discovered. For travelers seeking an adrenaline-filled getaway, April provides optimal conditions. This report synthesizes diverse research findings and travel insights to help you create an itinerary that combines the thrill of surfing, the challenge of hiking, and the excitement of water sports. + +Whether you're standing on the edge of a powerful reef break or trekking through lush tropical landscapes, the Caribbean in April invites you to dive into nature, adventure, and culture. The following sections break down the best destinations and activities, ensuring that every aspect of your trip is meticulously planned for an unforgettable experience. + +--- + +## Why April is the Perfect Time in the Caribbean + +April stands at the crossroads of seasons in many Caribbean destinations. It marks the tail end of the dry season, ensuring: + +- **Consistent Warm Temperatures:** Average daytime highs around 29°C (84°F) foster comfortable conditions for both land and water activities. +- **Pleasant Sea Temperatures:** With sea temperatures near 26°C (79°F), swimmers, surfers, and divers are treated to inviting waters. +- **Clear Skies and Minimal Rainfall:** Crisp, blue skies make for excellent visibility during snorkeling and diving, as well as clear panoramic views while hiking. +- **Festivals and Cultural Events:** Many islands host seasonal festivals such as Barbados' Fish Festival and Antigua's Sailing Week, adding a cultural layer to your vacation. + +These factors create an ideal backdrop for balancing your outdoor pursuits, whether you’re catching epic waves, trekking rugged trails, or partaking in water sports. + +--- + +## Surfing in the Caribbean + +Surfing in the Caribbean offers diverse wave experiences, ranging from gentle, beginner-friendly rollers to powerful reef breaks that challenge even seasoned surfers. April, in particular, provides excellent conditions for those looking to ride its picturesque waves. + +### Barbados: The Tale of Two Coasts + +Barbados is a prime destination: + +- **Soup Bowl in Bathsheba:** On the east coast, the Soup Bowl is famous for its consistent, powerful waves. This spot attracts experienced surfers who appreciate its challenging right-hand reef break with steep drops, providing the kind of performance wave rarely found elsewhere. +- **Freights Bay:** On the south coast, visitors find more forgiving, gentle wave conditions. Ideal for beginners and longboarders, this spot offers the perfect balance for those still mastering their craft. + +Barbados not only excels in its surfing credentials but also complements the experience with a rich local culture and events in April, making it a well-rounded destination. + +### Puerto Rico: Rincón and Beyond + +Rincón in Puerto Rico is hailed as the Caribbean’s surfing capital: + +- **Diverse Breaks:** With spots ranging from challenging reef breaks such as Tres Palmas and Dogman's to more inviting waves at Domes and Maria's, Puerto Rico offers a spectrum for all surfing skill levels. +- **Local Culture:** Aside from its surf culture, the island boasts vibrant local food scenes, historic sites, and exciting nightlife, enriching your overall travel experience. + +In addition, Puerto Rico’s coasts often feature opportunities for hiking and other outdoor adventures, making it an attractive option for multi-activity travelers. + +### Dominican Republic and Other Hotspots + +Other islands such as the Dominican Republic, with Playa Encuentro on its north coast, provide consistent surf year-round. Highlights include: + +- **Playa Encuentro:** A hotspot known for its dependable breaks, ideal for both intermediate and advanced surfers during the cooler months of October to April. +- **Jamaica and The Bahamas:** Jamaica’s Boston Bay offers a mix of beginner and intermediate waves, and The Bahamas’ Surfer’s Beach on Eleuthera draws parallels to the legendary surf spots of Hawaii, especially during the winter months. + +These destinations not only spotlight surfing but also serve as gateways to additional outdoor activities, ensuring there's never a dull moment whether you're balancing waves with hikes or cultural exploration. + +--- + +## Hiking Adventures Across the Caribbean + +The Caribbean's topography is as varied as it is beautiful. Its network of hiking trails traverses volcanic peaks, ancient rainforests, and dramatic coastal cliffs, offering breathtaking vistas to intrepid explorers. + +### Trekking Through Tropical Rainforests + +For nature enthusiasts, the lush forests of the Caribbean present an immersive encounter with biodiversity: + +- **El Yunque National Forest, Puerto Rico:** The only tropical rainforest within the U.S. National Forest System, El Yunque is rich in endemic species such as the Puerto Rican parrot and the famous coquí frog. Trails like the El Yunque Peak Trail and La Mina Falls Trail provide both challenging hikes and scenic rewards. +- **Virgin Islands National Park, St. John:** With over 20 well-defined trails, this park offers hikes that reveal historical petroglyphs, colonial ruins, and stunning coastal views along the Reef Bay Trail. + +### Volcanic Peaks and Rugged Landscapes + +For those seeking more rugged challenges, several destinations offer unforgettable adventures: + +- **Morne Trois Pitons National Park, Dominica:** A UNESCO World Heritage Site showcasing volcanic landscapes, hot springs, the famed Boiling Lake, and lush trails that lead to hidden waterfalls. +- **Gros Piton, Saint Lucia:** The iconic hike up Gros Piton provides a moderately challenging trek that ends with panoramic views of the Caribbean Sea, a truly rewarding experience for hikers. +- **La Soufrière, St. Vincent:** This active volcano not only offers a dynamic hiking environment but also the opportunity to observe the ongoing geological transformations up close. + +Other noteworthy hiking spots include the Blue Mountains in Jamaica for coffee plantation tours and expansive views, as well as trails in Martinique around Montagne Pelée, which combine historical context with natural beauty. + +--- + +## Diverse Water Sports Experiences + +While surfing and hiking attract a broad range of adventurers, the Caribbean also scores high on other water sports. Whether you're drawn to snorkeling, jet skiing, or wind- and kiteboarding, the islands offer a plethora of aquatic activities. + +### Snorkeling, Diving, and Jet Skiing + +Caribbean waters teem with life and color, making them ideal for underwater exploration: + +- **Bonaire:** Its protected marine parks serve as a magnet for divers and snorkelers. With vibrant coral reefs and diverse marine species, Bonaire is a top destination for those who appreciate the underwater world. +- **Cayman Islands:** Unique attractions such as Stingray City provide opportunities to interact with friendly stingrays in clear, calm waters. Additionally, the Underwater Sculpture Park is an innovative blend of art and nature. +- **The Bahamas:** In places like Eleuthera, excursions often cater to families and thrill-seekers alike. Options include jet ski rentals, where groups can explore hidden beaches and pristine coves while enjoying the vibrant marine life. + +### Kiteboarding and Windsurfing + +Harnessing the steady trade winds and warm Caribbean waters, several islands have become hubs for kiteboarding and windsurfing: + +- **Aruba:** Known as "One Happy Island," Aruba’s Fisherman's Huts area provides consistent winds, perfect for enthusiasts of windsurfing and kiteboarding alike. +- **Cabarete, Dominican Republic and Silver Rock, Barbados:** Both destinations benefit from reliable trade winds, making them popular among kitesurfers. These spots often combine water sports with a lively beach culture, ensuring that the fun continues on land as well. + +Local operators provide equipment rental and lessons, ensuring that even first-time adventurers can safely and confidently enjoy these exciting sports. + +--- + +## Combining Adventures: Multi-Activity Destinations + +For travelers seeking a comprehensive vacation where surfing, hiking, and water sports converge, several Caribbean destinations offer the best of all worlds. + +- **Puerto Rico:** With its robust surf scene in Rincón, world-class hiking in El Yunque, and opportunities for snorkeling and jet skiing in San Juan Bay, Puerto Rico is a true multi-adventure destination. +- **Barbados:** In addition to the surf breaks along its coasts, Barbados offers a mix of cultural events, local cuisine, and even hiking excursions to scenic rural areas, making for a well-rounded experience. +- **Dominican Republic and Jamaica:** Both are renowned not only for their consistent surf conditions but also for expansive hiking trails and water sports. From the rugged landscapes of the Dominican Republic to Jamaica’s blend of cultural history and natural exploration, these islands allow travelers to mix and match activities seamlessly. + +Group tours and local guides further enhance these experiences, providing insider tips, safe excursions, and personalized itineraries that cater to multiple interests within one trip. + +--- + +## Practical Advice and Travel Tips + +### Weather and Timing + +- **Optimal Climate:** April offers ideal weather conditions across the Caribbean. With minimal rainfall and warm temperatures, it is a great time to schedule outdoor activities. +- **Surfing Seasons:** While April marks the end of the prime surf season in some areas (like Rincón in Puerto Rico), many destinations maintain consistent conditions during this month. + +### Booking and Costs + +- **Surfing Lessons:** Expect to pay between $40 and $110 per session depending on the location. For instance, Puerto Rico typically charges around $75 for beginner lessons, while group lessons in the Dominican Republic average approximately $95. +- **Equipment Rentals:** Pricing for jet ski, surfboard, and snorkeling equipment may vary. In the Bahamas, an hour-long jet ski tour might cost about $120 per group, whereas a similar experience might be available at a lower cost in other regions. +- **Accommodations:** Prices also vary by island. Many travelers find that even affordable stays do not skimp on amenities, allowing you to invest more in guided excursions and local experiences. + +### Cultural Considerations + +- **Festivals and Events:** Check local event calendars. Destinations like Barbados and Antigua host festivals in April that combine cultural heritage with festive outdoor activities. +- **Local Cuisine:** Incorporate food tours into your itinerary. Caribbean cuisine—with its fusion of flavors—can be as adventurous as the outdoor activities. + +### Health and Safety + +- **Staying Hydrated:** The warm temperatures demand that you stay properly hydrated. Always carry water, especially during long hikes. +- **Sun Protection:** Use sunscreen, hats, and sunglasses to protect yourself during extended periods outdoors on both land and water. +- **Local Guides:** Utilize local tour operators for both hiking and water sports. Their expertise not only enriches your experience but also ensures safety in unfamiliar terrain or water bodies. + +--- + +## Conclusion + +The Caribbean in April is a haven for adventure seekers. With its pristine beaches, diverse ecosystems, and rich cultural tapestry, it offers something for every type of traveler. Whether you're chasing the perfect wave along the shores of Barbados and Puerto Rico, trekking through the lush landscapes of El Yunque or Morne Trois Pitons, or engaging in an array of water sports from snorkeling to kiteboarding, your ideal vacation is only a booking away. + +This report has outlined the best destinations and provided practical advice to optimize your vacation for surfing, hiking, and water sports. By considering the diverse offerings—from epic surf breaks and challenging hiking trails to vibrant water sports—the Caribbean stands out as a multi-adventure destination where every day brings a new experience. + +Plan carefully, pack wisely, and get ready to explore the vibrant mosaic of landscapes and activities that make the Caribbean in April a truly unforgettable adventure. + +Happy travels! + +--- + +*References available upon request. Many insights were drawn from trusted sources including Lonely Planet, TravelPug, and various Caribbean-centric exploration sites, ensuring a well-rounded and practical guide for your vacation planning.* + + + +=====FOLLOW UP QUESTIONS===== + + +Follow up questions: Would you like detailed profiles for any of the highlighted destinations (e.g., Puerto Rico or Barbados)? +Are you interested in more information about booking details and local tour operators in specific islands? +Do you need guidance on combining cultural events with outdoor adventures during your Caribbean vacation? \ No newline at end of file diff --git a/tests/examples/tools/computer_use.py b/tests/examples/tools/computer_use.py new file mode 100644 index 0000000..35fc865 --- /dev/null +++ b/tests/examples/tools/computer_use.py @@ -0,0 +1,165 @@ +import asyncio +import base64 +import logging +from typing import Literal, Union + +from playwright.async_api import Browser, Page, Playwright, async_playwright + +from agents import ( + Agent, + AsyncComputer, + Button, + ComputerTool, + Environment, + ModelSettings, + Runner, + trace, +) + +logging.getLogger("openai.agents").setLevel(logging.DEBUG) +logging.getLogger("openai.agents").addHandler(logging.StreamHandler()) + + +async def main(): + async with LocalPlaywrightComputer() as computer: + with trace("Computer use example"): + agent = Agent( + name="Browser user", + instructions="You are a helpful agent.", + tools=[ComputerTool(computer)], + # Use the computer using model, and set truncation to auto because its required + model="computer-use-preview-2025-02-04", + model_settings=ModelSettings(truncation="auto"), + ) + result = await Runner.run(agent, "Search for SF sports news and summarize.") + print(result.final_output) + + +CUA_KEY_TO_PLAYWRIGHT_KEY = { + "/": "Divide", + "\\": "Backslash", + "alt": "Alt", + "arrowdown": "ArrowDown", + "arrowleft": "ArrowLeft", + "arrowright": "ArrowRight", + "arrowup": "ArrowUp", + "backspace": "Backspace", + "capslock": "CapsLock", + "cmd": "Meta", + "ctrl": "Control", + "delete": "Delete", + "end": "End", + "enter": "Enter", + "esc": "Escape", + "home": "Home", + "insert": "Insert", + "option": "Alt", + "pagedown": "PageDown", + "pageup": "PageUp", + "shift": "Shift", + "space": " ", + "super": "Meta", + "tab": "Tab", + "win": "Meta", +} + + +class LocalPlaywrightComputer(AsyncComputer): + """A computer, implemented using a local Playwright browser.""" + + def __init__(self): + self._playwright: Union[Playwright, None] = None + self._browser: Union[Browser, None] = None + self._page: Union[Page, None] = None + + async def _get_browser_and_page(self) -> tuple[Browser, Page]: + width, height = self.dimensions + launch_args = [f"--window-size={width},{height}"] + browser = await self.playwright.chromium.launch(headless=False, args=launch_args) + page = await browser.new_page() + await page.set_viewport_size({"width": width, "height": height}) + await page.goto("https://www.bing.com") + return browser, page + + async def __aenter__(self): + # Start Playwright and call the subclass hook for getting browser/page + self._playwright = await async_playwright().start() + self._browser, self._page = await self._get_browser_and_page() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._browser: + await self._browser.close() + if self._playwright: + await self._playwright.stop() + + @property + def playwright(self) -> Playwright: + assert self._playwright is not None + return self._playwright + + @property + def browser(self) -> Browser: + assert self._browser is not None + return self._browser + + @property + def page(self) -> Page: + assert self._page is not None + return self._page + + @property + def environment(self) -> Environment: + return "browser" + + @property + def dimensions(self) -> tuple[int, int]: + return (1024, 768) + + async def screenshot(self) -> str: + """Capture only the viewport (not full_page).""" + png_bytes = await self.page.screenshot(full_page=False) + return base64.b64encode(png_bytes).decode("utf-8") + + async def click(self, x: int, y: int, button: Button = "left") -> None: + playwright_button: Literal["left", "middle", "right"] = "left" + + # Playwright only supports left, middle, right buttons + if button in ("left", "right", "middle"): + playwright_button = button # type: ignore + + await self.page.mouse.click(x, y, button=playwright_button) + + async def double_click(self, x: int, y: int) -> None: + await self.page.mouse.dblclick(x, y) + + async def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + await self.page.mouse.move(x, y) + await self.page.evaluate(f"window.scrollBy({scroll_x}, {scroll_y})") + + async def type(self, text: str) -> None: + await self.page.keyboard.type(text) + + async def wait(self) -> None: + await asyncio.sleep(1) + + async def move(self, x: int, y: int) -> None: + await self.page.mouse.move(x, y) + + async def keypress(self, keys: list[str]) -> None: + for key in keys: + mapped_key = CUA_KEY_TO_PLAYWRIGHT_KEY.get(key.lower(), key) + await self.page.keyboard.press(mapped_key) + + async def drag(self, path: list[tuple[int, int]]) -> None: + if not path: + return + await self.page.mouse.move(path[0][0], path[0][1]) + await self.page.mouse.down() + for px, py in path[1:]: + await self.page.mouse.move(px, py) + await self.page.mouse.up() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/tools/file_search.py b/tests/examples/tools/file_search.py new file mode 100644 index 0000000..2a3d4cf --- /dev/null +++ b/tests/examples/tools/file_search.py @@ -0,0 +1,36 @@ +import asyncio + +from agents import Agent, FileSearchTool, Runner, trace + + +async def main(): + agent = Agent( + name="File searcher", + instructions="You are a helpful agent.", + tools=[ + FileSearchTool( + max_num_results=3, + vector_store_ids=["vs_67bf88953f748191be42b462090e53e7"], + include_search_results=True, + ) + ], + ) + + with trace("File search example"): + result = await Runner.run( + agent, "Be concise, and tell me 1 sentence about Arrakis I might not know." + ) + print(result.final_output) + """ + Arrakis, the desert planet in Frank Herbert's "Dune," was inspired by the scarcity of water + as a metaphor for oil and other finite resources. + """ + + print("\n".join([str(out) for out in result.new_items])) + """ + {"id":"...", "queries":["Arrakis"], "results":[...]} + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/examples/tools/web_search.py b/tests/examples/tools/web_search.py new file mode 100644 index 0000000..35eeb68 --- /dev/null +++ b/tests/examples/tools/web_search.py @@ -0,0 +1,23 @@ +import asyncio + +from agents import Agent, Runner, WebSearchTool, trace + + +async def main(): + agent = Agent( + name="Web searcher", + instructions="You are a helpful agent.", + tools=[WebSearchTool(user_location={"type": "approximate", "city": "New York"})], + ) + + with trace("Web search example"): + result = await Runner.run( + agent, + "search the web for 'local sports news' and give me 1 interesting update in a sentence.", + ) + print(result.final_output) + # The New York Giants are reportedly pursuing quarterback Aaron Rodgers after his ... + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fake_model.py b/tests/fake_model.py new file mode 100644 index 0000000..f2ba622 --- /dev/null +++ b/tests/fake_model.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator + +from openai.types.responses import Response, ResponseCompletedEvent + +from agents.agent_output import AgentOutputSchema +from agents.handoffs import Handoff +from agents.items import ( + ModelResponse, + TResponseInputItem, + TResponseOutputItem, + TResponseStreamEvent, +) +from agents.model_settings import ModelSettings +from agents.models.interface import Model, ModelTracing +from agents.tool import Tool +from agents.tracing import SpanError, generation_span +from agents.usage import Usage + + +class FakeModel(Model): + def __init__( + self, + tracing_enabled: bool = False, + initial_output: list[TResponseOutputItem] | Exception | None = None, + ): + if initial_output is None: + initial_output = [] + self.turn_outputs: list[list[TResponseOutputItem] | Exception] = ( + [initial_output] if initial_output else [] + ) + self.tracing_enabled = tracing_enabled + + def set_next_output(self, output: list[TResponseOutputItem] | Exception): + self.turn_outputs.append(output) + + def add_multiple_turn_outputs(self, outputs: list[list[TResponseOutputItem] | Exception]): + self.turn_outputs.extend(outputs) + + def get_next_output(self) -> list[TResponseOutputItem] | Exception: + 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: + with generation_span(disabled=not self.tracing_enabled) as span: + output = self.get_next_output() + + if isinstance(output, Exception): + span.set_error( + SpanError( + message="Error", + data={ + "name": output.__class__.__name__, + "message": str(output), + }, + ) + ) + raise output + + return ModelResponse( + output=output, + usage=Usage(), + referenceable_id=None, + ) + + 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]: + with generation_span(disabled=not self.tracing_enabled) as span: + output = self.get_next_output() + if isinstance(output, Exception): + span.set_error( + SpanError( + message="Error", + data={ + "name": output.__class__.__name__, + "message": str(output), + }, + ) + ) + raise output + + yield ResponseCompletedEvent( + type="response.completed", + response=get_response_obj(output), + ) + + +def get_response_obj(output: list[TResponseOutputItem], response_id: str | None = None) -> Response: + return Response( + id=response_id or "123", + created_at=123, + model="test_model", + object="response", + output=output, + tool_choice="none", + tools=[], + top_p=None, + parallel_tool_calls=False, + ) diff --git a/tests/mkdocs.yml b/tests/mkdocs.yml new file mode 100644 index 0000000..398fb74 --- /dev/null +++ b/tests/mkdocs.yml @@ -0,0 +1,121 @@ +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 +nav: + - Intro: index.md + - Quickstart: quickstart.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 + +extra: + # 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 + +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + anchors: warn + +extra_css: + - stylesheets/extra.css + +watch: + - "src/agents" diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..24e08eb --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,119 @@ +[project] +name = "openai-agents" +version = "0.0.1" +description = "OpenAI Agents SDK" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [ + { name = "OpenAI", email = "support@openai.com" }, +] +dependencies = [ + "openai>=1.66.0", + "pydantic>=2.10, <3", + "griffe>=1.5.6, <2", + "typing-extensions>=4.12.2, <5", + "requests>=2.0, <3", + "types-requests>=2.0, <3", +] +classifiers = [ + "Typing :: Typed", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License" +] + +[project.urls] +Homepage = "https://github.com/openai/openai-agents-python" +Repository = "https://github.com/openai/openai-agents-python" + +[dependency-groups] +dev = [ + "mypy", + "ruff==0.9.2", + "pytest", + "pytest-asyncio", + "pytest-mock>=3.14.0", + "rich", + "mkdocs>=1.6.0", + "mkdocs-material>=9.6.0", + "mkdocstrings[python]>=0.28.0", + "coverage>=7.6.12", + "playwright==1.50.0", +] +[tool.uv.workspace] +members = ["agents"] + +[tool.uv.sources] +agents = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/agents"] + + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +isort = { combine-as-imports = true, known-first-party = ["agents"] } + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"examples/**/*.py" = ["E501"] + +[tool.mypy] +strict = true +disallow_incomplete_defs = false +disallow_untyped_defs = false +disallow_untyped_calls = false + +[tool.coverage.run] +source = [ + "tests", + "src/agents", +] + +[tool.coverage.report] +show_missing = true +sort = "-Cover" +exclude_also = [ + # This is only executed while typechecking + "if TYPE_CHECKING:", + "@abc.abstractmethod", + "raise NotImplementedError", + "logger.debug", +] + +[tool.pytest.ini_options] +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 + "ignore:coroutine 'test_async_input_filter_fails..invalid_input_filter' was never awaited:RuntimeWarning", +] +markers = [ + "allow_call_model_methods: mark test as allowing calls to real model implementations", +] \ No newline at end of file diff --git a/tests/src/agents/__init__.py b/tests/src/agents/__init__.py new file mode 100644 index 0000000..69c500a --- /dev/null +++ b/tests/src/agents/__init__.py @@ -0,0 +1,223 @@ +import logging +import sys +from typing import Literal + +from openai import AsyncOpenAI + +from . import _config +from .agent import Agent +from .agent_output import AgentOutputSchema +from .computer import AsyncComputer, Button, Computer, Environment +from .exceptions import ( + AgentsException, + InputGuardrailTripwireTriggered, + MaxTurnsExceeded, + ModelBehaviorError, + OutputGuardrailTripwireTriggered, + UserError, +) +from .guardrail import ( + GuardrailFunctionOutput, + InputGuardrail, + InputGuardrailResult, + OutputGuardrail, + OutputGuardrailResult, + input_guardrail, + output_guardrail, +) +from .handoffs import Handoff, HandoffInputData, HandoffInputFilter, handoff +from .items import ( + HandoffCallItem, + HandoffOutputItem, + ItemHelpers, + MessageOutputItem, + ModelResponse, + ReasoningItem, + RunItem, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, +) +from .lifecycle import AgentHooks, RunHooks +from .model_settings import ModelSettings +from .models.interface import Model, ModelProvider, ModelTracing +from .models.openai_chatcompletions import OpenAIChatCompletionsModel +from .models.openai_provider import OpenAIProvider +from .models.openai_responses import OpenAIResponsesModel +from .result import RunResult, RunResultStreaming +from .run import RunConfig, Runner +from .run_context import RunContextWrapper, TContext +from .stream_events import ( + AgentUpdatedStreamEvent, + RawResponsesStreamEvent, + RunItemStreamEvent, + StreamEvent, +) +from .tool import ( + ComputerTool, + FileSearchTool, + FunctionTool, + Tool, + WebSearchTool, + default_tool_error_function, + function_tool, +) +from .tracing import ( + AgentSpanData, + CustomSpanData, + FunctionSpanData, + GenerationSpanData, + GuardrailSpanData, + HandoffSpanData, + Span, + SpanData, + SpanError, + Trace, + add_trace_processor, + agent_span, + custom_span, + function_span, + gen_span_id, + gen_trace_id, + generation_span, + get_current_span, + get_current_trace, + guardrail_span, + handoff_span, + set_trace_processors, + set_tracing_disabled, + set_tracing_export_api_key, + trace, +) +from .usage import Usage + + +def set_default_openai_key(key: str) -> None: + """Set the default OpenAI API key to use for LLM requests and tracing. This is only necessary if + the OPENAI_API_KEY environment variable is not already set. + + If provided, this key will be used instead of the OPENAI_API_KEY environment variable. + """ + _config.set_default_openai_key(key) + + +def set_default_openai_client(client: AsyncOpenAI, use_for_tracing: bool = True) -> None: + """Set the default OpenAI client to use for LLM requests and/or tracing. If provided, this + client will be used instead of the default OpenAI client. + + Args: + client: The OpenAI client to use. + use_for_tracing: Whether to use the API key from this client for uploading traces. If False, + you'll either need to set the OPENAI_API_KEY environment variable or call + set_tracing_export_api_key() with the API key you want to use for tracing. + """ + _config.set_default_openai_client(client, use_for_tracing) + + +def set_default_openai_api(api: Literal["chat_completions", "responses"]) -> None: + """Set the default API to use for OpenAI LLM requests. By default, we will use the responses API + but you can set this to use the chat completions API instead. + """ + _config.set_default_openai_api(api) + + +def enable_verbose_stdout_logging(): + """Enables verbose logging to stdout. This is useful for debugging.""" + for name in ["openai.agents", "openai.agents.tracing"]: + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + logger.addHandler(logging.StreamHandler(sys.stdout)) + + +__all__ = [ + "Agent", + "Runner", + "Model", + "ModelProvider", + "ModelTracing", + "ModelSettings", + "OpenAIChatCompletionsModel", + "OpenAIProvider", + "OpenAIResponsesModel", + "AgentOutputSchema", + "Computer", + "AsyncComputer", + "Environment", + "Button", + "AgentsException", + "InputGuardrailTripwireTriggered", + "OutputGuardrailTripwireTriggered", + "MaxTurnsExceeded", + "ModelBehaviorError", + "UserError", + "InputGuardrail", + "InputGuardrailResult", + "OutputGuardrail", + "OutputGuardrailResult", + "GuardrailFunctionOutput", + "input_guardrail", + "output_guardrail", + "handoff", + "Handoff", + "HandoffInputData", + "HandoffInputFilter", + "TResponseInputItem", + "MessageOutputItem", + "ModelResponse", + "RunItem", + "HandoffCallItem", + "HandoffOutputItem", + "ToolCallItem", + "ToolCallOutputItem", + "ReasoningItem", + "ModelResponse", + "ItemHelpers", + "RunHooks", + "AgentHooks", + "RunContextWrapper", + "TContext", + "RunResult", + "RunResultStreaming", + "RunConfig", + "RawResponsesStreamEvent", + "RunItemStreamEvent", + "AgentUpdatedStreamEvent", + "StreamEvent", + "FunctionTool", + "ComputerTool", + "FileSearchTool", + "Tool", + "WebSearchTool", + "function_tool", + "Usage", + "add_trace_processor", + "agent_span", + "custom_span", + "function_span", + "generation_span", + "get_current_span", + "get_current_trace", + "guardrail_span", + "handoff_span", + "set_trace_processors", + "set_tracing_disabled", + "trace", + "Trace", + "SpanError", + "Span", + "SpanData", + "AgentSpanData", + "CustomSpanData", + "FunctionSpanData", + "GenerationSpanData", + "GuardrailSpanData", + "HandoffSpanData", + "set_default_openai_key", + "set_default_openai_client", + "set_default_openai_api", + "set_tracing_export_api_key", + "enable_verbose_stdout_logging", + "gen_trace_id", + "gen_span_id", + "default_tool_error_function", +] diff --git a/tests/src/agents/__pycache__/__init__.cpython-311.pyc b/tests/src/agents/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..daf05e7 Binary files /dev/null and b/tests/src/agents/__pycache__/__init__.cpython-311.pyc differ diff --git a/tests/src/agents/__pycache__/__init__.cpython-313.pyc b/tests/src/agents/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a1c330f Binary files /dev/null and b/tests/src/agents/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/__init__.cpython-39.pyc b/tests/src/agents/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..99cabb1 Binary files /dev/null and b/tests/src/agents/__pycache__/__init__.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/_config.cpython-311.pyc b/tests/src/agents/__pycache__/_config.cpython-311.pyc new file mode 100644 index 0000000..6fd0d6a Binary files /dev/null and b/tests/src/agents/__pycache__/_config.cpython-311.pyc differ diff --git a/tests/src/agents/__pycache__/_config.cpython-313.pyc b/tests/src/agents/__pycache__/_config.cpython-313.pyc new file mode 100644 index 0000000..25b6ae8 Binary files /dev/null and b/tests/src/agents/__pycache__/_config.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/_config.cpython-39.pyc b/tests/src/agents/__pycache__/_config.cpython-39.pyc new file mode 100644 index 0000000..3821305 Binary files /dev/null and b/tests/src/agents/__pycache__/_config.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/_debug.cpython-313.pyc b/tests/src/agents/__pycache__/_debug.cpython-313.pyc new file mode 100644 index 0000000..e958773 Binary files /dev/null and b/tests/src/agents/__pycache__/_debug.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/_debug.cpython-39.pyc b/tests/src/agents/__pycache__/_debug.cpython-39.pyc new file mode 100644 index 0000000..b32ba66 Binary files /dev/null and b/tests/src/agents/__pycache__/_debug.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/_run_impl.cpython-313.pyc b/tests/src/agents/__pycache__/_run_impl.cpython-313.pyc new file mode 100644 index 0000000..76570e7 Binary files /dev/null and b/tests/src/agents/__pycache__/_run_impl.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/_run_impl.cpython-39.pyc b/tests/src/agents/__pycache__/_run_impl.cpython-39.pyc new file mode 100644 index 0000000..7d04233 Binary files /dev/null and b/tests/src/agents/__pycache__/_run_impl.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/_strict_schema.cpython-313.pyc b/tests/src/agents/__pycache__/_strict_schema.cpython-313.pyc new file mode 100644 index 0000000..54b08cf Binary files /dev/null and b/tests/src/agents/__pycache__/_strict_schema.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/_utils.cpython-313.pyc b/tests/src/agents/__pycache__/_utils.cpython-313.pyc new file mode 100644 index 0000000..a45d8f7 Binary files /dev/null and b/tests/src/agents/__pycache__/_utils.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/_utils.cpython-39.pyc b/tests/src/agents/__pycache__/_utils.cpython-39.pyc new file mode 100644 index 0000000..999be28 Binary files /dev/null and b/tests/src/agents/__pycache__/_utils.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/agent.cpython-311.pyc b/tests/src/agents/__pycache__/agent.cpython-311.pyc new file mode 100644 index 0000000..3579bbe Binary files /dev/null and b/tests/src/agents/__pycache__/agent.cpython-311.pyc differ diff --git a/tests/src/agents/__pycache__/agent.cpython-313.pyc b/tests/src/agents/__pycache__/agent.cpython-313.pyc new file mode 100644 index 0000000..4342a13 Binary files /dev/null and b/tests/src/agents/__pycache__/agent.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/agent.cpython-39.pyc b/tests/src/agents/__pycache__/agent.cpython-39.pyc new file mode 100644 index 0000000..6d7f8f1 Binary files /dev/null and b/tests/src/agents/__pycache__/agent.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/agent_output.cpython-313.pyc b/tests/src/agents/__pycache__/agent_output.cpython-313.pyc new file mode 100644 index 0000000..e87ae3f Binary files /dev/null and b/tests/src/agents/__pycache__/agent_output.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/agent_output.cpython-39.pyc b/tests/src/agents/__pycache__/agent_output.cpython-39.pyc new file mode 100644 index 0000000..bfe37ef Binary files /dev/null and b/tests/src/agents/__pycache__/agent_output.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/call_agent_tool.cpython-313.pyc b/tests/src/agents/__pycache__/call_agent_tool.cpython-313.pyc new file mode 100644 index 0000000..281eefc Binary files /dev/null and b/tests/src/agents/__pycache__/call_agent_tool.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/call_agent_tool.cpython-39.pyc b/tests/src/agents/__pycache__/call_agent_tool.cpython-39.pyc new file mode 100644 index 0000000..3502ff9 Binary files /dev/null and b/tests/src/agents/__pycache__/call_agent_tool.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/computer.cpython-313.pyc b/tests/src/agents/__pycache__/computer.cpython-313.pyc new file mode 100644 index 0000000..871935a Binary files /dev/null and b/tests/src/agents/__pycache__/computer.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/computer.cpython-39.pyc b/tests/src/agents/__pycache__/computer.cpython-39.pyc new file mode 100644 index 0000000..8bfc37b Binary files /dev/null and b/tests/src/agents/__pycache__/computer.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/exceptions.cpython-311.pyc b/tests/src/agents/__pycache__/exceptions.cpython-311.pyc new file mode 100644 index 0000000..4d851c8 Binary files /dev/null and b/tests/src/agents/__pycache__/exceptions.cpython-311.pyc differ diff --git a/tests/src/agents/__pycache__/exceptions.cpython-313.pyc b/tests/src/agents/__pycache__/exceptions.cpython-313.pyc new file mode 100644 index 0000000..7ae5d4c Binary files /dev/null and b/tests/src/agents/__pycache__/exceptions.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/exceptions.cpython-39.pyc b/tests/src/agents/__pycache__/exceptions.cpython-39.pyc new file mode 100644 index 0000000..b82c80c Binary files /dev/null and b/tests/src/agents/__pycache__/exceptions.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/function_schema.cpython-313.pyc b/tests/src/agents/__pycache__/function_schema.cpython-313.pyc new file mode 100644 index 0000000..97ee533 Binary files /dev/null and b/tests/src/agents/__pycache__/function_schema.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/function_schema.cpython-39.pyc b/tests/src/agents/__pycache__/function_schema.cpython-39.pyc new file mode 100644 index 0000000..d2fbd3d Binary files /dev/null and b/tests/src/agents/__pycache__/function_schema.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/guardrail.cpython-311.pyc b/tests/src/agents/__pycache__/guardrail.cpython-311.pyc new file mode 100644 index 0000000..5a7e4df Binary files /dev/null and b/tests/src/agents/__pycache__/guardrail.cpython-311.pyc differ diff --git a/tests/src/agents/__pycache__/guardrail.cpython-313.pyc b/tests/src/agents/__pycache__/guardrail.cpython-313.pyc new file mode 100644 index 0000000..ac7e980 Binary files /dev/null and b/tests/src/agents/__pycache__/guardrail.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/guardrail.cpython-39.pyc b/tests/src/agents/__pycache__/guardrail.cpython-39.pyc new file mode 100644 index 0000000..99fcf76 Binary files /dev/null and b/tests/src/agents/__pycache__/guardrail.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/guardrail_base.cpython-313.pyc b/tests/src/agents/__pycache__/guardrail_base.cpython-313.pyc new file mode 100644 index 0000000..525e91e Binary files /dev/null and b/tests/src/agents/__pycache__/guardrail_base.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/guardrail_base.cpython-39.pyc b/tests/src/agents/__pycache__/guardrail_base.cpython-39.pyc new file mode 100644 index 0000000..400cd36 Binary files /dev/null and b/tests/src/agents/__pycache__/guardrail_base.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/guardrails.cpython-313.pyc b/tests/src/agents/__pycache__/guardrails.cpython-313.pyc new file mode 100644 index 0000000..bec8e3f Binary files /dev/null and b/tests/src/agents/__pycache__/guardrails.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/handoff.cpython-313.pyc b/tests/src/agents/__pycache__/handoff.cpython-313.pyc new file mode 100644 index 0000000..5442e41 Binary files /dev/null and b/tests/src/agents/__pycache__/handoff.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/handoff.cpython-39.pyc b/tests/src/agents/__pycache__/handoff.cpython-39.pyc new file mode 100644 index 0000000..753a02e Binary files /dev/null and b/tests/src/agents/__pycache__/handoff.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/handoffs.cpython-313.pyc b/tests/src/agents/__pycache__/handoffs.cpython-313.pyc new file mode 100644 index 0000000..47bd08b Binary files /dev/null and b/tests/src/agents/__pycache__/handoffs.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/handoffs.cpython-39.pyc b/tests/src/agents/__pycache__/handoffs.cpython-39.pyc new file mode 100644 index 0000000..a2f2c75 Binary files /dev/null and b/tests/src/agents/__pycache__/handoffs.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/items.cpython-311.pyc b/tests/src/agents/__pycache__/items.cpython-311.pyc new file mode 100644 index 0000000..6b0149f Binary files /dev/null and b/tests/src/agents/__pycache__/items.cpython-311.pyc differ diff --git a/tests/src/agents/__pycache__/items.cpython-313.pyc b/tests/src/agents/__pycache__/items.cpython-313.pyc new file mode 100644 index 0000000..fe2d759 Binary files /dev/null and b/tests/src/agents/__pycache__/items.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/items.cpython-39.pyc b/tests/src/agents/__pycache__/items.cpython-39.pyc new file mode 100644 index 0000000..3860032 Binary files /dev/null and b/tests/src/agents/__pycache__/items.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/lifecycle.cpython-313.pyc b/tests/src/agents/__pycache__/lifecycle.cpython-313.pyc new file mode 100644 index 0000000..76fad06 Binary files /dev/null and b/tests/src/agents/__pycache__/lifecycle.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/lifecycle.cpython-39.pyc b/tests/src/agents/__pycache__/lifecycle.cpython-39.pyc new file mode 100644 index 0000000..2f9b1a1 Binary files /dev/null and b/tests/src/agents/__pycache__/lifecycle.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/logger.cpython-313.pyc b/tests/src/agents/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000..663224c Binary files /dev/null and b/tests/src/agents/__pycache__/logger.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/logger.cpython-39.pyc b/tests/src/agents/__pycache__/logger.cpython-39.pyc new file mode 100644 index 0000000..5642df7 Binary files /dev/null and b/tests/src/agents/__pycache__/logger.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/model_settings.cpython-313.pyc b/tests/src/agents/__pycache__/model_settings.cpython-313.pyc new file mode 100644 index 0000000..88efd70 Binary files /dev/null and b/tests/src/agents/__pycache__/model_settings.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/model_settings.cpython-39.pyc b/tests/src/agents/__pycache__/model_settings.cpython-39.pyc new file mode 100644 index 0000000..6c57aaa Binary files /dev/null and b/tests/src/agents/__pycache__/model_settings.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/output_tool.cpython-313.pyc b/tests/src/agents/__pycache__/output_tool.cpython-313.pyc new file mode 100644 index 0000000..3569e33 Binary files /dev/null and b/tests/src/agents/__pycache__/output_tool.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/output_tool.cpython-39.pyc b/tests/src/agents/__pycache__/output_tool.cpython-39.pyc new file mode 100644 index 0000000..3bc8d2e Binary files /dev/null and b/tests/src/agents/__pycache__/output_tool.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/result.cpython-313.pyc b/tests/src/agents/__pycache__/result.cpython-313.pyc new file mode 100644 index 0000000..bbe643e Binary files /dev/null and b/tests/src/agents/__pycache__/result.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/result.cpython-39.pyc b/tests/src/agents/__pycache__/result.cpython-39.pyc new file mode 100644 index 0000000..638fdbb Binary files /dev/null and b/tests/src/agents/__pycache__/result.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/run.cpython-313.pyc b/tests/src/agents/__pycache__/run.cpython-313.pyc new file mode 100644 index 0000000..9f5f3c1 Binary files /dev/null and b/tests/src/agents/__pycache__/run.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/run.cpython-39.pyc b/tests/src/agents/__pycache__/run.cpython-39.pyc new file mode 100644 index 0000000..d4ed923 Binary files /dev/null and b/tests/src/agents/__pycache__/run.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/run_context.cpython-313.pyc b/tests/src/agents/__pycache__/run_context.cpython-313.pyc new file mode 100644 index 0000000..4ac6e93 Binary files /dev/null and b/tests/src/agents/__pycache__/run_context.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/run_context.cpython-39.pyc b/tests/src/agents/__pycache__/run_context.cpython-39.pyc new file mode 100644 index 0000000..cad1ca8 Binary files /dev/null and b/tests/src/agents/__pycache__/run_context.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/stream_events.cpython-313.pyc b/tests/src/agents/__pycache__/stream_events.cpython-313.pyc new file mode 100644 index 0000000..4d488fd Binary files /dev/null and b/tests/src/agents/__pycache__/stream_events.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/stream_events.cpython-39.pyc b/tests/src/agents/__pycache__/stream_events.cpython-39.pyc new file mode 100644 index 0000000..54f102c Binary files /dev/null and b/tests/src/agents/__pycache__/stream_events.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/strict_schema.cpython-313.pyc b/tests/src/agents/__pycache__/strict_schema.cpython-313.pyc new file mode 100644 index 0000000..dbc86f2 Binary files /dev/null and b/tests/src/agents/__pycache__/strict_schema.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/strict_schema.cpython-39.pyc b/tests/src/agents/__pycache__/strict_schema.cpython-39.pyc new file mode 100644 index 0000000..e855ab4 Binary files /dev/null and b/tests/src/agents/__pycache__/strict_schema.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/tool.cpython-313.pyc b/tests/src/agents/__pycache__/tool.cpython-313.pyc new file mode 100644 index 0000000..c3380a6 Binary files /dev/null and b/tests/src/agents/__pycache__/tool.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/tool.cpython-39.pyc b/tests/src/agents/__pycache__/tool.cpython-39.pyc new file mode 100644 index 0000000..d78bad3 Binary files /dev/null and b/tests/src/agents/__pycache__/tool.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/tool_converter.cpython-313.pyc b/tests/src/agents/__pycache__/tool_converter.cpython-313.pyc new file mode 100644 index 0000000..e6400c5 Binary files /dev/null and b/tests/src/agents/__pycache__/tool_converter.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/tool_converter.cpython-39.pyc b/tests/src/agents/__pycache__/tool_converter.cpython-39.pyc new file mode 100644 index 0000000..9ee20bd Binary files /dev/null and b/tests/src/agents/__pycache__/tool_converter.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/usage.cpython-313.pyc b/tests/src/agents/__pycache__/usage.cpython-313.pyc new file mode 100644 index 0000000..16617ab Binary files /dev/null and b/tests/src/agents/__pycache__/usage.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/usage.cpython-39.pyc b/tests/src/agents/__pycache__/usage.cpython-39.pyc new file mode 100644 index 0000000..f9d4939 Binary files /dev/null and b/tests/src/agents/__pycache__/usage.cpython-39.pyc differ diff --git a/tests/src/agents/__pycache__/version.cpython-313.pyc b/tests/src/agents/__pycache__/version.cpython-313.pyc new file mode 100644 index 0000000..6a204a7 Binary files /dev/null and b/tests/src/agents/__pycache__/version.cpython-313.pyc differ diff --git a/tests/src/agents/__pycache__/version.cpython-39.pyc b/tests/src/agents/__pycache__/version.cpython-39.pyc new file mode 100644 index 0000000..77e7970 Binary files /dev/null and b/tests/src/agents/__pycache__/version.cpython-39.pyc differ diff --git a/tests/src/agents/_config.py b/tests/src/agents/_config.py new file mode 100644 index 0000000..55ded64 --- /dev/null +++ b/tests/src/agents/_config.py @@ -0,0 +1,23 @@ +from openai import AsyncOpenAI +from typing_extensions import Literal + +from .models import _openai_shared +from .tracing import set_tracing_export_api_key + + +def set_default_openai_key(key: str) -> None: + set_tracing_export_api_key(key) + _openai_shared.set_default_openai_key(key) + + +def set_default_openai_client(client: AsyncOpenAI, use_for_tracing: bool) -> None: + if use_for_tracing: + set_tracing_export_api_key(client.api_key) + _openai_shared.set_default_openai_client(client) + + +def set_default_openai_api(api: Literal["chat_completions", "responses"]) -> None: + if api == "chat_completions": + _openai_shared.set_use_responses_by_default(False) + else: + _openai_shared.set_use_responses_by_default(True) diff --git a/tests/src/agents/_debug.py b/tests/src/agents/_debug.py new file mode 100644 index 0000000..4da91be --- /dev/null +++ b/tests/src/agents/_debug.py @@ -0,0 +1,17 @@ +import os + + +def _debug_flag_enabled(flag: str) -> bool: + flag_value = os.getenv(flag) + return flag_value is not None and (flag_value == "1" or flag_value.lower() == "true") + + +DONT_LOG_MODEL_DATA = _debug_flag_enabled("OPENAI_AGENTS_DONT_LOG_MODEL_DATA") +"""By default we don't log LLM inputs/outputs, to prevent exposing sensitive information. Set this +flag to enable logging them. +""" + +DONT_LOG_TOOL_DATA = _debug_flag_enabled("OPENAI_AGENTS_DONT_LOG_TOOL_DATA") +"""By default we don't log tool call inputs/outputs, to prevent exposing sensitive information. Set +this flag to enable logging them. +""" diff --git a/tests/src/agents/_run_impl.py b/tests/src/agents/_run_impl.py new file mode 100644 index 0000000..112819c --- /dev/null +++ b/tests/src/agents/_run_impl.py @@ -0,0 +1,792 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from openai.types.responses import ( + ResponseComputerToolCall, + ResponseFileSearchToolCall, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseOutputMessage, +) +from openai.types.responses.response_computer_tool_call import ( + ActionClick, + ActionDoubleClick, + ActionDrag, + ActionKeypress, + ActionMove, + ActionScreenshot, + ActionScroll, + ActionType, + ActionWait, +) +from openai.types.responses.response_input_param import ComputerCallOutput +from openai.types.responses.response_output_item import Reasoning + +from . import _utils +from .agent import Agent +from .agent_output import AgentOutputSchema +from .computer import AsyncComputer, Computer +from .exceptions import AgentsException, ModelBehaviorError, UserError +from .guardrail import InputGuardrail, InputGuardrailResult, OutputGuardrail, OutputGuardrailResult +from .handoffs import Handoff, HandoffInputData +from .items import ( + HandoffCallItem, + HandoffOutputItem, + ItemHelpers, + MessageOutputItem, + ModelResponse, + ReasoningItem, + RunItem, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, +) +from .lifecycle import RunHooks +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 .tracing import ( + SpanError, + Trace, + function_span, + get_current_trace, + guardrail_span, + handoff_span, + trace, +) + +if TYPE_CHECKING: + from .run import RunConfig + + +class QueueCompleteSentinel: + pass + + +QUEUE_COMPLETE_SENTINEL = QueueCompleteSentinel() + + +@dataclass +class ToolRunHandoff: + handoff: Handoff + tool_call: ResponseFunctionToolCall + + +@dataclass +class ToolRunFunction: + tool_call: ResponseFunctionToolCall + function_tool: FunctionTool + + +@dataclass +class ToolRunComputerAction: + tool_call: ResponseComputerToolCall + computer_tool: ComputerTool + + +@dataclass +class ProcessedResponse: + new_items: list[RunItem] + handoffs: list[ToolRunHandoff] + functions: list[ToolRunFunction] + computer_actions: list[ToolRunComputerAction] + + def has_tools_to_run(self) -> bool: + # Handoffs, functions and computer actions need local processing + # Hosted tools have already run, so there's nothing to do. + return any( + [ + self.handoffs, + self.functions, + self.computer_actions, + ] + ) + + +@dataclass +class NextStepHandoff: + new_agent: Agent[Any] + + +@dataclass +class NextStepFinalOutput: + output: Any + + +@dataclass +class NextStepRunAgain: + pass + + +@dataclass +class SingleStepResult: + original_input: str | list[TResponseInputItem] + """The input items i.e. the items before run() was called. May be mutated by handoff input + filters.""" + + model_response: ModelResponse + """The model response for the current step.""" + + pre_step_items: list[RunItem] + """Items generated before the current step.""" + + new_step_items: list[RunItem] + """Items generated during this current step.""" + + next_step: NextStepHandoff | NextStepFinalOutput | NextStepRunAgain + """The next step to take.""" + + @property + def generated_items(self) -> list[RunItem]: + """Items generated during the agent run (i.e. everything generated after + `original_input`).""" + return self.pre_step_items + self.new_step_items + + +def get_model_tracing_impl( + tracing_disabled: bool, trace_include_sensitive_data: bool +) -> ModelTracing: + if tracing_disabled: + return ModelTracing.DISABLED + elif trace_include_sensitive_data: + return ModelTracing.ENABLED + else: + return ModelTracing.ENABLED_WITHOUT_DATA + + +class RunImpl: + @classmethod + async def execute_tools_and_side_effects( + cls, + *, + agent: Agent[TContext], + # The original input to the Runner + original_input: str | list[TResponseInputItem], + # Eveything generated by Runner since the original input, but before the current step + pre_step_items: list[RunItem], + new_response: ModelResponse, + processed_response: ProcessedResponse, + output_schema: AgentOutputSchema | None, + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ) -> SingleStepResult: + # Make a copy of the generated items + pre_step_items = list(pre_step_items) + + new_step_items: list[RunItem] = [] + new_step_items.extend(processed_response.new_items) + + # First, lets run the tool calls - function tools and computer actions + function_results, computer_results = await asyncio.gather( + cls.execute_function_tool_calls( + agent=agent, + tool_runs=processed_response.functions, + hooks=hooks, + context_wrapper=context_wrapper, + config=run_config, + ), + cls.execute_computer_actions( + agent=agent, + actions=processed_response.computer_actions, + hooks=hooks, + context_wrapper=context_wrapper, + config=run_config, + ), + ) + new_step_items.extend(function_results) + new_step_items.extend(computer_results) + + # Second, check if there are any handoffs + if run_handoffs := processed_response.handoffs: + return await cls.execute_handoffs( + agent=agent, + original_input=original_input, + pre_step_items=pre_step_items, + new_step_items=new_step_items, + new_response=new_response, + run_handoffs=run_handoffs, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + + # 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)] + + # We'll use the last content output as the final output + potential_final_output_text = ( + ItemHelpers.extract_last_text(message_items[-1].raw_item) if message_items else None + ) + + # There are two possibilities that lead to a final output: + # 1. Structured output schema => always leads to a final output + # 2. Plain text output schema => only leads to a final output if there are no tool calls + if output_schema and not output_schema.is_plain_text() and potential_final_output_text: + final_output = output_schema.validate_json(potential_final_output_text) + 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=final_output, + hooks=hooks, + context_wrapper=context_wrapper, + ) + elif ( + not output_schema or output_schema.is_plain_text() + ) and not processed_response.has_tools_to_run(): + 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=potential_final_output_text or "", + hooks=hooks, + context_wrapper=context_wrapper, + ) + else: + # If there's no final output, we can just run again + return SingleStepResult( + original_input=original_input, + model_response=new_response, + pre_step_items=pre_step_items, + new_step_items=new_step_items, + next_step=NextStepRunAgain(), + ) + + @classmethod + def process_model_response( + cls, + *, + agent: Agent[Any], + response: ModelResponse, + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + ) -> ProcessedResponse: + items: list[RunItem] = [] + + run_handoffs = [] + functions = [] + computer_actions = [] + + handoff_map = {handoff.tool_name: handoff for handoff in handoffs} + function_map = {tool.name: tool for tool in agent.tools if isinstance(tool, FunctionTool)} + computer_tool = next((tool for tool in agent.tools if isinstance(tool, ComputerTool)), None) + + for output in response.output: + if isinstance(output, ResponseOutputMessage): + items.append(MessageOutputItem(raw_item=output, agent=agent)) + elif isinstance(output, ResponseFileSearchToolCall): + items.append(ToolCallItem(raw_item=output, agent=agent)) + elif isinstance(output, ResponseFunctionWebSearch): + items.append(ToolCallItem(raw_item=output, agent=agent)) + elif isinstance(output, Reasoning): + items.append(ReasoningItem(raw_item=output, agent=agent)) + elif isinstance(output, ResponseComputerToolCall): + items.append(ToolCallItem(raw_item=output, agent=agent)) + if not computer_tool: + _utils.attach_error_to_current_span( + SpanError( + message="Computer tool not found", + data={}, + ) + ) + raise ModelBehaviorError( + "Model produced computer action without a computer tool." + ) + computer_actions.append( + ToolRunComputerAction(tool_call=output, computer_tool=computer_tool) + ) + elif not isinstance(output, ResponseFunctionToolCall): + logger.warning(f"Unexpected output type, ignoring: {type(output)}") + continue + + # At this point we know it's a function tool call + if not isinstance(output, ResponseFunctionToolCall): + continue + + # Handoffs + if output.name in handoff_map: + items.append(HandoffCallItem(raw_item=output, agent=agent)) + handoff = ToolRunHandoff( + tool_call=output, + handoff=handoff_map[output.name], + ) + run_handoffs.append(handoff) + # Regular function tool call + else: + if output.name not in function_map: + _utils.attach_error_to_current_span( + SpanError( + message="Tool not found", + data={"tool_name": output.name}, + ) + ) + raise ModelBehaviorError(f"Tool {output.name} not found in agent {agent.name}") + items.append(ToolCallItem(raw_item=output, agent=agent)) + functions.append( + ToolRunFunction( + tool_call=output, + function_tool=function_map[output.name], + ) + ) + + return ProcessedResponse( + new_items=items, + handoffs=run_handoffs, + functions=functions, + computer_actions=computer_actions, + ) + + @classmethod + async def execute_function_tool_calls( + cls, + *, + agent: Agent[TContext], + tool_runs: list[ToolRunFunction], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + config: RunConfig, + ) -> list[RunItem]: + async def run_single_tool( + func_tool: FunctionTool, tool_call: ResponseFunctionToolCall + ) -> str: + with function_span(func_tool.name) as span_fn: + if config.trace_include_sensitive_data: + span_fn.span_data.input = tool_call.arguments + try: + _, _, result = await asyncio.gather( + hooks.on_tool_start(context_wrapper, agent, func_tool), + ( + agent.hooks.on_tool_start(context_wrapper, agent, func_tool) + if agent.hooks + else _utils.noop_coroutine() + ), + func_tool.on_invoke_tool(context_wrapper, tool_call.arguments), + ) + + await asyncio.gather( + hooks.on_tool_end(context_wrapper, agent, func_tool, result), + ( + agent.hooks.on_tool_end(context_wrapper, agent, func_tool, result) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + except Exception as e: + _utils.attach_error_to_current_span( + SpanError( + message="Error running tool", + data={"tool_name": func_tool.name, "error": str(e)}, + ) + ) + if isinstance(e, AgentsException): + raise e + raise UserError(f"Error running tool {func_tool.name}: {e}") from e + + if config.trace_include_sensitive_data: + span_fn.span_data.output = result + return result + + tasks = [] + for tool_run in tool_runs: + function_tool = tool_run.function_tool + tasks.append(run_single_tool(function_tool, tool_run.tool_call)) + + 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, + ) + for tool_run, result in zip(tool_runs, results) + ] + + @classmethod + async def execute_computer_actions( + cls, + *, + agent: Agent[TContext], + actions: list[ToolRunComputerAction], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + config: RunConfig, + ) -> list[RunItem]: + results: list[RunItem] = [] + # Need to run these serially, because each action can affect the computer state + for action in actions: + results.append( + await ComputerAction.execute( + agent=agent, + action=action, + hooks=hooks, + context_wrapper=context_wrapper, + config=config, + ) + ) + + return results + + @classmethod + async def execute_handoffs( + cls, + *, + agent: Agent[TContext], + original_input: str | list[TResponseInputItem], + pre_step_items: list[RunItem], + new_step_items: list[RunItem], + new_response: ModelResponse, + run_handoffs: list[ToolRunHandoff], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ) -> SingleStepResult: + # If there is more than one handoff, add tool responses that reject those handoffs + if len(run_handoffs) > 1: + output_message = "Multiple handoffs detected, ignoring this one." + new_step_items.extend( + [ + ToolCallOutputItem( + output=output_message, + raw_item=ItemHelpers.tool_call_output_item( + handoff.tool_call, output_message + ), + agent=agent, + ) + for handoff in run_handoffs[1:] + ] + ) + + actual_handoff = run_handoffs[0] + with handoff_span(from_agent=agent.name) as span_handoff: + handoff = actual_handoff.handoff + new_agent: Agent[Any] = await handoff.on_invoke_handoff( + context_wrapper, actual_handoff.tool_call.arguments + ) + span_handoff.span_data.to_agent = new_agent.name + + # Append a tool output item for the handoff + new_step_items.append( + HandoffOutputItem( + agent=agent, + raw_item=ItemHelpers.tool_call_output_item( + actual_handoff.tool_call, + handoff.get_transfer_message(new_agent), + ), + source_agent=agent, + target_agent=new_agent, + ) + ) + + # Execute handoff hooks + await asyncio.gather( + hooks.on_handoff( + context=context_wrapper, + from_agent=agent, + to_agent=new_agent, + ), + ( + agent.hooks.on_handoff( + context_wrapper, + agent=new_agent, + source=agent, + ) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + + # If there's an input filter, filter the input for the next agent + input_filter = handoff.input_filter or ( + run_config.handoff_input_filter if run_config else None + ) + if input_filter: + logger.debug("Filtering inputs for handoff") + handoff_input_data = HandoffInputData( + input_history=tuple(original_input) + if isinstance(original_input, list) + else original_input, + pre_handoff_items=tuple(pre_step_items), + new_items=tuple(new_step_items), + ) + if not callable(input_filter): + _utils.attach_error_to_span( + span_handoff, + SpanError( + message="Invalid input filter", + data={"details": "not callable()"}, + ), + ) + raise UserError(f"Invalid input filter: {input_filter}") + filtered = input_filter(handoff_input_data) + if not isinstance(filtered, HandoffInputData): + _utils.attach_error_to_span( + span_handoff, + SpanError( + message="Invalid input filter result", + data={"details": "not a HandoffInputData"}, + ), + ) + raise UserError(f"Invalid input filter result: {filtered}") + + original_input = ( + filtered.input_history + if isinstance(filtered.input_history, str) + else list(filtered.input_history) + ) + pre_step_items = list(filtered.pre_handoff_items) + new_step_items = list(filtered.new_items) + + return SingleStepResult( + original_input=original_input, + model_response=new_response, + pre_step_items=pre_step_items, + new_step_items=new_step_items, + next_step=NextStepHandoff(new_agent), + ) + + @classmethod + async def execute_final_output( + cls, + *, + agent: Agent[TContext], + original_input: str | list[TResponseInputItem], + new_response: ModelResponse, + pre_step_items: list[RunItem], + new_step_items: list[RunItem], + final_output: Any, + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + ) -> SingleStepResult: + # Run the on_end hooks + await cls.run_final_output_hooks(agent, hooks, context_wrapper, final_output) + + return SingleStepResult( + original_input=original_input, + model_response=new_response, + pre_step_items=pre_step_items, + new_step_items=new_step_items, + next_step=NextStepFinalOutput(final_output), + ) + + @classmethod + async def run_final_output_hooks( + cls, + agent: Agent[TContext], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + final_output: Any, + ): + await asyncio.gather( + hooks.on_agent_end(context_wrapper, agent, final_output), + agent.hooks.on_end(context_wrapper, agent, final_output) + if agent.hooks + else _utils.noop_coroutine(), + ) + + @classmethod + async def run_single_input_guardrail( + cls, + agent: Agent[Any], + guardrail: InputGuardrail[TContext], + input: str | list[TResponseInputItem], + context: RunContextWrapper[TContext], + ) -> InputGuardrailResult: + with guardrail_span(guardrail.get_name()) as span_guardrail: + result = await guardrail.run(agent, input, context) + span_guardrail.span_data.triggered = result.output.tripwire_triggered + return result + + @classmethod + async def run_single_output_guardrail( + cls, + guardrail: OutputGuardrail[TContext], + agent: Agent[Any], + agent_output: Any, + context: RunContextWrapper[TContext], + ) -> OutputGuardrailResult: + with guardrail_span(guardrail.get_name()) as span_guardrail: + result = await guardrail.run(agent=agent, agent_output=agent_output, context=context) + span_guardrail.span_data.triggered = result.output.tripwire_triggered + return result + + @classmethod + def stream_step_result_to_queue( + cls, + step_result: SingleStepResult, + queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel], + ): + for item in step_result.new_step_items: + if isinstance(item, MessageOutputItem): + event = RunItemStreamEvent(item=item, name="message_output_created") + elif isinstance(item, HandoffCallItem): + event = RunItemStreamEvent(item=item, name="handoff_requested") + elif isinstance(item, HandoffOutputItem): + event = RunItemStreamEvent(item=item, name="handoff_occured") + elif isinstance(item, ToolCallItem): + event = RunItemStreamEvent(item=item, name="tool_called") + elif isinstance(item, ToolCallOutputItem): + event = RunItemStreamEvent(item=item, name="tool_output") + elif isinstance(item, ReasoningItem): + event = RunItemStreamEvent(item=item, name="reasoning_item_created") + else: + logger.warning(f"Unexpected item type: {type(item)}") + event = None + + if event: + queue.put_nowait(event) + + +class TraceCtxManager: + """Creates a trace only if there is no current trace, and manages the trace lifecycle.""" + + def __init__( + self, + workflow_name: str, + trace_id: str | None, + group_id: str | None, + metadata: dict[str, Any] | None, + disabled: bool, + ): + self.trace: Trace | None = None + self.workflow_name = workflow_name + self.trace_id = trace_id + self.group_id = group_id + self.metadata = metadata + self.disabled = disabled + + def __enter__(self) -> TraceCtxManager: + current_trace = get_current_trace() + if not current_trace: + self.trace = trace( + workflow_name=self.workflow_name, + trace_id=self.trace_id, + group_id=self.group_id, + metadata=self.metadata, + disabled=self.disabled, + ) + self.trace.start(mark_as_current=True) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.trace: + self.trace.finish(reset_current=True) + + +class ComputerAction: + @classmethod + async def execute( + cls, + *, + agent: Agent[TContext], + action: ToolRunComputerAction, + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + config: RunConfig, + ) -> RunItem: + output_func = ( + cls._get_screenshot_async(action.computer_tool.computer, action.tool_call) + if isinstance(action.computer_tool.computer, AsyncComputer) + else cls._get_screenshot_sync(action.computer_tool.computer, action.tool_call) + ) + + _, _, output = await asyncio.gather( + hooks.on_tool_start(context_wrapper, agent, action.computer_tool), + ( + agent.hooks.on_tool_start(context_wrapper, agent, action.computer_tool) + if agent.hooks + else _utils.noop_coroutine() + ), + output_func, + ) + + await asyncio.gather( + hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output), + ( + agent.hooks.on_tool_end(context_wrapper, agent, action.computer_tool, output) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + + # TODO: don't send a screenshot every single time, use references + image_url = f"data:image/png;base64,{output}" + return ToolCallOutputItem( + agent=agent, + output=image_url, + raw_item=ComputerCallOutput( + call_id=action.tool_call.call_id, + output={ + "type": "computer_screenshot", + "image_url": image_url, + }, + type="computer_call_output", + ), + ) + + @classmethod + async def _get_screenshot_sync( + cls, + computer: Computer, + tool_call: ResponseComputerToolCall, + ) -> str: + action = tool_call.action + if isinstance(action, ActionClick): + computer.click(action.x, action.y, action.button) + elif isinstance(action, ActionDoubleClick): + computer.double_click(action.x, action.y) + elif isinstance(action, ActionDrag): + computer.drag([(p.x, p.y) for p in action.path]) + elif isinstance(action, ActionKeypress): + computer.keypress(action.keys) + elif isinstance(action, ActionMove): + computer.move(action.x, action.y) + elif isinstance(action, ActionScreenshot): + computer.screenshot() + elif isinstance(action, ActionScroll): + computer.scroll(action.x, action.y, action.scroll_x, action.scroll_y) + elif isinstance(action, ActionType): + computer.type(action.text) + elif isinstance(action, ActionWait): + computer.wait() + + return computer.screenshot() + + @classmethod + async def _get_screenshot_async( + cls, + computer: AsyncComputer, + tool_call: ResponseComputerToolCall, + ) -> str: + action = tool_call.action + if isinstance(action, ActionClick): + await computer.click(action.x, action.y, action.button) + elif isinstance(action, ActionDoubleClick): + await computer.double_click(action.x, action.y) + elif isinstance(action, ActionDrag): + await computer.drag([(p.x, p.y) for p in action.path]) + elif isinstance(action, ActionKeypress): + await computer.keypress(action.keys) + elif isinstance(action, ActionMove): + await computer.move(action.x, action.y) + elif isinstance(action, ActionScreenshot): + await computer.screenshot() + elif isinstance(action, ActionScroll): + await computer.scroll(action.x, action.y, action.scroll_x, action.scroll_y) + elif isinstance(action, ActionType): + await computer.type(action.text) + elif isinstance(action, ActionWait): + await computer.wait() + + return await computer.screenshot() diff --git a/tests/src/agents/_utils.py b/tests/src/agents/_utils.py new file mode 100644 index 0000000..2a0293a --- /dev/null +++ b/tests/src/agents/_utils.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import re +from collections.abc import Awaitable +from typing import Any, Literal, Union + +from pydantic import TypeAdapter, ValidationError +from typing_extensions import TypeVar + +from .exceptions import ModelBehaviorError +from .logger import logger +from .tracing import Span, SpanError, get_current_span + +T = TypeVar("T") + +MaybeAwaitable = Union[Awaitable[T], T] + + +def transform_string_function_style(name: str) -> str: + # Replace spaces with underscores + name = name.replace(" ", "_") + + # Replace non-alphanumeric characters with underscores + name = re.sub(r"[^a-zA-Z0-9]", "_", name) + + return name.lower() + + +def validate_json(json_str: str, type_adapter: TypeAdapter[T], partial: bool) -> T: + partial_setting: bool | Literal["off", "on", "trailing-strings"] = ( + "trailing-strings" if partial else False + ) + try: + validated = type_adapter.validate_json(json_str, experimental_allow_partial=partial_setting) + return validated + except ValidationError as e: + attach_error_to_current_span( + SpanError( + message="Invalid JSON provided", + data={}, + ) + ) + raise ModelBehaviorError( + f"Invalid JSON when parsing {json_str} for {type_adapter}; {e}" + ) from e + + +def attach_error_to_span(span: Span[Any], error: SpanError) -> None: + span.set_error(error) + + +def attach_error_to_current_span(error: SpanError) -> None: + span = get_current_span() + if span: + attach_error_to_span(span, error) + else: + logger.warning(f"No span to add error {error} to") + + +async def noop_coroutine() -> None: + pass diff --git a/tests/src/agents/agent.py b/tests/src/agents/agent.py new file mode 100644 index 0000000..61c0a89 --- /dev/null +++ b/tests/src/agents/agent.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import dataclasses +import inspect +from collections.abc import Awaitable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Generic, cast + +from . import _utils +from ._utils import MaybeAwaitable +from .guardrail import InputGuardrail, OutputGuardrail +from .handoffs import Handoff +from .items import ItemHelpers +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 + +if TYPE_CHECKING: + from .lifecycle import AgentHooks + from .result import RunResult + + +@dataclass +class Agent(Generic[TContext]): + """An agent is an AI model configured with instructions, tools, guardrails, handoffs and more. + + We strongly recommend passing `instructions`, which is the "system prompt" for the agent. In + addition, you can pass `description`, which is a human-readable description of the agent, used + when the agent is used inside tools/handoffs. + + Agents are generic on the context type. The context is a (mutable) object you create. It is + passed to tool functions, handoffs, guardrails, etc. + """ + + name: str + """The name of the agent.""" + + instructions: ( + str + | Callable[ + [RunContextWrapper[TContext], Agent[TContext]], + MaybeAwaitable[str], + ] + | None + ) = None + """The instructions for the agent. Will be used as the "system prompt" when this agent is + invoked. Describes what the agent should do, and how it responds. + + Can either be a string, or a function that dynamically generates instructions for the agent. If + you provide a function, it will be called with the context and the agent instance. It must + return a string. + """ + + handoff_description: str | None = None + """A description of the agent. This is used when the agent is used as a handoff, so that an + LLM knows what it does and when to invoke it. + """ + + handoffs: list[Agent[Any] | Handoff[TContext]] = field(default_factory=list) + """Handoffs are sub-agents that the agent can delegate to. You can provide a list of handoffs, + and the agent can choose to delegate to them if relevant. Allows for separation of concerns and + modularity. + """ + + model: str | Model | None = None + """The model implementation to use when invoking the LLM. + + By default, if not set, the agent will use the default model configured in + `model_settings.DEFAULT_MODEL`. + """ + + model_settings: ModelSettings = field(default_factory=ModelSettings) + """Configures model-specific tuning parameters (e.g. temperature, top_p). + """ + + tools: list[Tool] = field(default_factory=list) + """A list of tools that the agent can use.""" + + input_guardrails: list[InputGuardrail[TContext]] = field(default_factory=list) + """A list of checks that run in parallel to the agent's execution, before generating a + response. Runs only if the agent is the first agent in the chain. + """ + + output_guardrails: list[OutputGuardrail[TContext]] = field(default_factory=list) + """A list of checks that run on the final output of the agent, after generating a response. + Runs only if the agent produces a final output. + """ + + output_type: type[Any] | None = None + """The type of the output object. If not provided, the output will be `str`.""" + + hooks: AgentHooks[TContext] | None = None + """A class that receives callbacks on various lifecycle events for this agent. + """ + + def clone(self, **kwargs: Any) -> Agent[TContext]: + """Make a copy of the agent, with the given arguments changed. For example, you could do: + ``` + new_agent = agent.clone(instructions="New instructions") + ``` + """ + return dataclasses.replace(self, **kwargs) + + def as_tool( + self, + tool_name: str | None, + tool_description: str | None, + custom_output_extractor: Callable[[RunResult], Awaitable[str]] | None = None, + ) -> Tool: + """Transform this agent into a tool, callable by other agents. + + This is different from handoffs in two ways: + 1. In handoffs, the new agent receives the conversation history. In this tool, the new agent + receives generated input. + 2. In handoffs, the new agent takes over the conversation. In this tool, the new agent is + called as a tool, and the conversation is continued by the original agent. + + Args: + tool_name: The name of the tool. If not provided, the agent's name will be used. + tool_description: The description of the tool, which should indicate what it does and + when to use it. + custom_output_extractor: A function that extracts the output from the agent. If not + provided, the last message from the agent will be used. + """ + + @function_tool( + name_override=tool_name or _utils.transform_string_function_style(self.name), + description_override=tool_description or "", + ) + async def run_agent(context: RunContextWrapper, input: str) -> str: + from .run import Runner + + output = await Runner.run( + starting_agent=self, + input=input, + context=context.context, + ) + if custom_output_extractor: + return await custom_output_extractor(output) + + return ItemHelpers.text_message_outputs(output.new_items) + + return run_agent + + async def get_system_prompt(self, run_context: RunContextWrapper[TContext]) -> str | None: + """Get the system prompt for the agent.""" + if isinstance(self.instructions, str): + return self.instructions + elif callable(self.instructions): + if inspect.iscoroutinefunction(self.instructions): + return await cast(Awaitable[str], self.instructions(run_context, self)) + else: + return cast(str, self.instructions(run_context, self)) + elif self.instructions is not None: + logger.error(f"Instructions must be a string or a function, got {self.instructions}") + + return None diff --git a/tests/src/agents/agent_output.py b/tests/src/agents/agent_output.py new file mode 100644 index 0000000..8140d8c --- /dev/null +++ b/tests/src/agents/agent_output.py @@ -0,0 +1,144 @@ +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel, TypeAdapter +from typing_extensions import TypedDict, get_args, get_origin + +from . import _utils +from .exceptions import ModelBehaviorError, UserError +from .strict_schema import ensure_strict_json_schema +from .tracing import SpanError + +_WRAPPER_DICT_KEY = "response" + + +@dataclass(init=False) +class AgentOutputSchema: + """An object that captures the JSON schema of the output, as well as validating/parsing JSON + produced by the LLM into the output type. + """ + + output_type: type[Any] + """The type of the output.""" + + _type_adapter: TypeAdapter[Any] + """A type adapter that wraps the output type, so that we can validate JSON.""" + + _is_wrapped: bool + """Whether the output type is wrapped in a dictionary. This is generally done if the base + output type cannot be represented as a JSON Schema object. + """ + + _output_schema: dict[str, Any] + """The JSON schema of the output.""" + + strict_json_schema: bool + """Whether the JSON schema is in strict mode. We **strongly** recommend setting this to True, + as it increases the likelihood of correct JSON input. + """ + + def __init__(self, output_type: type[Any], strict_json_schema: bool = True): + """ + Args: + output_type: The type of the output. + strict_json_schema: Whether the JSON schema is in strict mode. We **strongly** recommend + setting this to True, as it increases the likelihood of correct JSON input. + """ + self.output_type = output_type + self.strict_json_schema = strict_json_schema + + if output_type is None or output_type is str: + self._is_wrapped = False + self._type_adapter = TypeAdapter(output_type) + self._output_schema = self._type_adapter.json_schema() + return + + # We should wrap for things that are not plain text, and for things that would definitely + # not be a JSON Schema object. + self._is_wrapped = not _is_subclass_of_base_model_or_dict(output_type) + + if self._is_wrapped: + OutputType = TypedDict( + "OutputType", + { + _WRAPPER_DICT_KEY: output_type, # type: ignore + }, + ) + self._type_adapter = TypeAdapter(OutputType) + self._output_schema = self._type_adapter.json_schema() + else: + self._type_adapter = TypeAdapter(output_type) + self._output_schema = self._type_adapter.json_schema() + + if self.strict_json_schema: + self._output_schema = ensure_strict_json_schema(self._output_schema) + + def is_plain_text(self) -> bool: + """Whether the output type is plain text (versus a JSON object).""" + return self.output_type is None or self.output_type is str + + def json_schema(self) -> dict[str, Any]: + """The JSON schema of the output type.""" + if self.is_plain_text(): + raise UserError("Output type is plain text, so no JSON schema is available") + return self._output_schema + + def validate_json(self, json_str: str, partial: bool = False) -> Any: + """Validate a JSON string against the output type. Returns the validated object, or raises + a `ModelBehaviorError` if the JSON is invalid. + """ + validated = _utils.validate_json(json_str, self._type_adapter, partial) + if self._is_wrapped: + if not isinstance(validated, dict): + _utils.attach_error_to_current_span( + SpanError( + message="Invalid JSON", + data={"details": f"Expected a dict, got {type(validated)}"}, + ) + ) + raise ModelBehaviorError( + f"Expected a dict, got {type(validated)} for JSON: {json_str}" + ) + + if _WRAPPER_DICT_KEY not in validated: + _utils.attach_error_to_current_span( + SpanError( + message="Invalid JSON", + data={"details": f"Could not find key {_WRAPPER_DICT_KEY} in JSON"}, + ) + ) + raise ModelBehaviorError( + f"Could not find key {_WRAPPER_DICT_KEY} in JSON: {json_str}" + ) + return validated[_WRAPPER_DICT_KEY] + return validated + + def output_type_name(self) -> str: + """The name of the output type.""" + return _type_to_str(self.output_type) + + +def _is_subclass_of_base_model_or_dict(t: Any) -> bool: + if not isinstance(t, type): + return False + + # If it's a generic alias, 'origin' will be the actual type, e.g. 'list' + origin = get_origin(t) + + allowed_types = (BaseModel, dict) + # If it's a generic alias e.g. list[str], then we should check the origin type i.e. list + return issubclass(origin or t, allowed_types) + + +def _type_to_str(t: type[Any]) -> str: + origin = get_origin(t) + args = get_args(t) + + if origin is None: + # It's a simple type like `str`, `int`, etc. + return t.__name__ + elif args: + args_str = ', '.join(_type_to_str(arg) for arg in args) + return f"{origin.__name__}[{args_str}]" + else: + return str(t) diff --git a/tests/src/agents/computer.py b/tests/src/agents/computer.py new file mode 100644 index 0000000..1b9224d --- /dev/null +++ b/tests/src/agents/computer.py @@ -0,0 +1,107 @@ +import abc +from typing import Literal + +Environment = Literal["mac", "windows", "ubuntu", "browser"] +Button = Literal["left", "right", "wheel", "back", "forward"] + + +class Computer(abc.ABC): + """A computer implemented with sync operations. The Computer interface abstracts the + operations needed to control a computer or browser.""" + + @property + @abc.abstractmethod + def environment(self) -> Environment: + pass + + @property + @abc.abstractmethod + def dimensions(self) -> tuple[int, int]: + pass + + @abc.abstractmethod + def screenshot(self) -> str: + pass + + @abc.abstractmethod + def click(self, x: int, y: int, button: Button) -> None: + pass + + @abc.abstractmethod + def double_click(self, x: int, y: int) -> None: + pass + + @abc.abstractmethod + def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + pass + + @abc.abstractmethod + def type(self, text: str) -> None: + pass + + @abc.abstractmethod + def wait(self) -> None: + pass + + @abc.abstractmethod + def move(self, x: int, y: int) -> None: + pass + + @abc.abstractmethod + def keypress(self, keys: list[str]) -> None: + pass + + @abc.abstractmethod + def drag(self, path: list[tuple[int, int]]) -> None: + pass + + +class AsyncComputer(abc.ABC): + """A computer implemented with async operations. The Computer interface abstracts the + operations needed to control a computer or browser.""" + + @property + @abc.abstractmethod + def environment(self) -> Environment: + pass + + @property + @abc.abstractmethod + def dimensions(self) -> tuple[int, int]: + pass + + @abc.abstractmethod + async def screenshot(self) -> str: + pass + + @abc.abstractmethod + async def click(self, x: int, y: int, button: Button) -> None: + pass + + @abc.abstractmethod + async def double_click(self, x: int, y: int) -> None: + pass + + @abc.abstractmethod + async def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + pass + + @abc.abstractmethod + async def type(self, text: str) -> None: + pass + + @abc.abstractmethod + async def wait(self) -> None: + pass + + @abc.abstractmethod + async def move(self, x: int, y: int) -> None: + pass + + @abc.abstractmethod + async def keypress(self, keys: list[str]) -> None: + pass + + @abc.abstractmethod + async def drag(self, path: list[tuple[int, int]]) -> None: + pass diff --git a/tests/src/agents/exceptions.py b/tests/src/agents/exceptions.py new file mode 100644 index 0000000..78898f0 --- /dev/null +++ b/tests/src/agents/exceptions.py @@ -0,0 +1,63 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .guardrail import InputGuardrailResult, OutputGuardrailResult + + +class AgentsException(Exception): + """Base class for all exceptions in the Agents SDK.""" + + +class MaxTurnsExceeded(AgentsException): + """Exception raised when the maximum number of turns is exceeded.""" + + message: str + + def __init__(self, message: str): + self.message = message + + +class ModelBehaviorError(AgentsException): + """Exception raised when the model does something unexpected, e.g. calling a tool that doesn't + exist, or providing malformed JSON. + """ + + message: str + + def __init__(self, message: str): + self.message = message + + +class UserError(AgentsException): + """Exception raised when the user makes an error using the SDK.""" + + message: str + + def __init__(self, message: str): + self.message = message + + +class InputGuardrailTripwireTriggered(AgentsException): + """Exception raised when a guardrail tripwire is triggered.""" + + guardrail_result: "InputGuardrailResult" + """The result data of the guardrail that was triggered.""" + + def __init__(self, guardrail_result: "InputGuardrailResult"): + self.guardrail_result = guardrail_result + super().__init__( + f"Guardrail {guardrail_result.guardrail.__class__.__name__} triggered tripwire" + ) + + +class OutputGuardrailTripwireTriggered(AgentsException): + """Exception raised when a guardrail tripwire is triggered.""" + + guardrail_result: "OutputGuardrailResult" + """The result data of the guardrail that was triggered.""" + + def __init__(self, guardrail_result: "OutputGuardrailResult"): + self.guardrail_result = guardrail_result + super().__init__( + f"Guardrail {guardrail_result.guardrail.__class__.__name__} triggered tripwire" + ) diff --git a/tests/src/agents/extensions/__init__.py b/tests/src/agents/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/src/agents/extensions/__pycache__/__init__.cpython-313.pyc b/tests/src/agents/extensions/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..bc837fb Binary files /dev/null and b/tests/src/agents/extensions/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/src/agents/extensions/__pycache__/__init__.cpython-39.pyc b/tests/src/agents/extensions/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..5e72c0c Binary files /dev/null and b/tests/src/agents/extensions/__pycache__/__init__.cpython-39.pyc differ diff --git a/tests/src/agents/extensions/__pycache__/handoff_filters.cpython-313.pyc b/tests/src/agents/extensions/__pycache__/handoff_filters.cpython-313.pyc new file mode 100644 index 0000000..bc28a3c Binary files /dev/null and b/tests/src/agents/extensions/__pycache__/handoff_filters.cpython-313.pyc differ diff --git a/tests/src/agents/extensions/__pycache__/handoff_filters.cpython-39.pyc b/tests/src/agents/extensions/__pycache__/handoff_filters.cpython-39.pyc new file mode 100644 index 0000000..9443e76 Binary files /dev/null and b/tests/src/agents/extensions/__pycache__/handoff_filters.cpython-39.pyc differ diff --git a/tests/src/agents/extensions/__pycache__/handoff_prompt.cpython-313.pyc b/tests/src/agents/extensions/__pycache__/handoff_prompt.cpython-313.pyc new file mode 100644 index 0000000..1b1154b Binary files /dev/null and b/tests/src/agents/extensions/__pycache__/handoff_prompt.cpython-313.pyc differ diff --git a/tests/src/agents/extensions/handoff_filters.py b/tests/src/agents/extensions/handoff_filters.py new file mode 100644 index 0000000..f4f9b8b --- /dev/null +++ b/tests/src/agents/extensions/handoff_filters.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from ..handoffs import HandoffInputData +from ..items import ( + HandoffCallItem, + HandoffOutputItem, + RunItem, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, +) + +"""Contains common handoff input filters, for convenience. """ + + +def remove_all_tools(handoff_input_data: HandoffInputData) -> HandoffInputData: + """Filters out all tool items: file search, web search and function calls+output.""" + + history = handoff_input_data.input_history + new_items = handoff_input_data.new_items + + filtered_history = ( + _remove_tool_types_from_input(history) if isinstance(history, tuple) else history + ) + filtered_pre_handoff_items = _remove_tools_from_items(handoff_input_data.pre_handoff_items) + filtered_new_items = _remove_tools_from_items(new_items) + + return HandoffInputData( + input_history=filtered_history, + pre_handoff_items=filtered_pre_handoff_items, + new_items=filtered_new_items, + ) + + +def _remove_tools_from_items(items: tuple[RunItem, ...]) -> tuple[RunItem, ...]: + filtered_items = [] + for item in items: + if ( + isinstance(item, HandoffCallItem) + or isinstance(item, HandoffOutputItem) + or isinstance(item, ToolCallItem) + or isinstance(item, ToolCallOutputItem) + ): + continue + filtered_items.append(item) + return tuple(filtered_items) + + +def _remove_tool_types_from_input( + items: tuple[TResponseInputItem, ...], +) -> tuple[TResponseInputItem, ...]: + tool_types = [ + "function_call", + "function_call_output", + "computer_call", + "computer_call_output", + "file_search_call", + "web_search_call", + ] + + filtered_items: list[TResponseInputItem] = [] + for item in items: + itype = item.get("type") + if itype in tool_types: + continue + filtered_items.append(item) + return tuple(filtered_items) diff --git a/tests/src/agents/extensions/handoff_prompt.py b/tests/src/agents/extensions/handoff_prompt.py new file mode 100644 index 0000000..cfb5ca7 --- /dev/null +++ b/tests/src/agents/extensions/handoff_prompt.py @@ -0,0 +1,19 @@ +# A recommended prompt prefix for agents that use handoffs. We recommend including this or +# similar instructions in any agents that use handoffs. +RECOMMENDED_PROMPT_PREFIX = ( + "# System context\n" + "You are part of a multi-agent system called the Agents SDK, designed to make agent " + "coordination and execution easy. Agents uses two primary abstraction: **Agents** and " + "**Handoffs**. An agent encompasses instructions and tools and can hand off a " + "conversation to another agent when appropriate. " + "Handoffs are achieved by calling a handoff function, generally named " + "`transfer_to_`. Transfers between agents are handled seamlessly in the background;" + " do not mention or draw attention to these transfers in your conversation with the user.\n" +) + + +def prompt_with_handoff_instructions(prompt: str) -> str: + """ + Add recommended instructions to the prompt for agents that use handoffs. + """ + return f"{RECOMMENDED_PROMPT_PREFIX}\n\n{prompt}" diff --git a/tests/src/agents/function_schema.py b/tests/src/agents/function_schema.py new file mode 100644 index 0000000..a4b5767 --- /dev/null +++ b/tests/src/agents/function_schema.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import contextlib +import inspect +import logging +import re +from dataclasses import dataclass +from typing import Any, Callable, Literal, get_args, get_origin, get_type_hints + +from griffe import Docstring, DocstringSectionKind +from pydantic import BaseModel, Field, create_model + +from .exceptions import UserError +from .run_context import RunContextWrapper +from .strict_schema import ensure_strict_json_schema + + +@dataclass +class FuncSchema: + """ + Captures the schema for a python function, in preparation for sending it to an LLM as a tool. + """ + + name: str + """The name of the function.""" + description: str | None + """The description of the function.""" + params_pydantic_model: type[BaseModel] + """A Pydantic model that represents the function's parameters.""" + params_json_schema: dict[str, Any] + """The JSON schema for the function's parameters, derived from the Pydantic model.""" + signature: inspect.Signature + """The signature of the function.""" + takes_context: bool = False + """Whether the function takes a RunContextWrapper argument (must be the first argument).""" + + def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: + """ + Converts validated data from the Pydantic model into (args, kwargs), suitable for calling + the original function. + """ + positional_args: list[Any] = [] + keyword_args: dict[str, Any] = {} + seen_var_positional = False + + # Use enumerate() so we can skip the first parameter if it's context. + for idx, (name, param) in enumerate(self.signature.parameters.items()): + # If the function takes a RunContextWrapper and this is the first parameter, skip it. + if self.takes_context and idx == 0: + continue + + value = getattr(data, name, None) + if param.kind == param.VAR_POSITIONAL: + # e.g. *args: extend positional args and mark that *args is now seen + positional_args.extend(value or []) + seen_var_positional = True + elif param.kind == param.VAR_KEYWORD: + # e.g. **kwargs handling + keyword_args.update(value or {}) + elif param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): + # Before *args, add to positional args. After *args, add to keyword args. + if not seen_var_positional: + positional_args.append(value) + else: + keyword_args[name] = value + else: + # For KEYWORD_ONLY parameters, always use keyword args. + keyword_args[name] = value + return positional_args, keyword_args + + +@dataclass +class FuncDocumentation: + """Contains metadata about a python function, extracted from its docstring.""" + + name: str + """The name of the function, via `__name__`.""" + description: str | None + """The description of the function, derived from the docstring.""" + param_descriptions: dict[str, str] | None + """The parameter descriptions of the function, derived from the docstring.""" + + +DocstringStyle = Literal["google", "numpy", "sphinx"] + + +# As of Feb 2025, the automatic style detection in griffe is an Insiders feature. This +# code approximates it. +def _detect_docstring_style(doc: str) -> DocstringStyle: + scores: dict[DocstringStyle, int] = {"sphinx": 0, "numpy": 0, "google": 0} + + # Sphinx style detection: look for :param, :type, :return:, and :rtype: + sphinx_patterns = [r"^:param\s", r"^:type\s", r"^:return:", r"^:rtype:"] + for pattern in sphinx_patterns: + if re.search(pattern, doc, re.MULTILINE): + scores["sphinx"] += 1 + + # Numpy style detection: look for headers like 'Parameters', 'Returns', or 'Yields' followed by + # a dashed underline + numpy_patterns = [ + r"^Parameters\s*\n\s*-{3,}", + r"^Returns\s*\n\s*-{3,}", + r"^Yields\s*\n\s*-{3,}", + ] + for pattern in numpy_patterns: + if re.search(pattern, doc, re.MULTILINE): + scores["numpy"] += 1 + + # Google style detection: look for section headers with a trailing colon + google_patterns = [r"^(Args|Arguments):", r"^(Returns):", r"^(Raises):"] + for pattern in google_patterns: + if re.search(pattern, doc, re.MULTILINE): + scores["google"] += 1 + + max_score = max(scores.values()) + if max_score == 0: + return "google" + + # Priority order: sphinx > numpy > google in case of tie + styles: list[DocstringStyle] = ["sphinx", "numpy", "google"] + + for style in styles: + if scores[style] == max_score: + return style + + return "google" + + +@contextlib.contextmanager +def _suppress_griffe_logging(): + # Supresses warnings about missing annotations for params + logger = logging.getLogger("griffe") + previous_level = logger.getEffectiveLevel() + logger.setLevel(logging.ERROR) + try: + yield + finally: + logger.setLevel(previous_level) + + +def generate_func_documentation( + func: Callable[..., Any], style: DocstringStyle | None = None +) -> FuncDocumentation: + """ + Extracts metadata from a function docstring, in preparation for sending it to an LLM as a tool. + + Args: + func: The function to extract documentation from. + style: The style of the docstring to use for parsing. If not provided, we will attempt to + auto-detect the style. + + Returns: + A FuncDocumentation object containing the function's name, description, and parameter + descriptions. + """ + name = func.__name__ + doc = inspect.getdoc(func) + if not doc: + return FuncDocumentation(name=name, description=None, param_descriptions=None) + + with _suppress_griffe_logging(): + docstring = Docstring(doc, lineno=1, parser=style or _detect_docstring_style(doc)) + parsed = docstring.parse() + + description: str | None = next( + (section.value for section in parsed if section.kind == DocstringSectionKind.text), None + ) + + param_descriptions: dict[str, str] = { + param.name: param.description + for section in parsed + if section.kind == DocstringSectionKind.parameters + for param in section.value + } + + return FuncDocumentation( + name=func.__name__, + description=description, + param_descriptions=param_descriptions or None, + ) + + +def function_schema( + func: Callable[..., Any], + docstring_style: DocstringStyle | None = None, + name_override: str | None = None, + description_override: str | None = None, + use_docstring_info: bool = True, + strict_json_schema: bool = True, +) -> FuncSchema: + """ + Given a python function, extracts a `FuncSchema` from it, capturing the name, description, + parameter descriptions, and other metadata. + + Args: + func: The function to extract the schema from. + docstring_style: The style of the docstring to use for parsing. If not provided, we will + attempt to auto-detect the style. + name_override: If provided, use this name instead of the function's `__name__`. + description_override: If provided, use this description instead of the one derived from the + docstring. + use_docstring_info: If True, uses the docstring to generate the description and parameter + descriptions. + strict_json_schema: Whether the JSON schema is in strict mode. If True, we'll ensure that + the schema adheres to the "strict" standard the OpenAI API expects. We **strongly** + recommend setting this to True, as it increases the likelihood of the LLM providing + correct JSON input. + + Returns: + A `FuncSchema` object containing the function's name, description, parameter descriptions, + and other metadata. + """ + + # 1. Grab docstring info + if use_docstring_info: + doc_info = generate_func_documentation(func, docstring_style) + param_descs = doc_info.param_descriptions or {} + else: + doc_info = None + param_descs = {} + + func_name = name_override or doc_info.name if doc_info else func.__name__ + + # 2. Inspect function signature and get type hints + sig = inspect.signature(func) + type_hints = get_type_hints(func) + params = list(sig.parameters.items()) + takes_context = False + filtered_params = [] + + if params: + first_name, first_param = params[0] + # Prefer the evaluated type hint if available + ann = type_hints.get(first_name, first_param.annotation) + if ann != inspect._empty: + origin = get_origin(ann) or ann + if origin is RunContextWrapper: + takes_context = True # Mark that the function takes context + else: + filtered_params.append((first_name, first_param)) + else: + filtered_params.append((first_name, first_param)) + + # For parameters other than the first, raise error if any use RunContextWrapper. + for name, param in params[1:]: + ann = type_hints.get(name, param.annotation) + if ann != inspect._empty: + origin = get_origin(ann) or ann + if origin is RunContextWrapper: + raise UserError( + f"RunContextWrapper param found at non-first position in function" + f" {func.__name__}" + ) + filtered_params.append((name, param)) + + # We will collect field definitions for create_model as a dict: + # field_name -> (type_annotation, default_value_or_Field(...)) + fields: dict[str, Any] = {} + + for name, param in filtered_params: + ann = type_hints.get(name, param.annotation) + default = param.default + + # If there's no type hint, assume `Any` + if ann == inspect._empty: + ann = Any + + # If a docstring param description exists, use it + field_description = param_descs.get(name, None) + + # Handle different parameter kinds + if param.kind == param.VAR_POSITIONAL: + # e.g. *args: extend positional args + if get_origin(ann) is tuple: + # e.g. def foo(*args: tuple[int, ...]) -> treat as List[int] + args_of_tuple = get_args(ann) + if len(args_of_tuple) == 2 and args_of_tuple[1] is Ellipsis: + ann = list[args_of_tuple[0]] # type: ignore + else: + ann = list[Any] + else: + # If user wrote *args: int, treat as List[int] + ann = list[ann] # type: ignore + + # Default factory to empty list + fields[name] = ( + ann, + Field(default_factory=list, description=field_description), # type: ignore + ) + + elif param.kind == param.VAR_KEYWORD: + # **kwargs handling + if get_origin(ann) is dict: + # e.g. def foo(**kwargs: dict[str, int]) + dict_args = get_args(ann) + if len(dict_args) == 2: + ann = dict[dict_args[0], dict_args[1]] # type: ignore + else: + ann = dict[str, Any] + else: + # e.g. def foo(**kwargs: int) -> Dict[str, int] + ann = dict[str, ann] # type: ignore + + fields[name] = ( + ann, + Field(default_factory=dict, description=field_description), # type: ignore + ) + + else: + # Normal parameter + if default == inspect._empty: + # Required field + fields[name] = ( + ann, + Field(..., description=field_description), + ) + else: + # Parameter with a default value + fields[name] = ( + ann, + Field(default=default, description=field_description), + ) + + # 3. Dynamically build a Pydantic model + dynamic_model = create_model(f"{func_name}_args", __base__=BaseModel, **fields) + + # 4. Build JSON schema from that model + json_schema = dynamic_model.model_json_schema() + if strict_json_schema: + json_schema = ensure_strict_json_schema(json_schema) + + # 5. Return as a FuncSchema dataclass + return FuncSchema( + name=func_name, + description=description_override or doc_info.description if doc_info else None, + params_pydantic_model=dynamic_model, + params_json_schema=json_schema, + signature=sig, + takes_context=takes_context, + ) diff --git a/tests/src/agents/guardrail.py b/tests/src/agents/guardrail.py new file mode 100644 index 0000000..fcae0b8 --- /dev/null +++ b/tests/src/agents/guardrail.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import inspect +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Generic, Union, overload + +from typing_extensions import TypeVar + +from ._utils import MaybeAwaitable +from .exceptions import UserError +from .items import TResponseInputItem +from .run_context import RunContextWrapper, TContext + +if TYPE_CHECKING: + from .agent import Agent + + +@dataclass +class GuardrailFunctionOutput: + """The output of a guardrail function.""" + + output_info: Any + """ + Optional information about the guardrail's output. For example, the guardrail could include + information about the checks it performed and granular results. + """ + + tripwire_triggered: bool + """ + Whether the tripwire was triggered. If triggered, the agent's execution will be halted. + """ + + +@dataclass +class InputGuardrailResult: + """The result of a guardrail run.""" + + guardrail: InputGuardrail[Any] + """ + The guardrail that was run. + """ + + output: GuardrailFunctionOutput + """The output of the guardrail function.""" + + +@dataclass +class OutputGuardrailResult: + """The result of a guardrail run.""" + + guardrail: OutputGuardrail[Any] + """ + The guardrail that was run. + """ + + agent_output: Any + """ + The output of the agent that was checked by the guardrail. + """ + + agent: Agent[Any] + """ + The agent that was checked by the guardrail. + """ + + output: GuardrailFunctionOutput + """The output of the guardrail function.""" + + +@dataclass +class InputGuardrail(Generic[TContext]): + """Input guardrails are checks that run in parallel to the agent's execution. + They can be used to do things like: + - Check if input messages are off-topic + - Take over control of the agent's execution if an unexpected input is detected + + You can use the `@input_guardrail()` decorator to turn a function into an `InputGuardrail`, or + create an `InputGuardrail` manually. + + Guardrails return a `GuardrailResult`. If `result.tripwire_triggered` is `True`, the agent + execution will immediately stop and a `InputGuardrailTripwireTriggered` exception will be raised + """ + + guardrail_function: Callable[ + [RunContextWrapper[TContext], Agent[Any], str | list[TResponseInputItem]], + MaybeAwaitable[GuardrailFunctionOutput], + ] + """A function that receives the the agent input and the context, and returns a + `GuardrailResult`. The result marks whether the tripwire was triggered, and can optionally + include information about the guardrail's output. + """ + + name: str | None = None + """The name of the guardrail, used for tracing. If not provided, we'll use the guardrail + function's name. + """ + + def get_name(self) -> str: + if self.name: + return self.name + + return self.guardrail_function.__name__ + + async def run( + self, + agent: Agent[Any], + input: str | list[TResponseInputItem], + context: RunContextWrapper[TContext], + ) -> InputGuardrailResult: + if not callable(self.guardrail_function): + raise UserError(f"Guardrail function must be callable, got {self.guardrail_function}") + + output = self.guardrail_function(context, agent, input) + if inspect.isawaitable(output): + return InputGuardrailResult( + guardrail=self, + output=await output, + ) + + return InputGuardrailResult( + guardrail=self, + output=output, + ) + + +@dataclass +class OutputGuardrail(Generic[TContext]): + """Output guardrails are checks that run on the final output of an agent. + They can be used to do check if the output passes certain validation criteria + + You can use the `@output_guardrail()` decorator to turn a function into an `OutputGuardrail`, + or create an `OutputGuardrail` manually. + + Guardrails return a `GuardrailResult`. If `result.tripwire_triggered` is `True`, a + `OutputGuardrailTripwireTriggered` exception will be raised. + """ + + guardrail_function: Callable[ + [RunContextWrapper[TContext], Agent[Any], Any], + MaybeAwaitable[GuardrailFunctionOutput], + ] + """A function that receives the final agent, its output, and the context, and returns a + `GuardrailResult`. The result marks whether the tripwire was triggered, and can optionally + include information about the guardrail's output. + """ + + name: str | None = None + """The name of the guardrail, used for tracing. If not provided, we'll use the guardrail + function's name. + """ + + def get_name(self) -> str: + if self.name: + return self.name + + return self.guardrail_function.__name__ + + async def run( + self, context: RunContextWrapper[TContext], agent: Agent[Any], agent_output: Any + ) -> OutputGuardrailResult: + if not callable(self.guardrail_function): + raise UserError(f"Guardrail function must be callable, got {self.guardrail_function}") + + output = self.guardrail_function(context, agent, agent_output) + if inspect.isawaitable(output): + return OutputGuardrailResult( + guardrail=self, + agent=agent, + agent_output=agent_output, + output=await output, + ) + + return OutputGuardrailResult( + guardrail=self, + agent=agent, + agent_output=agent_output, + output=output, + ) + + +TContext_co = TypeVar("TContext_co", bound=Any, covariant=True) + +# For InputGuardrail +_InputGuardrailFuncSync = Callable[ + [RunContextWrapper[TContext_co], "Agent[Any]", Union[str, list[TResponseInputItem]]], + GuardrailFunctionOutput, +] +_InputGuardrailFuncAsync = Callable[ + [RunContextWrapper[TContext_co], "Agent[Any]", Union[str, list[TResponseInputItem]]], + Awaitable[GuardrailFunctionOutput], +] + + +@overload +def input_guardrail( + func: _InputGuardrailFuncSync[TContext_co], +) -> InputGuardrail[TContext_co]: ... + + +@overload +def input_guardrail( + func: _InputGuardrailFuncAsync[TContext_co], +) -> InputGuardrail[TContext_co]: ... + + +@overload +def input_guardrail( + *, + name: str | None = None, +) -> Callable[ + [_InputGuardrailFuncSync[TContext_co] | _InputGuardrailFuncAsync[TContext_co]], + InputGuardrail[TContext_co], +]: ... + + +def input_guardrail( + func: _InputGuardrailFuncSync[TContext_co] + | _InputGuardrailFuncAsync[TContext_co] + | None = None, + *, + name: str | None = None, +) -> ( + InputGuardrail[TContext_co] + | Callable[ + [_InputGuardrailFuncSync[TContext_co] | _InputGuardrailFuncAsync[TContext_co]], + InputGuardrail[TContext_co], + ] +): + """ + Decorator that transforms a sync or async function into an `InputGuardrail`. + It can be used directly (no parentheses) or with keyword args, e.g.: + + @input_guardrail + def my_sync_guardrail(...): ... + + @input_guardrail(name="guardrail_name") + async def my_async_guardrail(...): ... + """ + + def decorator( + f: _InputGuardrailFuncSync[TContext_co] | _InputGuardrailFuncAsync[TContext_co], + ) -> InputGuardrail[TContext_co]: + return InputGuardrail(guardrail_function=f, name=name) + + if func is not None: + # Decorator was used without parentheses + return decorator(func) + + # Decorator used with keyword arguments + return decorator + + +_OutputGuardrailFuncSync = Callable[ + [RunContextWrapper[TContext_co], "Agent[Any]", Any], + GuardrailFunctionOutput, +] +_OutputGuardrailFuncAsync = Callable[ + [RunContextWrapper[TContext_co], "Agent[Any]", Any], + Awaitable[GuardrailFunctionOutput], +] + + +@overload +def output_guardrail( + func: _OutputGuardrailFuncSync[TContext_co], +) -> OutputGuardrail[TContext_co]: ... + + +@overload +def output_guardrail( + func: _OutputGuardrailFuncAsync[TContext_co], +) -> OutputGuardrail[TContext_co]: ... + + +@overload +def output_guardrail( + *, + name: str | None = None, +) -> Callable[ + [_OutputGuardrailFuncSync[TContext_co] | _OutputGuardrailFuncAsync[TContext_co]], + OutputGuardrail[TContext_co], +]: ... + + +def output_guardrail( + func: _OutputGuardrailFuncSync[TContext_co] + | _OutputGuardrailFuncAsync[TContext_co] + | None = None, + *, + name: str | None = None, +) -> ( + OutputGuardrail[TContext_co] + | Callable[ + [_OutputGuardrailFuncSync[TContext_co] | _OutputGuardrailFuncAsync[TContext_co]], + OutputGuardrail[TContext_co], + ] +): + """ + Decorator that transforms a sync or async function into an `OutputGuardrail`. + It can be used directly (no parentheses) or with keyword args, e.g.: + + @output_guardrail + def my_sync_guardrail(...): ... + + @output_guardrail(name="guardrail_name") + async def my_async_guardrail(...): ... + """ + + def decorator( + f: _OutputGuardrailFuncSync[TContext_co] | _OutputGuardrailFuncAsync[TContext_co], + ) -> OutputGuardrail[TContext_co]: + return OutputGuardrail(guardrail_function=f, name=name) + + if func is not None: + # Decorator was used without parentheses + return decorator(func) + + # Decorator used with keyword arguments + return decorator diff --git a/tests/src/agents/handoffs.py b/tests/src/agents/handoffs.py new file mode 100644 index 0000000..ac15740 --- /dev/null +++ b/tests/src/agents/handoffs.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import inspect +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Generic, cast, overload + +from pydantic import TypeAdapter +from typing_extensions import TypeAlias, TypeVar + +from . import _utils +from .exceptions import ModelBehaviorError, UserError +from .items import RunItem, TResponseInputItem +from .run_context import RunContextWrapper, TContext +from .strict_schema import ensure_strict_json_schema +from .tracing.spans import SpanError + +if TYPE_CHECKING: + from .agent import Agent + + +# The handoff input type is the type of data passed when the agent is called via a handoff. +THandoffInput = TypeVar("THandoffInput", default=Any) + +OnHandoffWithInput = Callable[[RunContextWrapper[Any], THandoffInput], Any] +OnHandoffWithoutInput = Callable[[RunContextWrapper[Any]], Any] + + +@dataclass(frozen=True) +class HandoffInputData: + input_history: str | tuple[TResponseInputItem, ...] + """ + The input history before `Runner.run()` was called. + """ + + pre_handoff_items: tuple[RunItem, ...] + """ + The items generated before the agent turn where the handoff was invoked. + """ + + new_items: tuple[RunItem, ...] + """ + The new items generated during the current agent turn, including the item that triggered the + handoff and the tool output message representing the response from the handoff output. + """ + + +HandoffInputFilter: TypeAlias = Callable[[HandoffInputData], HandoffInputData] +"""A function that filters the input data passed to the next agent.""" + + +@dataclass +class Handoff(Generic[TContext]): + """A handoff is when an agent delegates a task to another agent. + For example, in a customer support scenario you might have a "triage agent" that determines + which agent should handle the user's request, and sub-agents that specialize in different + areas like billing, account management, etc. + """ + + tool_name: str + """The name of the tool that represents the handoff.""" + + tool_description: str + """The description of the tool that represents the handoff.""" + + input_json_schema: dict[str, Any] + """The JSON schema for the handoff input. Can be empty if the handoff does not take an input. + """ + + on_invoke_handoff: Callable[[RunContextWrapper[Any], str], Awaitable[Agent[TContext]]] + """The function that invokes the handoff. The parameters passed are: + 1. The handoff run context + 2. The arguments from the LLM, as a JSON string. Empty string if input_json_schema is empty. + + Must return an agent. + """ + + agent_name: str + """The name of the agent that is being handed off to.""" + + input_filter: HandoffInputFilter | None = None + """A function that filters the inputs that are passed to the next agent. By default, the new + agent sees the entire conversation history. In some cases, you may want to filter inputs e.g. + to remove older inputs, or remove tools from existing inputs. + + The function will receive the entire conversation history so far, including the input item + that triggered the handoff and a tool call output item representing the handoff tool's output. + + You are free to modify the input history or new items as you see fit. The next agent that + runs will receive `handoff_input_data.all_items`. + + IMPORTANT: in streaming mode, we will not stream anything as a result of this function. The + items generated before will already have been streamed. + """ + + strict_json_schema: bool = True + """Whether the input JSON schema is in strict mode. We **strongly** recommend setting this to + True, as it increases the likelihood of correct JSON input. + """ + + def get_transfer_message(self, agent: Agent[Any]) -> str: + base = f"{{'assistant': '{agent.name}'}}" + return base + + @classmethod + def default_tool_name(cls, agent: Agent[Any]) -> str: + return _utils.transform_string_function_style(f"transfer_to_{agent.name}") + + @classmethod + def default_tool_description(cls, agent: Agent[Any]) -> str: + return ( + f"Handoff to the {agent.name} agent to handle the request. " + f"{agent.handoff_description or ''}" + ) + + +@overload +def handoff( + agent: Agent[TContext], + *, + tool_name_override: str | None = None, + tool_description_override: str | None = None, + input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None, +) -> Handoff[TContext]: ... + + +@overload +def handoff( + agent: Agent[TContext], + *, + on_handoff: OnHandoffWithInput[THandoffInput], + input_type: type[THandoffInput], + tool_description_override: str | None = None, + tool_name_override: str | None = None, + input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None, +) -> Handoff[TContext]: ... + + +@overload +def handoff( + agent: Agent[TContext], + *, + on_handoff: OnHandoffWithoutInput, + tool_description_override: str | None = None, + tool_name_override: str | None = None, + input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None, +) -> Handoff[TContext]: ... + + +def handoff( + agent: Agent[TContext], + tool_name_override: str | None = None, + tool_description_override: str | None = None, + on_handoff: OnHandoffWithInput[THandoffInput] | OnHandoffWithoutInput | None = None, + input_type: type[THandoffInput] | None = None, + input_filter: Callable[[HandoffInputData], HandoffInputData] | None = None, +) -> Handoff[TContext]: + """Create a handoff from an agent. + + Args: + agent: The agent to handoff to, or a function that returns an agent. + tool_name_override: Optional override for the name of the tool that represents the handoff. + tool_description_override: Optional override for the description of the tool that + represents the handoff. + on_handoff: A function that runs when the handoff is invoked. + input_type: the type of the input to the handoff. If provided, the input will be validated + against this type. Only relevant if you pass a function that takes an input. + input_filter: a function that filters the inputs that are passed to the next agent. + """ + assert (on_handoff and input_type) or not (on_handoff and input_type), ( + "You must provide either both on_input and input_type, or neither" + ) + type_adapter: TypeAdapter[Any] | None + if input_type is not None: + assert callable(on_handoff), "on_handoff must be callable" + sig = inspect.signature(on_handoff) + if len(sig.parameters) != 2: + raise UserError("on_handoff must take two arguments: context and input") + + type_adapter = TypeAdapter(input_type) + input_json_schema = type_adapter.json_schema() + else: + type_adapter = None + input_json_schema = {} + if on_handoff is not None: + sig = inspect.signature(on_handoff) + if len(sig.parameters) != 1: + raise UserError("on_handoff must take one argument: context") + + async def _invoke_handoff( + ctx: RunContextWrapper[Any], input_json: str | None = None + ) -> Agent[Any]: + if input_type is not None and type_adapter is not None: + if input_json is None: + _utils.attach_error_to_current_span( + SpanError( + message="Handoff function expected non-null input, but got None", + data={"details": "input_json is None"}, + ) + ) + raise ModelBehaviorError("Handoff function expected non-null input, but got None") + + validated_input = _utils.validate_json( + json_str=input_json, + type_adapter=type_adapter, + partial=False, + ) + input_func = cast(OnHandoffWithInput[THandoffInput], on_handoff) + if inspect.iscoroutinefunction(input_func): + await input_func(ctx, validated_input) + else: + input_func(ctx, validated_input) + elif on_handoff is not None: + no_input_func = cast(OnHandoffWithoutInput, on_handoff) + if inspect.iscoroutinefunction(no_input_func): + await no_input_func(ctx) + else: + no_input_func(ctx) + + return agent + + tool_name = tool_name_override or Handoff.default_tool_name(agent) + tool_description = tool_description_override or Handoff.default_tool_description(agent) + + # Always ensure the input JSON schema is in strict mode + # If there is a need, we can make this configurable in the future + input_json_schema = ensure_strict_json_schema(input_json_schema) + + return Handoff( + tool_name=tool_name, + tool_description=tool_description, + input_json_schema=input_json_schema, + on_invoke_handoff=_invoke_handoff, + input_filter=input_filter, + agent_name=agent.name, + ) diff --git a/tests/src/agents/items.py b/tests/src/agents/items.py new file mode 100644 index 0000000..bbaf49d --- /dev/null +++ b/tests/src/agents/items.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import abc +import copy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, Union + +from openai.types.responses import ( + Response, + ResponseComputerToolCall, + ResponseFileSearchToolCall, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseInputItemParam, + ResponseOutputItem, + ResponseOutputMessage, + ResponseOutputRefusal, + ResponseOutputText, + ResponseStreamEvent, +) +from openai.types.responses.response_input_item_param import ComputerCallOutput, FunctionCallOutput +from openai.types.responses.response_output_item import Reasoning +from pydantic import BaseModel +from typing_extensions import TypeAlias + +from .exceptions import AgentsException, ModelBehaviorError +from .usage import Usage + +if TYPE_CHECKING: + from .agent import Agent + +TResponse = Response +"""A type alias for the Response type from the OpenAI SDK.""" + +TResponseInputItem = ResponseInputItemParam +"""A type alias for the ResponseInputItemParam type from the OpenAI SDK.""" + +TResponseOutputItem = ResponseOutputItem +"""A type alias for the ResponseOutputItem type from the OpenAI SDK.""" + +TResponseStreamEvent = ResponseStreamEvent +"""A type alias for the ResponseStreamEvent type from the OpenAI SDK.""" + +T = TypeVar("T", bound=Union[TResponseOutputItem, TResponseInputItem]) + + +@dataclass +class RunItemBase(Generic[T], abc.ABC): + agent: Agent[Any] + """The agent whose run caused this item to be generated.""" + + raw_item: T + """The raw Responses item from the run. This will always be a either an output item (i.e. + `openai.types.responses.ResponseOutputItem` or an input item + (i.e. `openai.types.responses.ResponseInputItemParam`). + """ + + def to_input_item(self) -> TResponseInputItem: + """Converts this item into an input item suitable for passing to the model.""" + if isinstance(self.raw_item, dict): + # We know that input items are dicts, so we can ignore the type error + return self.raw_item # type: ignore + elif isinstance(self.raw_item, BaseModel): + # All output items are Pydantic models that can be converted to input items. + return self.raw_item.model_dump(exclude_unset=True) # type: ignore + else: + raise AgentsException(f"Unexpected raw item type: {type(self.raw_item)}") + + +@dataclass +class MessageOutputItem(RunItemBase[ResponseOutputMessage]): + """Represents a message from the LLM.""" + + raw_item: ResponseOutputMessage + """The raw response output message.""" + + type: Literal["message_output_item"] = "message_output_item" + + +@dataclass +class HandoffCallItem(RunItemBase[ResponseFunctionToolCall]): + """Represents a tool call for a handoff from one agent to another.""" + + raw_item: ResponseFunctionToolCall + """The raw response function tool call that represents the handoff.""" + + type: Literal["handoff_call_item"] = "handoff_call_item" + + +@dataclass +class HandoffOutputItem(RunItemBase[TResponseInputItem]): + """Represents the output of a handoff.""" + + raw_item: TResponseInputItem + """The raw input item that represents the handoff taking place.""" + + source_agent: Agent[Any] + """The agent that made the handoff.""" + + target_agent: Agent[Any] + """The agent that is being handed off to.""" + + type: Literal["handoff_output_item"] = "handoff_output_item" + + +ToolCallItemTypes: TypeAlias = Union[ + ResponseFunctionToolCall, + ResponseComputerToolCall, + ResponseFileSearchToolCall, + ResponseFunctionWebSearch, +] +"""A type that represents a tool call item.""" + + +@dataclass +class ToolCallItem(RunItemBase[ToolCallItemTypes]): + """Represents a tool call e.g. a function call or computer action call.""" + + raw_item: ToolCallItemTypes + """The raw tool call item.""" + + type: Literal["tool_call_item"] = "tool_call_item" + + +@dataclass +class ToolCallOutputItem(RunItemBase[Union[FunctionCallOutput, ComputerCallOutput]]): + """Represents the output of a tool call.""" + + raw_item: FunctionCallOutput | ComputerCallOutput + """The raw item from the model.""" + + output: str + """The output of the tool call.""" + + type: Literal["tool_call_output_item"] = "tool_call_output_item" + + +@dataclass +class ReasoningItem(RunItemBase[Reasoning]): + """Represents a reasoning item.""" + + raw_item: Reasoning + """The raw reasoning item.""" + + type: Literal["reasoning_item"] = "reasoning_item" + + +RunItem: TypeAlias = Union[ + MessageOutputItem, + HandoffCallItem, + HandoffOutputItem, + ToolCallItem, + ToolCallOutputItem, + ReasoningItem, +] +"""An item generated by an agent.""" + + +@dataclass +class ModelResponse: + output: list[TResponseOutputItem] + """A list of outputs (messages, tool calls, etc) generated by the model""" + + usage: Usage + """The usage information for the response.""" + + referenceable_id: str | None + """An ID for the response which can be used to refer to the response in subsequent calls to the + model. Not supported by all model providers. + """ + + def to_input_items(self) -> list[TResponseInputItem]: + """Convert the output into a list of input items suitable for passing to the model.""" + # We happen to know that the shape of the Pydantic output items are the same as the + # equivalent TypedDict input items, so we can just convert each one. + # This is also tested via unit tests. + return [it.model_dump(exclude_unset=True) for it in self.output] # type: ignore + + +class ItemHelpers: + @classmethod + def extract_last_content(cls, message: TResponseOutputItem) -> str: + """Extracts the last text content or refusal from a message.""" + if not isinstance(message, ResponseOutputMessage): + return "" + + last_content = message.content[-1] + if isinstance(last_content, ResponseOutputText): + return last_content.text + elif isinstance(last_content, ResponseOutputRefusal): + return last_content.refusal + else: + raise ModelBehaviorError(f"Unexpected content type: {type(last_content)}") + + @classmethod + def extract_last_text(cls, message: TResponseOutputItem) -> str | None: + """Extracts the last text content from a message, if any. Ignores refusals.""" + if isinstance(message, ResponseOutputMessage): + last_content = message.content[-1] + if isinstance(last_content, ResponseOutputText): + return last_content.text + + return None + + @classmethod + def input_to_new_input_list( + cls, input: str | list[TResponseInputItem] + ) -> list[TResponseInputItem]: + """Converts a string or list of input items into a list of input items.""" + if isinstance(input, str): + return [ + { + "content": input, + "role": "user", + } + ] + return copy.deepcopy(input) + + @classmethod + def text_message_outputs(cls, items: list[RunItem]) -> str: + """Concatenates all the text content from a list of message output items.""" + text = "" + for item in items: + if isinstance(item, MessageOutputItem): + text += cls.text_message_output(item) + return text + + @classmethod + def text_message_output(cls, message: MessageOutputItem) -> str: + """Extracts all the text content from a single message output item.""" + text = "" + for item in message.raw_item.content: + if isinstance(item, ResponseOutputText): + text += item.text + return text + + @classmethod + def tool_call_output_item( + cls, tool_call: ResponseFunctionToolCall, output: str + ) -> FunctionCallOutput: + """Creates a tool call output item from a tool call and its output.""" + return { + "call_id": tool_call.call_id, + "output": output, + "type": "function_call_output", + } diff --git a/tests/src/agents/lifecycle.py b/tests/src/agents/lifecycle.py new file mode 100644 index 0000000..8643248 --- /dev/null +++ b/tests/src/agents/lifecycle.py @@ -0,0 +1,105 @@ +from typing import Any, Generic + +from .agent import Agent +from .run_context import RunContextWrapper, TContext +from .tool import Tool + + +class RunHooks(Generic[TContext]): + """A class that receives callbacks on various lifecycle events in an agent run. Subclass and + override the methods you need. + """ + + async def on_agent_start( + self, context: RunContextWrapper[TContext], agent: Agent[TContext] + ) -> None: + """Called before the agent is invoked. Called each time the current agent changes.""" + pass + + async def on_agent_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + output: Any, + ) -> None: + """Called when the agent produces a final output.""" + pass + + async def on_handoff( + self, + context: RunContextWrapper[TContext], + from_agent: Agent[TContext], + to_agent: Agent[TContext], + ) -> None: + """Called when a handoff occurs.""" + pass + + async def on_tool_start( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + ) -> None: + """Called before a tool is invoked.""" + pass + + async def on_tool_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + result: str, + ) -> None: + """Called after a tool is invoked.""" + pass + + +class AgentHooks(Generic[TContext]): + """A class that receives callbacks on various lifecycle events for a specific agent. You can + set this on `agent.hooks` to receive events for that specific agent. + + Subclass and override the methods you need. + """ + + async def on_start(self, context: RunContextWrapper[TContext], agent: Agent[TContext]) -> None: + """Called before the agent is invoked. Called each time the running agent is changed to this + agent.""" + pass + + async def on_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + output: Any, + ) -> None: + """Called when the agent produces a final output.""" + pass + + async def on_handoff( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + source: Agent[TContext], + ) -> None: + """Called when the agent is being handed off to. The `source` is the agent that is handing + off to this agent.""" + pass + + async def on_tool_start( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + ) -> None: + """Called before a tool is invoked.""" + pass + + async def on_tool_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + result: str, + ) -> None: + """Called after a tool is invoked.""" + pass diff --git a/tests/src/agents/logger.py b/tests/src/agents/logger.py new file mode 100644 index 0000000..bd81a82 --- /dev/null +++ b/tests/src/agents/logger.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger("openai.agents") diff --git a/tests/src/agents/model_settings.py b/tests/src/agents/model_settings.py new file mode 100644 index 0000000..78cf9a8 --- /dev/null +++ b/tests/src/agents/model_settings.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +@dataclass +class ModelSettings: + """Settings to use when calling an LLM. + + This class holds optional model configuration parameters (e.g. temperature, + top_p, penalties, truncation, etc.). + """ + temperature: float | None = None + top_p: float | None = None + frequency_penalty: float | None = None + presence_penalty: float | None = None + tool_choice: Literal["auto", "required", "none"] | str | None = None + parallel_tool_calls: bool | None = False + truncation: Literal["auto", "disabled"] | None = None + + def resolve(self, override: ModelSettings | None) -> ModelSettings: + """Produce a new ModelSettings by overlaying any non-None values from the + override on top of this instance.""" + if override is None: + return self + return ModelSettings( + temperature=override.temperature or self.temperature, + top_p=override.top_p or self.top_p, + frequency_penalty=override.frequency_penalty or self.frequency_penalty, + presence_penalty=override.presence_penalty or self.presence_penalty, + tool_choice=override.tool_choice or self.tool_choice, + parallel_tool_calls=override.parallel_tool_calls or self.parallel_tool_calls, + truncation=override.truncation or self.truncation, + ) diff --git a/tests/src/agents/models/__init__.py b/tests/src/agents/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/src/agents/models/__pycache__/__init__.cpython-311.pyc b/tests/src/agents/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..809c19d Binary files /dev/null and b/tests/src/agents/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/tests/src/agents/models/__pycache__/__init__.cpython-313.pyc b/tests/src/agents/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..bcd3895 Binary files /dev/null and b/tests/src/agents/models/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/src/agents/models/__pycache__/__init__.cpython-39.pyc b/tests/src/agents/models/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..ce59e12 Binary files /dev/null and b/tests/src/agents/models/__pycache__/__init__.cpython-39.pyc differ diff --git a/tests/src/agents/models/__pycache__/_openai_shared.cpython-311.pyc b/tests/src/agents/models/__pycache__/_openai_shared.cpython-311.pyc new file mode 100644 index 0000000..31d9557 Binary files /dev/null and b/tests/src/agents/models/__pycache__/_openai_shared.cpython-311.pyc differ diff --git a/tests/src/agents/models/__pycache__/_openai_shared.cpython-313.pyc b/tests/src/agents/models/__pycache__/_openai_shared.cpython-313.pyc new file mode 100644 index 0000000..ad69a87 Binary files /dev/null and b/tests/src/agents/models/__pycache__/_openai_shared.cpython-313.pyc differ diff --git a/tests/src/agents/models/__pycache__/_openai_shared.cpython-39.pyc b/tests/src/agents/models/__pycache__/_openai_shared.cpython-39.pyc new file mode 100644 index 0000000..fb45fa3 Binary files /dev/null and b/tests/src/agents/models/__pycache__/_openai_shared.cpython-39.pyc differ diff --git a/tests/src/agents/models/__pycache__/fake_id.cpython-313.pyc b/tests/src/agents/models/__pycache__/fake_id.cpython-313.pyc new file mode 100644 index 0000000..b15d6e4 Binary files /dev/null and b/tests/src/agents/models/__pycache__/fake_id.cpython-313.pyc differ diff --git a/tests/src/agents/models/__pycache__/fake_id.cpython-39.pyc b/tests/src/agents/models/__pycache__/fake_id.cpython-39.pyc new file mode 100644 index 0000000..77f6bef Binary files /dev/null and b/tests/src/agents/models/__pycache__/fake_id.cpython-39.pyc differ diff --git a/tests/src/agents/models/__pycache__/interface.cpython-313.pyc b/tests/src/agents/models/__pycache__/interface.cpython-313.pyc new file mode 100644 index 0000000..db10238 Binary files /dev/null and b/tests/src/agents/models/__pycache__/interface.cpython-313.pyc differ diff --git a/tests/src/agents/models/__pycache__/interface.cpython-39.pyc b/tests/src/agents/models/__pycache__/interface.cpython-39.pyc new file mode 100644 index 0000000..fc80bac Binary files /dev/null and b/tests/src/agents/models/__pycache__/interface.cpython-39.pyc differ diff --git a/tests/src/agents/models/__pycache__/map.cpython-313.pyc b/tests/src/agents/models/__pycache__/map.cpython-313.pyc new file mode 100644 index 0000000..f648281 Binary files /dev/null and b/tests/src/agents/models/__pycache__/map.cpython-313.pyc differ diff --git a/tests/src/agents/models/__pycache__/map.cpython-39.pyc b/tests/src/agents/models/__pycache__/map.cpython-39.pyc new file mode 100644 index 0000000..091f73a Binary files /dev/null and b/tests/src/agents/models/__pycache__/map.cpython-39.pyc differ diff --git a/tests/src/agents/models/__pycache__/openai_chatcompletions.cpython-313.pyc b/tests/src/agents/models/__pycache__/openai_chatcompletions.cpython-313.pyc new file mode 100644 index 0000000..88538e8 Binary files /dev/null and b/tests/src/agents/models/__pycache__/openai_chatcompletions.cpython-313.pyc differ diff --git a/tests/src/agents/models/__pycache__/openai_chatcompletions.cpython-39.pyc b/tests/src/agents/models/__pycache__/openai_chatcompletions.cpython-39.pyc new file mode 100644 index 0000000..71cfc8f Binary files /dev/null and b/tests/src/agents/models/__pycache__/openai_chatcompletions.cpython-39.pyc differ diff --git a/tests/src/agents/models/__pycache__/openai_provider.cpython-313.pyc b/tests/src/agents/models/__pycache__/openai_provider.cpython-313.pyc new file mode 100644 index 0000000..f73f6f5 Binary files /dev/null and b/tests/src/agents/models/__pycache__/openai_provider.cpython-313.pyc differ diff --git a/tests/src/agents/models/__pycache__/openai_provider.cpython-39.pyc b/tests/src/agents/models/__pycache__/openai_provider.cpython-39.pyc new file mode 100644 index 0000000..d52a6bb Binary files /dev/null and b/tests/src/agents/models/__pycache__/openai_provider.cpython-39.pyc differ diff --git a/tests/src/agents/models/__pycache__/openai_responses.cpython-313.pyc b/tests/src/agents/models/__pycache__/openai_responses.cpython-313.pyc new file mode 100644 index 0000000..e538547 Binary files /dev/null and b/tests/src/agents/models/__pycache__/openai_responses.cpython-313.pyc differ diff --git a/tests/src/agents/models/__pycache__/openai_responses.cpython-39.pyc b/tests/src/agents/models/__pycache__/openai_responses.cpython-39.pyc new file mode 100644 index 0000000..0865feb Binary files /dev/null and b/tests/src/agents/models/__pycache__/openai_responses.cpython-39.pyc differ diff --git a/tests/src/agents/models/_openai_shared.py b/tests/src/agents/models/_openai_shared.py new file mode 100644 index 0000000..2e14501 --- /dev/null +++ b/tests/src/agents/models/_openai_shared.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from openai import AsyncOpenAI + +_default_openai_key: str | None = None +_default_openai_client: AsyncOpenAI | None = None +_use_responses_by_default: bool = True + + +def set_default_openai_key(key: str) -> None: + global _default_openai_key + _default_openai_key = key + + +def get_default_openai_key() -> str | None: + return _default_openai_key + + +def set_default_openai_client(client: AsyncOpenAI) -> None: + global _default_openai_client + _default_openai_client = client + + +def get_default_openai_client() -> AsyncOpenAI | None: + return _default_openai_client + + +def set_use_responses_by_default(use_responses: bool) -> None: + global _use_responses_by_default + _use_responses_by_default = use_responses + + +def get_use_responses_by_default() -> bool: + return _use_responses_by_default diff --git a/tests/src/agents/models/fake_id.py b/tests/src/agents/models/fake_id.py new file mode 100644 index 0000000..0565b0a --- /dev/null +++ b/tests/src/agents/models/fake_id.py @@ -0,0 +1,5 @@ +FAKE_RESPONSES_ID = "__fake_id__" +"""This is a placeholder ID used to fill in the `id` field in Responses API related objects. It's +useful when you're creating Responses objects from non-Responses APIs, e.g. the OpenAI Chat +Completions API or other LLM providers. +""" diff --git a/tests/src/agents/models/interface.py b/tests/src/agents/models/interface.py new file mode 100644 index 0000000..e9a8700 --- /dev/null +++ b/tests/src/agents/models/interface.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import abc +import enum +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING + +from ..agent_output import AgentOutputSchema +from ..handoffs import Handoff +from ..items import ModelResponse, TResponseInputItem, TResponseStreamEvent +from ..tool import Tool + +if TYPE_CHECKING: + from ..model_settings import ModelSettings + + +class ModelTracing(enum.Enum): + DISABLED = 0 + """Tracing is disabled entirely.""" + + ENABLED = 1 + """Tracing is enabled, and all data is included.""" + + ENABLED_WITHOUT_DATA = 2 + """Tracing is enabled, but inputs/outputs are not included.""" + + def is_disabled(self) -> bool: + return self == ModelTracing.DISABLED + + def include_data(self) -> bool: + return self == ModelTracing.ENABLED + + +class Model(abc.ABC): + """The base interface for calling an LLM.""" + + @abc.abstractmethod + 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: + """Get a response from the model. + + Args: + system_instructions: The system instructions to use. + input: The input items to the model, in OpenAI Responses format. + model_settings: The model settings to use. + tools: The tools available to the model. + output_schema: The output schema to use. + handoffs: The handoffs available to the model. + tracing: Tracing configuration. + + Returns: + The full model response. + """ + pass + + @abc.abstractmethod + 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]: + """Stream a response from the model. + + Args: + system_instructions: The system instructions to use. + input: The input items to the model, in OpenAI Responses format. + model_settings: The model settings to use. + tools: The tools available to the model. + output_schema: The output schema to use. + handoffs: The handoffs available to the model. + tracing: Tracing configuration. + + Returns: + An iterator of response stream events, in OpenAI Responses format. + """ + pass + + +class ModelProvider(abc.ABC): + """The base interface for a model provider. + + Model provider is responsible for looking up Models by name. + """ + + @abc.abstractmethod + def get_model(self, model_name: str | None) -> Model: + """Get a model by name. + + Args: + model_name: The name of the model to get. + + Returns: + The model. + """ diff --git a/tests/src/agents/models/openai_chatcompletions.py b/tests/src/agents/models/openai_chatcompletions.py new file mode 100644 index 0000000..a7340d0 --- /dev/null +++ b/tests/src/agents/models/openai_chatcompletions.py @@ -0,0 +1,952 @@ +from __future__ import annotations + +import dataclasses +import json +import time +from collections.abc import AsyncIterator, Iterable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Literal, cast, overload + +from openai import NOT_GIVEN, AsyncOpenAI, AsyncStream, NotGiven +from openai.types import ChatModel +from openai.types.chat import ( + ChatCompletion, + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionContentPartImageParam, + ChatCompletionContentPartParam, + ChatCompletionContentPartTextParam, + ChatCompletionDeveloperMessageParam, + ChatCompletionMessage, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolChoiceOptionParam, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_tool_param import ChatCompletionToolParam +from openai.types.chat.completion_create_params import ResponseFormat +from openai.types.completion_usage import CompletionUsage +from openai.types.responses import ( + EasyInputMessageParam, + Response, + ResponseCompletedEvent, + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseCreatedEvent, + ResponseFileSearchToolCallParam, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionToolCall, + ResponseFunctionToolCallParam, + ResponseInputContentParam, + ResponseInputImageParam, + ResponseInputTextParam, + ResponseOutputItem, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputMessageParam, + ResponseOutputRefusal, + ResponseOutputText, + ResponseRefusalDeltaEvent, + ResponseTextDeltaEvent, +) +from openai.types.responses.response_input_param import FunctionCallOutput, ItemReference, Message + +from .. import _debug +from ..agent_output import AgentOutputSchema +from ..exceptions import AgentsException, UserError +from ..handoffs import Handoff +from ..items import ModelResponse, TResponseInputItem, TResponseOutputItem, TResponseStreamEvent +from ..logger import logger +from ..tool import FunctionTool, Tool +from ..tracing import generation_span +from ..tracing.span_data import GenerationSpanData +from ..tracing.spans import Span +from ..usage import Usage +from ..version import __version__ +from .fake_id import FAKE_RESPONSES_ID +from .interface import Model, ModelTracing + +if TYPE_CHECKING: + from ..model_settings import ModelSettings + + +_USER_AGENT = f"Agents/Python {__version__}" +_HEADERS = {"User-Agent": _USER_AGENT} + + +@dataclass +class _StreamingState: + started: bool = False + text_content_index_and_output: tuple[int, ResponseOutputText] | None = None + refusal_content_index_and_output: tuple[int, ResponseOutputRefusal] | None = None + function_calls: dict[int, ResponseFunctionToolCall] = field(default_factory=dict) + + +class OpenAIChatCompletionsModel(Model): + def __init__( + self, + model: str | ChatModel, + openai_client: AsyncOpenAI, + ) -> None: + self.model = model + self._client = openai_client + + def _non_null_or_not_given(self, value: Any) -> Any: + return value if value is not None else NOT_GIVEN + + 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: + with generation_span( + model=str(self.model), + model_config=dataclasses.asdict(model_settings) + | {"base_url": str(self._client.base_url)}, + disabled=tracing.is_disabled(), + ) as span_generation: + response = await self._fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + span_generation, + tracing, + stream=False, + ) + + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("Received model response") + else: + logger.debug( + f"LLM resp:\n{json.dumps(response.choices[0].message.model_dump(), indent=2)}\n" + ) + + usage = ( + Usage( + requests=1, + input_tokens=response.usage.prompt_tokens, + output_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + ) + if response.usage + else Usage() + ) + if tracing.include_data(): + span_generation.span_data.output = [response.choices[0].message.model_dump()] + span_generation.span_data.usage = { + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + } + + items = _Converter.message_to_output_items(response.choices[0].message) + + return ModelResponse( + output=items, + usage=usage, + referenceable_id=None, + ) + + 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]: + """ + Yields a partial message as it is generated, as well as the usage information. + """ + with generation_span( + model=str(self.model), + model_config=dataclasses.asdict(model_settings) + | {"base_url": str(self._client.base_url)}, + disabled=tracing.is_disabled(), + ) as span_generation: + response, stream = await self._fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + span_generation, + tracing, + stream=True, + ) + + usage: CompletionUsage | None = None + state = _StreamingState() + + async for chunk in stream: + if not state.started: + state.started = True + yield ResponseCreatedEvent( + response=response, + type="response.created", + ) + + # The usage is only available in the last chunk + usage = chunk.usage + + if not chunk.choices or not chunk.choices[0].delta: + continue + + delta = chunk.choices[0].delta + + # Handle text + if delta.content: + if not state.text_content_index_and_output: + # Initialize a content tracker for streaming text + state.text_content_index_and_output = ( + 0 if not state.refusal_content_index_and_output else 1, + ResponseOutputText( + text="", + type="output_text", + annotations=[], + ), + ) + # Start a new assistant message stream + assistant_item = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="in_progress", + ) + # Notify consumers of the start of a new output message + first content part + yield ResponseOutputItemAddedEvent( + item=assistant_item, + output_index=0, + type="response.output_item.added", + ) + yield ResponseContentPartAddedEvent( + content_index=state.text_content_index_and_output[0], + item_id=FAKE_RESPONSES_ID, + output_index=0, + part=ResponseOutputText( + text="", + type="output_text", + annotations=[], + ), + type="response.content_part.added", + ) + # Emit the delta for this segment of content + yield ResponseTextDeltaEvent( + content_index=state.text_content_index_and_output[0], + delta=delta.content, + item_id=FAKE_RESPONSES_ID, + output_index=0, + type="response.output_text.delta", + ) + # Accumulate the text into the response part + state.text_content_index_and_output[1].text += delta.content + + # Handle refusals (model declines to answer) + if delta.refusal: + if not state.refusal_content_index_and_output: + # Initialize a content tracker for streaming refusal text + state.refusal_content_index_and_output = ( + 0 if not state.text_content_index_and_output else 1, + ResponseOutputRefusal(refusal="", type="refusal"), + ) + # Start a new assistant message if one doesn't exist yet (in-progress) + assistant_item = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="in_progress", + ) + # Notify downstream that assistant message + first content part are starting + yield ResponseOutputItemAddedEvent( + item=assistant_item, + output_index=0, + type="response.output_item.added", + ) + yield ResponseContentPartAddedEvent( + content_index=state.refusal_content_index_and_output[0], + item_id=FAKE_RESPONSES_ID, + output_index=0, + part=ResponseOutputText( + text="", + type="output_text", + annotations=[], + ), + type="response.content_part.added", + ) + # Emit the delta for this segment of refusal + yield ResponseRefusalDeltaEvent( + content_index=state.refusal_content_index_and_output[0], + delta=delta.refusal, + item_id=FAKE_RESPONSES_ID, + output_index=0, + type="response.refusal.delta", + ) + # Accumulate the refusal string in the output part + state.refusal_content_index_and_output[1].refusal += delta.refusal + + # Handle tool calls + # Because we don't know the name of the function until the end of the stream, we'll + # save everything and yield events at the end + if delta.tool_calls: + for tc_delta in delta.tool_calls: + if tc_delta.index not in state.function_calls: + state.function_calls[tc_delta.index] = ResponseFunctionToolCall( + id=FAKE_RESPONSES_ID, + arguments="", + name="", + type="function_call", + call_id="", + ) + tc_function = tc_delta.function + + state.function_calls[tc_delta.index].arguments += ( + tc_function.arguments if tc_function else "" + ) or "" + state.function_calls[tc_delta.index].name += ( + tc_function.name if tc_function else "" + ) or "" + state.function_calls[tc_delta.index].call_id += tc_delta.id or "" + + function_call_starting_index = 0 + if state.text_content_index_and_output: + function_call_starting_index += 1 + # Send end event for this content part + yield ResponseContentPartDoneEvent( + content_index=state.text_content_index_and_output[0], + item_id=FAKE_RESPONSES_ID, + output_index=0, + part=state.text_content_index_and_output[1], + type="response.content_part.done", + ) + + if state.refusal_content_index_and_output: + function_call_starting_index += 1 + # Send end event for this content part + yield ResponseContentPartDoneEvent( + content_index=state.refusal_content_index_and_output[0], + item_id=FAKE_RESPONSES_ID, + output_index=0, + part=state.refusal_content_index_and_output[1], + type="response.content_part.done", + ) + + # Actually send events for the function calls + for function_call in state.function_calls.values(): + # First, a ResponseOutputItemAdded for the function call + yield ResponseOutputItemAddedEvent( + item=ResponseFunctionToolCall( + id=FAKE_RESPONSES_ID, + call_id=function_call.call_id, + arguments=function_call.arguments, + name=function_call.name, + type="function_call", + ), + output_index=function_call_starting_index, + type="response.output_item.added", + ) + # Then, yield the args + yield ResponseFunctionCallArgumentsDeltaEvent( + delta=function_call.arguments, + item_id=FAKE_RESPONSES_ID, + output_index=function_call_starting_index, + type="response.function_call_arguments.delta", + ) + # Finally, the ResponseOutputItemDone + yield ResponseOutputItemDoneEvent( + item=ResponseFunctionToolCall( + id=FAKE_RESPONSES_ID, + call_id=function_call.call_id, + arguments=function_call.arguments, + name=function_call.name, + type="function_call", + ), + output_index=function_call_starting_index, + type="response.output_item.done", + ) + + # Finally, send the Response completed event + outputs: list[ResponseOutputItem] = [] + if state.text_content_index_and_output or state.refusal_content_index_and_output: + assistant_msg = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="completed", + ) + if state.text_content_index_and_output: + assistant_msg.content.append(state.text_content_index_and_output[1]) + if state.refusal_content_index_and_output: + assistant_msg.content.append(state.refusal_content_index_and_output[1]) + outputs.append(assistant_msg) + + # send a ResponseOutputItemDone for the assistant message + yield ResponseOutputItemDoneEvent( + item=assistant_msg, + output_index=0, + type="response.output_item.done", + ) + + for function_call in state.function_calls.values(): + outputs.append(function_call) + + final_response = response.model_copy(update={"output": outputs, "usage": usage}) + + yield ResponseCompletedEvent( + response=final_response, + type="response.completed", + ) + if tracing.include_data(): + span_generation.span_data.output = [final_response.model_dump()] + + if usage: + span_generation.span_data.usage = { + "input_tokens": usage.prompt_tokens, + "output_tokens": usage.completion_tokens, + } + + @overload + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + span: Span[GenerationSpanData], + tracing: ModelTracing, + stream: Literal[True], + ) -> tuple[Response, AsyncStream[ChatCompletionChunk]]: ... + + @overload + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + span: Span[GenerationSpanData], + tracing: ModelTracing, + stream: Literal[False], + ) -> ChatCompletion: ... + + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + span: Span[GenerationSpanData], + tracing: ModelTracing, + stream: bool = False, + ) -> ChatCompletion | tuple[Response, AsyncStream[ChatCompletionChunk]]: + converted_messages = _Converter.items_to_messages(input) + + if system_instructions: + converted_messages.insert( + 0, + { + "content": system_instructions, + "role": "system", + }, + ) + if tracing.include_data(): + span.span_data.input = converted_messages + + parallel_tool_calls = ( + True if model_settings.parallel_tool_calls and tools and len(tools) > 0 else NOT_GIVEN + ) + tool_choice = _Converter.convert_tool_choice(model_settings.tool_choice) + response_format = _Converter.convert_response_format(output_schema) + + converted_tools = [ToolConverter.to_openai(tool) for tool in tools] if tools else [] + + for handoff in handoffs: + converted_tools.append(ToolConverter.convert_handoff_tool(handoff)) + + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("Calling LLM") + else: + logger.debug( + f"{json.dumps(converted_messages, indent=2)}\n" + f"Tools:\n{json.dumps(converted_tools, indent=2)}\n" + f"Stream: {stream}\n" + f"Tool choice: {tool_choice}\n" + f"Response format: {response_format}\n" + ) + + ret = await self._get_client().chat.completions.create( + model=self.model, + messages=converted_messages, + tools=converted_tools or NOT_GIVEN, + temperature=self._non_null_or_not_given(model_settings.temperature), + top_p=self._non_null_or_not_given(model_settings.top_p), + frequency_penalty=self._non_null_or_not_given(model_settings.frequency_penalty), + presence_penalty=self._non_null_or_not_given(model_settings.presence_penalty), + tool_choice=tool_choice, + response_format=response_format, + parallel_tool_calls=parallel_tool_calls, + stream=stream, + stream_options={"include_usage": True} if stream else NOT_GIVEN, + extra_headers=_HEADERS, + ) + + if isinstance(ret, ChatCompletion): + return ret + + response = Response( + id=FAKE_RESPONSES_ID, + created_at=time.time(), + model=self.model, + object="response", + output=[], + tool_choice=cast(Literal["auto", "required", "none"], tool_choice) + if tool_choice != NOT_GIVEN + else "auto", + top_p=model_settings.top_p, + temperature=model_settings.temperature, + tools=[], + parallel_tool_calls=parallel_tool_calls or False, + ) + return response, ret + + def _get_client(self) -> AsyncOpenAI: + if self._client is None: + self._client = AsyncOpenAI() + return self._client + + +class _Converter: + @classmethod + def convert_tool_choice( + cls, tool_choice: Literal["auto", "required", "none"] | str | None + ) -> ChatCompletionToolChoiceOptionParam | NotGiven: + if tool_choice is None: + return NOT_GIVEN + elif tool_choice == "auto": + return "auto" + elif tool_choice == "required": + return "required" + elif tool_choice == "none": + return "none" + else: + return { + "type": "function", + "function": { + "name": tool_choice, + }, + } + + @classmethod + def convert_response_format( + cls, final_output_schema: AgentOutputSchema | None + ) -> ResponseFormat | NotGiven: + if not final_output_schema or final_output_schema.is_plain_text(): + return NOT_GIVEN + + return { + "type": "json_schema", + "json_schema": { + "name": "final_output", + "strict": final_output_schema.strict_json_schema, + "schema": final_output_schema.json_schema(), + }, + } + + @classmethod + def message_to_output_items(cls, message: ChatCompletionMessage) -> list[TResponseOutputItem]: + items: list[TResponseOutputItem] = [] + + message_item = ResponseOutputMessage( + id=FAKE_RESPONSES_ID, + content=[], + role="assistant", + type="message", + status="completed", + ) + if message.content: + message_item.content.append( + ResponseOutputText(text=message.content, type="output_text", annotations=[]) + ) + if message.refusal: + message_item.content.append( + ResponseOutputRefusal(refusal=message.refusal, type="refusal") + ) + if message.audio: + raise AgentsException("Audio is not currently supported") + + if message_item.content: + items.append(message_item) + + if message.tool_calls: + for tool_call in message.tool_calls: + items.append( + ResponseFunctionToolCall( + id=FAKE_RESPONSES_ID, + call_id=tool_call.id, + arguments=tool_call.function.arguments, + name=tool_call.function.name, + type="function_call", + ) + ) + + return items + + @classmethod + def maybe_easy_input_message(cls, item: Any) -> EasyInputMessageParam | None: + if not isinstance(item, dict): + return None + + keys = item.keys() + # EasyInputMessageParam only has these two keys + if keys != {"content", "role"}: + return None + + role = item.get("role", None) + if role not in ("user", "assistant", "system", "developer"): + return None + + if "content" not in item: + return None + + return cast(EasyInputMessageParam, item) + + @classmethod + def maybe_input_message(cls, item: Any) -> Message | None: + if ( + isinstance(item, dict) + and item.get("type") == "message" + and item.get("role") + in ( + "user", + "system", + "developer", + ) + ): + return cast(Message, item) + + return None + + @classmethod + def maybe_file_search_call(cls, item: Any) -> ResponseFileSearchToolCallParam | None: + if isinstance(item, dict) and item.get("type") == "file_search_call": + return cast(ResponseFileSearchToolCallParam, item) + return None + + @classmethod + def maybe_function_tool_call(cls, item: Any) -> ResponseFunctionToolCallParam | None: + if isinstance(item, dict) and item.get("type") == "function_call": + return cast(ResponseFunctionToolCallParam, item) + return None + + @classmethod + def maybe_function_tool_call_output( + cls, + item: Any, + ) -> FunctionCallOutput | None: + if isinstance(item, dict) and item.get("type") == "function_call_output": + return cast(FunctionCallOutput, item) + return None + + @classmethod + def maybe_item_reference(cls, item: Any) -> ItemReference | None: + if isinstance(item, dict) and item.get("type") == "item_reference": + return cast(ItemReference, item) + return None + + @classmethod + def maybe_response_output_message(cls, item: Any) -> ResponseOutputMessageParam | None: + # ResponseOutputMessage is only used for messages with role assistant + if ( + isinstance(item, dict) + and item.get("type") == "message" + and item.get("role") == "assistant" + ): + return cast(ResponseOutputMessageParam, item) + return None + + @classmethod + def extract_text_content( + cls, content: str | Iterable[ResponseInputContentParam] + ) -> str | list[ChatCompletionContentPartTextParam]: + all_content = cls.extract_all_content(content) + if isinstance(all_content, str): + return all_content + out: list[ChatCompletionContentPartTextParam] = [] + for c in all_content: + if c.get("type") == "text": + out.append(cast(ChatCompletionContentPartTextParam, c)) + return out + + @classmethod + def extract_all_content( + cls, content: str | Iterable[ResponseInputContentParam] + ) -> str | list[ChatCompletionContentPartParam]: + if isinstance(content, str): + return content + out: list[ChatCompletionContentPartParam] = [] + + for c in content: + if isinstance(c, dict) and c.get("type") == "input_text": + casted_text_param = cast(ResponseInputTextParam, c) + out.append( + ChatCompletionContentPartTextParam( + type="text", + text=casted_text_param["text"], + ) + ) + elif isinstance(c, dict) and c.get("type") == "input_image": + casted_image_param = cast(ResponseInputImageParam, c) + if "image_url" not in casted_image_param or not casted_image_param["image_url"]: + raise UserError( + f"Only image URLs are supported for input_image {casted_image_param}" + ) + out.append( + ChatCompletionContentPartImageParam( + type="image_url", + image_url={ + "url": casted_image_param["image_url"], + "detail": casted_image_param["detail"], + }, + ) + ) + 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}") + return out + + @classmethod + def items_to_messages( + cls, + items: str | Iterable[TResponseInputItem], + ) -> list[ChatCompletionMessageParam]: + """ + Convert a sequence of 'Item' objects into a list of ChatCompletionMessageParam. + + Rules: + - EasyInputMessage or InputMessage (role=user) => ChatCompletionUserMessageParam + - EasyInputMessage or InputMessage (role=system) => ChatCompletionSystemMessageParam + - EasyInputMessage or InputMessage (role=developer) => ChatCompletionDeveloperMessageParam + - InputMessage (role=assistant) => Start or flush a ChatCompletionAssistantMessageParam + - response_output_message => Also produces/flushes a ChatCompletionAssistantMessageParam + - tool calls get attached to the *current* assistant message, or create one if none. + - tool outputs => ChatCompletionToolMessageParam + """ + + if isinstance(items, str): + return [ + ChatCompletionUserMessageParam( + role="user", + content=items, + ) + ] + + result: list[ChatCompletionMessageParam] = [] + current_assistant_msg: ChatCompletionAssistantMessageParam | None = None + + def flush_assistant_message() -> None: + nonlocal current_assistant_msg + if current_assistant_msg is not None: + # The API doesn't support empty arrays for tool_calls + if not current_assistant_msg.get("tool_calls"): + del current_assistant_msg["tool_calls"] + result.append(current_assistant_msg) + current_assistant_msg = None + + def ensure_assistant_message() -> ChatCompletionAssistantMessageParam: + nonlocal current_assistant_msg + if current_assistant_msg is None: + current_assistant_msg = ChatCompletionAssistantMessageParam(role="assistant") + current_assistant_msg["tool_calls"] = [] + return current_assistant_msg + + for item in items: + # 1) Check easy input message + if easy_msg := cls.maybe_easy_input_message(item): + role = easy_msg["role"] + content = easy_msg["content"] + + if role == "user": + flush_assistant_message() + msg_user: ChatCompletionUserMessageParam = { + "role": "user", + "content": cls.extract_all_content(content), + } + result.append(msg_user) + elif role == "system": + flush_assistant_message() + msg_system: ChatCompletionSystemMessageParam = { + "role": "system", + "content": cls.extract_text_content(content), + } + result.append(msg_system) + elif role == "developer": + flush_assistant_message() + msg_developer: ChatCompletionDeveloperMessageParam = { + "role": "developer", + "content": cls.extract_text_content(content), + } + result.append(msg_developer) + else: + raise UserError(f"Unexpected role in easy_input_message: {role}") + + # 2) Check input message + elif in_msg := cls.maybe_input_message(item): + role = in_msg["role"] + content = in_msg["content"] + flush_assistant_message() + + if role == "user": + msg_user = { + "role": "user", + "content": cls.extract_all_content(content), + } + result.append(msg_user) + elif role == "system": + msg_system = { + "role": "system", + "content": cls.extract_text_content(content), + } + result.append(msg_system) + elif role == "developer": + msg_developer = { + "role": "developer", + "content": cls.extract_text_content(content), + } + result.append(msg_developer) + else: + raise UserError(f"Unexpected role in input_message: {role}") + + # 3) response output message => assistant + elif resp_msg := cls.maybe_response_output_message(item): + flush_assistant_message() + new_asst = ChatCompletionAssistantMessageParam(role="assistant") + contents = resp_msg["content"] + + text_segments = [] + for c in contents: + if c["type"] == "output_text": + text_segments.append(c["text"]) + elif c["type"] == "refusal": + new_asst["refusal"] = c["refusal"] + elif c["type"] == "output_audio": + # Can't handle this, b/c chat completions expects an ID which we dont have + raise UserError( + f"Only audio IDs are supported for chat completions, but got: {c}" + ) + else: + raise UserError(f"Unknown content type in ResponseOutputMessage: {c}") + + if text_segments: + combined = "\n".join(text_segments) + new_asst["content"] = combined + + new_asst["tool_calls"] = [] + current_assistant_msg = new_asst + + # 4) function/file-search calls => attach to assistant + elif file_search := cls.maybe_file_search_call(item): + asst = ensure_assistant_message() + tool_calls = list(asst.get("tool_calls", [])) + new_tool_call = ChatCompletionMessageToolCallParam( + id=file_search["id"], + type="function", + function={ + "name": "file_search_call", + "arguments": json.dumps( + { + "queries": file_search.get("queries", []), + "status": file_search.get("status"), + } + ), + }, + ) + tool_calls.append(new_tool_call) + asst["tool_calls"] = tool_calls + + elif func_call := cls.maybe_function_tool_call(item): + asst = ensure_assistant_message() + tool_calls = list(asst.get("tool_calls", [])) + new_tool_call = ChatCompletionMessageToolCallParam( + id=func_call["call_id"], + type="function", + function={ + "name": func_call["name"], + "arguments": func_call["arguments"], + }, + ) + tool_calls.append(new_tool_call) + asst["tool_calls"] = tool_calls + # 5) function call output => tool message + elif func_output := cls.maybe_function_tool_call_output(item): + flush_assistant_message() + msg: ChatCompletionToolMessageParam = { + "role": "tool", + "tool_call_id": func_output["call_id"], + "content": func_output["output"], + } + result.append(msg) + + # 6) item reference => handle or raise + elif item_ref := cls.maybe_item_reference(item): + raise UserError( + f"Encountered an item_reference, which is not supported: {item_ref}" + ) + + # 7) If we haven't recognized it => fail or ignore + else: + raise UserError(f"Unhandled item type or structure: {item}") + + flush_assistant_message() + return result + + +class ToolConverter: + @classmethod + def to_openai(cls, tool: Tool) -> ChatCompletionToolParam: + if isinstance(tool, FunctionTool): + return { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description or "", + "parameters": tool.params_json_schema, + }, + } + + raise UserError( + f"Hosted tools are not supported with the ChatCompletions API. FGot tool type: " + f"{type(tool)}, tool: {tool}" + ) + + @classmethod + def convert_handoff_tool(cls, handoff: Handoff[Any]) -> ChatCompletionToolParam: + return { + "type": "function", + "function": { + "name": handoff.tool_name, + "description": handoff.tool_description, + "parameters": handoff.input_json_schema, + }, + } diff --git a/tests/src/agents/models/openai_provider.py b/tests/src/agents/models/openai_provider.py new file mode 100644 index 0000000..5194663 --- /dev/null +++ b/tests/src/agents/models/openai_provider.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import httpx +from openai import AsyncOpenAI, DefaultAsyncHttpxClient + +from . import _openai_shared +from .interface import Model, ModelProvider +from .openai_chatcompletions import OpenAIChatCompletionsModel +from .openai_responses import OpenAIResponsesModel + +DEFAULT_MODEL: str = "gpt-4o" + + +_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 + + +class OpenAIProvider(ModelProvider): + 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, + use_responses: bool | None = None, + ) -> None: + 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 = openai_client + else: + self._client = _openai_shared.get_default_openai_client() or AsyncOpenAI( + api_key=api_key or _openai_shared.get_default_openai_key(), + base_url=base_url, + organization=organization, + project=project, + http_client=shared_http_client(), + ) + + self._is_openai_model = self._client.base_url.host.startswith("api.openai.com") + if use_responses is not None: + self._use_responses = use_responses + else: + self._use_responses = _openai_shared.get_use_responses_by_default() + + def get_model(self, model_name: str | None) -> Model: + if model_name is None: + model_name = DEFAULT_MODEL + + return ( + OpenAIResponsesModel(model=model_name, openai_client=self._client) + if self._use_responses + else OpenAIChatCompletionsModel(model=model_name, openai_client=self._client) + ) diff --git a/tests/src/agents/models/openai_responses.py b/tests/src/agents/models/openai_responses.py new file mode 100644 index 0000000..a10d7b9 --- /dev/null +++ b/tests/src/agents/models/openai_responses.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal, overload + +from openai import NOT_GIVEN, AsyncOpenAI, AsyncStream, NotGiven +from openai.types import ChatModel +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseStreamEvent, + ResponseTextConfigParam, + ToolParam, + WebSearchToolParam, + response_create_params, +) + +from .. import _debug +from ..agent_output import AgentOutputSchema +from ..exceptions import UserError +from ..handoffs import Handoff +from ..items import ItemHelpers, ModelResponse, TResponseInputItem +from ..logger import logger +from ..tool import ComputerTool, FileSearchTool, FunctionTool, Tool, WebSearchTool +from ..tracing import SpanError, response_span +from ..usage import Usage +from ..version import __version__ +from .interface import Model, ModelTracing + +if TYPE_CHECKING: + from ..model_settings import ModelSettings + + +_USER_AGENT = f"Agents/Python {__version__}" +_HEADERS = {"User-Agent": _USER_AGENT} + +# From the Responses API +IncludeLiteral = Literal[ + "file_search_call.results", + "message.input_image.image_url", + "computer_call_output.output.image_url", +] + + +class OpenAIResponsesModel(Model): + """ + Implementation of `Model` that uses the OpenAI Responses API. + """ + + def __init__( + self, + model: str | ChatModel, + openai_client: AsyncOpenAI, + ) -> None: + self.model = model + self._client = openai_client + + def _non_null_or_not_given(self, value: Any) -> Any: + return value if value is not None else NOT_GIVEN + + 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: + with response_span(disabled=tracing.is_disabled()) as span_response: + try: + response = await self._fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + stream=False, + ) + + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("LLM responsed") + else: + logger.debug( + "LLM resp:\n" + f"{json.dumps([x.model_dump() for x in response.output], indent=2)}\n" + ) + + usage = ( + Usage( + requests=1, + input_tokens=response.usage.input_tokens, + output_tokens=response.usage.output_tokens, + total_tokens=response.usage.total_tokens, + ) + if response.usage + else Usage() + ) + + if tracing.include_data(): + span_response.span_data.response = response + span_response.span_data.input = input + except Exception as e: + span_response.set_error( + SpanError( + message="Error getting response", + data={ + "error": str(e) if tracing.include_data() else e.__class__.__name__, + }, + ) + ) + logger.error(f"Error getting response: {e}") + raise + + return ModelResponse( + output=response.output, + usage=usage, + referenceable_id=response.id, + ) + + 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[ResponseStreamEvent]: + """ + Yields a partial message as it is generated, as well as the usage information. + """ + with response_span(disabled=tracing.is_disabled()) as span_response: + try: + stream = await self._fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + stream=True, + ) + + final_response: Response | None = None + + async for chunk in stream: + if isinstance(chunk, ResponseCompletedEvent): + final_response = chunk.response + yield chunk + + if final_response and tracing.include_data(): + span_response.span_data.response = final_response + span_response.span_data.input = input + + except Exception as e: + span_response.set_error( + SpanError( + message="Error streaming response", + data={ + "error": str(e) if tracing.include_data() else e.__class__.__name__, + }, + ) + ) + logger.error(f"Error streaming response: {e}") + raise + + @overload + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + stream: Literal[True], + ) -> AsyncStream[ResponseStreamEvent]: ... + + @overload + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + stream: Literal[False], + ) -> Response: ... + + async def _fetch_response( + self, + system_instructions: str | None, + input: str | list[TResponseInputItem], + model_settings: ModelSettings, + tools: list[Tool], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + stream: Literal[True] | Literal[False] = False, + ) -> Response | AsyncStream[ResponseStreamEvent]: + 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 + ) + + tool_choice = Converter.convert_tool_choice(model_settings.tool_choice) + converted_tools = Converter.convert_tools(tools, handoffs) + response_format = Converter.get_response_format(output_schema) + + if _debug.DONT_LOG_MODEL_DATA: + logger.debug("Calling LLM") + else: + logger.debug( + f"Calling LLM {self.model} with input:\n" + f"{json.dumps(list_input, indent=2)}\n" + f"Tools:\n{json.dumps(converted_tools.tools, indent=2)}\n" + f"Stream: {stream}\n" + f"Tool choice: {tool_choice}\n" + f"Response format: {response_format}\n" + ) + + return await self._client.responses.create( + instructions=self._non_null_or_not_given(system_instructions), + model=self.model, + input=list_input, + include=converted_tools.includes, + tools=converted_tools.tools, + temperature=self._non_null_or_not_given(model_settings.temperature), + top_p=self._non_null_or_not_given(model_settings.top_p), + truncation=self._non_null_or_not_given(model_settings.truncation), + tool_choice=tool_choice, + parallel_tool_calls=parallel_tool_calls, + stream=stream, + extra_headers=_HEADERS, + text=response_format, + ) + + def _get_client(self) -> AsyncOpenAI: + if self._client is None: + self._client = AsyncOpenAI() + return self._client + + +@dataclass +class ConvertedTools: + tools: list[ToolParam] + includes: list[IncludeLiteral] + + +class Converter: + @classmethod + def convert_tool_choice( + cls, tool_choice: Literal["auto", "required", "none"] | str | None + ) -> response_create_params.ToolChoice | NotGiven: + if tool_choice is None: + return NOT_GIVEN + elif tool_choice == "required": + return "required" + elif tool_choice == "auto": + return "auto" + elif tool_choice == "none": + return "none" + elif tool_choice == "file_search": + return { + "type": "file_search", + } + elif tool_choice == "web_search_preview": + return { + "type": "web_search_preview", + } + elif tool_choice == "computer_use_preview": + return { + "type": "computer_use_preview", + } + else: + return { + "type": "function", + "name": tool_choice, + } + + @classmethod + def get_response_format( + cls, output_schema: AgentOutputSchema | None + ) -> ResponseTextConfigParam | NotGiven: + if output_schema is None or output_schema.is_plain_text(): + return NOT_GIVEN + else: + return { + "format": { + "type": "json_schema", + "name": "final_output", + "schema": output_schema.json_schema(), + "strict": output_schema.strict_json_schema, + } + } + + @classmethod + def convert_tools( + cls, + tools: list[Tool], + handoffs: list[Handoff[Any]], + ) -> ConvertedTools: + converted_tools: list[ToolParam] = [] + includes: list[IncludeLiteral] = [] + + computer_tools = [tool for tool in tools if isinstance(tool, ComputerTool)] + if len(computer_tools) > 1: + raise UserError(f"You can only provide one computer tool. Got {len(computer_tools)}") + + for tool in tools: + converted_tool, include = cls._convert_tool(tool) + converted_tools.append(converted_tool) + if include: + includes.append(include) + + for handoff in handoffs: + converted_tools.append(cls._convert_handoff_tool(handoff)) + + return ConvertedTools(tools=converted_tools, includes=includes) + + @classmethod + def _convert_tool(cls, tool: Tool) -> tuple[ToolParam, IncludeLiteral | None]: + """Returns converted tool and includes""" + + if isinstance(tool, FunctionTool): + converted_tool: ToolParam = { + "name": tool.name, + "parameters": tool.params_json_schema, + "strict": tool.strict_json_schema, + "type": "function", + "description": tool.description, + } + includes: IncludeLiteral | None = None + elif isinstance(tool, WebSearchTool): + ws: WebSearchToolParam = { + "type": "web_search_preview", + "user_location": tool.user_location, + "search_context_size": tool.search_context_size, + } + converted_tool = ws + includes = None + elif isinstance(tool, FileSearchTool): + converted_tool = { + "type": "file_search", + "vector_store_ids": tool.vector_store_ids, + } + if tool.max_num_results: + converted_tool["max_num_results"] = tool.max_num_results + if tool.ranking_options: + converted_tool["ranking_options"] = tool.ranking_options + if tool.filters: + converted_tool["filters"] = tool.filters + + includes = "file_search_call.results" if tool.include_search_results else None + elif isinstance(tool, ComputerTool): + converted_tool = { + "type": "computer-preview", + "environment": tool.computer.environment, + "display_width": tool.computer.dimensions[0], + "display_height": tool.computer.dimensions[1], + } + includes = None + + else: + raise UserError(f"Unknown tool type: {type(tool)}, tool") + + return converted_tool, includes + + @classmethod + def _convert_handoff_tool(cls, handoff: Handoff) -> ToolParam: + return { + "name": handoff.tool_name, + "parameters": handoff.input_json_schema, + "strict": handoff.strict_json_schema, + "type": "function", + "description": handoff.tool_description, + } diff --git a/tests/src/agents/result.py b/tests/src/agents/result.py new file mode 100644 index 0000000..5683827 --- /dev/null +++ b/tests/src/agents/result.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import abc +import asyncio +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, cast + +from typing_extensions import TypeVar + +from ._run_impl import QueueCompleteSentinel +from .agent import Agent +from .agent_output import AgentOutputSchema +from .exceptions import InputGuardrailTripwireTriggered, MaxTurnsExceeded +from .guardrail import InputGuardrailResult, OutputGuardrailResult +from .items import ItemHelpers, ModelResponse, RunItem, TResponseInputItem +from .logger import logger +from .stream_events import StreamEvent +from .tracing import Trace + +if TYPE_CHECKING: + from ._run_impl import QueueCompleteSentinel + from .agent import Agent + +T = TypeVar("T") + + +@dataclass +class RunResultBase(abc.ABC): + input: str | list[TResponseInputItem] + """The original input items i.e. the items before run() was called. This may be a mutated + version of the input, if there are handoff input filters that mutate the input. + """ + + new_items: list[RunItem] + """The new items generated during the agent run. These include things like new messages, tool + calls and their outputs, etc. + """ + + raw_responses: list[ModelResponse] + """The raw LLM responses generated by the model during the agent run.""" + + final_output: Any + """The output of the last agent.""" + + input_guardrail_results: list[InputGuardrailResult] + """Guardrail results for the input messages.""" + + output_guardrail_results: list[OutputGuardrailResult] + """Guardrail results for the final output of the agent.""" + + @property + @abc.abstractmethod + def last_agent(self) -> Agent[Any]: + """The last agent that was run.""" + + def final_output_as(self, cls: type[T], raise_if_incorrect_type: bool = False) -> T: + """A convenience method to cast the final output to a specific type. By default, the cast + is only for the typechecker. If you set `raise_if_incorrect_type` to True, we'll raise a + TypeError if the final output is not of the given type. + + Args: + cls: The type to cast the final output to. + raise_if_incorrect_type: If True, we'll raise a TypeError if the final output is not of + the given type. + + Returns: + The final output casted to the given type. + """ + if raise_if_incorrect_type and not isinstance(self.final_output, cls): + raise TypeError(f"Final output is not of type {cls.__name__}") + + return cast(T, self.final_output) + + def to_input_list(self) -> list[TResponseInputItem]: + """Creates a new input list, merging the original input with all the new items generated.""" + original_items: list[TResponseInputItem] = ItemHelpers.input_to_new_input_list(self.input) + new_items = [item.to_input_item() for item in self.new_items] + + return original_items + new_items + + +@dataclass +class RunResult(RunResultBase): + _last_agent: Agent[Any] + + @property + def last_agent(self) -> Agent[Any]: + """The last agent that was run.""" + return self._last_agent + + +@dataclass +class RunResultStreaming(RunResultBase): + """The result of an agent run in streaming mode. You can use the `stream_events` method to + receive semantic events as they are generated. + + The streaming method will raise: + - A MaxTurnsExceeded exception if the agent exceeds the max_turns limit. + - A GuardrailTripwireTriggered exception if a guardrail is tripped. + """ + + current_agent: Agent[Any] + """The current agent that is running.""" + + current_turn: int + """The current turn number.""" + + max_turns: int + """The maximum number of turns the agent can run for.""" + + final_output: Any + """The final output of the agent. This is None until the agent has finished running.""" + + _current_agent_output_schema: AgentOutputSchema | None = field(repr=False) + + _trace: Trace | None = field(repr=False) + + is_complete: bool = False + """Whether the agent has finished running.""" + + # Queues that the background run_loop writes to + _event_queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel] = field( + default_factory=asyncio.Queue, repr=False + ) + _input_guardrail_queue: asyncio.Queue[InputGuardrailResult] = field( + default_factory=asyncio.Queue, repr=False + ) + + # Store the asyncio tasks that we're waiting on + _run_impl_task: asyncio.Task[Any] | None = field(default=None, repr=False) + _input_guardrails_task: asyncio.Task[Any] | None = field(default=None, repr=False) + _output_guardrails_task: asyncio.Task[Any] | None = field(default=None, repr=False) + _stored_exception: Exception | None = field(default=None, repr=False) + + @property + def last_agent(self) -> Agent[Any]: + """The last agent that was run. Updates as the agent run progresses, so the true last agent + is only available after the agent run is complete. + """ + return self.current_agent + + async def stream_events(self) -> AsyncIterator[StreamEvent]: + """Stream deltas for new items as they are generated. We're using the types from the + OpenAI Responses API, so these are semantic events: each event has a `type` field that + describes the type of the event, along with the data for that event. + + This will raise: + - A MaxTurnsExceeded exception if the agent exceeds the max_turns limit. + - A GuardrailTripwireTriggered exception if a guardrail is tripped. + """ + while True: + self._check_errors() + if self._stored_exception: + logger.debug("Breaking due to stored exception") + self.is_complete = True + break + + if self.is_complete and self._event_queue.empty(): + break + + try: + item = await self._event_queue.get() + except asyncio.CancelledError: + break + + if isinstance(item, QueueCompleteSentinel): + self._event_queue.task_done() + # Check for errors, in case the queue was completed due to an exception + self._check_errors() + break + + yield item + self._event_queue.task_done() + + if self._trace: + self._trace.finish(reset_current=True) + + self._cleanup_tasks() + + if self._stored_exception: + raise self._stored_exception + + def _check_errors(self): + if self.current_turn > self.max_turns: + self._stored_exception = MaxTurnsExceeded(f"Max turns ({self.max_turns}) exceeded") + + # Fetch all the completed guardrail results from the queue and raise if needed + while not self._input_guardrail_queue.empty(): + guardrail_result = self._input_guardrail_queue.get_nowait() + if guardrail_result.output.tripwire_triggered: + self._stored_exception = InputGuardrailTripwireTriggered(guardrail_result) + + # Check the tasks for any exceptions + if self._run_impl_task and self._run_impl_task.done(): + exc = self._run_impl_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + if self._input_guardrails_task and self._input_guardrails_task.done(): + exc = self._input_guardrails_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + if self._output_guardrails_task and self._output_guardrails_task.done(): + exc = self._output_guardrails_task.exception() + if exc and isinstance(exc, Exception): + self._stored_exception = exc + + def _cleanup_tasks(self): + if self._run_impl_task and not self._run_impl_task.done(): + self._run_impl_task.cancel() + + if self._input_guardrails_task and not self._input_guardrails_task.done(): + self._input_guardrails_task.cancel() + + if self._output_guardrails_task and not self._output_guardrails_task.done(): + self._output_guardrails_task.cancel() + self._output_guardrails_task.cancel() + self._output_guardrails_task.cancel() diff --git a/tests/src/agents/run.py b/tests/src/agents/run.py new file mode 100644 index 0000000..dfff7e3 --- /dev/null +++ b/tests/src/agents/run.py @@ -0,0 +1,904 @@ +from __future__ import annotations + +import asyncio +import copy +from dataclasses import dataclass, field +from typing import Any, cast + +from openai.types.responses import ResponseCompletedEvent + +from . import Model, _utils +from ._run_impl import ( + NextStepFinalOutput, + NextStepHandoff, + NextStepRunAgain, + QueueCompleteSentinel, + RunImpl, + SingleStepResult, + TraceCtxManager, + get_model_tracing_impl, +) +from .agent import Agent +from .agent_output import AgentOutputSchema +from .exceptions import ( + AgentsException, + InputGuardrailTripwireTriggered, + MaxTurnsExceeded, + ModelBehaviorError, + OutputGuardrailTripwireTriggered, +) +from .guardrail import InputGuardrail, InputGuardrailResult, OutputGuardrail, OutputGuardrailResult +from .handoffs import Handoff, HandoffInputFilter, handoff +from .items import ItemHelpers, ModelResponse, RunItem, TResponseInputItem +from .lifecycle import RunHooks +from .logger import logger +from .model_settings import ModelSettings +from .models.interface import ModelProvider +from .models.openai_provider import OpenAIProvider +from .result import RunResult, RunResultStreaming +from .run_context import RunContextWrapper, TContext +from .stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent +from .tracing import Span, SpanError, agent_span, get_current_trace, trace +from .tracing.span_data import AgentSpanData +from .usage import Usage + +DEFAULT_MAX_TURNS = 10 + + +@dataclass +class RunConfig: + """Configures settings for the entire agent run.""" + + model: str | Model | None = None + """The model to use for the entire agent run. If set, will override the model set on every + agent. The model_provider passed in below must be able to resolve this model name. + """ + + model_provider: ModelProvider = field(default_factory=OpenAIProvider) + """The model provider to use when looking up string model names. Defaults to OpenAI.""" + + model_settings: ModelSettings | None = None + """Configure global model settings. Any non-null values will override the agent-specific model + settings. + """ + + handoff_input_filter: HandoffInputFilter | None = None + """A global input filter to apply to all handoffs. If `Handoff.input_filter` is set, then that + will take precedence. The input filter allows you to edit the inputs that are sent to the new + agent. See the documentation in `Handoff.input_filter` for more details. + """ + + input_guardrails: list[InputGuardrail[Any]] | None = None + """A list of input guardrails to run on the initial run input.""" + + output_guardrails: list[OutputGuardrail[Any]] | None = None + """A list of output guardrails to run on the final output of the run.""" + + tracing_disabled: bool = False + """Whether tracing is disabled for the agent run. If disabled, we will not trace the agent run. + """ + + trace_include_sensitive_data: bool = True + """Whether we include potentially sensitive data (for example: inputs/outputs of tool calls or + LLM generations) in traces. If False, we'll still create spans for these events, but the + sensitive data will not be included. + """ + + workflow_name: str = "Agent workflow" + """The name of the run, used for tracing. Should be a logical name for the run, like + "Code generation workflow" or "Customer support agent". + """ + + trace_id: str | None = None + """A custom trace ID to use for tracing. If not provided, we will generate a new trace ID.""" + + group_id: str | None = None + """ + A grouping identifier to use for tracing, to link multiple traces from the same conversation + or process. For example, you might use a chat thread ID. + """ + + trace_metadata: dict[str, Any] | None = None + """ + An optional dictionary of additional metadata to include with the trace. + """ + + +class Runner: + @classmethod + async def run( + cls, + starting_agent: Agent[TContext], + input: str | list[TResponseInputItem], + *, + context: TContext | None = None, + max_turns: int = DEFAULT_MAX_TURNS, + hooks: RunHooks[TContext] | None = None, + run_config: RunConfig | None = None, + ) -> RunResult: + """Run a workflow starting at the given agent. The agent will run in a loop until a final + output is generated. The loop runs like so: + 1. The agent is invoked with the given input. + 2. If there is a final output (i.e. the agent produces something of type + `agent.output_type`, the loop terminates. + 3. If there's a handoff, we run the loop again, with the new agent. + 4. Else, we run tool calls (if any), and re-run the loop. + + In two cases, the agent may raise an exception: + 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised. + 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised. + + Note that only the first agent's input guardrails are run. + + Args: + starting_agent: The starting agent to run. + input: The initial input to the agent. You can pass a single string for a user message, + or a list of input items. + context: The context to run the agent with. + max_turns: The maximum number of turns to run the agent for. A turn is defined as one + AI invocation (including any tool calls that might occur). + hooks: An object that receives callbacks on various lifecycle events. + run_config: Global settings for the entire agent run. + + Returns: + A run result containing all the inputs, guardrail results and the output of the last + agent. Agents may perform handoffs, so we don't know the specific type of the output. + """ + if hooks is None: + hooks = RunHooks[Any]() + if run_config is None: + run_config = RunConfig() + + with TraceCtxManager( + workflow_name=run_config.workflow_name, + trace_id=run_config.trace_id, + group_id=run_config.group_id, + metadata=run_config.trace_metadata, + disabled=run_config.tracing_disabled, + ): + current_turn = 0 + original_input: str | list[TResponseInputItem] = copy.deepcopy(input) + generated_items: list[RunItem] = [] + model_responses: list[ModelResponse] = [] + + context_wrapper: RunContextWrapper[TContext] = RunContextWrapper( + context=context, # type: ignore + ) + + input_guardrail_results: list[InputGuardrailResult] = [] + + current_span: Span[AgentSpanData] | None = None + current_agent = starting_agent + should_run_agent_start_hooks = True + + try: + while True: + # Start an agent span if we don't have one. This span is ended if the current + # agent changes, or if the agent loop ends. + if current_span is None: + handoff_names = [h.agent_name for h in cls._get_handoffs(current_agent)] + tool_names = [t.name for t in current_agent.tools] + if output_schema := cls._get_output_schema(current_agent): + output_type_name = output_schema.output_type_name() + else: + output_type_name = "str" + + current_span = agent_span( + name=current_agent.name, + handoffs=handoff_names, + tools=tool_names, + output_type=output_type_name, + ) + current_span.start(mark_as_current=True) + + current_turn += 1 + if current_turn > max_turns: + _utils.attach_error_to_span( + current_span, + SpanError( + message="Max turns exceeded", + data={"max_turns": max_turns}, + ), + ) + raise MaxTurnsExceeded(f"Max turns ({max_turns}) exceeded") + + logger.debug( + f"Running agent {current_agent.name} (turn {current_turn})", + ) + + if current_turn == 1: + input_guardrail_results, turn_result = await asyncio.gather( + cls._run_input_guardrails( + starting_agent, + starting_agent.input_guardrails + + (run_config.input_guardrails or []), + copy.deepcopy(input), + context_wrapper, + ), + cls._run_single_turn( + agent=current_agent, + original_input=original_input, + generated_items=generated_items, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + should_run_agent_start_hooks=should_run_agent_start_hooks, + ), + ) + else: + turn_result = await cls._run_single_turn( + agent=current_agent, + original_input=original_input, + generated_items=generated_items, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + should_run_agent_start_hooks=should_run_agent_start_hooks, + ) + should_run_agent_start_hooks = False + + model_responses.append(turn_result.model_response) + original_input = turn_result.original_input + generated_items = turn_result.generated_items + + if isinstance(turn_result.next_step, NextStepFinalOutput): + output_guardrail_results = await cls._run_output_guardrails( + current_agent.output_guardrails + (run_config.output_guardrails or []), + current_agent, + turn_result.next_step.output, + context_wrapper, + ) + return RunResult( + input=original_input, + new_items=generated_items, + raw_responses=model_responses, + final_output=turn_result.next_step.output, + _last_agent=current_agent, + input_guardrail_results=input_guardrail_results, + output_guardrail_results=output_guardrail_results, + ) + elif isinstance(turn_result.next_step, NextStepHandoff): + current_agent = cast(Agent[TContext], turn_result.next_step.new_agent) + current_span.finish(reset_current=True) + current_span = None + should_run_agent_start_hooks = True + elif isinstance(turn_result.next_step, NextStepRunAgain): + pass + else: + raise AgentsException( + f"Unknown next step type: {type(turn_result.next_step)}" + ) + finally: + if current_span: + current_span.finish(reset_current=True) + + @classmethod + def run_sync( + cls, + starting_agent: Agent[TContext], + input: str | list[TResponseInputItem], + *, + context: TContext | None = None, + max_turns: int = DEFAULT_MAX_TURNS, + hooks: RunHooks[TContext] | None = None, + run_config: RunConfig | None = None, + ) -> RunResult: + """Run a workflow synchronously, starting at the given agent. Note that this just wraps the + `run` method, so it will not work if there's already an event loop (e.g. inside an async + function, or in a Jupyter notebook or async context like FastAPI). For those cases, use + the `run` method instead. + + The agent will run in a loop until a final output is generated. The loop runs like so: + 1. The agent is invoked with the given input. + 2. If there is a final output (i.e. the agent produces something of type + `agent.output_type`, the loop terminates. + 3. If there's a handoff, we run the loop again, with the new agent. + 4. Else, we run tool calls (if any), and re-run the loop. + + In two cases, the agent may raise an exception: + 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised. + 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised. + + Note that only the first agent's input guardrails are run. + + Args: + starting_agent: The starting agent to run. + input: The initial input to the agent. You can pass a single string for a user message, + or a list of input items. + context: The context to run the agent with. + max_turns: The maximum number of turns to run the agent for. A turn is defined as one + AI invocation (including any tool calls that might occur). + hooks: An object that receives callbacks on various lifecycle events. + run_config: Global settings for the entire agent run. + + Returns: + A run result containing all the inputs, guardrail results and the output of the last + agent. Agents may perform handoffs, so we don't know the specific type of the output. + """ + return asyncio.get_event_loop().run_until_complete( + cls.run( + starting_agent, + input, + context=context, + max_turns=max_turns, + hooks=hooks, + run_config=run_config, + ) + ) + + @classmethod + def run_streamed( + cls, + starting_agent: Agent[TContext], + input: str | list[TResponseInputItem], + context: TContext | None = None, + max_turns: int = DEFAULT_MAX_TURNS, + hooks: RunHooks[TContext] | None = None, + run_config: RunConfig | None = None, + ) -> RunResultStreaming: + """Run a workflow starting at the given agent in streaming mode. The returned result object + contains a method you can use to stream semantic events as they are generated. + + The agent will run in a loop until a final output is generated. The loop runs like so: + 1. The agent is invoked with the given input. + 2. If there is a final output (i.e. the agent produces something of type + `agent.output_type`, the loop terminates. + 3. If there's a handoff, we run the loop again, with the new agent. + 4. Else, we run tool calls (if any), and re-run the loop. + + In two cases, the agent may raise an exception: + 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised. + 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised. + + Note that only the first agent's input guardrails are run. + + Args: + starting_agent: The starting agent to run. + input: The initial input to the agent. You can pass a single string for a user message, + or a list of input items. + context: The context to run the agent with. + max_turns: The maximum number of turns to run the agent for. A turn is defined as one + AI invocation (including any tool calls that might occur). + hooks: An object that receives callbacks on various lifecycle events. + run_config: Global settings for the entire agent run. + + Returns: + A result object that contains data about the run, as well as a method to stream events. + """ + if hooks is None: + hooks = RunHooks[Any]() + if run_config is None: + run_config = RunConfig() + + # If there's already a trace, we don't create a new one. In addition, we can't end the + # trace here, because the actual work is done in `stream_events` and this method ends + # before that. + new_trace = ( + None + if get_current_trace() + else trace( + workflow_name=run_config.workflow_name, + trace_id=run_config.trace_id, + group_id=run_config.group_id, + metadata=run_config.trace_metadata, + disabled=run_config.tracing_disabled, + ) + ) + # Need to start the trace here, because the current trace contextvar is captured at + # asyncio.create_task time + if new_trace: + new_trace.start(mark_as_current=True) + + output_schema = cls._get_output_schema(starting_agent) + context_wrapper: RunContextWrapper[TContext] = RunContextWrapper( + context=context # type: ignore + ) + + streamed_result = RunResultStreaming( + input=copy.deepcopy(input), + new_items=[], + current_agent=starting_agent, + raw_responses=[], + final_output=None, + is_complete=False, + current_turn=0, + max_turns=max_turns, + input_guardrail_results=[], + output_guardrail_results=[], + _current_agent_output_schema=output_schema, + _trace=new_trace, + ) + + # Kick off the actual agent loop in the background and return the streamed result object. + streamed_result._run_impl_task = asyncio.create_task( + cls._run_streamed_impl( + starting_input=input, + streamed_result=streamed_result, + starting_agent=starting_agent, + max_turns=max_turns, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + ) + return streamed_result + + @classmethod + async def _run_input_guardrails_with_queue( + cls, + agent: Agent[Any], + guardrails: list[InputGuardrail[TContext]], + input: str | list[TResponseInputItem], + context: RunContextWrapper[TContext], + streamed_result: RunResultStreaming, + parent_span: Span[Any], + ): + queue = streamed_result._input_guardrail_queue + + # We'll run the guardrails and push them onto the queue as they complete + guardrail_tasks = [ + asyncio.create_task( + RunImpl.run_single_input_guardrail(agent, guardrail, input, context) + ) + for guardrail in guardrails + ] + guardrail_results = [] + try: + for done in asyncio.as_completed(guardrail_tasks): + result = await done + if result.output.tripwire_triggered: + _utils.attach_error_to_span( + parent_span, + SpanError( + message="Guardrail tripwire triggered", + data={ + "guardrail": result.guardrail.get_name(), + "type": "input_guardrail", + }, + ), + ) + queue.put_nowait(result) + guardrail_results.append(result) + except Exception: + for t in guardrail_tasks: + t.cancel() + raise + + streamed_result.input_guardrail_results = guardrail_results + + @classmethod + async def _run_streamed_impl( + cls, + starting_input: str | list[TResponseInputItem], + streamed_result: RunResultStreaming, + starting_agent: Agent[TContext], + max_turns: int, + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ): + current_span: Span[AgentSpanData] | None = None + current_agent = starting_agent + current_turn = 0 + should_run_agent_start_hooks = True + + streamed_result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=current_agent)) + + try: + while True: + if streamed_result.is_complete: + break + + # Start an agent span if we don't have one. This span is ended if the current + # agent changes, or if the agent loop ends. + if current_span is None: + handoff_names = [h.agent_name for h in cls._get_handoffs(current_agent)] + tool_names = [t.name for t in current_agent.tools] + if output_schema := cls._get_output_schema(current_agent): + output_type_name = output_schema.output_type_name() + else: + output_type_name = "str" + + current_span = agent_span( + name=current_agent.name, + handoffs=handoff_names, + tools=tool_names, + output_type=output_type_name, + ) + current_span.start(mark_as_current=True) + + current_turn += 1 + streamed_result.current_turn = current_turn + + if current_turn > max_turns: + _utils.attach_error_to_span( + current_span, + SpanError( + message="Max turns exceeded", + data={"max_turns": max_turns}, + ), + ) + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + break + + if current_turn == 1: + # Run the input guardrails in the background and put the results on the queue + streamed_result._input_guardrails_task = asyncio.create_task( + cls._run_input_guardrails_with_queue( + starting_agent, + starting_agent.input_guardrails + (run_config.input_guardrails or []), + copy.deepcopy(ItemHelpers.input_to_new_input_list(starting_input)), + context_wrapper, + streamed_result, + current_span, + ) + ) + try: + turn_result = await cls._run_single_turn_streamed( + streamed_result, + current_agent, + hooks, + context_wrapper, + run_config, + should_run_agent_start_hooks, + ) + should_run_agent_start_hooks = False + + streamed_result.raw_responses = streamed_result.raw_responses + [ + turn_result.model_response + ] + streamed_result.input = turn_result.original_input + streamed_result.new_items = turn_result.generated_items + + if isinstance(turn_result.next_step, NextStepHandoff): + current_agent = turn_result.next_step.new_agent + current_span.finish(reset_current=True) + current_span = None + should_run_agent_start_hooks = True + streamed_result._event_queue.put_nowait( + AgentUpdatedStreamEvent(new_agent=current_agent) + ) + elif isinstance(turn_result.next_step, NextStepFinalOutput): + streamed_result._output_guardrails_task = asyncio.create_task( + cls._run_output_guardrails( + current_agent.output_guardrails + + (run_config.output_guardrails or []), + current_agent, + turn_result.next_step.output, + context_wrapper, + ) + ) + + try: + output_guardrail_results = await streamed_result._output_guardrails_task + except Exception: + # Exceptions will be checked in the stream_events loop + output_guardrail_results = [] + + streamed_result.output_guardrail_results = output_guardrail_results + streamed_result.final_output = turn_result.next_step.output + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + elif isinstance(turn_result.next_step, NextStepRunAgain): + pass + except Exception as e: + if current_span: + _utils.attach_error_to_span( + current_span, + SpanError( + message="Error in agent run", + data={"error": str(e)}, + ), + ) + streamed_result.is_complete = True + streamed_result._event_queue.put_nowait(QueueCompleteSentinel()) + raise + + streamed_result.is_complete = True + finally: + if current_span: + current_span.finish(reset_current=True) + + @classmethod + async def _run_single_turn_streamed( + cls, + streamed_result: RunResultStreaming, + agent: Agent[TContext], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + should_run_agent_start_hooks: bool, + ) -> SingleStepResult: + if should_run_agent_start_hooks: + await asyncio.gather( + hooks.on_agent_start(context_wrapper, agent), + ( + agent.hooks.on_start(context_wrapper, agent) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + + output_schema = cls._get_output_schema(agent) + + streamed_result.current_agent = agent + streamed_result._current_agent_output_schema = output_schema + + system_prompt = await agent.get_system_prompt(context_wrapper) + + handoffs = cls._get_handoffs(agent) + + model = cls._get_model(agent, run_config) + model_settings = agent.model_settings.resolve(run_config.model_settings) + final_response: ModelResponse | None = None + + input = ItemHelpers.input_to_new_input_list(streamed_result.input) + input.extend([item.to_input_item() for item in streamed_result.new_items]) + + # 1. Stream the output events + async for event in model.stream_response( + system_prompt, + input, + model_settings, + agent.tools, + output_schema, + handoffs, + get_model_tracing_impl( + run_config.tracing_disabled, run_config.trace_include_sensitive_data + ), + ): + if isinstance(event, ResponseCompletedEvent): + usage = ( + Usage( + requests=1, + input_tokens=event.response.usage.input_tokens, + output_tokens=event.response.usage.output_tokens, + total_tokens=event.response.usage.total_tokens, + ) + if event.response.usage + else Usage() + ) + final_response = ModelResponse( + output=event.response.output, + usage=usage, + referenceable_id=event.response.id, + ) + + streamed_result._event_queue.put_nowait(RawResponsesStreamEvent(data=event)) + + # 2. At this point, the streaming is complete for this turn of the agent loop. + if not final_response: + raise ModelBehaviorError("Model did not produce a final response!") + + # 3. Now, we can process the turn as we do in the non-streaming case + single_step_result = await cls._get_single_step_result_from_response( + agent=agent, + original_input=streamed_result.input, + pre_step_items=streamed_result.new_items, + new_response=final_response, + output_schema=output_schema, + handoffs=handoffs, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + + RunImpl.stream_step_result_to_queue(single_step_result, streamed_result._event_queue) + return single_step_result + + @classmethod + async def _run_single_turn( + cls, + *, + agent: Agent[TContext], + original_input: str | list[TResponseInputItem], + generated_items: list[RunItem], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + should_run_agent_start_hooks: bool, + ) -> SingleStepResult: + # Ensure we run the hooks before anything else + if should_run_agent_start_hooks: + await asyncio.gather( + hooks.on_agent_start(context_wrapper, agent), + ( + agent.hooks.on_start(context_wrapper, agent) + if agent.hooks + else _utils.noop_coroutine() + ), + ) + + system_prompt = await agent.get_system_prompt(context_wrapper) + + output_schema = cls._get_output_schema(agent) + handoffs = cls._get_handoffs(agent) + input = ItemHelpers.input_to_new_input_list(original_input) + input.extend([generated_item.to_input_item() for generated_item in generated_items]) + + new_response = await cls._get_new_response( + agent, + system_prompt, + input, + output_schema, + handoffs, + context_wrapper, + run_config, + ) + + return await cls._get_single_step_result_from_response( + agent=agent, + original_input=original_input, + pre_step_items=generated_items, + new_response=new_response, + output_schema=output_schema, + handoffs=handoffs, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + + @classmethod + async def _get_single_step_result_from_response( + cls, + *, + agent: Agent[TContext], + original_input: str | list[TResponseInputItem], + pre_step_items: list[RunItem], + new_response: ModelResponse, + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + hooks: RunHooks[TContext], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ) -> SingleStepResult: + processed_response = RunImpl.process_model_response( + agent=agent, + response=new_response, + output_schema=output_schema, + handoffs=handoffs, + ) + return await RunImpl.execute_tools_and_side_effects( + agent=agent, + original_input=original_input, + pre_step_items=pre_step_items, + new_response=new_response, + processed_response=processed_response, + output_schema=output_schema, + hooks=hooks, + context_wrapper=context_wrapper, + run_config=run_config, + ) + + @classmethod + async def _run_input_guardrails( + cls, + agent: Agent[Any], + guardrails: list[InputGuardrail[TContext]], + input: str | list[TResponseInputItem], + context: RunContextWrapper[TContext], + ) -> list[InputGuardrailResult]: + if not guardrails: + return [] + + guardrail_tasks = [ + asyncio.create_task( + RunImpl.run_single_input_guardrail(agent, guardrail, input, context) + ) + for guardrail in guardrails + ] + + guardrail_results = [] + + for done in asyncio.as_completed(guardrail_tasks): + result = await done + if result.output.tripwire_triggered: + # Cancel all guardrail tasks if a tripwire is triggered. + for t in guardrail_tasks: + t.cancel() + _utils.attach_error_to_current_span( + SpanError( + message="Guardrail tripwire triggered", + data={"guardrail": result.guardrail.get_name()}, + ) + ) + raise InputGuardrailTripwireTriggered(result) + else: + guardrail_results.append(result) + + return guardrail_results + + @classmethod + async def _run_output_guardrails( + cls, + guardrails: list[OutputGuardrail[TContext]], + agent: Agent[TContext], + agent_output: Any, + context: RunContextWrapper[TContext], + ) -> list[OutputGuardrailResult]: + if not guardrails: + return [] + + guardrail_tasks = [ + asyncio.create_task( + RunImpl.run_single_output_guardrail(guardrail, agent, agent_output, context) + ) + for guardrail in guardrails + ] + + guardrail_results = [] + + for done in asyncio.as_completed(guardrail_tasks): + result = await done + if result.output.tripwire_triggered: + # Cancel all guardrail tasks if a tripwire is triggered. + for t in guardrail_tasks: + t.cancel() + _utils.attach_error_to_current_span( + SpanError( + message="Guardrail tripwire triggered", + data={"guardrail": result.guardrail.get_name()}, + ) + ) + raise OutputGuardrailTripwireTriggered(result) + else: + guardrail_results.append(result) + + return guardrail_results + + @classmethod + async def _get_new_response( + cls, + agent: Agent[TContext], + system_prompt: str | None, + input: list[TResponseInputItem], + output_schema: AgentOutputSchema | None, + handoffs: list[Handoff], + context_wrapper: RunContextWrapper[TContext], + run_config: RunConfig, + ) -> ModelResponse: + model = cls._get_model(agent, run_config) + model_settings = agent.model_settings.resolve(run_config.model_settings) + new_response = await model.get_response( + system_instructions=system_prompt, + input=input, + model_settings=model_settings, + tools=agent.tools, + output_schema=output_schema, + handoffs=handoffs, + tracing=get_model_tracing_impl( + run_config.tracing_disabled, run_config.trace_include_sensitive_data + ), + ) + + context_wrapper.usage.add(new_response.usage) + + return new_response + + @classmethod + def _get_output_schema(cls, agent: Agent[Any]) -> AgentOutputSchema | None: + if agent.output_type is None or agent.output_type is str: + return None + + return AgentOutputSchema(agent.output_type) + + @classmethod + def _get_handoffs(cls, agent: Agent[Any]) -> list[Handoff]: + handoffs = [] + for handoff_item in agent.handoffs: + if isinstance(handoff_item, Handoff): + handoffs.append(handoff_item) + elif isinstance(handoff_item, Agent): + handoffs.append(handoff(handoff_item)) + return handoffs + + @classmethod + def _get_model(cls, agent: Agent[Any], run_config: RunConfig) -> Model: + if isinstance(run_config.model, Model): + return run_config.model + elif isinstance(run_config.model, str): + return run_config.model_provider.get_model(run_config.model) + elif isinstance(agent.model, Model): + return agent.model + + return run_config.model_provider.get_model(agent.model) diff --git a/tests/src/agents/run_context.py b/tests/src/agents/run_context.py new file mode 100644 index 0000000..579a215 --- /dev/null +++ b/tests/src/agents/run_context.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field +from typing import Any, Generic + +from typing_extensions import TypeVar + +from .usage import Usage + +TContext = TypeVar("TContext", default=Any) + + +@dataclass +class RunContextWrapper(Generic[TContext]): + """This wraps the context object that you passed to `Runner.run()`. It also contains + information about the usage of the agent run so far. + + NOTE: Contexts are not passed to the LLM. They're a way to pass dependencies and data to code + you implement, like tool functions, callbacks, hooks, etc. + """ + + context: TContext + """The context object (or None), passed by you to `Runner.run()`""" + + usage: Usage = field(default_factory=Usage) + """The usage of the agent run so far. For streamed responses, the usage will be stale until the + last chunk of the stream is processed. + """ diff --git a/tests/src/agents/stream_events.py b/tests/src/agents/stream_events.py new file mode 100644 index 0000000..bd37d11 --- /dev/null +++ b/tests/src/agents/stream_events.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, Union + +from typing_extensions import TypeAlias + +from .agent import Agent +from .items import RunItem, TResponseStreamEvent + + +@dataclass +class RawResponsesStreamEvent: + """Streaming event from the LLM. These are 'raw' events, i.e. they are directly passed through + from the LLM. + """ + + data: TResponseStreamEvent + """The raw responses streaming event from the LLM.""" + + type: Literal["raw_response_event"] = "raw_response_event" + """The type of the event.""" + + +@dataclass +class RunItemStreamEvent: + """Streaming events that wrap a `RunItem`. As the agent processes the LLM response, it will + generate these events for new messages, tool calls, tool outputs, handoffs, etc. + """ + + name: Literal[ + "message_output_created", + "handoff_requested", + "handoff_occured", + "tool_called", + "tool_output", + "reasoning_item_created", + ] + """The name of the event.""" + + item: RunItem + """The item that was created.""" + + type: Literal["run_item_stream_event"] = "run_item_stream_event" + + +@dataclass +class AgentUpdatedStreamEvent: + """Event that notifies that there is a new agent running.""" + + new_agent: Agent[Any] + """The new agent.""" + + type: Literal["agent_updated_stream_event"] = "agent_updated_stream_event" + + +StreamEvent: TypeAlias = Union[RawResponsesStreamEvent, RunItemStreamEvent, AgentUpdatedStreamEvent] +"""A streaming event from an agent.""" diff --git a/tests/src/agents/strict_schema.py b/tests/src/agents/strict_schema.py new file mode 100644 index 0000000..910ad85 --- /dev/null +++ b/tests/src/agents/strict_schema.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from typing import Any + +from openai import NOT_GIVEN +from typing_extensions import TypeGuard + +from .exceptions import UserError + +_EMPTY_SCHEMA = { + "additionalProperties": False, + "type": "object", + "properties": {}, + "required": [], +} + + +def ensure_strict_json_schema( + schema: dict[str, Any], +) -> dict[str, Any]: + """Mutates the given JSON schema to ensure it conforms to the `strict` standard + that the OpenAI API expects. + """ + if schema == {}: + return _EMPTY_SCHEMA + return _ensure_strict_json_schema(schema, path=(), root=schema) + + +# Adapted from https://github.com/openai/openai-python/blob/main/src/openai/lib/_pydantic.py +def _ensure_strict_json_schema( + json_schema: object, + *, + path: tuple[str, ...], + root: dict[str, object], +) -> dict[str, Any]: + if not is_dict(json_schema): + raise TypeError(f"Expected {json_schema} to be a dictionary; path={path}") + + defs = json_schema.get("$defs") + if is_dict(defs): + for def_name, def_schema in defs.items(): + _ensure_strict_json_schema(def_schema, path=(*path, "$defs", def_name), root=root) + + definitions = json_schema.get("definitions") + if is_dict(definitions): + for definition_name, definition_schema in definitions.items(): + _ensure_strict_json_schema( + definition_schema, path=(*path, "definitions", definition_name), root=root + ) + + typ = json_schema.get("type") + if typ == "object" and "additionalProperties" not in json_schema: + json_schema["additionalProperties"] = False + elif ( + typ == "object" + and "additionalProperties" in json_schema + and json_schema["additionalProperties"] is True + ): + raise UserError( + "additionalProperties should not be set for object types. This could be because " + "you're using an older version of Pydantic, or because you configured additional " + "properties to be allowed. If you really need this, update the function or output tool " + "to not use a strict schema." + ) + + # object types + # { 'type': 'object', 'properties': { 'a': {...} } } + properties = json_schema.get("properties") + if is_dict(properties): + json_schema["required"] = list(properties.keys()) + json_schema["properties"] = { + key: _ensure_strict_json_schema(prop_schema, path=(*path, "properties", key), root=root) + for key, prop_schema in properties.items() + } + + # arrays + # { 'type': 'array', 'items': {...} } + items = json_schema.get("items") + if is_dict(items): + json_schema["items"] = _ensure_strict_json_schema(items, path=(*path, "items"), root=root) + + # unions + any_of = json_schema.get("anyOf") + if is_list(any_of): + json_schema["anyOf"] = [ + _ensure_strict_json_schema(variant, path=(*path, "anyOf", str(i)), root=root) + for i, variant in enumerate(any_of) + ] + + # intersections + all_of = json_schema.get("allOf") + if is_list(all_of): + if len(all_of) == 1: + json_schema.update( + _ensure_strict_json_schema(all_of[0], path=(*path, "allOf", "0"), root=root) + ) + json_schema.pop("allOf") + else: + json_schema["allOf"] = [ + _ensure_strict_json_schema(entry, path=(*path, "allOf", str(i)), root=root) + for i, entry in enumerate(all_of) + ] + + # strip `None` defaults as there's no meaningful distinction here + # the schema will still be `nullable` and the model will default + # to using `None` anyway + if json_schema.get("default", NOT_GIVEN) is None: + json_schema.pop("default") + + # we can't use `$ref`s if there are also other properties defined, e.g. + # `{"$ref": "...", "description": "my description"}` + # + # so we unravel the ref + # `{"type": "string", "description": "my description"}` + ref = json_schema.get("$ref") + if ref and has_more_than_n_keys(json_schema, 1): + assert isinstance(ref, str), f"Received non-string $ref - {ref}" + + resolved = resolve_ref(root=root, ref=ref) + if not is_dict(resolved): + raise ValueError( + f"Expected `$ref: {ref}` to resolved to a dictionary but got {resolved}" + ) + + # properties from the json schema take priority over the ones on the `$ref` + json_schema.update({**resolved, **json_schema}) + json_schema.pop("$ref") + # Since the schema expanded from `$ref` might not have `additionalProperties: false` applied + # we call `_ensure_strict_json_schema` again to fix the inlined schema and ensure it's valid + return _ensure_strict_json_schema(json_schema, path=path, root=root) + + return json_schema + + +def resolve_ref(*, root: dict[str, object], ref: str) -> object: + if not ref.startswith("#/"): + raise ValueError(f"Unexpected $ref format {ref!r}; Does not start with #/") + + path = ref[2:].split("/") + resolved = root + for key in path: + value = resolved[key] + assert is_dict(value), ( + f"encountered non-dictionary entry while resolving {ref} - {resolved}" + ) + resolved = value + + return resolved + + +def is_dict(obj: object) -> TypeGuard[dict[str, object]]: + # just pretend that we know there are only `str` keys + # as that check is not worth the performance cost + return isinstance(obj, dict) + + +def is_list(obj: object) -> TypeGuard[list[object]]: + return isinstance(obj, list) + + +def has_more_than_n_keys(obj: dict[str, object], n: int) -> bool: + i = 0 + for _ in obj.keys(): + i += 1 + if i > n: + return True + return False diff --git a/tests/src/agents/tool.py b/tests/src/agents/tool.py new file mode 100644 index 0000000..7587268 --- /dev/null +++ b/tests/src/agents/tool.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import inspect +import json +from collections.abc import Awaitable +from dataclasses import dataclass +from typing import Any, Callable, Literal, Union, overload + +from openai.types.responses.file_search_tool_param import Filters, RankingOptions +from openai.types.responses.web_search_tool_param import UserLocation +from pydantic import ValidationError +from typing_extensions import Concatenate, ParamSpec + +from . import _debug, _utils +from ._utils import MaybeAwaitable +from .computer import AsyncComputer, Computer +from .exceptions import ModelBehaviorError +from .function_schema import DocstringStyle, function_schema +from .logger import logger +from .run_context import RunContextWrapper +from .tracing import SpanError + +ToolParams = ParamSpec("ToolParams") + +ToolFunctionWithoutContext = Callable[ToolParams, Any] +ToolFunctionWithContext = Callable[Concatenate[RunContextWrapper[Any], ToolParams], Any] + +ToolFunction = Union[ToolFunctionWithoutContext[ToolParams], ToolFunctionWithContext[ToolParams]] + + +@dataclass +class FunctionTool: + """A tool that wraps a function. In most cases, you should use the `function_tool` helpers to + create a FunctionTool, as they let you easily wrap a Python function. + """ + + name: str + """The name of the tool, as shown to the LLM. Generally the name of the function.""" + + description: str + """A description of the tool, as shown to the LLM.""" + + params_json_schema: dict[str, Any] + """The JSON schema for the tool's parameters.""" + + on_invoke_tool: Callable[[RunContextWrapper[Any], str], Awaitable[str]] + """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). + """ + + strict_json_schema: bool = True + """Whether the JSON schema is in strict mode. We **strongly** recommend setting this to True, + as it increases the likelihood of correct JSON input.""" + + +@dataclass +class FileSearchTool: + """A hosted tool that lets the LLM search through a vector store. Currently only supported with + OpenAI models, using the Responses API. + """ + + vector_store_ids: list[str] + """The IDs of the vector stores to search.""" + + max_num_results: int | None = None + """The maximum number of results to return.""" + + include_search_results: bool = False + """Whether to include the search results in the output produced by the LLM.""" + + ranking_options: RankingOptions | None = None + """Ranking options for search.""" + + filters: Filters | None = None + """A filter to apply based on file attributes.""" + + @property + def name(self): + return "file_search" + + +@dataclass +class WebSearchTool: + """A hosted tool that lets the LLM search the web. Currently only supported with OpenAI models, + using the Responses API. + """ + + user_location: UserLocation | None = None + """Optional location for the search. Lets you customize results to be relevant to a location.""" + + search_context_size: Literal["low", "medium", "high"] = "medium" + """The amount of context to use for the search.""" + + @property + def name(self): + return "web_search_preview" + + +@dataclass +class ComputerTool: + """A hosted tool that lets the LLM control a computer.""" + + computer: Computer | AsyncComputer + """The computer implementation, which describes the environment and dimensions of the computer, + as well as implements the computer actions like click, screenshot, etc. + """ + + @property + def name(self): + return "computer_use_preview" + + +Tool = Union[FunctionTool, FileSearchTool, WebSearchTool, ComputerTool] +"""A tool that can be used in an agent.""" + + +def default_tool_error_function(ctx: RunContextWrapper[Any], error: Exception) -> str: + """The default tool error function, which just returns a generic error message.""" + return f"An error occurred while running the tool. Please try again. Error: {str(error)}" + + +ToolErrorFunction = Callable[[RunContextWrapper[Any], Exception], MaybeAwaitable[str]] + + +@overload +def function_tool( + func: ToolFunction[...], + *, + name_override: str | None = None, + description_override: str | None = None, + docstring_style: DocstringStyle | None = None, + use_docstring_info: bool = True, + failure_error_function: ToolErrorFunction | None = None, +) -> FunctionTool: + """Overload for usage as @function_tool (no parentheses).""" + ... + + +@overload +def function_tool( + *, + name_override: str | None = None, + description_override: str | None = None, + docstring_style: DocstringStyle | None = None, + use_docstring_info: bool = True, + failure_error_function: ToolErrorFunction | None = None, +) -> Callable[[ToolFunction[...]], FunctionTool]: + """Overload for usage as @function_tool(...).""" + ... + + +def function_tool( + func: ToolFunction[...] | None = None, + *, + name_override: str | None = None, + description_override: str | None = None, + docstring_style: DocstringStyle | None = None, + use_docstring_info: bool = True, + failure_error_function: ToolErrorFunction | None = default_tool_error_function, +) -> FunctionTool | Callable[[ToolFunction[...]], FunctionTool]: + """ + Decorator to create a FunctionTool from a function. By default, we will: + 1. Parse the function signature to create a JSON schema for the tool's parameters. + 2. Use the function's docstring to populate the tool's description. + 3. Use the function's docstring to populate argument descriptions. + The docstring style is detected automatically, but you can override it. + + If the function takes a `RunContextWrapper` as the first argument, it *must* match the + context type of the agent that uses the tool. + + Args: + func: The function to wrap. + name_override: If provided, use this name for the tool instead of the function's name. + description_override: If provided, use this description for the tool instead of the + function's docstring. + docstring_style: If provided, use this style for the tool's docstring. If not provided, + we will attempt to auto-detect the style. + use_docstring_info: If True, use the function's docstring to populate the tool's + description and argument descriptions. + 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. + """ + + def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: + schema = function_schema( + func=the_func, + name_override=name_override, + description_override=description_override, + docstring_style=docstring_style, + use_docstring_info=use_docstring_info, + ) + + async def _on_invoke_tool_impl(ctx: RunContextWrapper[Any], input: str) -> str: + try: + json_data: dict[str, Any] = json.loads(input) if input else {} + except Exception as e: + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invalid JSON input for tool {schema.name}") + else: + logger.debug(f"Invalid JSON input for tool {schema.name}: {input}") + raise ModelBehaviorError( + f"Invalid JSON input for tool {schema.name}: {input}" + ) from e + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Invoking tool {schema.name}") + else: + logger.debug(f"Invoking tool {schema.name} with input {input}") + + try: + parsed = ( + schema.params_pydantic_model(**json_data) + if json_data + else schema.params_pydantic_model() + ) + except ValidationError as e: + raise ModelBehaviorError(f"Invalid JSON input for tool {schema.name}: {e}") from e + + args, kwargs_dict = schema.to_call_args(parsed) + + if not _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Tool call args: {args}, kwargs: {kwargs_dict}") + + if inspect.iscoroutinefunction(the_func): + if schema.takes_context: + result = await the_func(ctx, *args, **kwargs_dict) + else: + result = await the_func(*args, **kwargs_dict) + else: + if schema.takes_context: + result = the_func(ctx, *args, **kwargs_dict) + else: + result = the_func(*args, **kwargs_dict) + + if _debug.DONT_LOG_TOOL_DATA: + logger.debug(f"Tool {schema.name} completed.") + else: + logger.debug(f"Tool {schema.name} returned {result}") + + return str(result) + + async def _on_invoke_tool(ctx: RunContextWrapper[Any], input: str) -> str: + try: + return await _on_invoke_tool_impl(ctx, input) + except Exception as e: + if failure_error_function is None: + raise + + result = failure_error_function(ctx, e) + if inspect.isawaitable(result): + return await result + + _utils.attach_error_to_current_span( + SpanError( + message="Error running tool (non-fatal)", + data={ + "tool_name": schema.name, + "error": str(e), + }, + ) + ) + return result + + return FunctionTool( + name=schema.name, + description=schema.description or "", + params_json_schema=schema.params_json_schema, + on_invoke_tool=_on_invoke_tool, + ) + + # If func is actually a callable, we were used as @function_tool with no parentheses + if callable(func): + return _create_function_tool(func) + + # Otherwise, we were used as @function_tool(...), so return a decorator + def decorator(real_func: ToolFunction[...]) -> FunctionTool: + return _create_function_tool(real_func) + + return decorator diff --git a/tests/src/agents/tracing/__init__.py b/tests/src/agents/tracing/__init__.py new file mode 100644 index 0000000..8e80201 --- /dev/null +++ b/tests/src/agents/tracing/__init__.py @@ -0,0 +1,97 @@ +import atexit + +from .create import ( + agent_span, + custom_span, + function_span, + generation_span, + get_current_span, + get_current_trace, + guardrail_span, + handoff_span, + response_span, + trace, +) +from .processor_interface import TracingProcessor +from .processors import default_exporter, default_processor +from .setup import GLOBAL_TRACE_PROVIDER +from .span_data import ( + AgentSpanData, + CustomSpanData, + FunctionSpanData, + GenerationSpanData, + GuardrailSpanData, + HandoffSpanData, + ResponseSpanData, + SpanData, +) +from .spans import Span, SpanError +from .traces import Trace +from .util import gen_span_id, gen_trace_id + +__all__ = [ + "add_trace_processor", + "agent_span", + "custom_span", + "function_span", + "generation_span", + "get_current_span", + "get_current_trace", + "guardrail_span", + "handoff_span", + "response_span", + "set_trace_processors", + "set_tracing_disabled", + "trace", + "Trace", + "SpanError", + "Span", + "SpanData", + "AgentSpanData", + "CustomSpanData", + "FunctionSpanData", + "GenerationSpanData", + "GuardrailSpanData", + "HandoffSpanData", + "ResponseSpanData", + "TracingProcessor", + "gen_trace_id", + "gen_span_id", +] + + +def add_trace_processor(span_processor: TracingProcessor) -> None: + """ + Adds a new trace processor. This processor will receive all traces/spans. + """ + GLOBAL_TRACE_PROVIDER.register_processor(span_processor) + + +def set_trace_processors(processors: list[TracingProcessor]) -> None: + """ + Set the list of trace processors. This will replace the current list of processors. + """ + GLOBAL_TRACE_PROVIDER.set_processors(processors) + + +def set_tracing_disabled(disabled: bool) -> None: + """ + Set whether tracing is globally disabled. + """ + GLOBAL_TRACE_PROVIDER.set_disabled(disabled) + + +def set_tracing_export_api_key(api_key: str) -> None: + """ + Set the OpenAI API key for the backend exporter. + """ + default_exporter().set_api_key(api_key) + + +# Add the default processor, which exports traces and spans to the backend in batches. You can +# change the default behavior by either: +# 1. calling add_trace_processor(), which adds additional processors, or +# 2. calling set_trace_processors(), which replaces the default processor. +add_trace_processor(default_processor()) + +atexit.register(GLOBAL_TRACE_PROVIDER.shutdown) diff --git a/tests/src/agents/tracing/__pycache__/__init__.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..8d78e76 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/__init__.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/__init__.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..6a7836a Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/__init__.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..14152d8 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/__init__.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/_strict_schema.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/_strict_schema.cpython-313.pyc new file mode 100644 index 0000000..79c5357 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/_strict_schema.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/create.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/create.cpython-311.pyc new file mode 100644 index 0000000..62b33cf Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/create.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/create.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/create.cpython-313.pyc new file mode 100644 index 0000000..9296d5f Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/create.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/create.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/create.cpython-39.pyc new file mode 100644 index 0000000..7b5f7ba Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/create.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/logger.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/logger.cpython-311.pyc new file mode 100644 index 0000000..07d2503 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/logger.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/logger.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/logger.cpython-313.pyc new file mode 100644 index 0000000..01611fc Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/logger.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/logger.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/logger.cpython-39.pyc new file mode 100644 index 0000000..25e02e5 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/logger.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/processor_interface.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/processor_interface.cpython-311.pyc new file mode 100644 index 0000000..5ea4cfd Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/processor_interface.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/processor_interface.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/processor_interface.cpython-313.pyc new file mode 100644 index 0000000..a25145e Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/processor_interface.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/processor_interface.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/processor_interface.cpython-39.pyc new file mode 100644 index 0000000..1065734 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/processor_interface.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/processors.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/processors.cpython-311.pyc new file mode 100644 index 0000000..719f468 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/processors.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/processors.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/processors.cpython-313.pyc new file mode 100644 index 0000000..dd3c5b8 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/processors.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/processors.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/processors.cpython-39.pyc new file mode 100644 index 0000000..631993a Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/processors.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/scope.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/scope.cpython-311.pyc new file mode 100644 index 0000000..ca52084 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/scope.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/scope.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/scope.cpython-313.pyc new file mode 100644 index 0000000..f45326f Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/scope.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/scope.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/scope.cpython-39.pyc new file mode 100644 index 0000000..1a7d6c0 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/scope.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/setup.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/setup.cpython-311.pyc new file mode 100644 index 0000000..3aed1e6 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/setup.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/setup.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/setup.cpython-313.pyc new file mode 100644 index 0000000..3fb2e5d Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/setup.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/setup.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/setup.cpython-39.pyc new file mode 100644 index 0000000..6021541 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/setup.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/span_data.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/span_data.cpython-311.pyc new file mode 100644 index 0000000..0cc7384 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/span_data.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/span_data.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/span_data.cpython-313.pyc new file mode 100644 index 0000000..2cae2b7 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/span_data.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/span_data.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/span_data.cpython-39.pyc new file mode 100644 index 0000000..d1d71fc Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/span_data.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/spans.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/spans.cpython-311.pyc new file mode 100644 index 0000000..156ba23 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/spans.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/spans.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/spans.cpython-313.pyc new file mode 100644 index 0000000..ba21bd2 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/spans.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/spans.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/spans.cpython-39.pyc new file mode 100644 index 0000000..9c538b8 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/spans.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/traces.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/traces.cpython-311.pyc new file mode 100644 index 0000000..3f45b35 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/traces.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/traces.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/traces.cpython-313.pyc new file mode 100644 index 0000000..8ea5bb7 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/traces.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/traces.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/traces.cpython-39.pyc new file mode 100644 index 0000000..8a3ed18 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/traces.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/util.cpython-311.pyc b/tests/src/agents/tracing/__pycache__/util.cpython-311.pyc new file mode 100644 index 0000000..70a74e9 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/util.cpython-311.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/util.cpython-313.pyc b/tests/src/agents/tracing/__pycache__/util.cpython-313.pyc new file mode 100644 index 0000000..723c198 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/util.cpython-313.pyc differ diff --git a/tests/src/agents/tracing/__pycache__/util.cpython-39.pyc b/tests/src/agents/tracing/__pycache__/util.cpython-39.pyc new file mode 100644 index 0000000..d8bcc53 Binary files /dev/null and b/tests/src/agents/tracing/__pycache__/util.cpython-39.pyc differ diff --git a/tests/src/agents/tracing/create.py b/tests/src/agents/tracing/create.py new file mode 100644 index 0000000..8d7fc49 --- /dev/null +++ b/tests/src/agents/tracing/create.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any + +from .logger import logger +from .setup import GLOBAL_TRACE_PROVIDER +from .span_data import ( + AgentSpanData, + CustomSpanData, + FunctionSpanData, + GenerationSpanData, + GuardrailSpanData, + HandoffSpanData, + ResponseSpanData, +) +from .spans import Span +from .traces import Trace + +if TYPE_CHECKING: + from openai.types.responses import Response + + +def trace( + workflow_name: str, + trace_id: str | None = None, + group_id: str | None = None, + metadata: dict[str, Any] | None = None, + disabled: bool = False, +) -> Trace: + """ + Create a new trace. The trace will not be started automatically; you should either use + it as a context manager (`with trace(...):`) or call `trace.start()` + `trace.finish()` + manually. + + In addition to the workflow name and optional grouping identifier, you can provide + an arbitrary metadata dictionary to attach additional user-defined information to + the trace. + + Args: + workflow_name: The name of the logical app or workflow. For example, you might provide + "code_bot" for a coding agent, or "customer_support_agent" for a customer support agent. + trace_id: The ID of the trace. Optional. If not provided, we will generate an ID. We + recommend using `util.gen_trace_id()` to generate a trace ID, to guarantee that IDs are + correctly formatted. + group_id: Optional grouping identifier to link multiple traces from the same conversation + or process. For instance, you might use a chat thread ID. + metadata: Optional dictionary of additional metadata to attach to the trace. + disabled: If True, we will return a Trace but the Trace will not be recorded. This will + not be checked if there's an existing trace and `even_if_trace_running` is True. + + Returns: + The newly created trace object. + """ + current_trace = GLOBAL_TRACE_PROVIDER.get_current_trace() + if current_trace: + logger.warning( + "Trace already exists. Creating a new trace, but this is probably a mistake." + ) + + return GLOBAL_TRACE_PROVIDER.create_trace( + name=workflow_name, + trace_id=trace_id, + group_id=group_id, + metadata=metadata, + disabled=disabled, + ) + + +def get_current_trace() -> Trace | None: + """Returns the currently active trace, if present.""" + return GLOBAL_TRACE_PROVIDER.get_current_trace() + + +def get_current_span() -> Span[Any] | None: + """Returns the currently active span, if present.""" + return GLOBAL_TRACE_PROVIDER.get_current_span() + + +def agent_span( + name: str, + handoffs: list[str] | None = None, + tools: list[str] | None = None, + output_type: str | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[AgentSpanData]: + """Create a new agent span. The span will not be started automatically, you should either do + `with agent_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + name: The name of the agent. + handoffs: Optional list of agent names to which this agent could hand off control. + tools: Optional list of tool names available to this agent. + output_type: Optional name of the output type produced by the agent. + 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 agent span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=AgentSpanData(name=name, handoffs=handoffs, tools=tools, output_type=output_type), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def function_span( + name: str, + input: str | None = None, + output: str | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[FunctionSpanData]: + """Create a new function span. The span will not be started automatically, you should either do + `with function_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + name: The name of the function. + input: The input to the function. + output: The output of the function. + 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 function span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=FunctionSpanData(name=name, input=input, output=output), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def generation_span( + input: Sequence[Mapping[str, Any]] | None = None, + output: Sequence[Mapping[str, Any]] | None = None, + model: str | None = None, + model_config: Mapping[str, Any] | None = None, + usage: dict[str, Any] | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[GenerationSpanData]: + """Create a new generation span. The span will not be started automatically, you should either + do `with generation_span() ...` or call `span.start()` + `span.finish()` manually. + + This span captures the details of a model generation, including the + input message sequence, any generated outputs, the model name and + configuration, and usage data. If you only need to capture a model + response identifier, use `response_span()` instead. + + Args: + input: The sequence of input messages sent to the model. + output: The sequence of output messages received from the model. + model: The model identifier used for the generation. + model_config: The model configuration (hyperparameters) used. + usage: A dictionary of usage information (input tokens, output tokens, etc.). + 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 generation span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=GenerationSpanData( + input=input, output=output, model=model, model_config=model_config, usage=usage + ), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def response_span( + response: Response | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[ResponseSpanData]: + """Create a new response span. The span will not be started automatically, you should either do + `with response_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + response: The OpenAI Response object. + 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=ResponseSpanData(response=response), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def handoff_span( + from_agent: str | None = None, + to_agent: str | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[HandoffSpanData]: + """Create a new handoff span. The span will not be started automatically, you should either do + `with handoff_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + from_agent: The name of the agent that is handing off. + to_agent: The name of the agent that is receiving the handoff. + 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 handoff span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=HandoffSpanData(from_agent=from_agent, to_agent=to_agent), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def custom_span( + name: str, + data: dict[str, Any] | None = None, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[CustomSpanData]: + """Create a new custom span, to which you can add your own metadata. The span will not be + started automatically, you should either do `with custom_span() ...` or call + `span.start()` + `span.finish()` manually. + + Args: + name: The name of the custom span. + data: Arbitrary structured data to associate with the span. + 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 custom span. + """ + return GLOBAL_TRACE_PROVIDER.create_span( + span_data=CustomSpanData(name=name, data=data or {}), + span_id=span_id, + parent=parent, + disabled=disabled, + ) + + +def guardrail_span( + name: str, + triggered: bool = False, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, +) -> Span[GuardrailSpanData]: + """Create a new guardrail span. The span will not be started automatically, you should either + do `with guardrail_span() ...` or call `span.start()` + `span.finish()` manually. + + Args: + name: The name of the guardrail. + triggered: Whether the guardrail was triggered. + 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=GuardrailSpanData(name=name, triggered=triggered), + span_id=span_id, + parent=parent, + disabled=disabled, + ) diff --git a/tests/src/agents/tracing/logger.py b/tests/src/agents/tracing/logger.py new file mode 100644 index 0000000..661d09b --- /dev/null +++ b/tests/src/agents/tracing/logger.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger("openai.agents.tracing") diff --git a/tests/src/agents/tracing/processor_interface.py b/tests/src/agents/tracing/processor_interface.py new file mode 100644 index 0000000..4dcd897 --- /dev/null +++ b/tests/src/agents/tracing/processor_interface.py @@ -0,0 +1,69 @@ +import abc +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .spans import Span + from .traces import Trace + + +class TracingProcessor(abc.ABC): + """Interface for processing spans.""" + + @abc.abstractmethod + def on_trace_start(self, trace: "Trace") -> None: + """Called when a trace is started. + + Args: + trace: The trace that started. + """ + pass + + @abc.abstractmethod + def on_trace_end(self, trace: "Trace") -> None: + """Called when a trace is finished. + + Args: + trace: The trace that started. + """ + pass + + @abc.abstractmethod + def on_span_start(self, span: "Span[Any]") -> None: + """Called when a span is started. + + Args: + span: The span that started. + """ + pass + + @abc.abstractmethod + def on_span_end(self, span: "Span[Any]") -> None: + """Called when a span is finished. Should not block or raise exceptions. + + Args: + span: The span that finished. + """ + pass + + @abc.abstractmethod + def shutdown(self) -> None: + """Called when the application stops.""" + pass + + @abc.abstractmethod + def force_flush(self) -> None: + """Forces an immediate flush of all queued spans/traces.""" + pass + + +class TracingExporter(abc.ABC): + """Exports traces and spans. For example, could log them or send them to a backend.""" + + @abc.abstractmethod + def export(self, items: list["Trace | Span[Any]"]) -> None: + """Exports a list of traces and spans. + + Args: + items: The items to export. + """ + pass diff --git a/tests/src/agents/tracing/processors.py b/tests/src/agents/tracing/processors.py new file mode 100644 index 0000000..282bc23 --- /dev/null +++ b/tests/src/agents/tracing/processors.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import os +import queue +import random +import threading +import time +from typing import Any + +import httpx + +from .logger import logger +from .processor_interface import TracingExporter, TracingProcessor +from .spans import Span +from .traces import Trace + + +class ConsoleSpanExporter(TracingExporter): + """Prints the traces and spans to the console.""" + + def export(self, items: list[Trace | Span[Any]]) -> None: + for item in items: + if isinstance(item, Trace): + print(f"[Exporter] Export trace_id={item.trace_id}, name={item.name}, ") + else: + print(f"[Exporter] Export span: {item.export()}") + + +class BackendSpanExporter(TracingExporter): + def __init__( + self, + api_key: str | None = None, + organization: str | None = None, + project: str | None = None, + endpoint: str = "https://api.openai.com/v1/traces/ingest", + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 30.0, + ): + """ + Args: + api_key: The API key for the "Authorization" header. Defaults to + `os.environ["OPENAI_TRACE_API_KEY"]` if not provided. + organization: The OpenAI organization to use. Defaults to + `os.environ["OPENAI_ORG_ID"]` if not provided. + project: The OpenAI project to use. Defaults to + `os.environ["OPENAI_PROJECT_ID"]` if not provided. + endpoint: The HTTP endpoint to which traces/spans are posted. + max_retries: Maximum number of retries upon failures. + 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.endpoint = endpoint + self.max_retries = max_retries + self.base_delay = base_delay + self.max_delay = max_delay + + # Keep a client open for connection pooling across multiple export calls + self._client = httpx.Client(timeout=httpx.Timeout(timeout=60, connect=5.0)) + + def set_api_key(self, api_key: str): + """Set the OpenAI API key for the exporter. + + Args: + api_key: The OpenAI API key to use. This is the same key used by the OpenAI Python + client. + """ + self.api_key = api_key + + def export(self, items: list[Trace | Span[Any]]) -> None: + if not items: + return + + if not self.api_key: + logger.warning("OPENAI_API_KEY is not set, skipping trace export") + return + + traces: list[dict[str, Any]] = [] + spans: list[dict[str, Any]] = [] + + data = [item.export() for item in items if item.export()] + payload = {"data": data} + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "OpenAI-Beta": "traces=v1", + } + + # Exponential backoff loop + attempt = 0 + delay = self.base_delay + while True: + attempt += 1 + try: + response = self._client.post(url=self.endpoint, headers=headers, json=payload) + + # If the response is successful, break out of the loop + if response.status_code < 300: + logger.debug(f"Exported {len(traces)} traces, {len(spans)} spans") + return + + # 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}") + return + + # For 5xx or other unexpected codes, treat it as transient and retry + logger.warning(f"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}") + + # 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.") + return + + # Exponential backoff + jitter + sleep_time = delay + random.uniform(0, 0.1 * delay) # 10% jitter + time.sleep(sleep_time) + delay = min(delay * 2, self.max_delay) + + def close(self): + """Close the underlying HTTP client.""" + self._client.close() + + +class BatchTraceProcessor(TracingProcessor): + """Some implementation notes: + 1. Using Queue, which is thread-safe. + 2. Using a background thread to export spans, to minimize any performance issues. + 3. Spans are stored in memory until they are exported. + """ + + def __init__( + self, + exporter: TracingExporter, + max_queue_size: int = 8192, + max_batch_size: int = 128, + schedule_delay: float = 5.0, + export_trigger_ratio: float = 0.7, + ): + """ + Args: + exporter: The exporter to use. + max_queue_size: The maximum number of spans to store in the queue. After this, we will + start dropping spans. + max_batch_size: The maximum number of spans to export in a single batch. + schedule_delay: The delay between checks for new spans to export. + export_trigger_ratio: The ratio of the queue size at which we will trigger an export. + """ + self._exporter = exporter + self._queue: queue.Queue[Trace | Span[Any]] = queue.Queue(maxsize=max_queue_size) + self._max_queue_size = max_queue_size + self._max_batch_size = max_batch_size + self._schedule_delay = schedule_delay + self._shutdown_event = threading.Event() + + # The queue size threshold at which we export immediately. + self._export_trigger_size = int(max_queue_size * export_trigger_ratio) + + # Track when we next *must* perform a scheduled export + self._next_export_time = time.time() + self._schedule_delay + + self._shutdown_event = threading.Event() + self._worker_thread = threading.Thread(target=self._run, daemon=True) + self._worker_thread.start() + + def on_trace_start(self, trace: Trace) -> None: + try: + self._queue.put_nowait(trace) + except queue.Full: + logger.warning("Queue is full, dropping trace.") + + def on_trace_end(self, trace: Trace) -> None: + # We send traces via on_trace_start, so we don't need to do anything here. + pass + + def on_span_start(self, span: Span[Any]) -> None: + # We send spans via on_span_end, so we don't need to do anything here. + pass + + def on_span_end(self, span: Span[Any]) -> None: + try: + self._queue.put_nowait(span) + except queue.Full: + logger.warning("Queue is full, dropping span.") + + def shutdown(self, timeout: float | None = None): + """ + Called when the application stops. We signal our thread to stop, then join it. + """ + self._shutdown_event.set() + self._worker_thread.join(timeout=timeout) + + def force_flush(self): + """ + Forces an immediate flush of all queued spans. + """ + self._export_batches(force=True) + + def _run(self): + while not self._shutdown_event.is_set(): + current_time = time.time() + queue_size = self._queue.qsize() + + # If it's time for a scheduled flush or queue is above the trigger threshold + if current_time >= self._next_export_time or queue_size >= self._export_trigger_size: + self._export_batches(force=False) + # Reset the next scheduled flush time + self._next_export_time = time.time() + self._schedule_delay + else: + # Sleep a short interval so we don't busy-wait. + time.sleep(0.2) + + # Final drain after shutdown + self._export_batches(force=True) + + def _export_batches(self, force: bool = False): + """Drains the queue and exports in batches. If force=True, export everything. + Otherwise, export up to `max_batch_size` repeatedly until the queue is empty or below a + certain threshold. + """ + while True: + items_to_export: list[Span[Any] | Trace] = [] + + # Gather a batch of spans up to max_batch_size + while not self._queue.empty() and ( + force or len(items_to_export) < self._max_batch_size + ): + try: + items_to_export.append(self._queue.get_nowait()) + except queue.Empty: + # Another thread might have emptied the queue between checks + break + + # If we collected nothing, we're done + if not items_to_export: + break + + # Export the batch + self._exporter.export(items_to_export) + + +# Create a shared global instance: +_global_exporter = BackendSpanExporter() +_global_processor = BatchTraceProcessor(_global_exporter) + + +def default_exporter() -> BackendSpanExporter: + """The default exporter, which exports traces and spans to the backend in batches.""" + return _global_exporter + + +def default_processor() -> BatchTraceProcessor: + """The default processor, which exports traces and spans to the backend in batches.""" + return _global_processor diff --git a/tests/src/agents/tracing/scope.py b/tests/src/agents/tracing/scope.py new file mode 100644 index 0000000..9ccd9f8 --- /dev/null +++ b/tests/src/agents/tracing/scope.py @@ -0,0 +1,45 @@ +# Holds the current active span +import contextvars +from typing import TYPE_CHECKING, Any + +from .logger import logger + +if TYPE_CHECKING: + from .spans import Span + from .traces import Trace + +_current_span: contextvars.ContextVar["Span[Any] | None"] = contextvars.ContextVar( + "current_span", default=None +) + +_current_trace: contextvars.ContextVar["Trace | None"] = contextvars.ContextVar( + "current_trace", default=None +) + + +class Scope: + @classmethod + def get_current_span(cls) -> "Span[Any] | None": + return _current_span.get() + + @classmethod + def set_current_span(cls, span: "Span[Any] | None") -> "contextvars.Token[Span[Any] | None]": + return _current_span.set(span) + + @classmethod + def reset_current_span(cls, token: "contextvars.Token[Span[Any] | None]") -> None: + _current_span.reset(token) + + @classmethod + def get_current_trace(cls) -> "Trace | None": + return _current_trace.get() + + @classmethod + def set_current_trace(cls, trace: "Trace | None") -> "contextvars.Token[Trace | None]": + logger.debug(f"Setting current trace: {trace.trace_id if trace else None}") + return _current_trace.set(trace) + + @classmethod + def reset_current_trace(cls, token: "contextvars.Token[Trace | None]") -> None: + logger.debug("Resetting current trace") + _current_trace.reset(token) diff --git a/tests/src/agents/tracing/setup.py b/tests/src/agents/tracing/setup.py new file mode 100644 index 0000000..bc340c9 --- /dev/null +++ b/tests/src/agents/tracing/setup.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import os +import threading +from typing import Any + +from . import util +from .logger import logger +from .processor_interface import TracingProcessor +from .scope import Scope +from .spans import NoOpSpan, Span, SpanImpl, TSpanData +from .traces import NoOpTrace, Trace, TraceImpl + + +class SynchronousMultiTracingProcessor(TracingProcessor): + """ + Forwards all calls to a list of TracingProcessors, in order of registration. + """ + + def __init__(self): + # Using a tuple to avoid race conditions when iterating over processors + self._processors: tuple[TracingProcessor, ...] = () + self._lock = threading.Lock() + + def add_tracing_processor(self, tracing_processor: TracingProcessor): + """ + Add a processor to the list of processors. Each processor will receive all traces/spans. + """ + with self._lock: + self._processors += (tracing_processor,) + + def set_processors(self, processors: list[TracingProcessor]): + """ + Set the list of processors. This will replace the current list of processors. + """ + with self._lock: + self._processors = tuple(processors) + + def on_trace_start(self, trace: Trace) -> None: + """ + Called when a trace is started. + """ + for processor in self._processors: + processor.on_trace_start(trace) + + def on_trace_end(self, trace: Trace) -> None: + """ + Called when a trace is finished. + """ + for processor in self._processors: + processor.on_trace_end(trace) + + def on_span_start(self, span: Span[Any]) -> None: + """ + Called when a span is started. + """ + for processor in self._processors: + processor.on_span_start(span) + + def on_span_end(self, span: Span[Any]) -> None: + """ + Called when a span is finished. + """ + for processor in self._processors: + processor.on_span_end(span) + + def shutdown(self) -> None: + """ + Called when the application stops. + """ + for processor in self._processors: + logger.debug(f"Shutting down trace processor {processor}") + processor.shutdown() + + def force_flush(self): + """ + Force the processors to flush their buffers. + """ + for processor in self._processors: + processor.force_flush() + + +class TraceProvider: + def __init__(self): + self._multi_processor = SynchronousMultiTracingProcessor() + self._disabled = os.environ.get("OPENAI_AGENTS_DISABLE_TRACING", "false").lower() in ( + "true", + "1", + ) + + def register_processor(self, processor: TracingProcessor): + """ + Add a processor to the list of processors. Each processor will receive all traces/spans. + """ + self._multi_processor.add_tracing_processor(processor) + + def set_processors(self, processors: list[TracingProcessor]): + """ + Set the list of processors. This will replace the current list of processors. + """ + self._multi_processor.set_processors(processors) + + def get_current_trace(self) -> Trace | None: + """ + Returns the currently active trace, if any. + """ + return Scope.get_current_trace() + + def get_current_span(self) -> Span[Any] | None: + """ + Returns the currently active span, if any. + """ + return Scope.get_current_span() + + def set_disabled(self, disabled: bool) -> None: + """ + Set whether tracing is disabled. + """ + self._disabled = disabled + + def create_trace( + self, + name: str, + trace_id: str | None = None, + group_id: str | None = None, + metadata: dict[str, Any] | None = None, + disabled: bool = False, + ) -> Trace: + """ + Create a new trace. + """ + if self._disabled or disabled: + logger.debug(f"Tracing is disabled. Not creating trace {name}") + return NoOpTrace() + + trace_id = trace_id or util.gen_trace_id() + + logger.debug(f"Creating trace {name} with id {trace_id}") + + return TraceImpl( + name=name, + trace_id=trace_id, + group_id=group_id, + metadata=metadata, + processor=self._multi_processor, + ) + + def create_span( + self, + span_data: TSpanData, + span_id: str | None = None, + parent: Trace | Span[Any] | None = None, + disabled: bool = False, + ) -> Span[TSpanData]: + """ + Create a new span. + """ + if self._disabled or disabled: + logger.debug(f"Tracing is disabled. Not creating span {span_data}") + return NoOpSpan(span_data) + + if not parent: + current_span = Scope.get_current_span() + current_trace = Scope.get_current_trace() + if current_trace is None: + logger.error( + "No active trace. Make sure to start a trace with `trace()` first" + "Returning NoOpSpan." + ) + return NoOpSpan(span_data) + elif isinstance(current_trace, NoOpTrace) or isinstance(current_span, NoOpSpan): + logger.debug( + f"Parent {current_span} or {current_trace} is no-op, returning NoOpSpan" + ) + return NoOpSpan(span_data) + + parent_id = current_span.span_id if current_span else None + trace_id = current_trace.trace_id + + elif isinstance(parent, Trace): + if isinstance(parent, NoOpTrace): + logger.debug(f"Parent {parent} is no-op, returning NoOpSpan") + return NoOpSpan(span_data) + trace_id = parent.trace_id + parent_id = None + elif isinstance(parent, Span): + if isinstance(parent, NoOpSpan): + logger.debug(f"Parent {parent} is no-op, returning NoOpSpan") + return NoOpSpan(span_data) + parent_id = parent.span_id + trace_id = parent.trace_id + + logger.debug(f"Creating span {span_data} with id {span_id}") + + return SpanImpl( + trace_id=trace_id, + span_id=span_id, + parent_id=parent_id, + processor=self._multi_processor, + span_data=span_data, + ) + + def shutdown(self) -> None: + try: + logger.debug("Shutting down trace provider") + self._multi_processor.shutdown() + except Exception as e: + logger.error(f"Error shutting down trace provider: {e}") + + +GLOBAL_TRACE_PROVIDER = TraceProvider() diff --git a/tests/src/agents/tracing/span_data.py b/tests/src/agents/tracing/span_data.py new file mode 100644 index 0000000..5e5d38c --- /dev/null +++ b/tests/src/agents/tracing/span_data.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import abc +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from openai.types.responses import Response, ResponseInputItemParam + + +class SpanData(abc.ABC): + @abc.abstractmethod + def export(self) -> dict[str, Any]: + pass + + @property + @abc.abstractmethod + def type(self) -> str: + pass + + +class AgentSpanData(SpanData): + __slots__ = ("name", "handoffs", "tools", "output_type") + + def __init__( + self, + name: str, + handoffs: list[str] | None = None, + tools: list[str] | None = None, + output_type: str | None = None, + ): + self.name = name + self.handoffs: list[str] | None = handoffs + self.tools: list[str] | None = tools + self.output_type: str | None = output_type + + @property + def type(self) -> str: + return "agent" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "name": self.name, + "handoffs": self.handoffs, + "tools": self.tools, + "output_type": self.output_type, + } + + +class FunctionSpanData(SpanData): + __slots__ = ("name", "input", "output") + + def __init__(self, name: str, input: str | None, output: str | None): + self.name = name + self.input = input + self.output = output + + @property + def type(self) -> str: + return "function" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "name": self.name, + "input": self.input, + "output": self.output, + } + + +class GenerationSpanData(SpanData): + __slots__ = ( + "input", + "output", + "model", + "model_config", + "usage", + ) + + def __init__( + self, + input: Sequence[Mapping[str, Any]] | None = None, + output: Sequence[Mapping[str, Any]] | None = None, + model: str | None = None, + model_config: Mapping[str, Any] | None = None, + usage: dict[str, Any] | None = None, + ): + self.input = input + self.output = output + self.model = model + self.model_config = model_config + self.usage = usage + + @property + def type(self) -> str: + return "generation" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "input": self.input, + "output": self.output, + "model": self.model, + "model_config": self.model_config, + "usage": self.usage, + } + + +class ResponseSpanData(SpanData): + __slots__ = ("response", "input") + + def __init__( + self, + response: Response | None = None, + input: str | list[ResponseInputItemParam] | None = None, + ) -> None: + self.response = response + # This is not used by the OpenAI trace processors, but is useful for other tracing + # processor implementations + self.input = input + + @property + def type(self) -> str: + return "response" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "response_id": self.response.id if self.response else None, + } + + +class HandoffSpanData(SpanData): + __slots__ = ("from_agent", "to_agent") + + def __init__(self, from_agent: str | None, to_agent: str | None): + self.from_agent = from_agent + self.to_agent = to_agent + + @property + def type(self) -> str: + return "handoff" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "from_agent": self.from_agent, + "to_agent": self.to_agent, + } + + +class CustomSpanData(SpanData): + __slots__ = ("name", "data") + + def __init__(self, name: str, data: dict[str, Any]): + self.name = name + self.data = data + + @property + def type(self) -> str: + return "custom" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "name": self.name, + "data": self.data, + } + + +class GuardrailSpanData(SpanData): + __slots__ = ("name", "triggered") + + def __init__(self, name: str, triggered: bool = False): + self.name = name + self.triggered = triggered + + @property + def type(self) -> str: + return "guardrail" + + def export(self) -> dict[str, Any]: + return { + "type": self.type, + "name": self.name, + "triggered": self.triggered, + } diff --git a/tests/src/agents/tracing/spans.py b/tests/src/agents/tracing/spans.py new file mode 100644 index 0000000..d682a9a --- /dev/null +++ b/tests/src/agents/tracing/spans.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import abc +import contextvars +from typing import Any, Generic, TypeVar + +from typing_extensions import TypedDict + +from . import util +from .logger import logger +from .processor_interface import TracingProcessor +from .scope import Scope +from .span_data import SpanData + +TSpanData = TypeVar("TSpanData", bound=SpanData) + + +class SpanError(TypedDict): + message: str + data: dict[str, Any] | None + + +class Span(abc.ABC, Generic[TSpanData]): + @property + @abc.abstractmethod + def trace_id(self) -> str: + pass + + @property + @abc.abstractmethod + def span_id(self) -> str: + pass + + @property + @abc.abstractmethod + def span_data(self) -> TSpanData: + pass + + @abc.abstractmethod + def start(self, mark_as_current: bool = False): + """ + Start the span. + + Args: + mark_as_current: If true, the span will be marked as the current span. + """ + pass + + @abc.abstractmethod + def finish(self, reset_current: bool = False) -> None: + """ + Finish the span. + + Args: + reset_current: If true, the span will be reset as the current span. + """ + pass + + @abc.abstractmethod + def __enter__(self) -> Span[TSpanData]: + pass + + @abc.abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + @property + @abc.abstractmethod + def parent_id(self) -> str | None: + pass + + @abc.abstractmethod + def set_error(self, error: SpanError) -> None: + pass + + @property + @abc.abstractmethod + def error(self) -> SpanError | None: + pass + + @abc.abstractmethod + def export(self) -> dict[str, Any] | None: + pass + + @property + @abc.abstractmethod + def started_at(self) -> str | None: + pass + + @property + @abc.abstractmethod + def ended_at(self) -> str | None: + pass + + +class NoOpSpan(Span[TSpanData]): + __slots__ = ("_span_data", "_prev_span_token") + + def __init__(self, span_data: TSpanData): + self._span_data = span_data + self._prev_span_token: contextvars.Token[Span[TSpanData] | None] | None = None + + @property + def trace_id(self) -> str: + return "no-op" + + @property + def span_id(self) -> str: + return "no-op" + + @property + def span_data(self) -> TSpanData: + return self._span_data + + @property + def parent_id(self) -> str | None: + return None + + def start(self, mark_as_current: bool = False): + if mark_as_current: + self._prev_span_token = Scope.set_current_span(self) + + def finish(self, reset_current: bool = False) -> None: + if reset_current and self._prev_span_token is not None: + Scope.reset_current_span(self._prev_span_token) + self._prev_span_token = None + + def __enter__(self) -> Span[TSpanData]: + self.start(mark_as_current=True) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + reset_current = True + if exc_type is GeneratorExit: + logger.debug("GeneratorExit, skipping span reset") + reset_current = False + + self.finish(reset_current=reset_current) + + def set_error(self, error: SpanError) -> None: + pass + + @property + def error(self) -> SpanError | None: + return None + + def export(self) -> dict[str, Any] | None: + return None + + @property + def started_at(self) -> str | None: + return None + + @property + def ended_at(self) -> str | None: + return None + + +class SpanImpl(Span[TSpanData]): + __slots__ = ( + "_trace_id", + "_span_id", + "_parent_id", + "_started_at", + "_ended_at", + "_error", + "_prev_span_token", + "_processor", + "_span_data", + ) + + def __init__( + self, + trace_id: str, + span_id: str | None, + parent_id: str | None, + processor: TracingProcessor, + span_data: TSpanData, + ): + self._trace_id = trace_id + self._span_id = span_id or util.gen_span_id() + self._parent_id = parent_id + self._started_at: str | None = None + self._ended_at: str | None = None + self._processor = processor + self._error: SpanError | None = None + self._prev_span_token: contextvars.Token[Span[TSpanData] | None] | None = None + self._span_data = span_data + + @property + def trace_id(self) -> str: + return self._trace_id + + @property + def span_id(self) -> str: + return self._span_id + + @property + def span_data(self) -> TSpanData: + return self._span_data + + @property + def parent_id(self) -> str | None: + return self._parent_id + + def start(self, mark_as_current: bool = False): + if self.started_at is not None: + logger.warning("Span already started") + return + + self._started_at = util.time_iso() + self._processor.on_span_start(self) + if mark_as_current: + self._prev_span_token = Scope.set_current_span(self) + + def finish(self, reset_current: bool = False) -> None: + if self.ended_at is not None: + logger.warning("Span already finished") + return + + self._ended_at = util.time_iso() + self._processor.on_span_end(self) + if reset_current and self._prev_span_token is not None: + Scope.reset_current_span(self._prev_span_token) + self._prev_span_token = None + + def __enter__(self) -> Span[TSpanData]: + self.start(mark_as_current=True) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + reset_current = True + if exc_type is GeneratorExit: + logger.debug("GeneratorExit, skipping span reset") + reset_current = False + + self.finish(reset_current=reset_current) + + def set_error(self, error: SpanError) -> None: + self._error = error + + @property + def error(self) -> SpanError | None: + return self._error + + @property + def started_at(self) -> str | None: + return self._started_at + + @property + def ended_at(self) -> str | None: + return self._ended_at + + def export(self) -> dict[str, Any] | None: + return { + "object": "trace.span", + "id": self.span_id, + "trace_id": self.trace_id, + "parent_id": self._parent_id, + "started_at": self._started_at, + "ended_at": self._ended_at, + "span_data": self.span_data.export(), + "error": self._error, + } diff --git a/tests/src/agents/tracing/traces.py b/tests/src/agents/tracing/traces.py new file mode 100644 index 0000000..bf3b43d --- /dev/null +++ b/tests/src/agents/tracing/traces.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import abc +import contextvars +from typing import Any + +from . import util +from .logger import logger +from .processor_interface import TracingProcessor +from .scope import Scope + + +class Trace: + """ + A trace is the root level object that tracing creates. It represents a logical "workflow". + """ + + @abc.abstractmethod + def __enter__(self) -> Trace: + pass + + @abc.abstractmethod + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + @abc.abstractmethod + def start(self, mark_as_current: bool = False): + """ + Start the trace. + + Args: + mark_as_current: If true, the trace will be marked as the current trace. + """ + pass + + @abc.abstractmethod + def finish(self, reset_current: bool = False): + """ + Finish the trace. + + Args: + reset_current: If true, the trace will be reset as the current trace. + """ + pass + + @property + @abc.abstractmethod + def trace_id(self) -> str: + """ + The trace ID. + """ + pass + + @property + @abc.abstractmethod + def name(self) -> str: + """ + The name of the workflow being traced. + """ + pass + + @abc.abstractmethod + def export(self) -> dict[str, Any] | None: + """ + Export the trace as a dictionary. + """ + pass + + +class NoOpTrace(Trace): + """ + A no-op trace that will not be recorded. + """ + + def __init__(self): + self._started = False + self._prev_context_token: contextvars.Token[Trace | None] | None = None + + def __enter__(self) -> Trace: + if self._started: + if not self._prev_context_token: + logger.error("Trace already started but no context token set") + return self + + self._started = True + self.start(mark_as_current=True) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.finish(reset_current=True) + + def start(self, mark_as_current: bool = False): + if mark_as_current: + self._prev_context_token = Scope.set_current_trace(self) + + def finish(self, reset_current: bool = False): + if reset_current and self._prev_context_token is not None: + Scope.reset_current_trace(self._prev_context_token) + self._prev_context_token = None + + @property + def trace_id(self) -> str: + return "no-op" + + @property + def name(self) -> str: + return "no-op" + + def export(self) -> dict[str, Any] | None: + return None + + +NO_OP_TRACE = NoOpTrace() + + +class TraceImpl(Trace): + """ + A trace that will be recorded by the tracing library. + """ + + __slots__ = ( + "_name", + "_trace_id", + "group_id", + "metadata", + "_prev_context_token", + "_processor", + "_started", + ) + + def __init__( + self, + name: str, + trace_id: str | None, + group_id: str | None, + metadata: dict[str, Any] | None, + processor: TracingProcessor, + ): + self._name = name + self._trace_id = trace_id or util.gen_trace_id() + self.group_id = group_id + self.metadata = metadata + self._prev_context_token: contextvars.Token[Trace | None] | None = None + self._processor = processor + self._started = False + + @property + def trace_id(self) -> str: + return self._trace_id + + @property + def name(self) -> str: + return self._name + + def start(self, mark_as_current: bool = False): + if self._started: + return + + self._started = True + self._processor.on_trace_start(self) + + if mark_as_current: + self._prev_context_token = Scope.set_current_trace(self) + + def finish(self, reset_current: bool = False): + if not self._started: + return + + self._processor.on_trace_end(self) + + if reset_current and self._prev_context_token is not None: + Scope.reset_current_trace(self._prev_context_token) + self._prev_context_token = None + + def __enter__(self) -> Trace: + if self._started: + if not self._prev_context_token: + logger.error("Trace already started but no context token set") + return self + + self.start(mark_as_current=True) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.finish(reset_current=exc_type is not GeneratorExit) + + def export(self) -> dict[str, Any] | None: + return { + "object": "trace", + "id": self.trace_id, + "workflow_name": self.name, + "group_id": self.group_id, + "metadata": self.metadata, + } diff --git a/tests/src/agents/tracing/util.py b/tests/src/agents/tracing/util.py new file mode 100644 index 0000000..3e5cad9 --- /dev/null +++ b/tests/src/agents/tracing/util.py @@ -0,0 +1,17 @@ +import uuid +from datetime import datetime, timezone + + +def time_iso() -> str: + """Returns the current time in ISO 8601 format.""" + return datetime.now(timezone.utc).isoformat() + + +def gen_trace_id() -> str: + """Generates a new trace ID.""" + return f"trace_{uuid.uuid4().hex}" + + +def gen_span_id() -> str: + """Generates a new span ID.""" + return f"span_{uuid.uuid4().hex[:24]}" diff --git a/tests/src/agents/usage.py b/tests/src/agents/usage.py new file mode 100644 index 0000000..23d989b --- /dev/null +++ b/tests/src/agents/usage.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass +class Usage: + requests: int = 0 + """Total requests made to the LLM API.""" + + input_tokens: int = 0 + """Total input tokens sent, across all requests.""" + + output_tokens: int = 0 + """Total output tokens received, across all requests.""" + + total_tokens: int = 0 + """Total tokens sent and received, across all requests.""" + + def add(self, other: "Usage") -> None: + self.requests += other.requests if other.requests else 0 + self.input_tokens += other.input_tokens if other.input_tokens else 0 + self.output_tokens += other.output_tokens if other.output_tokens else 0 + self.total_tokens += other.total_tokens if other.total_tokens else 0 diff --git a/tests/src/agents/version.py b/tests/src/agents/version.py new file mode 100644 index 0000000..a0b7e9b --- /dev/null +++ b/tests/src/agents/version.py @@ -0,0 +1,7 @@ +import importlib.metadata + +try: + __version__ = importlib.metadata.version("agents") +except importlib.metadata.PackageNotFoundError: + # Fallback if running from source without being installed + __version__ = "0.0.0" diff --git a/tests/src/openai_agents.egg-info/PKG-INFO b/tests/src/openai_agents.egg-info/PKG-INFO new file mode 100644 index 0000000..ebf2d7c --- /dev/null +++ b/tests/src/openai_agents.egg-info/PKG-INFO @@ -0,0 +1,217 @@ +Metadata-Version: 2.2 +Name: openai-agents +Version: 0.0.1 +Summary: OpenAI Agents SDK +Author-email: OpenAI +Project-URL: Homepage, https://github.com/openai/openai-agents-python +Project-URL: Repository, https://github.com/openai/openai-agents-python +Classifier: Typing :: Typed +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Intended Audience :: Developers +Classifier: Intended Audience :: Information Technology +Classifier: Operating System :: OS Independent +Classifier: Operating System :: POSIX +Classifier: Operating System :: MacOS +Classifier: Operating System :: POSIX :: Linux +Classifier: Operating System :: Microsoft :: Windows +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.9 +Description-Content-Type: text/markdown +Requires-Dist: openai@ {root:parent:uri}/openai-1.30.1-py3-none-any.whl +Requires-Dist: pydantic<3,>=2.10 +Requires-Dist: griffe<2,>=1.5.6 +Requires-Dist: typing-extensions<5,>=4.12.2 +Requires-Dist: requests<3,>=2.0 +Requires-Dist: types-requests<3,>=2.0 + +# OpenAI Agents SDK + +The OpenAI Agents SDK is a lightweight yet powerful framework for building multi-agent workflows. + +### Core concepts: +1. [**Agents,**](docs/agents.md) which are LLMs configured with instructions, tools, guardrails, and handoffs +2. [**Handoffs,**](docs/handoffs.md) which allow agents to transfer control to other agents for specific tasks +3. [**Guardrails,**](docs/guardrails.md) which makes it easy to watch an agent execution and validate inputs/outputs +4. [**Tracing,**](docs/tracing.md) which automatically captures the entire agentic run, allowing you to view, debug and optimize your workflows + +Explore examples of the SDK in action in the [examples](examples) directory. + +## Using the SDK + +1. Set up python env + +``` +python -m venv env +source env/bin/activate +``` + +2. Install Agents SDK + +``` +pip install git+ssh://git@github.com/openai/agentsdk_prototype.git#subdirectory=agents +``` + +## Development (only needed if you need to edit the SDK/examples) + +0. Ensure you have [`uv`](https://docs.astral.sh/uv/) installed. + +```bash +uv --version +``` + +1. Install dependencies/setup virtual environment + +```bash +uv sync +``` + +2. Install the dependencies + +```bash +uv sync --all-extras --all-packages +``` + +3. Activate the virtual environment + +```bash +source .venv/bin/activate +``` + +## Tests + +Make sure the virtual environment is activated first. + +```bash +pytest +``` + +## Hello world example + +```py +from agents.agent import Agent +from agents.run import Runner +import asyncio + +agent = Agent( + name="Hello world", + instructions="You are a helpful agent." +) + +async def main(): + out = await Runner.run(agent, input="Hola, ¿cómo estás?") + print(out) + + +if __name__ == "__main__": + asyncio.run(main()) + +# The capital of the United States is Washington, D.C. +``` + +## Handoffs example + +```py +from agents.agent import Agent +from agents.run import Runner +import asyncio + +spanish_agent = Agent( + name="spanish_agent", + instructions="You only speak Spanish.", +) + +english_agent = Agent( + name="english_agent", + instructions="You only speak English", +) + +triage_agent = Agent( + name="triage_agent", + instructions="Handoff to the appropriate agent based on the language of the request.", + handoffs=[spanish_agent, english_agent], +) + + +async def main(): + out = await Runner.run(triage_agent, input="Hola, ¿cómo estás?") + print(out) + + +if __name__ == "__main__": + asyncio.run(main()) + +# ¡Hola! Estoy bien, gracias por preguntar. ¿Y tú, cómo estás? +``` + +## Functions example + +```python +from agents.agent import Agent +from agents.run import Runner +import asyncio +from agents.tool import function_tool + + +@function_tool +def get_weather(city: str) -> str: + print(f"Getting weather for {city}") + return f"The weather in {city} is sunny." + + +agent = Agent( + name="Hello world", + instructions="You are a helpful agent.", + tools=[get_weather], +) + + +async def main(): + out = await Runner.run(agent, input="What's the weather in Tokyo?") + print(out.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +For more complex systems, we recommend including detailed instructions about handoffs. We have a recommendation in `handoff.RECOMMENDED_PROMPT_PREFIX` that can be used to add these instructions to an agent. + +```py +agent = Agent( + ..., + instructions=f"{handoff.RECOMMENDED_PROMPT_PREFIX}\n\n{instructions}" +) +``` + +## The agent loop + +When you call `Runner.run()`, we run a loop until we get a final output. + +1. We call the LLM, using the model and settings on the agent, and the message history. +2. The LLM returns a response, which may include tool calls. +3. If the response has a final output (see below for the more on this), we return it and end the loop. +4. If the response has a handoff, we set the agent to the new agent and go back to step 1. +5. We process the tool calls (if any) and append the tool responses messsages. Then we go to step 1. + +There is a `max_turns` parameter that you can use to limit the number of times the loop executes. + +### Final output + +There are two ways to get a **final output**: + +1. If you set an `output_type` on the agent, the LLM is given a special tool called `final_output`. If it uses this tool, the output of the tool is the final output. +2. If there's no `output_type`, then we assume the final output is a string. As soon as the LLM produces a message without any tool calls, that is considered the final output. + +As a result, the mental model for the agent loop is: + +1. If the current agent has an `output_type`, the loop runs until the agent uses that tool to return the final output. +2. If the current agent does not have an `output_type`, the loop runs until the current agent produces a message without any tool calls. + +## Common agent patterns + +There are a number of useful patterns in agentic apps. There are a number of examples in [`examples/agent_patterns`](examples/agent_patterns), and we recommend reading them. diff --git a/tests/src/openai_agents.egg-info/SOURCES.txt b/tests/src/openai_agents.egg-info/SOURCES.txt new file mode 100644 index 0000000..695ad1f --- /dev/null +++ b/tests/src/openai_agents.egg-info/SOURCES.txt @@ -0,0 +1,81 @@ +README.md +pyproject.toml +src/agents/__init__.py +src/agents/_config.py +src/agents/_debug.py +src/agents/_run_impl.py +src/agents/_utils.py +src/agents/agent.py +src/agents/agent_output.py +src/agents/call_agent_tool.py +src/agents/computer.py +src/agents/exceptions.py +src/agents/function_schema.py +src/agents/guardrail.py +src/agents/handoffs.py +src/agents/items.py +src/agents/lifecycle.py +src/agents/logger.py +src/agents/model_settings.py +src/agents/result.py +src/agents/run.py +src/agents/run_context.py +src/agents/strict_schema.py +src/agents/tool.py +src/agents/usage.py +src/agents/version.py +src/agents/extensions/__init__.py +src/agents/extensions/handoff_filters.py +src/agents/extensions/handoff_prompt.py +src/agents/models/__init__.py +src/agents/models/_openai_shared.py +src/agents/models/fake_id.py +src/agents/models/interface.py +src/agents/models/map.py +src/agents/models/openai_chatcompletions.py +src/agents/models/openai_responses.py +src/agents/tracing/__init__.py +src/agents/tracing/create.py +src/agents/tracing/logger.py +src/agents/tracing/processor_interface.py +src/agents/tracing/processors.py +src/agents/tracing/scope.py +src/agents/tracing/setup.py +src/agents/tracing/span_data.py +src/agents/tracing/spans.py +src/agents/tracing/traces.py +src/agents/tracing/util.py +src/openai_agents.egg-info/PKG-INFO +src/openai_agents.egg-info/SOURCES.txt +src/openai_agents.egg-info/dependency_links.txt +src/openai_agents.egg-info/requires.txt +src/openai_agents.egg-info/top_level.txt +tests/test_agent_config.py +tests/test_agent_hooks.py +tests/test_agent_runner.py +tests/test_agent_runner_streamed.py +tests/test_agent_tracing.py +tests/test_config.py +tests/test_doc_parsing.py +tests/test_function_schema.py +tests/test_function_tool.py +tests/test_function_tool_decorator.py +tests/test_global_hooks.py +tests/test_guardrails.py +tests/test_handoff_tool.py +tests/test_items_helpers.py +tests/test_max_turns.py +tests/test_model_mapper.py +tests/test_openai_chatcompletions_converter.py +tests/test_openai_responses_converter.py +tests/test_output_tool.py +tests/test_responses.py +tests/test_run_config.py +tests/test_run_step_execution.py +tests/test_run_step_processing.py +tests/test_tool_converter.py +tests/test_trace_processor.py +tests/test_tracing.py +tests/test_tracing_errors.py +tests/test_tracing_errors_streamed.py +tests/testing_processor.py \ No newline at end of file diff --git a/tests/src/openai_agents.egg-info/dependency_links.txt b/tests/src/openai_agents.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/src/openai_agents.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/tests/src/openai_agents.egg-info/requires.txt b/tests/src/openai_agents.egg-info/requires.txt new file mode 100644 index 0000000..3dbad2b --- /dev/null +++ b/tests/src/openai_agents.egg-info/requires.txt @@ -0,0 +1,6 @@ +openai@ {root:parent:uri}/openai-1.30.1-py3-none-any.whl +pydantic<3,>=2.10 +griffe<2,>=1.5.6 +typing-extensions<5,>=4.12.2 +requests<3,>=2.0 +types-requests<3,>=2.0 diff --git a/tests/src/openai_agents.egg-info/top_level.txt b/tests/src/openai_agents.egg-info/top_level.txt new file mode 100644 index 0000000..4a33ff6 --- /dev/null +++ b/tests/src/openai_agents.egg-info/top_level.txt @@ -0,0 +1 @@ +agents diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py new file mode 100644 index 0000000..44339da --- /dev/null +++ b/tests/test_agent_config.py @@ -0,0 +1,167 @@ +import pytest +from pydantic import BaseModel + +from agents import Agent, Handoff, RunContextWrapper, Runner, handoff + + +@pytest.mark.asyncio +async def test_system_instructions(): + agent = Agent[None]( + name="test", + instructions="abc123", + ) + context = RunContextWrapper(None) + + assert await agent.get_system_prompt(context) == "abc123" + + def sync_instructions(agent: Agent[None], context: RunContextWrapper[None]) -> str: + return "sync_123" + + agent = agent.clone(instructions=sync_instructions) + assert await agent.get_system_prompt(context) == "sync_123" + + async def async_instructions(agent: Agent[None], context: RunContextWrapper[None]) -> str: + return "async_123" + + agent = agent.clone(instructions=async_instructions) + assert await agent.get_system_prompt(context) == "async_123" + + +@pytest.mark.asyncio +async def test_handoff_with_agents(): + agent_1 = Agent( + name="agent_1", + ) + + agent_2 = Agent( + name="agent_2", + ) + + agent_3 = Agent( + name="agent_3", + handoffs=[agent_1, agent_2], + ) + + handoffs = Runner._get_handoffs(agent_3) + assert len(handoffs) == 2 + + assert handoffs[0].agent_name == "agent_1" + assert handoffs[1].agent_name == "agent_2" + + first_return = await handoffs[0].on_invoke_handoff(RunContextWrapper(None), "") + assert first_return == agent_1 + + second_return = await handoffs[1].on_invoke_handoff(RunContextWrapper(None), "") + assert second_return == agent_2 + + +@pytest.mark.asyncio +async def test_handoff_with_handoff_obj(): + agent_1 = Agent( + name="agent_1", + ) + + agent_2 = Agent( + name="agent_2", + ) + + agent_3 = Agent( + name="agent_3", + handoffs=[ + handoff(agent_1), + handoff( + agent_2, + tool_name_override="transfer_to_2", + tool_description_override="description_2", + ), + ], + ) + + handoffs = Runner._get_handoffs(agent_3) + assert len(handoffs) == 2 + + assert handoffs[0].agent_name == "agent_1" + assert handoffs[1].agent_name == "agent_2" + + assert handoffs[0].tool_name == Handoff.default_tool_name(agent_1) + assert handoffs[1].tool_name == "transfer_to_2" + + assert handoffs[0].tool_description == Handoff.default_tool_description(agent_1) + assert handoffs[1].tool_description == "description_2" + + first_return = await handoffs[0].on_invoke_handoff(RunContextWrapper(None), "") + assert first_return == agent_1 + + second_return = await handoffs[1].on_invoke_handoff(RunContextWrapper(None), "") + assert second_return == agent_2 + + +@pytest.mark.asyncio +async def test_handoff_with_handoff_obj_and_agent(): + agent_1 = Agent( + name="agent_1", + ) + + agent_2 = Agent( + name="agent_2", + ) + + agent_3 = Agent( + name="agent_3", + handoffs=[handoff(agent_1), agent_2], + ) + + handoffs = Runner._get_handoffs(agent_3) + assert len(handoffs) == 2 + + assert handoffs[0].agent_name == "agent_1" + assert handoffs[1].agent_name == "agent_2" + + assert handoffs[0].tool_name == Handoff.default_tool_name(agent_1) + assert handoffs[1].tool_name == Handoff.default_tool_name(agent_2) + + assert handoffs[0].tool_description == Handoff.default_tool_description(agent_1) + assert handoffs[1].tool_description == Handoff.default_tool_description(agent_2) + + first_return = await handoffs[0].on_invoke_handoff(RunContextWrapper(None), "") + assert first_return == agent_1 + + second_return = await handoffs[1].on_invoke_handoff(RunContextWrapper(None), "") + assert second_return == agent_2 + + +@pytest.mark.asyncio +async def test_agent_cloning(): + agent = Agent( + name="test", + handoff_description="test_description", + model="o3-mini", + ) + + cloned = agent.clone( + handoff_description="new_description", + model="o1", + ) + + assert cloned.name == "test" + assert cloned.handoff_description == "new_description" + assert cloned.model == "o1" + + +class Foo(BaseModel): + bar: str + + +@pytest.mark.asyncio +async def test_agent_final_output(): + agent = Agent( + name="test", + output_type=Foo, + ) + + schema = Runner._get_output_schema(agent) + assert schema is not None + assert schema.output_type == Foo + assert schema.strict_json_schema is True + assert schema.json_schema() is not None + assert not schema.is_plain_text() diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py new file mode 100644 index 0000000..33107cb --- /dev/null +++ b/tests/test_agent_hooks.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from typing import Any + +import pytest +from typing_extensions import TypedDict + +from agents.agent import Agent +from agents.lifecycle import AgentHooks +from agents.run import Runner +from agents.run_context import RunContextWrapper, TContext +from agents.tool import Tool + +from .fake_model import FakeModel +from .test_responses import ( + get_final_output_message, + get_function_tool, + get_function_tool_call, + get_handoff_tool_call, + get_text_message, +) + + +class AgentHooksForTests(AgentHooks): + def __init__(self): + self.events: dict[str, int] = defaultdict(int) + + def reset(self): + self.events.clear() + + async def on_start(self, context: RunContextWrapper[TContext], agent: Agent[TContext]) -> None: + self.events["on_start"] += 1 + + async def on_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + output: Any, + ) -> None: + self.events["on_end"] += 1 + + async def on_handoff( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + source: Agent[TContext], + ) -> None: + self.events["on_handoff"] += 1 + + async def on_tool_start( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + ) -> None: + self.events["on_tool_start"] += 1 + + async def on_tool_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + result: str, + ) -> None: + self.events["on_tool_end"] += 1 + + +@pytest.mark.asyncio +async def test_non_streamed_agent_hooks(): + hooks = AgentHooksForTests() + model = FakeModel() + agent_1 = Agent( + name="test_1", + model=model, + ) + agent_2 = Agent( + name="test_2", + model=model, + ) + agent_3 = Agent( + name="test_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + hooks=hooks, + ) + + agent_1.handoffs.append(agent_3) + + model.set_next_output([get_text_message("user_message")]) + output = await Runner.run(agent_3, input="user_message") + assert hooks.events == {"on_start": 1, "on_end": 1}, f"{output}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: text message + [get_text_message("done")], + ] + ) + await Runner.run(agent_3, input="user_message") + + # Shouldn't have on_end because it's not the last agent + assert hooks.events == { + "on_start": 1, # Agent runs once + "on_tool_start": 1, # Only one tool call + "on_tool_end": 1, # Only one tool call + "on_handoff": 1, # Only one handoff + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message, another tool call, and a handoff + [ + get_text_message("a_message"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_handoff_tool_call(agent_1), + ], + # Third turn: a message and a handoff back to the orig agent + [get_text_message("a_message"), get_handoff_tool_call(agent_3)], + # Fourth turn: text message + [get_text_message("done")], + ] + ) + await Runner.run(agent_3, input="user_message") + + assert hooks.events == { + "on_start": 2, # Agent runs twice + "on_tool_start": 2, # Only one tool call + "on_tool_end": 2, # Only one tool call + "on_handoff": 1, # Only one handoff + "on_end": 1, # Agent 3 is the last agent + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + +@pytest.mark.asyncio +async def test_streamed_agent_hooks(): + hooks = AgentHooksForTests() + model = FakeModel() + agent_1 = Agent(name="test_1", model=model) + agent_2 = Agent(name="test_2", model=model) + agent_3 = Agent( + name="test_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + hooks=hooks, + ) + + agent_1.handoffs.append(agent_3) + + model.set_next_output([get_text_message("user_message")]) + output = Runner.run_streamed(agent_3, input="user_message") + async for _ in output.stream_events(): + pass + assert hooks.events == {"on_start": 1, "on_end": 1}, f"{output}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: text message + [get_text_message("done")], + ] + ) + output = Runner.run_streamed(agent_3, input="user_message") + async for _ in output.stream_events(): + pass + + # Shouldn't have on_end because it's not the last agent + assert hooks.events == { + "on_start": 1, # Agent runs twice + "on_tool_start": 1, # Only one tool call + "on_tool_end": 1, # Only one tool call + "on_handoff": 1, # Only one handoff + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message, another tool call, and a handoff + [ + get_text_message("a_message"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_handoff_tool_call(agent_1), + ], + # Third turn: a message and a handoff back to the orig agent + [get_text_message("a_message"), get_handoff_tool_call(agent_3)], + # Fourth turn: text message + [get_text_message("done")], + ] + ) + output = Runner.run_streamed(agent_3, input="user_message") + async for _ in output.stream_events(): + pass + + assert hooks.events == { + "on_start": 2, # Agent runs twice + "on_tool_start": 2, # Only one tool call + "on_tool_end": 2, # Only one tool call + "on_handoff": 1, # Only one handoff + "on_end": 1, # Agent 3 is the last agent + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + +class Foo(TypedDict): + a: str + + +@pytest.mark.asyncio +async def test_structed_output_non_streamed_agent_hooks(): + hooks = AgentHooksForTests() + model = FakeModel() + agent_1 = Agent(name="test_1", model=model) + agent_2 = Agent(name="test_2", model=model) + agent_3 = Agent( + name="test_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + hooks=hooks, + output_type=Foo, + ) + + agent_1.handoffs.append(agent_3) + + model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + output = await Runner.run(agent_3, input="user_message") + assert hooks.events == {"on_start": 1, "on_end": 1}, f"{output}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: end message (for agent 1) + [get_text_message("done")], + ] + ) + await Runner.run(agent_3, input="user_message") + + # Shouldn't have on_end because it's not the last agent + assert hooks.events == { + "on_start": 1, # Agent runs twice + "on_tool_start": 1, # Only one tool call + "on_tool_end": 1, # Only one tool call + "on_handoff": 1, # Only one handoff + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message, another tool call, and a handoff + [ + get_text_message("a_message"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_handoff_tool_call(agent_1), + ], + # Third turn: a message and a handoff back to the orig agent + [get_text_message("a_message"), get_handoff_tool_call(agent_3)], + # Fourth turn: end message (for agent 3) + [get_final_output_message(json.dumps({"a": "b"}))], + ] + ) + await Runner.run(agent_3, input="user_message") + + assert hooks.events == { + "on_start": 2, # Agent runs twice + "on_tool_start": 2, # Only one tool call + "on_tool_end": 2, # Only one tool call + "on_handoff": 1, # Only one handoff + "on_end": 1, # Agent 3 is the last agent + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + +@pytest.mark.asyncio +async def test_structed_output_streamed_agent_hooks(): + hooks = AgentHooksForTests() + model = FakeModel() + agent_1 = Agent(name="test_1", model=model) + agent_2 = Agent(name="test_2", model=model) + agent_3 = Agent( + name="test_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + hooks=hooks, + output_type=Foo, + ) + + agent_1.handoffs.append(agent_3) + + model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + output = Runner.run_streamed(agent_3, input="user_message") + async for _ in output.stream_events(): + pass + assert hooks.events == {"on_start": 1, "on_end": 1}, f"{output}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: end message (for agent 1) + [get_text_message("done")], + ] + ) + await Runner.run(agent_3, input="user_message") + # Shouldn't have on_end because it's not the last agent + assert hooks.events == { + "on_start": 1, # Agent runs twice + "on_tool_start": 1, # Only one tool call + "on_tool_end": 1, # Only one tool call + "on_handoff": 1, # Only one handoff + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message, another tool call, and a handoff + [ + get_text_message("a_message"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_handoff_tool_call(agent_1), + ], + # Third turn: a message and a handoff back to the orig agent + [get_text_message("a_message"), get_handoff_tool_call(agent_3)], + # Fourth turn: end message (for agent 3) + [get_final_output_message(json.dumps({"a": "b"}))], + ] + ) + output = Runner.run_streamed(agent_3, input="user_message") + async for _ in output.stream_events(): + pass + + assert hooks.events == { + "on_start": 2, # Agent runs twice + "on_tool_start": 2, # 2 tool calls + "on_tool_end": 2, # 2 tool calls + "on_handoff": 1, # 1 handoff + "on_end": 1, # Agent 3 is the last agent + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + +class EmptyAgentHooks(AgentHooks): + pass + + +@pytest.mark.asyncio +async def test_base_agent_hooks_dont_crash(): + hooks = EmptyAgentHooks() + model = FakeModel() + agent_1 = Agent(name="test_1", model=model) + agent_2 = Agent(name="test_2", model=model) + agent_3 = Agent( + name="test_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + hooks=hooks, + output_type=Foo, + ) + agent_1.handoffs.append(agent_3) + + model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + output = Runner.run_streamed(agent_3, input="user_message") + async for _ in output.stream_events(): + pass + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: end message (for agent 1) + [get_text_message("done")], + ] + ) + await Runner.run(agent_3, input="user_message") + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message, another tool call, and a handoff + [ + get_text_message("a_message"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_handoff_tool_call(agent_1), + ], + # Third turn: a message and a handoff back to the orig agent + [get_text_message("a_message"), get_handoff_tool_call(agent_3)], + # Fourth turn: end message (for agent 3) + [get_final_output_message(json.dumps({"a": "b"}))], + ] + ) + output = Runner.run_streamed(agent_3, input="user_message") + async for _ in output.stream_events(): + pass diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py new file mode 100644 index 0000000..c124915 --- /dev/null +++ b/tests/test_agent_runner.py @@ -0,0 +1,554 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest +from typing_extensions import TypedDict + +from agents import ( + Agent, + GuardrailFunctionOutput, + Handoff, + HandoffInputData, + InputGuardrail, + InputGuardrailTripwireTriggered, + ModelBehaviorError, + OutputGuardrail, + OutputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + UserError, + handoff, +) + +from .fake_model import FakeModel +from .test_responses import ( + get_final_output_message, + get_function_tool, + get_function_tool_call, + get_handoff_tool_call, + get_text_input_item, + get_text_message, +) + + +@pytest.mark.asyncio +async def test_simple_first_run(): + model = FakeModel() + agent = Agent( + name="test", + model=model, + ) + model.set_next_output([get_text_message("first")]) + + result = await Runner.run(agent, input="test") + assert result.input == "test" + assert len(result.new_items) == 1, "exactly one item should be generated" + assert result.final_output == "first" + assert len(result.raw_responses) == 1, "exactly one model response should be generated" + assert result.raw_responses[0].output == [get_text_message("first")] + assert result.last_agent == agent + + assert len(result.to_input_list()) == 2, "should have original input and generated item" + + model.set_next_output([get_text_message("second")]) + + result = await Runner.run( + agent, input=[get_text_input_item("message"), get_text_input_item("another_message")] + ) + assert len(result.new_items) == 1, "exactly one item should be generated" + assert result.final_output == "second" + assert len(result.raw_responses) == 1, "exactly one model response should be generated" + assert len(result.to_input_list()) == 3, "should have original input and generated item" + + +@pytest.mark.asyncio +async def test_subsequent_runs(): + model = FakeModel() + agent = Agent( + name="test", + model=model, + ) + model.set_next_output([get_text_message("third")]) + + result = await Runner.run(agent, input="test") + assert result.input == "test" + assert len(result.new_items) == 1, "exactly one item should be generated" + assert len(result.to_input_list()) == 2, "should have original input and generated item" + + model.set_next_output([get_text_message("fourth")]) + + result = await Runner.run(agent, input=result.to_input_list()) + assert len(result.input) == 2, f"should have previous input but got {result.input}" + assert len(result.new_items) == 1, "exactly one item should be generated" + assert result.final_output == "fourth" + assert len(result.raw_responses) == 1, "exactly one model response should be generated" + assert result.raw_responses[0].output == [get_text_message("fourth")] + assert result.last_agent == agent + assert len(result.to_input_list()) == 3, "should have original input and generated items" + + +@pytest.mark.asyncio +async def test_tool_call_runs(): + model = FakeModel() + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("foo", "tool_result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], + # Second turn: text message + [get_text_message("done")], + ] + ) + + result = await Runner.run(agent, input="user_message") + + assert result.final_output == "done" + assert len(result.raw_responses) == 2, ( + "should have two responses: the first which produces a tool call, and the second which" + "handles the tool result" + ) + + assert len(result.to_input_list()) == 5, ( + "should have five inputs: the original input, the message, the tool call, the tool result " + "and the done message" + ) + + +@pytest.mark.asyncio +async def test_handoffs(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + agent_2 = Agent( + name="test", + model=model, + ) + agent_3 = Agent( + name="test", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: text message + [get_text_message("done")], + ] + ) + + result = await Runner.run(agent_3, input="user_message") + + assert result.final_output == "done" + assert len(result.raw_responses) == 3, "should have three model responses" + assert len(result.to_input_list()) == 7, ( + "should have 7 inputs: orig input, tool call, tool result, message, handoff, handoff" + "result, and done message" + ) + assert result.last_agent == agent_1, "should have handed off to agent_1" + + +class Foo(TypedDict): + bar: str + + +@pytest.mark.asyncio +async def test_structured_output(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + tools=[get_function_tool("bar", "bar_result")], + output_type=Foo, + ) + + agent_2 = Agent( + name="test", + model=model, + tools=[get_function_tool("foo", "foo_result")], + handoffs=[agent_1], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("foo", json.dumps({"bar": "baz"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: tool call and structured output + [ + get_function_tool_call("bar", json.dumps({"bar": "baz"})), + get_final_output_message(json.dumps(Foo(bar="baz"))), + ], + ] + ) + + result = await Runner.run( + agent_2, + input=[ + get_text_input_item("user_message"), + get_text_input_item("another_message"), + ], + ) + + assert result.final_output == Foo(bar="baz") + assert len(result.raw_responses) == 3, "should have three model responses" + assert len(result.to_input_list()) == 10, ( + "should have input: 2 orig inputs, function call, function call result, message, handoff, " + "handoff output, tool call, tool call result, final output message" + ) + + assert result.last_agent == agent_1, "should have handed off to agent_1" + assert result.final_output == Foo(bar="baz"), "should have structured output" + + +def remove_new_items(handoff_input_data: HandoffInputData) -> HandoffInputData: + return HandoffInputData( + input_history=handoff_input_data.input_history, + pre_handoff_items=(), + new_items=(), + ) + + +@pytest.mark.asyncio +async def test_handoff_filters(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + agent_2 = Agent( + name="test", + model=model, + handoffs=[ + handoff( + agent=agent_1, + input_filter=remove_new_items, + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], + [get_text_message("last")], + ] + ) + + result = await Runner.run(agent_2, input="user_message") + + assert result.final_output == "last" + assert len(result.raw_responses) == 2, "should have two model responses" + assert len(result.to_input_list()) == 2, ( + "should only have 2 inputs: orig input and last message" + ) + + +@pytest.mark.asyncio +async def test_async_input_filter_fails(): + # DO NOT rename this without updating pyproject.toml + + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]: + return agent_1 + + async def invalid_input_filter(data: HandoffInputData) -> HandoffInputData: + return data # pragma: no cover + + agent_2 = Agent[None]( + name="test", + model=model, + handoffs=[ + Handoff( + tool_name=Handoff.default_tool_name(agent_1), + tool_description=Handoff.default_tool_description(agent_1), + input_json_schema={}, + on_invoke_handoff=on_invoke_handoff, + agent_name=agent_1.name, + # Purposely ignoring the type error here to simulate invalid input + input_filter=invalid_input_filter, # type: ignore + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], + [get_text_message("last")], + ] + ) + + with pytest.raises(UserError): + await Runner.run(agent_2, input="user_message") + + +@pytest.mark.asyncio +async def test_invalid_input_filter_fails(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]: + return agent_1 + + def invalid_input_filter(data: HandoffInputData) -> HandoffInputData: + # Purposely returning a string to simulate invalid output + return "foo" # type: ignore + + agent_2 = Agent[None]( + name="test", + model=model, + handoffs=[ + Handoff( + tool_name=Handoff.default_tool_name(agent_1), + tool_description=Handoff.default_tool_description(agent_1), + input_json_schema={}, + on_invoke_handoff=on_invoke_handoff, + agent_name=agent_1.name, + input_filter=invalid_input_filter, + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], + [get_text_message("last")], + ] + ) + + with pytest.raises(UserError): + await Runner.run(agent_2, input="user_message") + + +@pytest.mark.asyncio +async def test_non_callable_input_filter_causes_error(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]: + return agent_1 + + agent_2 = Agent[None]( + name="test", + model=model, + handoffs=[ + Handoff( + tool_name=Handoff.default_tool_name(agent_1), + tool_description=Handoff.default_tool_description(agent_1), + input_json_schema={}, + on_invoke_handoff=on_invoke_handoff, + agent_name=agent_1.name, + # Purposely ignoring the type error here to simulate invalid input + input_filter="foo", # type: ignore + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], + [get_text_message("last")], + ] + ) + + with pytest.raises(UserError): + await Runner.run(agent_2, input="user_message") + + +@pytest.mark.asyncio +async def test_handoff_on_input(): + call_output: str | None = None + + def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: + nonlocal call_output + call_output = data["bar"] + + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + agent_2 = Agent( + name="test", + model=model, + handoffs=[ + handoff( + agent=agent_1, + on_handoff=on_input, + input_type=Foo, + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [ + get_text_message("1"), + get_text_message("2"), + get_handoff_tool_call(agent_1, args=json.dumps(Foo(bar="test_input"))), + ], + [get_text_message("last")], + ] + ) + + result = await Runner.run(agent_2, input="user_message") + + assert result.final_output == "last" + + assert call_output == "test_input", "should have called the handoff with the correct input" + + +@pytest.mark.asyncio +async def test_async_handoff_on_input(): + call_output: str | None = None + + async def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: + nonlocal call_output + call_output = data["bar"] + + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + agent_2 = Agent( + name="test", + model=model, + handoffs=[ + handoff( + agent=agent_1, + on_handoff=on_input, + input_type=Foo, + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [ + get_text_message("1"), + get_text_message("2"), + get_handoff_tool_call(agent_1, args=json.dumps(Foo(bar="test_input"))), + ], + [get_text_message("last")], + ] + ) + + result = await Runner.run(agent_2, input="user_message") + + assert result.final_output == "last" + + assert call_output == "test_input", "should have called the handoff with the correct input" + + +@pytest.mark.asyncio +async def test_wrong_params_on_input_causes_error(): + agent_1 = Agent( + name="test", + ) + + def _on_handoff_too_many_params(ctx: RunContextWrapper[Any], foo: Foo, bar: str) -> None: + pass + + with pytest.raises(UserError): + handoff( + agent_1, + input_type=Foo, + # Purposely ignoring the type error here to simulate invalid input + on_handoff=_on_handoff_too_many_params, # type: ignore + ) + + def on_handoff_too_few_params(ctx: RunContextWrapper[Any]) -> None: + pass + + with pytest.raises(UserError): + handoff( + agent_1, + input_type=Foo, + # Purposely ignoring the type error here to simulate invalid input + on_handoff=on_handoff_too_few_params, # type: ignore + ) + + +@pytest.mark.asyncio +async def test_invalid_handoff_input_json_causes_error(): + agent = Agent(name="test") + h = handoff(agent, input_type=Foo, on_handoff=lambda _ctx, _input: None) + + with pytest.raises(ModelBehaviorError): + await h.on_invoke_handoff( + RunContextWrapper(None), + # Purposely ignoring the type error here to simulate invalid input + None, # type: ignore + ) + + with pytest.raises(ModelBehaviorError): + await h.on_invoke_handoff(RunContextWrapper(None), "invalid") + + +@pytest.mark.asyncio +async def test_input_guardrail_tripwire_triggered_causes_exception(): + def guardrail_function( + context: RunContextWrapper[Any], agent: Agent[Any], input: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=True, + ) + + agent = Agent( + name="test", input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)] + ) + model = FakeModel() + model.set_next_output([get_text_message("user_message")]) + + with pytest.raises(InputGuardrailTripwireTriggered): + await Runner.run(agent, input="user_message") + + +@pytest.mark.asyncio +async def test_output_guardrail_tripwire_triggered_causes_exception(): + def guardrail_function( + context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=True, + ) + + model = FakeModel() + agent = Agent( + name="test", + output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)], + model=model, + ) + model.set_next_output([get_text_message("user_message")]) + + with pytest.raises(OutputGuardrailTripwireTriggered): + await Runner.run(agent, input="user_message") diff --git a/tests/test_agent_runner_streamed.py b/tests/test_agent_runner_streamed.py new file mode 100644 index 0000000..4c7c7ef --- /dev/null +++ b/tests/test_agent_runner_streamed.py @@ -0,0 +1,686 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest +from typing_extensions import TypedDict + +from agents import ( + Agent, + GuardrailFunctionOutput, + Handoff, + HandoffInputData, + InputGuardrail, + InputGuardrailTripwireTriggered, + OutputGuardrail, + OutputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + UserError, + handoff, +) +from agents.items import RunItem +from agents.run import RunConfig +from agents.stream_events import AgentUpdatedStreamEvent + +from .fake_model import FakeModel +from .test_responses import ( + get_final_output_message, + get_function_tool, + get_function_tool_call, + get_handoff_tool_call, + get_text_input_item, + get_text_message, +) + + +@pytest.mark.asyncio +async def test_simple_first_run(): + model = FakeModel() + agent = Agent( + name="test", + model=model, + ) + model.set_next_output([get_text_message("first")]) + + result = Runner.run_streamed(agent, input="test") + async for _ in result.stream_events(): + pass + + assert result.input == "test" + assert len(result.new_items) == 1, "exactly one item should be generated" + assert result.final_output == "first" + assert len(result.raw_responses) == 1, "exactly one model response should be generated" + assert result.raw_responses[0].output == [get_text_message("first")] + assert result.last_agent == agent + + assert len(result.to_input_list()) == 2, "should have original input and generated item" + + model.set_next_output([get_text_message("second")]) + + result = Runner.run_streamed( + agent, input=[get_text_input_item("message"), get_text_input_item("another_message")] + ) + async for _ in result.stream_events(): + pass + + assert len(result.new_items) == 1, "exactly one item should be generated" + assert result.final_output == "second" + assert len(result.raw_responses) == 1, "exactly one model response should be generated" + assert len(result.to_input_list()) == 3, "should have original input and generated item" + + +@pytest.mark.asyncio +async def test_subsequent_runs(): + model = FakeModel() + agent = Agent( + name="test", + model=model, + ) + model.set_next_output([get_text_message("third")]) + + result = Runner.run_streamed(agent, input="test") + async for _ in result.stream_events(): + pass + + assert result.input == "test" + assert len(result.new_items) == 1, "exactly one item should be generated" + assert len(result.to_input_list()) == 2, "should have original input and generated item" + + model.set_next_output([get_text_message("fourth")]) + + result = Runner.run_streamed(agent, input=result.to_input_list()) + async for _ in result.stream_events(): + pass + + assert len(result.input) == 2, f"should have previous input but got {result.input}" + assert len(result.new_items) == 1, "exactly one item should be generated" + assert result.final_output == "fourth" + assert len(result.raw_responses) == 1, "exactly one model response should be generated" + assert result.raw_responses[0].output == [get_text_message("fourth")] + assert result.last_agent == agent + assert len(result.to_input_list()) == 3, "should have original input and generated items" + + +@pytest.mark.asyncio +async def test_tool_call_runs(): + model = FakeModel() + agent = Agent( + name="test", + model=model, + tools=[get_function_tool("foo", "tool_result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], + # Second turn: text message + [get_text_message("done")], + ] + ) + + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + + assert result.final_output == "done" + assert len(result.raw_responses) == 2, ( + "should have two responses: the first which produces a tool call, and the second which" + "handles the tool result" + ) + + assert len(result.to_input_list()) == 5, ( + "should have five inputs: the original input, the message, the tool call, the tool result " + "and the done message" + ) + + +@pytest.mark.asyncio +async def test_handoffs(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + agent_2 = Agent( + name="test", + model=model, + ) + agent_3 = Agent( + name="test", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: text message + [get_text_message("done")], + ] + ) + + result = Runner.run_streamed(agent_3, input="user_message") + async for _ in result.stream_events(): + pass + + assert result.final_output == "done" + assert len(result.raw_responses) == 3, "should have three model responses" + assert len(result.to_input_list()) == 7, ( + "should have 7 inputs: orig input, tool call, tool result, message, handoff, handoff" + "result, and done message" + ) + assert result.last_agent == agent_1, "should have handed off to agent_1" + + +class Foo(TypedDict): + bar: str + + +@pytest.mark.asyncio +async def test_structured_output(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + tools=[get_function_tool("bar", "bar_result")], + output_type=Foo, + ) + + agent_2 = Agent( + name="test", + model=model, + tools=[get_function_tool("foo", "foo_result")], + handoffs=[agent_1], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("foo", json.dumps({"bar": "baz"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: tool call and structured output + [ + get_function_tool_call("bar", json.dumps({"bar": "baz"})), + get_final_output_message(json.dumps(Foo(bar="baz"))), + ], + ] + ) + + result = Runner.run_streamed( + agent_2, + input=[ + get_text_input_item("user_message"), + get_text_input_item("another_message"), + ], + ) + async for _ in result.stream_events(): + pass + + assert result.final_output == Foo(bar="baz") + assert len(result.raw_responses) == 3, "should have three model responses" + assert len(result.to_input_list()) == 10, ( + "should have input: 2 orig inputs, function call, function call result, message, handoff, " + "handoff output, tool call, tool call result, final output" + ) + + assert result.last_agent == agent_1, "should have handed off to agent_1" + assert result.final_output == Foo(bar="baz"), "should have structured output" + + +def remove_new_items(handoff_input_data: HandoffInputData) -> HandoffInputData: + return HandoffInputData( + input_history=handoff_input_data.input_history, + pre_handoff_items=(), + new_items=(), + ) + + +@pytest.mark.asyncio +async def test_handoff_filters(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + agent_2 = Agent( + name="test", + model=model, + handoffs=[ + handoff( + agent=agent_1, + input_filter=remove_new_items, + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], + [get_text_message("last")], + ] + ) + + result = Runner.run_streamed(agent_2, input="user_message") + async for _ in result.stream_events(): + pass + + assert result.final_output == "last" + assert len(result.raw_responses) == 2, "should have two model responses" + assert len(result.to_input_list()) == 2, ( + "should only have 2 inputs: orig input and last message" + ) + + +@pytest.mark.asyncio +async def test_async_input_filter_fails(): + # DO NOT rename this without updating pyproject.toml + + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]: + return agent_1 + + async def invalid_input_filter(data: HandoffInputData) -> HandoffInputData: + return data # pragma: no cover + + agent_2 = Agent[None]( + name="test", + model=model, + handoffs=[ + Handoff( + tool_name=Handoff.default_tool_name(agent_1), + tool_description=Handoff.default_tool_description(agent_1), + input_json_schema={}, + on_invoke_handoff=on_invoke_handoff, + agent_name=agent_1.name, + # Purposely ignoring the type error here to simulate invalid input + input_filter=invalid_input_filter, # type: ignore + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], + [get_text_message("last")], + ] + ) + + with pytest.raises(UserError): + result = Runner.run_streamed(agent_2, input="user_message") + async for _ in result.stream_events(): + pass + + +@pytest.mark.asyncio +async def test_invalid_input_filter_fails(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]: + return agent_1 + + def invalid_input_filter(data: HandoffInputData) -> HandoffInputData: + # Purposely returning a string to simulate invalid output + return "foo" # type: ignore + + agent_2 = Agent[None]( + name="test", + model=model, + handoffs=[ + Handoff( + tool_name=Handoff.default_tool_name(agent_1), + tool_description=Handoff.default_tool_description(agent_1), + input_json_schema={}, + on_invoke_handoff=on_invoke_handoff, + agent_name=agent_1.name, + input_filter=invalid_input_filter, + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], + [get_text_message("last")], + ] + ) + + with pytest.raises(UserError): + result = Runner.run_streamed(agent_2, input="user_message") + async for _ in result.stream_events(): + pass + + +@pytest.mark.asyncio +async def test_non_callable_input_filter_causes_error(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]: + return agent_1 + + agent_2 = Agent[None]( + name="test", + model=model, + handoffs=[ + Handoff( + tool_name=Handoff.default_tool_name(agent_1), + tool_description=Handoff.default_tool_description(agent_1), + input_json_schema={}, + on_invoke_handoff=on_invoke_handoff, + agent_name=agent_1.name, + # Purposely ignoring the type error here to simulate invalid input + input_filter="foo", # type: ignore + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)], + [get_text_message("last")], + ] + ) + + with pytest.raises(UserError): + result = Runner.run_streamed(agent_2, input="user_message") + async for _ in result.stream_events(): + pass + + +@pytest.mark.asyncio +async def test_handoff_on_input(): + call_output: str | None = None + + def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: + nonlocal call_output + call_output = data["bar"] + + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + agent_2 = Agent( + name="test", + model=model, + handoffs=[ + handoff( + agent=agent_1, + on_handoff=on_input, + input_type=Foo, + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [ + get_text_message("1"), + get_text_message("2"), + get_handoff_tool_call(agent_1, args=json.dumps(Foo(bar="test_input"))), + ], + [get_text_message("last")], + ] + ) + + result = Runner.run_streamed(agent_2, input="user_message") + async for _ in result.stream_events(): + pass + + assert result.final_output == "last" + + assert call_output == "test_input", "should have called the handoff with the correct input" + + +@pytest.mark.asyncio +async def test_async_handoff_on_input(): + call_output: str | None = None + + async def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None: + nonlocal call_output + call_output = data["bar"] + + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + ) + + agent_2 = Agent( + name="test", + model=model, + handoffs=[ + handoff( + agent=agent_1, + on_handoff=on_input, + input_type=Foo, + ) + ], + ) + + model.add_multiple_turn_outputs( + [ + [ + get_text_message("1"), + get_text_message("2"), + get_handoff_tool_call(agent_1, args=json.dumps(Foo(bar="test_input"))), + ], + [get_text_message("last")], + ] + ) + + result = Runner.run_streamed(agent_2, input="user_message") + async for _ in result.stream_events(): + pass + + assert result.final_output == "last" + + assert call_output == "test_input", "should have called the handoff with the correct input" + + +@pytest.mark.asyncio +async def test_input_guardrail_tripwire_triggered_causes_exception_streamed(): + def guardrail_function( + context: RunContextWrapper[Any], agent: Agent[Any], input: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=True, + ) + + agent = Agent( + name="test", + input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)], + model=FakeModel(), + ) + + with pytest.raises(InputGuardrailTripwireTriggered): + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + + +@pytest.mark.asyncio +async def test_output_guardrail_tripwire_triggered_causes_exception_streamed(): + def guardrail_function( + context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=True, + ) + + model = FakeModel(initial_output=[get_text_message("first_test")]) + + agent = Agent( + name="test", + output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)], + model=model, + ) + + with pytest.raises(OutputGuardrailTripwireTriggered): + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + + +@pytest.mark.asyncio +async def test_run_input_guardrail_tripwire_triggered_causes_exception_streamed(): + def guardrail_function( + context: RunContextWrapper[Any], agent: Agent[Any], input: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=True, + ) + + agent = Agent( + name="test", + model=FakeModel(), + ) + + with pytest.raises(InputGuardrailTripwireTriggered): + result = Runner.run_streamed( + agent, + input="user_message", + run_config=RunConfig( + input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)] + ), + ) + async for _ in result.stream_events(): + pass + + +@pytest.mark.asyncio +async def test_run_output_guardrail_tripwire_triggered_causes_exception_streamed(): + def guardrail_function( + context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any + ) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=True, + ) + + model = FakeModel(initial_output=[get_text_message("first_test")]) + + agent = Agent( + name="test", + model=model, + ) + + with pytest.raises(OutputGuardrailTripwireTriggered): + result = Runner.run_streamed( + agent, + input="user_message", + run_config=RunConfig( + output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)] + ), + ) + async for _ in result.stream_events(): + pass + + +@pytest.mark.asyncio +async def test_streaming_events(): + model = FakeModel() + agent_1 = Agent( + name="test", + model=model, + tools=[get_function_tool("bar", "bar_result")], + output_type=Foo, + ) + + agent_2 = Agent( + name="test", + model=model, + tools=[get_function_tool("foo", "foo_result")], + handoffs=[agent_1], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("foo", json.dumps({"bar": "baz"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: tool call and structured output + [ + get_function_tool_call("bar", json.dumps({"bar": "baz"})), + get_final_output_message(json.dumps(Foo(bar="baz"))), + ], + ] + ) + + # event_type: (count, event) + event_counts: dict[str, int] = {} + item_data: list[RunItem] = [] + agent_data: list[AgentUpdatedStreamEvent] = [] + + result = Runner.run_streamed( + agent_2, + input=[ + get_text_input_item("user_message"), + get_text_input_item("another_message"), + ], + ) + async for event in result.stream_events(): + event_counts[event.type] = event_counts.get(event.type, 0) + 1 + if event.type == "run_item_stream_event": + item_data.append(event.item) + elif event.type == "agent_updated_stream_event": + agent_data.append(event) + + assert result.final_output == Foo(bar="baz") + assert len(result.raw_responses) == 3, "should have three model responses" + assert len(result.to_input_list()) == 10, ( + "should have input: 2 orig inputs, function call, function call result, message, handoff, " + "handoff output, tool call, tool call result, final output" + ) + + assert result.last_agent == agent_1, "should have handed off to agent_1" + assert result.final_output == Foo(bar="baz"), "should have structured output" + + # Now lets check the events + + expected_item_type_map = { + "tool_call": 2, + "tool_call_output": 2, + "message": 2, + "handoff": 1, + "handoff_output": 1, + } + + 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 events were: {expected_item_type_map}, got {event_counts}" + ) + + assert len(item_data) == total_expected_item_count, ( + f"should have {total_expected_item_count} run items" + ) + assert len(agent_data) == 2, "should have 2 agent updated events" + assert agent_data[0].new_agent == agent_2, "should have started with agent_2" + assert agent_data[1].new_agent == agent_1, "should have handed off to agent_1" diff --git a/tests/test_agent_tracing.py b/tests/test_agent_tracing.py new file mode 100644 index 0000000..24bd72f --- /dev/null +++ b/tests/test_agent_tracing.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from agents import Agent, RunConfig, Runner, trace + +from .fake_model import FakeModel +from .test_responses import get_text_message +from .testing_processor import fetch_ordered_spans, fetch_traces + + +@pytest.mark.asyncio +async def test_single_run_is_single_trace(): + agent = Agent( + name="test_agent", + model=FakeModel( + initial_output=[get_text_message("first_test")], + ), + ) + + await Runner.run(agent, input="first_test") + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + 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(): + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_text_message("first_test")], + [get_text_message("second_test")], + ] + ) + agent = Agent( + name="test_agent_1", + model=model, + ) + + 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)}" + + 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(): + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_text_message("first_test")], + [get_text_message("second_test")], + [get_text_message("third_test")], + ] + ) + with trace(workflow_name="test_workflow"): + agent = Agent( + name="test_agent_1", + model=model, + ) + + await Runner.run(agent, input="first_test") + 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)}" + + 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(): + with trace(workflow_name="test_workflow", disabled=True): + agent = Agent( + name="test_agent", + model=FakeModel( + initial_output=[get_text_message("first_test")], + ), + ) + + await Runner.run(agent, input="first_test") + + traces = fetch_traces() + assert len(traces) == 0, f"Expected 0 traces, got {len(traces)}" + 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(): + agent = Agent( + name="test_agent", + model=FakeModel( + initial_output=[get_text_message("first_test")], + ), + ) + + 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)}" + 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(): + agent = Agent( + name="test_agent", + model=FakeModel( + initial_output=[get_text_message("first_test")], + ), + ) + + await Runner.run( + agent, + input="first_test", + run_config=RunConfig(workflow_name="Foo bar", group_id="123", trace_id="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" + + +@pytest.mark.asyncio +async def test_not_starting_streaming_creates_trace(): + agent = Agent( + name="test_agent", + model=FakeModel( + initial_output=[get_text_message("first_test")], + ), + ) + + result = Runner.run_streamed(agent, input="first_test") + + # Purposely don't await the stream + while True: + if result.is_complete: + break + await asyncio.sleep(0.1) + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + 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 + + +@pytest.mark.asyncio +async def test_streaming_single_run_is_single_trace(): + agent = Agent( + name="test_agent", + model=FakeModel( + initial_output=[get_text_message("first_test")], + ), + ) + + x = Runner.run_streamed(agent, input="first_test") + async for _ in x.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + +@pytest.mark.asyncio +async def test_multiple_streamed_runs_are_multiple_traces(): + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_text_message("first_test")], + [get_text_message("second_test")], + ] + ) + agent = Agent( + name="test_agent_1", + model=model, + ) + + x = Runner.run_streamed(agent, input="first_test") + async for _ in x.stream_events(): + pass + + x = Runner.run_streamed(agent, input="second_test") + async for _ in x.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 2, f"Expected 2 traces, got {len(traces)}" + + +@pytest.mark.asyncio +async def test_wrapped_streaming_trace_is_single_trace(): + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_text_message("first_test")], + [get_text_message("second_test")], + [get_text_message("third_test")], + ] + ) + with trace(workflow_name="test_workflow"): + agent = Agent( + name="test_agent_1", + model=model, + ) + + x = Runner.run_streamed(agent, input="first_test") + async for _ in x.stream_events(): + pass + + x = Runner.run_streamed(agent, input="second_test") + async for _ in x.stream_events(): + pass + + x = Runner.run_streamed(agent, input="third_test") + async for _ in x.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + +@pytest.mark.asyncio +async def test_wrapped_mixed_trace_is_single_trace(): + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_text_message("first_test")], + [get_text_message("second_test")], + [get_text_message("third_test")], + ] + ) + with trace(workflow_name="test_workflow"): + agent = Agent( + name="test_agent_1", + model=model, + ) + + x = Runner.run_streamed(agent, input="first_test") + async for _ in x.stream_events(): + pass + + await Runner.run(agent, input="second_test") + + x = Runner.run_streamed(agent, input="third_test") + async for _ in x.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + +@pytest.mark.asyncio +async def test_parent_disabled_trace_disables_streaming_agent_trace(): + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_text_message("first_test")], + [get_text_message("second_test")], + ] + ) + with trace(workflow_name="test_workflow", disabled=True): + agent = Agent( + name="test_agent", + model=model, + ) + + x = Runner.run_streamed(agent, input="first_test") + async for _ in x.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 0, f"Expected 0 traces, got {len(traces)}" + + +@pytest.mark.asyncio +async def test_manual_streaming_disabling_works(): + model = FakeModel() + model.add_multiple_turn_outputs( + [ + [get_text_message("first_test")], + [get_text_message("second_test")], + ] + ) + agent = Agent( + name="test_agent", + model=model, + ) + + x = Runner.run_streamed(agent, input="first_test", run_config=RunConfig(tracing_disabled=True)) + async for _ in x.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 0, f"Expected 0 traces, got {len(traces)}" diff --git a/tests/test_computer_action.py b/tests/test_computer_action.py new file mode 100644 index 0000000..70dcabd --- /dev/null +++ b/tests/test_computer_action.py @@ -0,0 +1,311 @@ +"""Unit tests for the ComputerAction methods in `agents._run_impl`. + +These confirm that the correct computer action method is invoked for each action type and +that screenshots are taken and wrapped appropriately, and that the execute function invokes +hooks and returns the expected ToolCallOutputItem.""" + +from typing import Any + +import pytest +from openai.types.responses.response_computer_tool_call import ( + ActionClick, + ActionDoubleClick, + ActionDrag, + ActionDragPath, + ActionKeypress, + ActionMove, + ActionScreenshot, + ActionScroll, + ActionType, + ActionWait, + ResponseComputerToolCall, +) + +from agents import ( + Agent, + AgentHooks, + AsyncComputer, + Computer, + ComputerTool, + RunConfig, + RunContextWrapper, + RunHooks, +) +from agents._run_impl import ComputerAction, ToolRunComputerAction +from agents.items import ToolCallOutputItem + + +class LoggingComputer(Computer): + """A `Computer` implementation that logs calls to its methods for verification in tests.""" + + def __init__(self, screenshot_return: str = "screenshot"): + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + self._screenshot_return = screenshot_return + + @property + def environment(self): + return "mac" + + @property + def dimensions(self) -> tuple[int, int]: + return (800, 600) + + def screenshot(self) -> str: + self.calls.append(("screenshot", ())) + return self._screenshot_return + + def click(self, x: int, y: int, button: str) -> None: + self.calls.append(("click", (x, y, button))) + + def double_click(self, x: int, y: int) -> None: + self.calls.append(("double_click", (x, y))) + + def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + self.calls.append(("scroll", (x, y, scroll_x, scroll_y))) + + def type(self, text: str) -> None: + self.calls.append(("type", (text,))) + + def wait(self) -> None: + self.calls.append(("wait", ())) + + def move(self, x: int, y: int) -> None: + self.calls.append(("move", (x, y))) + + def keypress(self, keys: list[str]) -> None: + self.calls.append(("keypress", (keys,))) + + def drag(self, path: list[tuple[int, int]]) -> None: + self.calls.append(("drag", (tuple(path),))) + + +class LoggingAsyncComputer(AsyncComputer): + """An `AsyncComputer` implementation that logs calls to its methods for verification.""" + + def __init__(self, screenshot_return: str = "async_screenshot"): + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + self._screenshot_return = screenshot_return + + @property + def environment(self): + return "mac" + + @property + def dimensions(self) -> tuple[int, int]: + return (800, 600) + + async def screenshot(self) -> str: + self.calls.append(("screenshot", ())) + return self._screenshot_return + + async def click(self, x: int, y: int, button: str) -> None: + self.calls.append(("click", (x, y, button))) + + async def double_click(self, x: int, y: int) -> None: + self.calls.append(("double_click", (x, y))) + + async def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + self.calls.append(("scroll", (x, y, scroll_x, scroll_y))) + + async def type(self, text: str) -> None: + self.calls.append(("type", (text,))) + + async def wait(self) -> None: + self.calls.append(("wait", ())) + + async def move(self, x: int, y: int) -> None: + self.calls.append(("move", (x, y))) + + async def keypress(self, keys: list[str]) -> None: + self.calls.append(("keypress", (keys,))) + + async def drag(self, path: list[tuple[int, int]]) -> None: + self.calls.append(("drag", (tuple(path),))) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "action,expected_call", + [ + (ActionClick(type="click", x=10, y=21, button="left"), ("click", (10, 21, "left"))), + (ActionDoubleClick(type="double_click", x=42, y=47), ("double_click", (42, 47))), + ( + ActionDrag(type="drag", path=[ActionDragPath(x=1, y=2), ActionDragPath(x=3, y=4)]), + ("drag", (((1, 2), (3, 4)),)), + ), + (ActionKeypress(type="keypress", keys=["a", "b"]), ("keypress", (["a", "b"],))), + (ActionMove(type="move", x=100, y=200), ("move", (100, 200))), + (ActionScreenshot(type="screenshot"), ("screenshot", ())), + ( + ActionScroll(type="scroll", x=1, y=2, scroll_x=3, scroll_y=4), + ("scroll", (1, 2, 3, 4)), + ), + (ActionType(type="type", text="hello"), ("type", ("hello",))), + (ActionWait(type="wait"), ("wait", ())), + ], +) +async def test_get_screenshot_sync_executes_action_and_takes_screenshot( + action: Any, expected_call: tuple[str, tuple[Any, ...]] +) -> None: + """For each action type, assert that the corresponding computer method is invoked + and that a screenshot is taken and returned.""" + computer = LoggingComputer(screenshot_return="synthetic") + tool_call = ResponseComputerToolCall( + id="c1", + type="computer_call", + action=action, + call_id="c1", + pending_safety_checks=[], + status="completed", + ) + screenshot_output = await ComputerAction._get_screenshot_sync(computer, tool_call) + # The last call is always to screenshot() + if isinstance(action, ActionScreenshot): + # Screenshot is taken twice: initial explicit call plus final capture. + assert computer.calls == [("screenshot", ()), ("screenshot", ())] + else: + assert computer.calls == [expected_call, ("screenshot", ())] + assert screenshot_output == "synthetic" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "action,expected_call", + [ + (ActionClick(type="click", x=2, y=3, button="right"), ("click", (2, 3, "right"))), + (ActionDoubleClick(type="double_click", x=12, y=13), ("double_click", (12, 13))), + ( + ActionDrag(type="drag", path=[ActionDragPath(x=5, y=6), ActionDragPath(x=6, y=7)]), + ("drag", (((5, 6), (6, 7)),)), + ), + (ActionKeypress(type="keypress", keys=["ctrl", "c"]), ("keypress", (["ctrl", "c"],))), + (ActionMove(type="move", x=8, y=9), ("move", (8, 9))), + (ActionScreenshot(type="screenshot"), ("screenshot", ())), + ( + ActionScroll(type="scroll", x=9, y=8, scroll_x=7, scroll_y=6), + ("scroll", (9, 8, 7, 6)), + ), + (ActionType(type="type", text="world"), ("type", ("world",))), + (ActionWait(type="wait"), ("wait", ())), + ], +) +async def test_get_screenshot_async_executes_action_and_takes_screenshot( + action: Any, expected_call: tuple[str, tuple[Any, ...]] +) -> None: + """For each action type on an `AsyncComputer`, the corresponding coroutine should be awaited + and a screenshot taken.""" + computer = LoggingAsyncComputer(screenshot_return="async_return") + assert computer.environment == "mac" + assert computer.dimensions == (800, 600) + tool_call = ResponseComputerToolCall( + id="c2", + type="computer_call", + action=action, + call_id="c2", + pending_safety_checks=[], + status="completed", + ) + screenshot_output = await ComputerAction._get_screenshot_async(computer, tool_call) + if isinstance(action, ActionScreenshot): + assert computer.calls == [("screenshot", ()), ("screenshot", ())] + else: + assert computer.calls == [expected_call, ("screenshot", ())] + assert screenshot_output == "async_return" + + +class LoggingRunHooks(RunHooks[Any]): + """Capture on_tool_start and on_tool_end invocations.""" + + def __init__(self) -> None: + super().__init__() + self.started: list[tuple[Agent[Any], Any]] = [] + self.ended: list[tuple[Agent[Any], Any, str]] = [] + + async def on_tool_start( + self, context: RunContextWrapper[Any], agent: Agent[Any], tool: Any + ) -> None: + self.started.append((agent, tool)) + + async def on_tool_end( + self, context: RunContextWrapper[Any], agent: Agent[Any], tool: Any, result: str + ) -> None: + self.ended.append((agent, tool, result)) + + +class LoggingAgentHooks(AgentHooks[Any]): + """Minimal override to capture agent's tool hook invocations.""" + + def __init__(self) -> None: + super().__init__() + self.started: list[tuple[Agent[Any], Any]] = [] + self.ended: list[tuple[Agent[Any], Any, str]] = [] + + async def on_tool_start( + self, context: RunContextWrapper[Any], agent: Agent[Any], tool: Any + ) -> None: + self.started.append((agent, tool)) + + async def on_tool_end( + self, context: RunContextWrapper[Any], agent: Agent[Any], tool: Any, result: str + ) -> None: + self.ended.append((agent, tool, result)) + + +@pytest.mark.asyncio +async def test_execute_invokes_hooks_and_returns_tool_call_output() -> None: + # ComputerAction.execute should invoke lifecycle hooks and return a proper ToolCallOutputItem. + computer = LoggingComputer(screenshot_return="xyz") + comptool = ComputerTool(computer=computer) + # Create a dummy click action to trigger a click and screenshot. + action = ActionClick(type="click", x=1, y=2, button="left") + tool_call = ResponseComputerToolCall( + id="tool123", + type="computer_call", + action=action, + call_id="tool123", + pending_safety_checks=[], + status="completed", + ) + tool_call.call_id = "tool123" + + # Wrap tool call in ToolRunComputerAction + tool_run = ToolRunComputerAction(tool_call=tool_call, computer_tool=comptool) + # Setup agent and hooks. + agent = Agent(name="test_agent", tools=[comptool]) + # Attach per-agent hooks as well as global run hooks. + agent_hooks = LoggingAgentHooks() + agent.hooks = agent_hooks + run_hooks = LoggingRunHooks() + context_wrapper: RunContextWrapper[Any] = RunContextWrapper(context=None) + # Execute the computer action. + output_item = await ComputerAction.execute( + agent=agent, + action=tool_run, + hooks=run_hooks, + context_wrapper=context_wrapper, + config=RunConfig(), + ) + # Both global and per-agent hooks should have been called once. + assert len(run_hooks.started) == 1 and len(agent_hooks.started) == 1 + assert len(run_hooks.ended) == 1 and len(agent_hooks.ended) == 1 + # The hook invocations should refer to our agent and tool. + assert run_hooks.started[0][0] is agent + assert run_hooks.ended[0][0] is agent + assert run_hooks.started[0][1] is comptool + assert run_hooks.ended[0][1] is comptool + # The result passed to on_tool_end should be the raw screenshot string. + assert run_hooks.ended[0][2] == "xyz" + assert agent_hooks.ended[0][2] == "xyz" + # The computer should have performed a click then a screenshot. + assert computer.calls == [("click", (1, 2, "left")), ("screenshot", ())] + # The returned item should include the agent, output string, and a ComputerCallOutput. + assert output_item.agent is agent + assert isinstance(output_item, ToolCallOutputItem) + assert output_item.output == "data:image/png;base64,xyz" + raw = output_item.raw_item + # Raw item is a dict-like mapping with expected output fields. + assert isinstance(raw, dict) + assert raw["type"] == "computer_call_output" + assert raw["output"]["type"] == "computer_screenshot" + assert "image_url" in raw["output"] + assert raw["output"]["image_url"].endswith("xyz") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..8f37200 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,61 @@ +import os + +import openai +import pytest + +from agents import set_default_openai_api, set_default_openai_client, set_default_openai_key +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from agents.models.openai_provider import OpenAIProvider +from agents.models.openai_responses import OpenAIResponsesModel + + +def test_cc_no_default_key_errors(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + with pytest.raises(openai.OpenAIError): + OpenAIProvider(use_responses=False).get_model("gpt-4") + + +def test_cc_set_default_openai_key(): + set_default_openai_key("test_key") + chat_model = OpenAIProvider(use_responses=False).get_model("gpt-4") + assert chat_model._client.api_key == "test_key" # type: ignore + + +def test_cc_set_default_openai_client(): + client = openai.AsyncOpenAI(api_key="test_key") + set_default_openai_client(client) + chat_model = OpenAIProvider(use_responses=False).get_model("gpt-4") + assert chat_model._client.api_key == "test_key" # type: ignore + + +def test_resp_no_default_key_errors(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + assert os.getenv("OPENAI_API_KEY") is None + with pytest.raises(openai.OpenAIError): + OpenAIProvider(use_responses=True).get_model("gpt-4") + + +def test_resp_set_default_openai_key(): + set_default_openai_key("test_key") + resp_model = OpenAIProvider(use_responses=True).get_model("gpt-4") + assert resp_model._client.api_key == "test_key" # type: ignore + + +def test_resp_set_default_openai_client(): + client = openai.AsyncOpenAI(api_key="test_key") + set_default_openai_client(client) + resp_model = OpenAIProvider(use_responses=True).get_model("gpt-4") + assert resp_model._client.api_key == "test_key" # type: ignore + + +def test_set_default_openai_api(): + assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), \ + "Default should be responses" + + set_default_openai_api("chat_completions") + assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIChatCompletionsModel), \ + "Should be chat completions model" + + set_default_openai_api("responses") + assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), \ + "Should be responses model" diff --git a/tests/test_doc_parsing.py b/tests/test_doc_parsing.py new file mode 100644 index 0000000..6c7a95d --- /dev/null +++ b/tests/test_doc_parsing.py @@ -0,0 +1,115 @@ +from agents.function_schema import generate_func_documentation + + +def func_foo_google(a: int, b: float) -> str: + """ + This is func_foo. + + Args: + a: The first argument. + b: The second argument. + + Returns: + A result + """ + + return "ok" + + +def func_foo_numpy(a: int, b: float) -> str: + """ + This is func_foo. + + Parameters + ---------- + a: int + The first argument. + b: float + The second argument. + + Returns + ------- + str + A result + """ + return "ok" + + +def func_foo_sphinx(a: int, b: float) -> str: + """ + This is func_foo. + + :param a: The first argument. + :param b: The second argument. + :return: A result + """ + return "ok" + + +class Bar: + def func_bar(self, a: int, b: float) -> str: + """ + This is func_bar. + + Args: + a: The first argument. + b: The second argument. + + Returns: + A result + """ + return "ok" + + @classmethod + def func_baz(cls, a: int, b: float) -> str: + """ + This is func_baz. + + Args: + a: The first argument. + b: The second argument. + + Returns: + A result + """ + return "ok" + + +def test_functions_are_ok(): + func_foo_google(1, 2.0) + func_foo_numpy(1, 2.0) + func_foo_sphinx(1, 2.0) + Bar().func_bar(1, 2.0) + Bar.func_baz(1, 2.0) + + +def test_auto_detection() -> None: + doc = generate_func_documentation(func_foo_google) + assert doc.name == "func_foo_google" + assert doc.description == "This is func_foo." + assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."} + + doc = generate_func_documentation(func_foo_numpy) + assert doc.name == "func_foo_numpy" + assert doc.description == "This is func_foo." + assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."} + + doc = generate_func_documentation(func_foo_sphinx) + assert doc.name == "func_foo_sphinx" + assert doc.description == "This is func_foo." + assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."} + + +def test_instance_method() -> None: + bar = Bar() + doc = generate_func_documentation(bar.func_bar) + assert doc.name == "func_bar" + assert doc.description == "This is func_bar." + assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."} + + +def test_classmethod() -> None: + doc = generate_func_documentation(Bar.func_baz) + assert doc.name == "func_baz" + assert doc.description == "This is func_baz." + assert doc.param_descriptions == {"a": "The first argument.", "b": "The second argument."} diff --git a/tests/test_extension_filters.py b/tests/test_extension_filters.py new file mode 100644 index 0000000..4cb017a --- /dev/null +++ b/tests/test_extension_filters.py @@ -0,0 +1,188 @@ +from openai.types.responses import ResponseOutputMessage, ResponseOutputText + +from agents import Agent, HandoffInputData +from agents.extensions.handoff_filters import remove_all_tools +from agents.items import ( + HandoffOutputItem, + MessageOutputItem, + ToolCallOutputItem, + TResponseInputItem, +) + + +def fake_agent(): + return Agent( + name="fake_agent", + ) + + +def _get_message_input_item(content: str) -> TResponseInputItem: + return { + "role": "assistant", + "content": content, + } + + +def _get_function_result_input_item(content: str) -> TResponseInputItem: + return { + "call_id": "1", + "output": content, + "type": "function_call_output", + } + + +def _get_message_output_run_item(content: str) -> MessageOutputItem: + return MessageOutputItem( + agent=fake_agent(), + raw_item=ResponseOutputMessage( + id="1", + content=[ResponseOutputText(text=content, annotations=[], type="output_text")], + role="assistant", + status="completed", + type="message", + ), + ) + + +def _get_tool_output_run_item(content: str) -> ToolCallOutputItem: + return ToolCallOutputItem( + agent=fake_agent(), + raw_item={ + "call_id": "1", + "output": content, + "type": "function_call_output", + }, + output=content, + ) + + +def _get_handoff_input_item(content: str) -> TResponseInputItem: + return { + "call_id": "1", + "output": content, + "type": "function_call_output", + } + + +def _get_handoff_output_run_item(content: str) -> HandoffOutputItem: + return HandoffOutputItem( + agent=fake_agent(), + raw_item={ + "call_id": "1", + "output": content, + "type": "function_call_output", + }, + source_agent=fake_agent(), + target_agent=fake_agent(), + ) + + +def test_empty_data(): + handoff_input_data = HandoffInputData(input_history=(), pre_handoff_items=(), new_items=()) + filtered_data = remove_all_tools(handoff_input_data) + assert filtered_data == handoff_input_data + + +def test_str_historyonly(): + handoff_input_data = HandoffInputData(input_history="Hello", pre_handoff_items=(), new_items=()) + filtered_data = remove_all_tools(handoff_input_data) + assert filtered_data == handoff_input_data + + +def test_str_history_and_list(): + handoff_input_data = HandoffInputData( + input_history="Hello", + pre_handoff_items=(), + new_items=(_get_message_output_run_item("Hello"),), + ) + filtered_data = remove_all_tools(handoff_input_data) + assert filtered_data == handoff_input_data + + +def test_list_history_and_list(): + handoff_input_data = HandoffInputData( + input_history=(_get_message_input_item("Hello"),), + pre_handoff_items=(_get_message_output_run_item("123"),), + new_items=(_get_message_output_run_item("World"),), + ) + filtered_data = remove_all_tools(handoff_input_data) + assert filtered_data == handoff_input_data + + +def test_removes_tools_from_history(): + handoff_input_data = HandoffInputData( + input_history=( + _get_message_input_item("Hello1"), + _get_function_result_input_item("World"), + _get_message_input_item("Hello2"), + ), + pre_handoff_items=( + _get_tool_output_run_item("abc"), + _get_message_output_run_item("123"), + ), + new_items=(_get_message_output_run_item("World"),), + ) + filtered_data = remove_all_tools(handoff_input_data) + assert len(filtered_data.input_history) == 2 + assert len(filtered_data.pre_handoff_items) == 1 + assert len(filtered_data.new_items) == 1 + + +def test_removes_tools_from_new_items(): + handoff_input_data = HandoffInputData( + input_history=(), + pre_handoff_items=(), + new_items=( + _get_message_output_run_item("Hello"), + _get_tool_output_run_item("World"), + ), + ) + filtered_data = remove_all_tools(handoff_input_data) + assert len(filtered_data.input_history) == 0 + assert len(filtered_data.pre_handoff_items) == 0 + assert len(filtered_data.new_items) == 1 + + +def test_removes_tools_from_new_items_and_history(): + handoff_input_data = HandoffInputData( + input_history=( + _get_message_input_item("Hello1"), + _get_function_result_input_item("World"), + _get_message_input_item("Hello2"), + ), + pre_handoff_items=( + _get_message_output_run_item("123"), + _get_tool_output_run_item("456"), + ), + new_items=( + _get_message_output_run_item("Hello"), + _get_tool_output_run_item("World"), + ), + ) + filtered_data = remove_all_tools(handoff_input_data) + assert len(filtered_data.input_history) == 2 + assert len(filtered_data.pre_handoff_items) == 1 + assert len(filtered_data.new_items) == 1 + + +def test_removes_handoffs_from_history(): + handoff_input_data = HandoffInputData( + input_history=( + _get_message_input_item("Hello1"), + _get_handoff_input_item("World"), + ), + pre_handoff_items=( + _get_message_output_run_item("Hello"), + _get_tool_output_run_item("World"), + _get_handoff_output_run_item("World"), + ), + new_items=( + _get_message_output_run_item("Hello"), + _get_tool_output_run_item("World"), + _get_handoff_output_run_item("World"), + ), + ) + filtered_data = remove_all_tools(handoff_input_data) + assert len(filtered_data.input_history) == 1 + assert len(filtered_data.pre_handoff_items) == 1 + assert len(filtered_data.new_items) == 1 diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py new file mode 100644 index 0000000..2407ab0 --- /dev/null +++ b/tests/test_function_schema.py @@ -0,0 +1,430 @@ +from enum import Enum +from typing import Any, Literal + +import pytest +from pydantic import BaseModel, ValidationError +from typing_extensions import TypedDict + +from agents import RunContextWrapper +from agents.exceptions import UserError +from agents.function_schema import function_schema + + +def no_args_function(): + """This function has no args.""" + + return "ok" + + +def test_no_args_function(): + func_schema = function_schema(no_args_function) + assert func_schema.params_json_schema.get("title") == "no_args_function_args" + assert func_schema.description == "This function has no args." + assert not func_schema.takes_context + + parsed = func_schema.params_pydantic_model() + args, kwargs_dict = func_schema.to_call_args(parsed) + result = no_args_function(*args, **kwargs_dict) + assert result == "ok" + + +def no_args_function_with_context(ctx: RunContextWrapper[str]): + return "ok" + + +def test_no_args_function_with_context() -> None: + func_schema = function_schema(no_args_function_with_context) + assert func_schema.takes_context + + context = RunContextWrapper(context="test") + parsed = func_schema.params_pydantic_model() + args, kwargs_dict = func_schema.to_call_args(parsed) + result = no_args_function_with_context(context, *args, **kwargs_dict) + assert result == "ok" + + +def simple_function(a: int, b: int = 5): + """ + Args: + a: The first argument + b: The second argument + + Returns: + The sum of a and b + """ + return a + b + + +def test_simple_function(): + """Test a function that has simple typed parameters and defaults.""" + + func_schema = function_schema(simple_function) + # Check that the JSON schema is a dictionary with title, type, etc. + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "simple_function_args" + assert ( + func_schema.params_json_schema.get("properties", {}).get("a").get("description") + == "The first argument" + ) + assert ( + func_schema.params_json_schema.get("properties", {}).get("b").get("description") + == "The second argument" + ) + assert not func_schema.takes_context + + # Valid input + valid_input = {"a": 3} + parsed = func_schema.params_pydantic_model(**valid_input) + args_tuple, kwargs_dict = func_schema.to_call_args(parsed) + result = simple_function(*args_tuple, **kwargs_dict) + assert result == 8 # 3 + 5 + + # Another valid input + valid_input2 = {"a": 3, "b": 10} + parsed2 = func_schema.params_pydantic_model(**valid_input2) + args_tuple2, kwargs_dict2 = func_schema.to_call_args(parsed2) + result2 = simple_function(*args_tuple2, **kwargs_dict2) + assert result2 == 13 # 3 + 10 + + # Invalid input: 'a' must be int + with pytest.raises(ValidationError): + func_schema.params_pydantic_model(**{"a": "not an integer"}) + + +def varargs_function(x: int, *numbers: float, flag: bool = False, **kwargs: Any): + return x, numbers, flag, kwargs + + +def test_varargs_function(): + """Test a function that uses *args and **kwargs.""" + + func_schema = function_schema(varargs_function) + # Check JSON schema structure + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "varargs_function_args" + + # Valid input including *args in 'numbers' and **kwargs in 'kwargs' + valid_input = { + "x": 10, + "numbers": [1.1, 2.2, 3.3], + "flag": True, + "kwargs": {"extra1": "hello", "extra2": 42}, + } + parsed = func_schema.params_pydantic_model(**valid_input) + args, kwargs_dict = func_schema.to_call_args(parsed) + + result = varargs_function(*args, **kwargs_dict) + # result should be (10, (1.1, 2.2, 3.3), True, {"extra1": "hello", "extra2": 42}) + assert result[0] == 10 + assert result[1] == (1.1, 2.2, 3.3) + assert result[2] is True + assert result[3] == {"extra1": "hello", "extra2": 42} + + # Missing 'x' should raise error + with pytest.raises(ValidationError): + func_schema.params_pydantic_model(**{"numbers": [1.1, 2.2]}) + + # 'flag' can be omitted because it has a default + valid_input_no_flag = {"x": 7, "numbers": [9.9], "kwargs": {"some_key": "some_value"}} + parsed2 = func_schema.params_pydantic_model(**valid_input_no_flag) + args2, kwargs_dict2 = func_schema.to_call_args(parsed2) + result2 = varargs_function(*args2, **kwargs_dict2) + # result2 should be (7, (9.9,), False, {'some_key': 'some_value'}) + assert result2 == (7, (9.9,), False, {"some_key": "some_value"}) + + +class Foo(TypedDict): + a: int + b: str + + +class InnerModel(BaseModel): + a: int + b: str + + +class OuterModel(BaseModel): + inner: InnerModel + foo: Foo + + +def complex_args_function(model: OuterModel) -> str: + return f"{model.inner.a}, {model.inner.b}, {model.foo['a']}, {model.foo['b']}" + + +def test_nested_data_function(): + func_schema = function_schema(complex_args_function) + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "complex_args_function_args" + + # Valid input + model = OuterModel(inner=InnerModel(a=1, b="hello"), foo=Foo(a=2, b="world")) + valid_input = { + "model": model.model_dump(), + } + + parsed = func_schema.params_pydantic_model(**valid_input) + args, kwargs_dict = func_schema.to_call_args(parsed) + + result = complex_args_function(*args, **kwargs_dict) + assert result == "1, hello, 2, world" + + +def complex_args_and_docs_function(model: OuterModel, some_flag: int = 0) -> str: + """ + This function takes a model and a flag, and returns a string. + + Args: + model: A model with an inner and foo field + some_flag: An optional flag with a default of 0 + + Returns: + A string with the values of the model and flag + """ + return f"{model.inner.a}, {model.inner.b}, {model.foo['a']}, {model.foo['b']}, {some_flag or 0}" + + +def test_complex_args_and_docs_function(): + func_schema = function_schema(complex_args_and_docs_function) + + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "complex_args_and_docs_function_args" + + # Check docstring is parsed correctly + properties = func_schema.params_json_schema.get("properties", {}) + assert properties.get("model").get("description") == "A model with an inner and foo field" + assert properties.get("some_flag").get("description") == "An optional flag with a default of 0" + + # Valid input + model = OuterModel(inner=InnerModel(a=1, b="hello"), foo=Foo(a=2, b="world")) + valid_input = { + "model": model.model_dump(), + } + + parsed = func_schema.params_pydantic_model(**valid_input) + args, kwargs_dict = func_schema.to_call_args(parsed) + + result = complex_args_and_docs_function(*args, **kwargs_dict) + assert result == "1, hello, 2, world, 0" + + # Invalid input: 'some_flag' must be int + with pytest.raises(ValidationError): + func_schema.params_pydantic_model( + **{"model": model.model_dump(), "some_flag": "not an int"} + ) + + # Valid input: 'some_flag' can be omitted because it has a default + valid_input_no_flag = {"model": model.model_dump()} + parsed2 = func_schema.params_pydantic_model(**valid_input_no_flag) + args2, kwargs_dict2 = func_schema.to_call_args(parsed2) + result2 = complex_args_and_docs_function(*args2, **kwargs_dict2) + assert result2 == "1, hello, 2, world, 0" + + +def function_with_context(ctx: RunContextWrapper[str], a: int, b: int = 5): + return a + b + + +def test_function_with_context(): + func_schema = function_schema(function_with_context) + assert func_schema.takes_context + + context = RunContextWrapper(context="test") + + input = {"a": 1, "b": 2} + parsed = func_schema.params_pydantic_model(**input) + args, kwargs_dict = func_schema.to_call_args(parsed) + + result = function_with_context(context, *args, **kwargs_dict) + assert result == 3 + + +class MyClass: + def foo(self, a: int, b: int = 5): + return a + b + + def foo_ctx(self, ctx: RunContextWrapper[str], a: int, b: int = 5): + return a + b + + @classmethod + def bar(cls, a: int, b: int = 5): + return a + b + + @classmethod + def bar_ctx(cls, ctx: RunContextWrapper[str], a: int, b: int = 5): + return a + b + + @staticmethod + def baz(a: int, b: int = 5): + return a + b + + @staticmethod + def baz_ctx(ctx: RunContextWrapper[str], a: int, b: int = 5): + return a + b + + +def test_class_based_functions(): + context = RunContextWrapper(context="test") + + # Instance method + instance = MyClass() + func_schema = function_schema(instance.foo) + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "foo_args" + + input = {"a": 1, "b": 2} + parsed = func_schema.params_pydantic_model(**input) + args, kwargs_dict = func_schema.to_call_args(parsed) + result = instance.foo(*args, **kwargs_dict) + assert result == 3 + + # Instance method with context + func_schema = function_schema(instance.foo_ctx) + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "foo_ctx_args" + assert func_schema.takes_context + + input = {"a": 1, "b": 2} + parsed = func_schema.params_pydantic_model(**input) + args, kwargs_dict = func_schema.to_call_args(parsed) + result = instance.foo_ctx(context, *args, **kwargs_dict) + assert result == 3 + + # Class method + func_schema = function_schema(MyClass.bar) + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "bar_args" + + input = {"a": 1, "b": 2} + parsed = func_schema.params_pydantic_model(**input) + args, kwargs_dict = func_schema.to_call_args(parsed) + result = MyClass.bar(*args, **kwargs_dict) + assert result == 3 + + # Class method with context + func_schema = function_schema(MyClass.bar_ctx) + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "bar_ctx_args" + assert func_schema.takes_context + + input = {"a": 1, "b": 2} + parsed = func_schema.params_pydantic_model(**input) + args, kwargs_dict = func_schema.to_call_args(parsed) + result = MyClass.bar_ctx(context, *args, **kwargs_dict) + assert result == 3 + + # Static method + func_schema = function_schema(MyClass.baz) + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "baz_args" + + input = {"a": 1, "b": 2} + parsed = func_schema.params_pydantic_model(**input) + args, kwargs_dict = func_schema.to_call_args(parsed) + result = MyClass.baz(*args, **kwargs_dict) + assert result == 3 + + # Static method with context + func_schema = function_schema(MyClass.baz_ctx) + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "baz_ctx_args" + assert func_schema.takes_context + + input = {"a": 1, "b": 2} + parsed = func_schema.params_pydantic_model(**input) + args, kwargs_dict = func_schema.to_call_args(parsed) + result = MyClass.baz_ctx(context, *args, **kwargs_dict) + assert result == 3 + + +class MyEnum(str, Enum): + FOO = "foo" + BAR = "bar" + BAZ = "baz" + + +def enum_and_literal_function(a: MyEnum, b: Literal["a", "b", "c"]) -> str: + return f"{a.value} {b}" + + +def test_enum_and_literal_function(): + func_schema = function_schema(enum_and_literal_function) + assert isinstance(func_schema.params_json_schema, dict) + assert func_schema.params_json_schema.get("title") == "enum_and_literal_function_args" + + # Check that the enum values are included in the JSON schema + assert func_schema.params_json_schema.get("$defs", {}).get("MyEnum", {}).get("enum") == [ + "foo", + "bar", + "baz", + ] + + # Check that the enum is expressed as a def + assert ( + func_schema.params_json_schema.get("properties", {}).get("a", {}).get("$ref") + == "#/$defs/MyEnum" + ) + + # Check that the literal values are included in the JSON schema + assert func_schema.params_json_schema.get("properties", {}).get("b", {}).get("enum") == [ + "a", + "b", + "c", + ] + + # Valid input + valid_input = {"a": "foo", "b": "a"} + parsed = func_schema.params_pydantic_model(**valid_input) + args, kwargs_dict = func_schema.to_call_args(parsed) + result = enum_and_literal_function(*args, **kwargs_dict) + assert result == "foo a" + + # Invalid input: 'a' must be a valid enum value + with pytest.raises(ValidationError): + func_schema.params_pydantic_model(**{"a": "not an enum value", "b": "a"}) + + # Invalid input: 'b' must be a valid literal value + with pytest.raises(ValidationError): + func_schema.params_pydantic_model(**{"a": "foo", "b": "not a literal value"}) + + +def test_run_context_in_non_first_position_raises_value_error(): + # When a parameter (after the first) is annotated as RunContextWrapper, + # function_schema() should raise a UserError. + def func(a: int, context: RunContextWrapper) -> None: + pass + + with pytest.raises(UserError): + function_schema(func, use_docstring_info=False) + + +def test_var_positional_tuple_annotation(): + # When a function has a var-positional parameter annotated with a tuple type, + # function_schema() should convert it into a field with type List[]. + def func(*args: tuple[int, ...]) -> int: + total = 0 + for arg in args: + total += sum(arg) + return total + + fs = function_schema(func, use_docstring_info=False) + + properties = fs.params_json_schema.get("properties", {}) + assert properties.get("args").get("type") == "array" + assert properties.get("args").get("items").get("type") == "integer" + + +def test_var_keyword_dict_annotation(): + # Case 3: + # When a function has a var-keyword parameter annotated with a dict type, + # function_schema() should convert it into a field with type Dict[, ]. + def func(**kwargs: dict[str, int]): + return kwargs + + fs = function_schema(func, use_docstring_info=False) + + properties = fs.params_json_schema.get("properties", {}) + # The name of the field is "kwargs", and it's a JSON object i.e. a dict. + assert properties.get("kwargs").get("type") == "object" + # The values in the dict are integers. + assert properties.get("kwargs").get("additionalProperties").get("type") == "integer" diff --git a/tests/test_function_tool.py b/tests/test_function_tool.py new file mode 100644 index 0000000..6a78309 --- /dev/null +++ b/tests/test_function_tool.py @@ -0,0 +1,257 @@ +import json +from typing import Any + +import pytest +from pydantic import BaseModel +from typing_extensions import TypedDict + +from agents import FunctionTool, ModelBehaviorError, RunContextWrapper, function_tool +from agents.tool import default_tool_error_function + + +def argless_function() -> str: + return "ok" + + +@pytest.mark.asyncio +async def test_argless_function(): + tool = function_tool(argless_function) + assert tool.name == "argless_function" + + result = await tool.on_invoke_tool(RunContextWrapper(None), "") + assert result == "ok" + + +def argless_with_context(ctx: RunContextWrapper[str]) -> str: + return "ok" + + +@pytest.mark.asyncio +async def test_argless_with_context(): + tool = function_tool(argless_with_context) + assert tool.name == "argless_with_context" + + result = await tool.on_invoke_tool(RunContextWrapper(None), "") + assert result == "ok" + + # Extra JSON should not raise an error + result = await tool.on_invoke_tool(RunContextWrapper(None), '{"a": 1}') + assert result == "ok" + + +def simple_function(a: int, b: int = 5): + return a + b + + +@pytest.mark.asyncio +async def test_simple_function(): + tool = function_tool(simple_function, failure_error_function=None) + assert tool.name == "simple_function" + + result = await tool.on_invoke_tool(RunContextWrapper(None), '{"a": 1}') + assert result == "6" + + result = await tool.on_invoke_tool(RunContextWrapper(None), '{"a": 1, "b": 2}') + assert result == "3" + + # Missing required argument should raise an error + with pytest.raises(ModelBehaviorError): + await tool.on_invoke_tool(RunContextWrapper(None), "") + + +class Foo(BaseModel): + a: int + b: int = 5 + + +class Bar(TypedDict): + x: str + y: int + + +def complex_args_function(foo: Foo, bar: Bar, baz: str = "hello"): + return f"{foo.a + foo.b} {bar['x']}{bar['y']} {baz}" + + +@pytest.mark.asyncio +async def test_complex_args_function(): + tool = function_tool(complex_args_function, failure_error_function=None) + assert tool.name == "complex_args_function" + + valid_json = json.dumps( + { + "foo": Foo(a=1).model_dump(), + "bar": Bar(x="hello", y=10), + } + ) + result = await tool.on_invoke_tool(RunContextWrapper(None), valid_json) + assert result == "6 hello10 hello" + + valid_json = json.dumps( + { + "foo": Foo(a=1, b=2).model_dump(), + "bar": Bar(x="hello", y=10), + } + ) + result = await tool.on_invoke_tool(RunContextWrapper(None), valid_json) + assert result == "3 hello10 hello" + + valid_json = json.dumps( + { + "foo": Foo(a=1, b=2).model_dump(), + "bar": Bar(x="hello", y=10), + "baz": "world", + } + ) + result = await tool.on_invoke_tool(RunContextWrapper(None), valid_json) + assert result == "3 hello10 world" + + # Missing required argument should raise an error + with pytest.raises(ModelBehaviorError): + await tool.on_invoke_tool(RunContextWrapper(None), '{"foo": {"a": 1}}') + + +def test_function_config_overrides(): + tool = function_tool(simple_function, name_override="custom_name") + assert tool.name == "custom_name" + + tool = function_tool(simple_function, description_override="custom description") + assert tool.description == "custom description" + + tool = function_tool( + simple_function, + name_override="custom_name", + description_override="custom description", + ) + assert tool.name == "custom_name" + assert tool.description == "custom description" + + +def test_func_schema_is_strict(): + tool = function_tool(simple_function) + assert tool.strict_json_schema, "Should be strict by default" + assert ( + "additionalProperties" in tool.params_json_schema + and not tool.params_json_schema["additionalProperties"] + ) + + tool = function_tool(complex_args_function) + assert tool.strict_json_schema, "Should be strict by default" + assert ( + "additionalProperties" in tool.params_json_schema + and not tool.params_json_schema["additionalProperties"] + ) + + +@pytest.mark.asyncio +async def test_manual_function_tool_creation_works(): + def do_some_work(data: str) -> str: + return f"{data}_done" + + class FunctionArgs(BaseModel): + data: str + + async def run_function(ctx: RunContextWrapper[Any], args: str) -> str: + parsed = FunctionArgs.model_validate_json(args) + return do_some_work(data=parsed.data) + + tool = FunctionTool( + name="test", + description="Processes extracted user data", + params_json_schema=FunctionArgs.model_json_schema(), + on_invoke_tool=run_function, + ) + + assert tool.name == "test" + assert tool.description == "Processes extracted user data" + for key, value in FunctionArgs.model_json_schema().items(): + assert tool.params_json_schema[key] == value + assert tool.strict_json_schema + + result = await tool.on_invoke_tool(RunContextWrapper(None), '{"data": "hello"}') + assert result == "hello_done" + + tool_not_strict = FunctionTool( + name="test", + description="Processes extracted user data", + params_json_schema=FunctionArgs.model_json_schema(), + on_invoke_tool=run_function, + strict_json_schema=False, + ) + + assert not tool_not_strict.strict_json_schema + assert "additionalProperties" not in tool_not_strict.params_json_schema + + result = await tool_not_strict.on_invoke_tool( + RunContextWrapper(None), '{"data": "hello", "bar": "baz"}' + ) + assert result == "hello_done" + + +@pytest.mark.asyncio +async def test_function_tool_default_error_works(): + def my_func(a: int, b: int = 5): + raise ValueError("test") + + tool = function_tool(my_func) + ctx = RunContextWrapper(None) + + result = await tool.on_invoke_tool(ctx, "") + assert "Invalid JSON" in str(result) + + result = await tool.on_invoke_tool(ctx, "{}") + assert "Invalid JSON" in str(result) + + result = await tool.on_invoke_tool(ctx, '{"a": 1}') + assert result == default_tool_error_function(ctx, ValueError("test")) + + result = await tool.on_invoke_tool(ctx, '{"a": 1, "b": 2}') + assert result == default_tool_error_function(ctx, ValueError("test")) + + +@pytest.mark.asyncio +async def test_sync_custom_error_function_works(): + def my_func(a: int, b: int = 5): + raise ValueError("test") + + def custom_sync_error_function(ctx: RunContextWrapper[Any], error: Exception) -> str: + return f"error_{error.__class__.__name__}" + + tool = function_tool(my_func, failure_error_function=custom_sync_error_function) + ctx = RunContextWrapper(None) + + result = await tool.on_invoke_tool(ctx, "") + assert result == "error_ModelBehaviorError" + + result = await tool.on_invoke_tool(ctx, "{}") + assert result == "error_ModelBehaviorError" + + result = await tool.on_invoke_tool(ctx, '{"a": 1}') + assert result == "error_ValueError" + + result = await tool.on_invoke_tool(ctx, '{"a": 1, "b": 2}') + assert result == "error_ValueError" + + +@pytest.mark.asyncio +async def test_async_custom_error_function_works(): + async def my_func(a: int, b: int = 5): + raise ValueError("test") + + def custom_sync_error_function(ctx: RunContextWrapper[Any], error: Exception) -> str: + return f"error_{error.__class__.__name__}" + + tool = function_tool(my_func, failure_error_function=custom_sync_error_function) + ctx = RunContextWrapper(None) + + result = await tool.on_invoke_tool(ctx, "") + assert result == "error_ModelBehaviorError" + + result = await tool.on_invoke_tool(ctx, "{}") + assert result == "error_ModelBehaviorError" + + result = await tool.on_invoke_tool(ctx, '{"a": 1}') + assert result == "error_ValueError" + + result = await tool.on_invoke_tool(ctx, '{"a": 1, "b": 2}') + assert result == "error_ValueError" diff --git a/tests/test_function_tool_decorator.py b/tests/test_function_tool_decorator.py new file mode 100644 index 0000000..3a47deb --- /dev/null +++ b/tests/test_function_tool_decorator.py @@ -0,0 +1,144 @@ +import asyncio +import json +from typing import Any + +import pytest + +from agents import function_tool +from agents.run_context import RunContextWrapper + + +class DummyContext: + def __init__(self): + self.data = "something" + + +def ctx_wrapper() -> RunContextWrapper[DummyContext]: + return RunContextWrapper(DummyContext()) + + +@function_tool +def sync_no_context_no_args() -> str: + return "test_1" + + +@pytest.mark.asyncio +async def test_sync_no_context_no_args_invocation(): + tool = sync_no_context_no_args + output = await tool.on_invoke_tool(ctx_wrapper(), "") + assert output == "test_1" + + +@function_tool +def sync_no_context_with_args(a: int, b: int) -> int: + return a + b + + +@pytest.mark.asyncio +async def test_sync_no_context_with_args_invocation(): + tool = sync_no_context_with_args + input_data = {"a": 5, "b": 7} + output = await tool.on_invoke_tool(ctx_wrapper(), json.dumps(input_data)) + assert int(output) == 12 + + +@function_tool +def sync_with_context(ctx: RunContextWrapper[DummyContext], name: str) -> str: + return f"{name}_{ctx.context.data}" + + +@pytest.mark.asyncio +async def test_sync_with_context_invocation(): + tool = sync_with_context + input_data = {"name": "Alice"} + output = await tool.on_invoke_tool(ctx_wrapper(), json.dumps(input_data)) + assert output == "Alice_something" + + +@function_tool +async def async_no_context(a: int, b: int) -> int: + await asyncio.sleep(0) # Just to illustrate async + return a * b + + +@pytest.mark.asyncio +async def test_async_no_context_invocation(): + tool = async_no_context + input_data = {"a": 3, "b": 4} + output = await tool.on_invoke_tool(ctx_wrapper(), json.dumps(input_data)) + assert int(output) == 12 + + +@function_tool +async def async_with_context(ctx: RunContextWrapper[DummyContext], prefix: str, num: int) -> str: + await asyncio.sleep(0) + return f"{prefix}-{num}-{ctx.context.data}" + + +@pytest.mark.asyncio +async def test_async_with_context_invocation(): + tool = async_with_context + input_data = {"prefix": "Value", "num": 42} + output = await tool.on_invoke_tool(ctx_wrapper(), json.dumps(input_data)) + assert output == "Value-42-something" + + +@function_tool(name_override="my_custom_tool", description_override="custom desc") +def sync_no_context_override() -> str: + return "override_result" + + +@pytest.mark.asyncio +async def test_sync_no_context_override_invocation(): + tool = sync_no_context_override + assert tool.name == "my_custom_tool" + assert tool.description == "custom desc" + output = await tool.on_invoke_tool(ctx_wrapper(), "") + assert output == "override_result" + + +@function_tool(failure_error_function=None) +def will_fail_on_bad_json(x: int) -> int: + return x * 2 # pragma: no cover + + +@pytest.mark.asyncio +async def test_error_on_invalid_json(): + tool = will_fail_on_bad_json + # Passing an invalid JSON string + with pytest.raises(Exception) as exc_info: + await tool.on_invoke_tool(ctx_wrapper(), "{not valid json}") + assert "Invalid JSON input for tool" in str(exc_info.value) + + +def sync_error_handler(ctx: RunContextWrapper[Any], error: Exception) -> str: + return f"error_{error.__class__.__name__}" + + +@function_tool(failure_error_function=sync_error_handler) +def will_not_fail_on_bad_json(x: int) -> int: + return x * 2 # pragma: no cover + + +@pytest.mark.asyncio +async def test_no_error_on_invalid_json(): + tool = will_not_fail_on_bad_json + # Passing an invalid JSON string + result = await tool.on_invoke_tool(ctx_wrapper(), "{not valid json}") + assert result == "error_ModelBehaviorError" + + +def async_error_handler(ctx: RunContextWrapper[Any], error: Exception) -> str: + return f"error_{error.__class__.__name__}" + + +@function_tool(failure_error_function=sync_error_handler) +def will_not_fail_on_bad_json_async(x: int) -> int: + return x * 2 # pragma: no cover + + +@pytest.mark.asyncio +async def test_no_error_on_invalid_json_async(): + tool = will_not_fail_on_bad_json_async + result = await tool.on_invoke_tool(ctx_wrapper(), "{not valid json}") + assert result == "error_ModelBehaviorError" diff --git a/tests/test_global_hooks.py b/tests/test_global_hooks.py new file mode 100644 index 0000000..6ac35b9 --- /dev/null +++ b/tests/test_global_hooks.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import json +from collections import defaultdict +from typing import Any + +import pytest +from typing_extensions import TypedDict + +from agents import Agent, RunContextWrapper, RunHooks, Runner, TContext, Tool + +from .fake_model import FakeModel +from .test_responses import ( + get_final_output_message, + get_function_tool, + get_function_tool_call, + get_handoff_tool_call, + get_text_message, +) + + +class RunHooksForTests(RunHooks): + def __init__(self): + self.events: dict[str, int] = defaultdict(int) + + def reset(self): + self.events.clear() + + async def on_agent_start( + self, context: RunContextWrapper[TContext], agent: Agent[TContext] + ) -> None: + self.events["on_agent_start"] += 1 + + async def on_agent_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + output: Any, + ) -> None: + self.events["on_agent_end"] += 1 + + async def on_handoff( + self, + context: RunContextWrapper[TContext], + from_agent: Agent[TContext], + to_agent: Agent[TContext], + ) -> None: + self.events["on_handoff"] += 1 + + async def on_tool_start( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + ) -> None: + self.events["on_tool_start"] += 1 + + async def on_tool_end( + self, + context: RunContextWrapper[TContext], + agent: Agent[TContext], + tool: Tool, + result: str, + ) -> None: + self.events["on_tool_end"] += 1 + + +@pytest.mark.asyncio +async def test_non_streamed_agent_hooks(): + hooks = RunHooksForTests() + model = FakeModel() + agent_1 = Agent(name="test_1", model=model) + agent_2 = Agent(name="test_2", model=model) + agent_3 = Agent( + name="test_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + ) + + agent_1.handoffs.append(agent_3) + + model.set_next_output([get_text_message("user_message")]) + output = await Runner.run(agent_3, input="user_message", hooks=hooks) + assert hooks.events == {"on_agent_start": 1, "on_agent_end": 1}, f"{output}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: text message + [get_text_message("done")], + ] + ) + await Runner.run(agent_3, input="user_message", hooks=hooks) + assert hooks.events == { + # We only invoke on_agent_start when we begin executing a new agent. + # Although agent_3 runs two turns internally before handing off, + # that's one logical agent segment, so on_agent_start fires once. + # Then we hand off to agent_1, so on_agent_start fires for that agent. + "on_agent_start": 2, + "on_tool_start": 1, # Only one tool call + "on_tool_end": 1, # Only one tool call + "on_handoff": 1, # Only one handoff + "on_agent_end": 1, # Should always have one end + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message, another tool call, and a handoff + [ + get_text_message("a_message"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_handoff_tool_call(agent_1), + ], + # Third turn: a message and a handoff back to the orig agent + [get_text_message("a_message"), get_handoff_tool_call(agent_3)], + # Fourth turn: text message + [get_text_message("done")], + ] + ) + await Runner.run(agent_3, input="user_message", hooks=hooks) + + assert hooks.events == { + # agent_3 starts (fires on_agent_start), runs two turns and hands off. + # agent_1 starts (fires on_agent_start), then hands back to agent_3. + # agent_3 starts again (fires on_agent_start) to complete execution. + "on_agent_start": 3, + "on_tool_start": 2, # 2 tool calls + "on_tool_end": 2, # 2 tool calls + "on_handoff": 2, # 2 handoffs + "on_agent_end": 1, # Should always have one end + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + +@pytest.mark.asyncio +async def test_streamed_agent_hooks(): + hooks = RunHooksForTests() + model = FakeModel() + agent_1 = Agent(name="test_1", model=model) + agent_2 = Agent(name="test_2", model=model) + agent_3 = Agent( + name="test_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + ) + + agent_1.handoffs.append(agent_3) + + model.set_next_output([get_text_message("user_message")]) + output = Runner.run_streamed(agent_3, input="user_message", hooks=hooks) + async for _ in output.stream_events(): + pass + assert hooks.events == {"on_agent_start": 1, "on_agent_end": 1}, f"{output}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: text message + [get_text_message("done")], + ] + ) + output = Runner.run_streamed(agent_3, input="user_message", hooks=hooks) + async for _ in output.stream_events(): + pass + assert hooks.events == { + # As in the non-streamed case above, two logical agent segments: + # starting agent_3, then handoff to agent_1. + "on_agent_start": 2, + "on_tool_start": 1, # Only one tool call + "on_tool_end": 1, # Only one tool call + "on_handoff": 1, # Only one handoff + "on_agent_end": 1, # Should always have one end + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message, another tool call, and a handoff + [ + get_text_message("a_message"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_handoff_tool_call(agent_1), + ], + # Third turn: a message and a handoff back to the orig agent + [get_text_message("a_message"), get_handoff_tool_call(agent_3)], + # Fourth turn: text message + [get_text_message("done")], + ] + ) + output = Runner.run_streamed(agent_3, input="user_message", hooks=hooks) + async for _ in output.stream_events(): + pass + + assert hooks.events == { + # Same three logical agent segments as in the non-streamed case, + # so on_agent_start fires three times. + "on_agent_start": 3, + "on_tool_start": 2, # 2 tool calls + "on_tool_end": 2, # 2 tool calls + "on_handoff": 2, # 2 handoffs + "on_agent_end": 1, # Should always have one end + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + +class Foo(TypedDict): + a: str + + +@pytest.mark.asyncio +async def test_structed_output_non_streamed_agent_hooks(): + hooks = RunHooksForTests() + model = FakeModel() + agent_1 = Agent(name="test_1", model=model) + agent_2 = Agent(name="test_2", model=model) + agent_3 = Agent( + name="test_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + output_type=Foo, + ) + + agent_1.handoffs.append(agent_3) + + model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + output = await Runner.run(agent_3, input="user_message", hooks=hooks) + assert hooks.events == {"on_agent_start": 1, "on_agent_end": 1}, f"{output}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: end message (for agent 1) + [get_text_message("done")], + ] + ) + output = await Runner.run(agent_3, input="user_message", hooks=hooks) + + assert hooks.events == { + # As with unstructured output, we expect on_agent_start once for + # agent_3 and once for agent_1. + "on_agent_start": 2, + "on_tool_start": 1, # Only one tool call + "on_tool_end": 1, # Only one tool call + "on_handoff": 1, # Only one handoff + "on_agent_end": 1, # Should always have one end + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message, another tool call, and a handoff + [ + get_text_message("a_message"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_handoff_tool_call(agent_1), + ], + # Third turn: a message and a handoff back to the orig agent + [get_text_message("a_message"), get_handoff_tool_call(agent_3)], + # Fourth turn: end message (for agent 3) + [get_final_output_message(json.dumps({"a": "b"}))], + ] + ) + await Runner.run(agent_3, input="user_message", hooks=hooks) + + assert hooks.events == { + # We still expect three logical agent segments, as before. + "on_agent_start": 3, + "on_tool_start": 2, # 2 tool calls + "on_tool_end": 2, # 2 tool calls + "on_handoff": 2, # 2 handoffs + "on_agent_end": 1, # Should always have one end + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + +@pytest.mark.asyncio +async def test_structed_output_streamed_agent_hooks(): + hooks = RunHooksForTests() + model = FakeModel() + agent_1 = Agent(name="test_1", model=model) + agent_2 = Agent(name="test_2", model=model) + agent_3 = Agent( + name="test_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + output_type=Foo, + ) + + agent_1.handoffs.append(agent_3) + + model.set_next_output([get_final_output_message(json.dumps({"a": "b"}))]) + output = Runner.run_streamed(agent_3, input="user_message", hooks=hooks) + async for _ in output.stream_events(): + pass + assert hooks.events == {"on_agent_start": 1, "on_agent_end": 1}, f"{output}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and a handoff + [get_text_message("a_message"), get_handoff_tool_call(agent_1)], + # Third turn: end message (for agent 1) + [get_text_message("done")], + ] + ) + output = Runner.run_streamed(agent_3, input="user_message", hooks=hooks) + async for _ in output.stream_events(): + pass + + assert hooks.events == { + # Two agent segments: agent_3 and then agent_1. + "on_agent_start": 2, + "on_tool_start": 1, # Only one tool call + "on_tool_end": 1, # Only one tool call + "on_handoff": 1, # Only one handoff + "on_agent_end": 1, # Should always have one end + }, f"got unexpected event count: {hooks.events}" + hooks.reset() + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message, another tool call, and a handoff + [ + get_text_message("a_message"), + get_function_tool_call("some_function", json.dumps({"a": "b"})), + get_handoff_tool_call(agent_1), + ], + # Third turn: a message and a handoff back to the orig agent + [get_text_message("a_message"), get_handoff_tool_call(agent_3)], + # Fourth turn: end message (for agent 3) + [get_final_output_message(json.dumps({"a": "b"}))], + ] + ) + output = Runner.run_streamed(agent_3, input="user_message", hooks=hooks) + async for _ in output.stream_events(): + pass + + assert hooks.events == { + # Three agent segments: agent_3, agent_1, agent_3 again. + "on_agent_start": 3, + "on_tool_start": 2, # 2 tool calls + "on_tool_end": 2, # 2 tool calls + "on_handoff": 2, # 2 handoffs + "on_agent_end": 1, # Should always have one end + }, f"got unexpected event count: {hooks.events}" + hooks.reset() diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py new file mode 100644 index 0000000..c9f318c --- /dev/null +++ b/tests/test_guardrails.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrail, + OutputGuardrail, + RunContextWrapper, + TResponseInputItem, + UserError, +) +from agents.guardrail import input_guardrail, output_guardrail + + +def get_sync_guardrail(triggers: bool, output_info: Any | None = None): + def sync_guardrail( + context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ): + return GuardrailFunctionOutput( + output_info=output_info, + tripwire_triggered=triggers, + ) + + return sync_guardrail + + +@pytest.mark.asyncio +async def test_sync_input_guardrail(): + guardrail = InputGuardrail(guardrail_function=get_sync_guardrail(triggers=False)) + result = await guardrail.run( + agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None) + ) + assert not result.output.tripwire_triggered + assert result.output.output_info is None + + guardrail = InputGuardrail(guardrail_function=get_sync_guardrail(triggers=True)) + result = await guardrail.run( + agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None) + ) + assert result.output.tripwire_triggered + assert result.output.output_info is None + + guardrail = InputGuardrail( + guardrail_function=get_sync_guardrail(triggers=True, output_info="test") + ) + result = await guardrail.run( + agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None) + ) + assert result.output.tripwire_triggered + assert result.output.output_info == "test" + + +def get_async_input_guardrail(triggers: bool, output_info: Any | None = None): + async def async_guardrail( + context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ): + return GuardrailFunctionOutput( + output_info=output_info, + tripwire_triggered=triggers, + ) + + return async_guardrail + + +@pytest.mark.asyncio +async def test_async_input_guardrail(): + guardrail = InputGuardrail(guardrail_function=get_async_input_guardrail(triggers=False)) + result = await guardrail.run( + agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None) + ) + assert not result.output.tripwire_triggered + assert result.output.output_info is None + + guardrail = InputGuardrail(guardrail_function=get_async_input_guardrail(triggers=True)) + result = await guardrail.run( + agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None) + ) + assert result.output.tripwire_triggered + assert result.output.output_info is None + + guardrail = InputGuardrail( + guardrail_function=get_async_input_guardrail(triggers=True, output_info="test") + ) + result = await guardrail.run( + agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None) + ) + assert result.output.tripwire_triggered + assert result.output.output_info == "test" + + +@pytest.mark.asyncio +async def test_invalid_input_guardrail_raises_user_error(): + with pytest.raises(UserError): + # Purposely ignoring type error + guardrail = InputGuardrail(guardrail_function="foo") # type: ignore + await guardrail.run( + agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None) + ) + + +def get_sync_output_guardrail(triggers: bool, output_info: Any | None = None): + def sync_guardrail(context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any): + return GuardrailFunctionOutput( + output_info=output_info, + tripwire_triggered=triggers, + ) + + return sync_guardrail + + +@pytest.mark.asyncio +async def test_sync_output_guardrail(): + guardrail = OutputGuardrail(guardrail_function=get_sync_output_guardrail(triggers=False)) + result = await guardrail.run( + agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None) + ) + assert not result.output.tripwire_triggered + assert result.output.output_info is None + + guardrail = OutputGuardrail(guardrail_function=get_sync_output_guardrail(triggers=True)) + result = await guardrail.run( + agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None) + ) + assert result.output.tripwire_triggered + assert result.output.output_info is None + + guardrail = OutputGuardrail( + guardrail_function=get_sync_output_guardrail(triggers=True, output_info="test") + ) + result = await guardrail.run( + agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None) + ) + assert result.output.tripwire_triggered + assert result.output.output_info == "test" + + +def get_async_output_guardrail(triggers: bool, output_info: Any | None = None): + async def async_guardrail( + context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any + ): + return GuardrailFunctionOutput( + output_info=output_info, + tripwire_triggered=triggers, + ) + + return async_guardrail + + +@pytest.mark.asyncio +async def test_async_output_guardrail(): + guardrail = OutputGuardrail(guardrail_function=get_async_output_guardrail(triggers=False)) + result = await guardrail.run( + agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None) + ) + assert not result.output.tripwire_triggered + assert result.output.output_info is None + + guardrail = OutputGuardrail(guardrail_function=get_async_output_guardrail(triggers=True)) + result = await guardrail.run( + agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None) + ) + assert result.output.tripwire_triggered + assert result.output.output_info is None + + guardrail = OutputGuardrail( + guardrail_function=get_async_output_guardrail(triggers=True, output_info="test") + ) + result = await guardrail.run( + agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None) + ) + assert result.output.tripwire_triggered + assert result.output.output_info == "test" + + +@pytest.mark.asyncio +async def test_invalid_output_guardrail_raises_user_error(): + with pytest.raises(UserError): + # Purposely ignoring type error + guardrail = OutputGuardrail(guardrail_function="foo") # type: ignore + await guardrail.run( + agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None) + ) + + +@input_guardrail +def decorated_input_guardrail( + context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info="test_1", + tripwire_triggered=False, + ) + + +@input_guardrail(name="Custom name") +def decorated_named_input_guardrail( + context: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info="test_2", + tripwire_triggered=False, + ) + + +@pytest.mark.asyncio +async def test_input_guardrail_decorators(): + guardrail = decorated_input_guardrail + result = await guardrail.run( + agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None) + ) + assert not result.output.tripwire_triggered + assert result.output.output_info == "test_1" + + guardrail = decorated_named_input_guardrail + result = await guardrail.run( + agent=Agent(name="test"), input="test", context=RunContextWrapper(context=None) + ) + assert not result.output.tripwire_triggered + assert result.output.output_info == "test_2" + assert guardrail.get_name() == "Custom name" + + +@output_guardrail +def decorated_output_guardrail( + context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info="test_3", + tripwire_triggered=False, + ) + + +@output_guardrail(name="Custom name") +def decorated_named_output_guardrail( + context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info="test_4", + tripwire_triggered=False, + ) + + +@pytest.mark.asyncio +async def test_output_guardrail_decorators(): + guardrail = decorated_output_guardrail + result = await guardrail.run( + agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None) + ) + assert not result.output.tripwire_triggered + assert result.output.output_info == "test_3" + + guardrail = decorated_named_output_guardrail + result = await guardrail.run( + agent=Agent(name="test"), agent_output="test", context=RunContextWrapper(context=None) + ) + assert not result.output.tripwire_triggered + assert result.output.output_info == "test_4" + assert guardrail.get_name() == "Custom name" diff --git a/tests/test_handoff_tool.py b/tests/test_handoff_tool.py new file mode 100644 index 0000000..a2a0620 --- /dev/null +++ b/tests/test_handoff_tool.py @@ -0,0 +1,278 @@ +from typing import Any + +import pytest +from openai.types.responses import ResponseOutputMessage, ResponseOutputText +from pydantic import BaseModel + +from agents import ( + Agent, + Handoff, + HandoffInputData, + MessageOutputItem, + ModelBehaviorError, + RunContextWrapper, + Runner, + UserError, + handoff, +) + + +def message_item(content: str, agent: Agent[Any]) -> MessageOutputItem: + return MessageOutputItem( + agent=agent, + raw_item=ResponseOutputMessage( + id="123", + status="completed", + role="assistant", + type="message", + content=[ResponseOutputText(text=content, type="output_text", annotations=[])], + ), + ) + + +def get_len(data: HandoffInputData) -> int: + input_len = len(data.input_history) if isinstance(data.input_history, tuple) else 1 + pre_handoff_len = len(data.pre_handoff_items) + new_items_len = len(data.new_items) + return input_len + pre_handoff_len + new_items_len + + +def test_single_handoff_setup(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2", handoffs=[agent_1]) + + assert not agent_1.handoffs + assert agent_2.handoffs == [agent_1] + + assert not Runner._get_handoffs(agent_1) + + handoff_objects = Runner._get_handoffs(agent_2) + assert len(handoff_objects) == 1 + obj = handoff_objects[0] + assert obj.tool_name == Handoff.default_tool_name(agent_1) + assert obj.tool_description == Handoff.default_tool_description(agent_1) + assert obj.agent_name == agent_1.name + + +def test_multiple_handoffs_setup(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2") + agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2]) + + assert agent_3.handoffs == [agent_1, agent_2] + assert not agent_1.handoffs + assert not agent_2.handoffs + + handoff_objects = Runner._get_handoffs(agent_3) + assert len(handoff_objects) == 2 + assert handoff_objects[0].tool_name == Handoff.default_tool_name(agent_1) + assert handoff_objects[1].tool_name == Handoff.default_tool_name(agent_2) + + assert handoff_objects[0].tool_description == Handoff.default_tool_description(agent_1) + assert handoff_objects[1].tool_description == Handoff.default_tool_description(agent_2) + + assert handoff_objects[0].agent_name == agent_1.name + assert handoff_objects[1].agent_name == agent_2.name + + +def test_custom_handoff_setup(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2") + agent_3 = Agent( + name="test_3", + handoffs=[ + agent_1, + handoff( + agent_2, + tool_name_override="custom_tool_name", + tool_description_override="custom tool description", + ), + ], + ) + + assert len(agent_3.handoffs) == 2 + assert not agent_1.handoffs + assert not agent_2.handoffs + + handoff_objects = Runner._get_handoffs(agent_3) + assert len(handoff_objects) == 2 + + first_handoff = handoff_objects[0] + assert isinstance(first_handoff, Handoff) + assert first_handoff.tool_name == Handoff.default_tool_name(agent_1) + assert first_handoff.tool_description == Handoff.default_tool_description(agent_1) + assert first_handoff.agent_name == agent_1.name + + second_handoff = handoff_objects[1] + assert isinstance(second_handoff, Handoff) + assert second_handoff.tool_name == "custom_tool_name" + assert second_handoff.tool_description == "custom tool description" + assert second_handoff.agent_name == agent_2.name + + +class Foo(BaseModel): + bar: str + + +@pytest.mark.asyncio +async def test_handoff_input_type(): + async def _on_handoff(ctx: RunContextWrapper[Any], input: Foo): + pass + + agent = Agent(name="test") + obj = handoff(agent, input_type=Foo, on_handoff=_on_handoff) + for key, value in Foo.model_json_schema().items(): + assert obj.input_json_schema[key] == value + + # Invalid JSON should raise an error + with pytest.raises(ModelBehaviorError): + await obj.on_invoke_handoff(RunContextWrapper(agent), "not json") + + # Empty JSON should raise an error + with pytest.raises(ModelBehaviorError): + await obj.on_invoke_handoff(RunContextWrapper(agent), "") + + # Valid JSON should call the on_handoff function + invoked = await obj.on_invoke_handoff( + RunContextWrapper(agent), Foo(bar="baz").model_dump_json() + ) + assert invoked == agent + + +@pytest.mark.asyncio +async def test_on_handoff_called(): + was_called = False + + async def _on_handoff(ctx: RunContextWrapper[Any], input: Foo): + nonlocal was_called + was_called = True + + agent = Agent(name="test") + obj = handoff(agent, input_type=Foo, on_handoff=_on_handoff) + for key, value in Foo.model_json_schema().items(): + assert obj.input_json_schema[key] == value + + invoked = await obj.on_invoke_handoff( + RunContextWrapper(agent), Foo(bar="baz").model_dump_json() + ) + assert invoked == agent + + assert was_called, "on_handoff should have been called" + + +@pytest.mark.asyncio +async def test_on_handoff_without_input_called(): + was_called = False + + def _on_handoff(ctx: RunContextWrapper[Any]): + nonlocal was_called + was_called = True + + agent = Agent(name="test") + obj = handoff(agent, on_handoff=_on_handoff) + + invoked = await obj.on_invoke_handoff(RunContextWrapper(agent), "") + assert invoked == agent + + assert was_called, "on_handoff should have been called" + + +@pytest.mark.asyncio +async def test_async_on_handoff_without_input_called(): + was_called = False + + async def _on_handoff(ctx: RunContextWrapper[Any]): + nonlocal was_called + was_called = True + + agent = Agent(name="test") + obj = handoff(agent, on_handoff=_on_handoff) + + invoked = await obj.on_invoke_handoff(RunContextWrapper(agent), "") + assert invoked == agent + + assert was_called, "on_handoff should have been called" + + +@pytest.mark.asyncio +async def test_invalid_on_handoff_raises_error(): + was_called = False + + async def _on_handoff(ctx: RunContextWrapper[Any], blah: str): + nonlocal was_called + was_called = True # pragma: no cover + + agent = Agent(name="test") + + with pytest.raises(UserError): + # Purposely ignoring the type error here to simulate invalid input + handoff(agent, on_handoff=_on_handoff) # type: ignore + + +def test_handoff_input_data(): + agent = Agent(name="test") + + data = HandoffInputData( + input_history="", + pre_handoff_items=(), + new_items=(), + ) + assert get_len(data) == 1 + + data = HandoffInputData( + input_history=({"role": "user", "content": "foo"},), + pre_handoff_items=(), + new_items=(), + ) + assert get_len(data) == 1 + + data = HandoffInputData( + input_history=( + {"role": "user", "content": "foo"}, + {"role": "assistant", "content": "bar"}, + ), + pre_handoff_items=(), + new_items=(), + ) + assert get_len(data) == 2 + + data = HandoffInputData( + input_history=({"role": "user", "content": "foo"},), + pre_handoff_items=( + message_item("foo", agent), + message_item("foo2", agent), + ), + new_items=( + message_item("bar", agent), + message_item("baz", agent), + ), + ) + assert get_len(data) == 5 + + data = HandoffInputData( + input_history=( + {"role": "user", "content": "foo"}, + {"role": "assistant", "content": "bar"}, + ), + pre_handoff_items=(message_item("baz", agent),), + new_items=( + message_item("baz", agent), + message_item("qux", agent), + ), + ) + + assert get_len(data) == 5 + + +def test_handoff_input_schema_is_strict(): + agent = Agent(name="test") + obj = handoff(agent, input_type=Foo, on_handoff=lambda ctx, input: None) + for key, value in Foo.model_json_schema().items(): + assert obj.input_json_schema[key] == value + + assert obj.strict_json_schema, "Input schema should be strict" + + assert ( + "additionalProperties" in obj.input_json_schema + and not obj.input_json_schema["additionalProperties"] + ), "Input schema should be strict and have additionalProperties=False" diff --git a/tests/test_items_helpers.py b/tests/test_items_helpers.py new file mode 100644 index 0000000..64e2dcd --- /dev/null +++ b/tests/test_items_helpers.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from openai.types.responses.response_computer_tool_call import ( + ActionScreenshot, + ResponseComputerToolCall, +) +from openai.types.responses.response_computer_tool_call_param import ResponseComputerToolCallParam +from openai.types.responses.response_file_search_tool_call import ResponseFileSearchToolCall +from openai.types.responses.response_file_search_tool_call_param import ( + ResponseFileSearchToolCallParam, +) +from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall +from openai.types.responses.response_function_tool_call_param import ResponseFunctionToolCallParam +from openai.types.responses.response_function_web_search import ResponseFunctionWebSearch +from openai.types.responses.response_function_web_search_param import ResponseFunctionWebSearchParam +from openai.types.responses.response_input_item_param import Reasoning as ReasoningInputParam +from openai.types.responses.response_output_item import Reasoning, ReasoningContent +from openai.types.responses.response_output_message import ResponseOutputMessage +from openai.types.responses.response_output_message_param import ResponseOutputMessageParam +from openai.types.responses.response_output_refusal import ResponseOutputRefusal +from openai.types.responses.response_output_text import ResponseOutputText + +from agents import ( + Agent, + ItemHelpers, + MessageOutputItem, + ModelResponse, + ReasoningItem, + RunItem, + TResponseInputItem, + Usage, +) + + +def make_message( + content_items: list[ResponseOutputText | ResponseOutputRefusal], +) -> ResponseOutputMessage: + """ + Helper to construct a ResponseOutputMessage with a single batch of content + items, using a fixed id/status. + """ + return ResponseOutputMessage( + id="msg123", + content=content_items, + role="assistant", + status="completed", + type="message", + ) + + +def test_extract_last_content_of_text_message() -> None: + # Build a message containing two text segments. + content1 = ResponseOutputText(annotations=[], text="Hello ", type="output_text") + content2 = ResponseOutputText(annotations=[], text="world!", type="output_text") + message = make_message([content1, content2]) + # Helpers should yield the last segment's text. + assert ItemHelpers.extract_last_content(message) == "world!" + + +def test_extract_last_content_of_refusal_message() -> None: + # Build a message whose last content entry is a refusal. + content1 = ResponseOutputText(annotations=[], text="Before refusal", type="output_text") + refusal = ResponseOutputRefusal(refusal="I cannot do that", type="refusal") + message = make_message([content1, refusal]) + # Helpers should extract the refusal string when last content is a refusal. + assert ItemHelpers.extract_last_content(message) == "I cannot do that" + + +def test_extract_last_content_non_message_returns_empty() -> None: + # Construct some other type of output item, e.g. a tool call, to verify non-message returns "". + tool_call = ResponseFunctionToolCall( + id="tool123", + arguments="{}", + call_id="call123", + name="func", + type="function_call", + ) + assert ItemHelpers.extract_last_content(tool_call) == "" + + +def test_extract_last_text_returns_text_only() -> None: + # A message whose last segment is text yields the text. + first_text = ResponseOutputText(annotations=[], text="part1", type="output_text") + second_text = ResponseOutputText(annotations=[], text="part2", type="output_text") + message = make_message([first_text, second_text]) + assert ItemHelpers.extract_last_text(message) == "part2" + # Whereas when last content is a refusal, extract_last_text returns None. + message2 = make_message([first_text, ResponseOutputRefusal(refusal="no", type="refusal")]) + assert ItemHelpers.extract_last_text(message2) is None + + +def test_input_to_new_input_list_from_string() -> None: + result = ItemHelpers.input_to_new_input_list("hi") + # Should wrap the string into a list with a single dict containing content and user role. + assert isinstance(result, list) + assert result == [{"content": "hi", "role": "user"}] + + +def test_input_to_new_input_list_deep_copies_lists() -> None: + # Given a list of message dictionaries, ensure the returned list is a deep copy. + original: list[TResponseInputItem] = [{"content": "abc", "role": "developer"}] + new_list = ItemHelpers.input_to_new_input_list(original) + assert new_list == original + # Mutating the returned list should not mutate the original. + new_list.pop() + assert "content" in original[0] and original[0].get("content") == "abc" + + +def test_text_message_output_concatenates_text_segments() -> None: + # Build a message with both text and refusal segments, only text segments are concatenated. + pieces: list[ResponseOutputText | ResponseOutputRefusal] = [] + pieces.append(ResponseOutputText(annotations=[], text="a", type="output_text")) + pieces.append(ResponseOutputRefusal(refusal="denied", type="refusal")) + pieces.append(ResponseOutputText(annotations=[], text="b", type="output_text")) + message = make_message(pieces) + # Wrap into MessageOutputItem to feed into text_message_output. + item = MessageOutputItem(agent=Agent(name="test"), raw_item=message) + assert ItemHelpers.text_message_output(item) == "ab" + + +def test_text_message_outputs_across_list_of_runitems() -> None: + """ + Compose several RunItem instances, including a non-message run item, and ensure + that only MessageOutputItem instances contribute any text. The non-message + (ReasoningItem) should be ignored by Helpers.text_message_outputs. + """ + message1 = make_message([ResponseOutputText(annotations=[], text="foo", type="output_text")]) + message2 = make_message([ResponseOutputText(annotations=[], text="bar", type="output_text")]) + item1: RunItem = MessageOutputItem(agent=Agent(name="test"), raw_item=message1) + item2: RunItem = MessageOutputItem(agent=Agent(name="test"), raw_item=message2) + # Create a non-message run item of a different type, e.g., a reasoning trace. + reasoning = Reasoning(id="rid", content=[], type="reasoning") + non_message_item: RunItem = ReasoningItem(agent=Agent(name="test"), raw_item=reasoning) + # Confirm only the message outputs are concatenated. + assert ItemHelpers.text_message_outputs([item1, non_message_item, item2]) == "foobar" + + +def test_tool_call_output_item_constructs_function_call_output_dict(): + # Build a simple ResponseFunctionToolCall. + call = ResponseFunctionToolCall( + id="call-abc", + arguments='{"x": 1}', + call_id="call-abc", + name="do_something", + type="function_call", + ) + payload = ItemHelpers.tool_call_output_item(call, "result-string") + + assert isinstance(payload, dict) + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.id + assert payload["output"] == "result-string" + + +# The following tests ensure that every possible output item type defined by +# OpenAI's API can be converted back into an input item dict via +# ModelResponse.to_input_items. The output and input schema for each item are +# intended to be symmetric, so given any ResponseOutputItem, its model_dump +# should produce a dict that can satisfy the corresponding TypedDict input +# type. These tests construct minimal valid instances of each output type, +# invoke to_input_items, and then verify that the resulting dict can be used +# to round-trip back into a Pydantic output model without errors. + + +def test_to_input_items_for_message() -> None: + """An output message should convert into an input dict matching the message's own structure.""" + content = ResponseOutputText(annotations=[], text="hello world", type="output_text") + message = ResponseOutputMessage( + id="m1", content=[content], role="assistant", status="completed", type="message" + ) + resp = ModelResponse(output=[message], usage=Usage(), referenceable_id=None) + input_items = resp.to_input_items() + assert isinstance(input_items, list) and len(input_items) == 1 + # The dict should contain exactly the primitive values of the message + expected: ResponseOutputMessageParam = { + "id": "m1", + "content": [ + { + "annotations": [], + "text": "hello world", + "type": "output_text", + } + ], + "role": "assistant", + "status": "completed", + "type": "message", + } + assert input_items[0] == expected + + +def test_to_input_items_for_function_call() -> None: + """A function tool call output should produce the same dict as a function tool call input.""" + tool_call = ResponseFunctionToolCall( + id="f1", arguments="{}", call_id="c1", name="func", type="function_call" + ) + resp = ModelResponse(output=[tool_call], usage=Usage(), referenceable_id=None) + input_items = resp.to_input_items() + assert isinstance(input_items, list) and len(input_items) == 1 + expected: ResponseFunctionToolCallParam = { + "id": "f1", + "arguments": "{}", + "call_id": "c1", + "name": "func", + "type": "function_call", + } + assert input_items[0] == expected + + +def test_to_input_items_for_file_search_call() -> None: + """A file search tool call output should produce the same dict as a file search input.""" + fs_call = ResponseFileSearchToolCall( + id="fs1", queries=["query"], status="completed", type="file_search_call" + ) + resp = ModelResponse(output=[fs_call], usage=Usage(), referenceable_id=None) + input_items = resp.to_input_items() + assert isinstance(input_items, list) and len(input_items) == 1 + expected: ResponseFileSearchToolCallParam = { + "id": "fs1", + "queries": ["query"], + "status": "completed", + "type": "file_search_call", + } + assert input_items[0] == expected + + +def test_to_input_items_for_web_search_call() -> None: + """A web search tool call output should produce the same dict as a web search input.""" + ws_call = ResponseFunctionWebSearch(id="w1", status="completed", type="web_search_call") + resp = ModelResponse(output=[ws_call], usage=Usage(), referenceable_id=None) + input_items = resp.to_input_items() + assert isinstance(input_items, list) and len(input_items) == 1 + expected: ResponseFunctionWebSearchParam = { + "id": "w1", + "status": "completed", + "type": "web_search_call", + } + assert input_items[0] == expected + + +def test_to_input_items_for_computer_call_click() -> None: + """A computer call output should yield a dict whose shape matches the computer call input.""" + action = ActionScreenshot(type="screenshot") + comp_call = ResponseComputerToolCall( + id="comp1", + action=action, + type="computer_call", + call_id="comp1", + pending_safety_checks=[], + status="completed", + ) + resp = ModelResponse(output=[comp_call], usage=Usage(), referenceable_id=None) + input_items = resp.to_input_items() + assert isinstance(input_items, list) and len(input_items) == 1 + converted_dict = input_items[0] + # Top-level keys should match what we expect for a computer call input + expected: ResponseComputerToolCallParam = { + "id": "comp1", + "type": "computer_call", + "action": {"type": "screenshot"}, + "call_id": "comp1", + "pending_safety_checks": [], + "status": "completed", + } + assert converted_dict == expected + + +def test_to_input_items_for_reasoning() -> None: + """A reasoning output should produce the same dict as a reasoning input item.""" + rc = ReasoningContent(text="why", type="reasoning_summary") + reasoning = Reasoning(id="rid1", content=[rc], type="reasoning") + resp = ModelResponse(output=[reasoning], usage=Usage(), referenceable_id=None) + input_items = resp.to_input_items() + assert isinstance(input_items, list) and len(input_items) == 1 + converted_dict = input_items[0] + + expected: ReasoningInputParam = { + "id": "rid1", + "content": [{"text": "why", "type": "reasoning_summary"}], + "type": "reasoning", + } + assert converted_dict == expected diff --git a/tests/test_max_turns.py b/tests/test_max_turns.py new file mode 100644 index 0000000..f01bb18 --- /dev/null +++ b/tests/test_max_turns.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import json + +import pytest +from typing_extensions import TypedDict + +from agents import Agent, MaxTurnsExceeded, Runner + +from .fake_model import FakeModel +from .test_responses import get_function_tool, get_function_tool_call, get_text_message + + +@pytest.mark.asyncio +async def test_non_streamed_max_turns(): + model = FakeModel() + agent = Agent( + name="test_1", + model=model, + tools=[get_function_tool("some_function", "result")], + ) + + func_output = json.dumps({"a": "b"}) + + model.add_multiple_turn_outputs( + [ + [get_text_message("1"), get_function_tool_call("some_function", func_output)], + [get_text_message("2"), get_function_tool_call("some_function", func_output)], + [get_text_message("3"), get_function_tool_call("some_function", func_output)], + [get_text_message("4"), get_function_tool_call("some_function", func_output)], + [get_text_message("5"), get_function_tool_call("some_function", func_output)], + ] + ) + with pytest.raises(MaxTurnsExceeded): + await Runner.run(agent, input="user_message", max_turns=3) + + +@pytest.mark.asyncio +async def test_streamed_max_turns(): + model = FakeModel() + agent = Agent( + name="test_1", + model=model, + tools=[get_function_tool("some_function", "result")], + ) + func_output = json.dumps({"a": "b"}) + + model.add_multiple_turn_outputs( + [ + [ + get_text_message("1"), + get_function_tool_call("some_function", func_output), + ], + [ + get_text_message("2"), + get_function_tool_call("some_function", func_output), + ], + [ + get_text_message("3"), + get_function_tool_call("some_function", func_output), + ], + [ + get_text_message("4"), + get_function_tool_call("some_function", func_output), + ], + [ + get_text_message("5"), + get_function_tool_call("some_function", func_output), + ], + ] + ) + with pytest.raises(MaxTurnsExceeded): + output = Runner.run_streamed(agent, input="user_message", max_turns=3) + async for _ in output.stream_events(): + pass + + +class Foo(TypedDict): + a: str + + +@pytest.mark.asyncio +async def test_structured_output_non_streamed_max_turns(): + model = FakeModel() + agent = Agent( + name="test_1", + model=model, + output_type=Foo, + tools=[get_function_tool("tool_1", "result")], + ) + + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("tool_1")], + [get_function_tool_call("tool_1")], + [get_function_tool_call("tool_1")], + [get_function_tool_call("tool_1")], + [get_function_tool_call("tool_1")], + ] + ) + with pytest.raises(MaxTurnsExceeded): + await Runner.run(agent, input="user_message", max_turns=3) + + +@pytest.mark.asyncio +async def test_structured_output_streamed_max_turns(): + model = FakeModel() + agent = Agent( + name="test_1", + model=model, + output_type=Foo, + tools=[get_function_tool("tool_1", "result")], + ) + + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("tool_1")], + [get_function_tool_call("tool_1")], + [get_function_tool_call("tool_1")], + [get_function_tool_call("tool_1")], + [get_function_tool_call("tool_1")], + ] + ) + with pytest.raises(MaxTurnsExceeded): + output = Runner.run_streamed(agent, input="user_message", max_turns=3) + async for _ in output.stream_events(): + pass diff --git a/tests/test_openai_chatcompletions.py b/tests/test_openai_chatcompletions.py new file mode 100644 index 0000000..9521647 --- /dev/null +++ b/tests/test_openai_chatcompletions.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import pytest +from openai import NOT_GIVEN +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_chunk import ChatCompletionChunk +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.chat.chat_completion_message_tool_call import ( + ChatCompletionMessageToolCall, + Function, +) +from openai.types.completion_usage import CompletionUsage +from openai.types.responses import ( + Response, + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputRefusal, + ResponseOutputText, +) + +from agents import ( + ModelResponse, + ModelSettings, + ModelTracing, + OpenAIChatCompletionsModel, + OpenAIProvider, + generation_span, +) +from agents.models.fake_id import FAKE_RESPONSES_ID + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_with_text_message(monkeypatch) -> None: + """ + When the model returns a ChatCompletionMessage with plain text content, + `get_response` should produce a single `ResponseOutputMessage` containing + a `ResponseOutputText` with that content, and a `Usage` populated from + the completion's usage. + """ + msg = ChatCompletionMessage(role="assistant", content="Hello") + choice = Choice(index=0, finish_reason="stop", message=msg) + chat = ChatCompletion( + id="resp-id", + created=0, + model="fake", + object="chat.completion", + choices=[choice], + usage=CompletionUsage(completion_tokens=5, prompt_tokens=7, total_tokens=12), + ) + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + resp: ModelResponse = await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ) + # Should have produced exactly one output message with one text part + assert isinstance(resp, ModelResponse) + assert len(resp.output) == 1 + assert isinstance(resp.output[0], ResponseOutputMessage) + msg_item = resp.output[0] + assert len(msg_item.content) == 1 + assert isinstance(msg_item.content[0], ResponseOutputText) + assert msg_item.content[0].text == "Hello" + # Usage should be preserved from underlying ChatCompletion.usage + assert resp.usage.input_tokens == 7 + assert resp.usage.output_tokens == 5 + assert resp.usage.total_tokens == 12 + assert resp.referenceable_id is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_with_refusal(monkeypatch) -> None: + """ + When the model returns a ChatCompletionMessage with a `refusal` instead + of normal `content`, `get_response` should produce a single + `ResponseOutputMessage` containing a `ResponseOutputRefusal` part. + """ + msg = ChatCompletionMessage(role="assistant", refusal="No thanks") + choice = Choice(index=0, finish_reason="stop", message=msg) + chat = ChatCompletion( + id="resp-id", + created=0, + model="fake", + object="chat.completion", + choices=[choice], + usage=None, + ) + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + resp: ModelResponse = await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ) + assert len(resp.output) == 1 + assert isinstance(resp.output[0], ResponseOutputMessage) + refusal_part = resp.output[0].content[0] + assert isinstance(refusal_part, ResponseOutputRefusal) + assert refusal_part.refusal == "No thanks" + # With no usage from the completion, usage defaults to zeros. + assert resp.usage.requests == 0 + assert resp.usage.input_tokens == 0 + assert resp.usage.output_tokens == 0 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_with_tool_call(monkeypatch) -> None: + """ + If the ChatCompletionMessage includes one or more tool_calls, `get_response` + should append corresponding `ResponseFunctionToolCall` items after the + assistant message item with matching name/arguments. + """ + tool_call = ChatCompletionMessageToolCall( + id="call-id", + type="function", + function=Function(name="do_thing", arguments="{'x':1}"), + ) + msg = ChatCompletionMessage(role="assistant", content="Hi", tool_calls=[tool_call]) + choice = Choice(index=0, finish_reason="stop", message=msg) + chat = ChatCompletion( + id="resp-id", + created=0, + model="fake", + object="chat.completion", + choices=[choice], + usage=None, + ) + + async def patched_fetch_response(self, *args, **kwargs): + return chat + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + resp: ModelResponse = await model.get_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ) + # Expect a message item followed by a function tool call item. + assert len(resp.output) == 2 + assert isinstance(resp.output[0], ResponseOutputMessage) + fn_call_item = resp.output[1] + assert isinstance(fn_call_item, ResponseFunctionToolCall) + assert fn_call_item.call_id == "call-id" + assert fn_call_item.name == "do_thing" + assert fn_call_item.arguments == "{'x':1}" + + +@pytest.mark.asyncio +async def test_fetch_response_non_stream(monkeypatch) -> None: + """ + Verify that `_fetch_response` builds the correct OpenAI API call when not + streaming and returns the ChatCompletion object directly. We supply a + dummy ChatCompletion through a stubbed OpenAI client and inspect the + captured kwargs. + """ + + # Dummy completions to record kwargs + class DummyCompletions: + def __init__(self) -> None: + self.kwargs: dict[str, Any] = {} + + async def create(self, **kwargs: Any) -> Any: + self.kwargs = kwargs + return chat + + class DummyClient: + def __init__(self, completions: DummyCompletions) -> None: + self.chat = type("_Chat", (), {"completions": completions})() + self.base_url = httpx.URL("http://fake") + + msg = ChatCompletionMessage(role="assistant", content="ignored") + choice = Choice(index=0, finish_reason="stop", message=msg) + chat = ChatCompletion( + id="resp-id", + created=0, + model="fake", + object="chat.completion", + choices=[choice], + ) + completions = DummyCompletions() + dummy_client = DummyClient(completions) + model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=dummy_client) # type: ignore + # Execute the private fetch with a system instruction and simple string input. + with generation_span(disabled=True) as span: + result = await model._fetch_response( + system_instructions="sys", + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + span=span, + tracing=ModelTracing.DISABLED, + stream=False, + ) + assert result is chat + # Ensure expected args were passed through to OpenAI client. + kwargs = completions.kwargs + assert kwargs["stream"] is False + assert kwargs["model"] == "gpt-4" + assert kwargs["messages"][0]["role"] == "system" + assert kwargs["messages"][0]["content"] == "sys" + assert kwargs["messages"][1]["role"] == "user" + # Defaults for optional fields become the NOT_GIVEN sentinel + assert kwargs["tools"] is NOT_GIVEN + assert kwargs["tool_choice"] is NOT_GIVEN + assert kwargs["response_format"] is NOT_GIVEN + assert kwargs["stream_options"] is NOT_GIVEN + + +@pytest.mark.asyncio +async def test_fetch_response_stream(monkeypatch) -> None: + """ + When `stream=True`, `_fetch_response` should return a bare `Response` + object along with the underlying async stream. The OpenAI client call + should include `stream_options` to request usage-delimited chunks. + """ + + async def event_stream() -> AsyncIterator[ChatCompletionChunk]: + if False: # pragma: no cover + yield # pragma: no cover + + class DummyCompletions: + def __init__(self) -> None: + self.kwargs: dict[str, Any] = {} + + async def create(self, **kwargs: Any) -> Any: + self.kwargs = kwargs + return event_stream() + + class DummyClient: + def __init__(self, completions: DummyCompletions) -> None: + self.chat = type("_Chat", (), {"completions": completions})() + self.base_url = httpx.URL("http://fake") + + completions = DummyCompletions() + dummy_client = DummyClient(completions) + model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=dummy_client) # type: ignore + with generation_span(disabled=True) as span: + response, stream = await model._fetch_response( + system_instructions=None, + input="hi", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + span=span, + tracing=ModelTracing.DISABLED, + stream=True, + ) + # Check OpenAI client was called for streaming + assert completions.kwargs["stream"] is True + assert completions.kwargs["stream_options"] == {"include_usage": True} + # Response is a proper openai Response + assert isinstance(response, Response) + assert response.id == FAKE_RESPONSES_ID + assert response.model == "gpt-4" + assert response.object == "response" + assert response.output == [] + # We returned the async iterator produced by our dummy. + assert hasattr(stream, "__aiter__") diff --git a/tests/test_openai_chatcompletions_converter.py b/tests/test_openai_chatcompletions_converter.py new file mode 100644 index 0000000..8cf07d7 --- /dev/null +++ b/tests/test_openai_chatcompletions_converter.py @@ -0,0 +1,395 @@ +# Copyright (c) OpenAI +# +# Licensed under the MIT License. +# See LICENSE file in the project root for full license information. + +""" +Unit tests for the internal `_Converter` class defined in +`agents.models.openai_chatcompletions`. The converter is responsible for +translating between internal "item" structures (e.g., `ResponseOutputMessage` +and related types from `openai.types.responses`) and the ChatCompletion message +structures defined by the OpenAI client library. + +These tests exercise both conversion directions: + +- `_Converter.message_to_output_items` turns a `ChatCompletionMessage` (as + returned by the OpenAI API) into a list of `ResponseOutputItem` instances. + +- `_Converter.items_to_messages` takes in either a simple string prompt, or a + list of input/output items such as `ResponseOutputMessage` and + `ResponseFunctionToolCallParam` dicts, and constructs a list of + `ChatCompletionMessageParam` dicts suitable for sending back to the API. +""" + +from __future__ import annotations + +from typing import Literal, cast + +import pytest +from openai.types.chat import ChatCompletionMessage, ChatCompletionMessageToolCall +from openai.types.chat.chat_completion_message_tool_call import Function +from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseFunctionToolCallParam, + ResponseInputTextParam, + ResponseOutputMessage, + ResponseOutputRefusal, + ResponseOutputText, +) +from openai.types.responses.response_input_item_param import FunctionCallOutput + +from agents.agent_output import AgentOutputSchema +from agents.exceptions import UserError +from agents.items import TResponseInputItem +from agents.models.fake_id import FAKE_RESPONSES_ID +from agents.models.openai_chatcompletions import _Converter + + +def test_message_to_output_items_with_text_only(): + """ + Make sure a simple ChatCompletionMessage with string content is converted + into a single ResponseOutputMessage containing one ResponseOutputText. + """ + msg = ChatCompletionMessage(role="assistant", content="Hello") + items = _Converter.message_to_output_items(msg) + # Expect exactly one output item (the message) + assert len(items) == 1 + message_item = cast(ResponseOutputMessage, items[0]) + assert message_item.id == FAKE_RESPONSES_ID + assert message_item.role == "assistant" + assert message_item.type == "message" + assert message_item.status == "completed" + # Message content should have exactly one text part with the same text. + assert len(message_item.content) == 1 + text_part = cast(ResponseOutputText, message_item.content[0]) + assert text_part.type == "output_text" + assert text_part.text == "Hello" + + +def test_message_to_output_items_with_refusal(): + """ + Make sure a message with a refusal string produces a ResponseOutputMessage + with a ResponseOutputRefusal content part. + """ + msg = ChatCompletionMessage(role="assistant", refusal="I'm sorry") + items = _Converter.message_to_output_items(msg) + assert len(items) == 1 + message_item = cast(ResponseOutputMessage, items[0]) + assert len(message_item.content) == 1 + refusal_part = cast(ResponseOutputRefusal, message_item.content[0]) + assert refusal_part.type == "refusal" + assert refusal_part.refusal == "I'm sorry" + + +def test_message_to_output_items_with_tool_call(): + """ + If the ChatCompletionMessage contains one or more tool_calls, they should + be reflected as separate `ResponseFunctionToolCall` items appended after + the message item. + """ + tool_call = ChatCompletionMessageToolCall( + id="tool1", + type="function", + function=Function(name="myfn", arguments='{"x":1}'), + ) + msg = ChatCompletionMessage(role="assistant", content="Hi", tool_calls=[tool_call]) + items = _Converter.message_to_output_items(msg) + # Should produce a message item followed by one function tool call item + assert len(items) == 2 + message_item = cast(ResponseOutputMessage, items[0]) + assert isinstance(message_item, ResponseOutputMessage) + fn_call_item = cast(ResponseFunctionToolCall, items[1]) + assert fn_call_item.id == FAKE_RESPONSES_ID + assert fn_call_item.call_id == tool_call.id + assert fn_call_item.name == tool_call.function.name + assert fn_call_item.arguments == tool_call.function.arguments + assert fn_call_item.type == "function_call" + + +def test_items_to_messages_with_string_user_content(): + """ + A simple string as the items argument should be converted into a user + message param dict with the same content. + """ + result = _Converter.items_to_messages("Ask me anything") + assert isinstance(result, list) + assert len(result) == 1 + msg = result[0] + assert msg["role"] == "user" + assert msg["content"] == "Ask me anything" + + +def test_items_to_messages_with_easy_input_message(): + """ + Given an easy input message dict (just role/content), the converter should + produce the appropriate ChatCompletionMessageParam with the same content. + """ + items: list[TResponseInputItem] = [ + { + "role": "user", + "content": "How are you?", + } + ] + messages = _Converter.items_to_messages(items) + assert len(messages) == 1 + out = messages[0] + assert out["role"] == "user" + # For simple string inputs, the converter returns the content as a bare string + assert out["content"] == "How are you?" + + +def test_items_to_messages_with_output_message_and_function_call(): + """ + Given a sequence of one ResponseOutputMessageParam followed by a + ResponseFunctionToolCallParam, the converter should produce a single + ChatCompletionAssistantMessageParam that includes both the assistant's + textual content and a populated `tool_calls` reflecting the function call. + """ + # Construct output message param dict with two content parts. + output_text: ResponseOutputText = ResponseOutputText( + text="Part 1", + type="output_text", + annotations=[], + ) + refusal: ResponseOutputRefusal = ResponseOutputRefusal( + refusal="won't do that", + type="refusal", + ) + resp_msg: ResponseOutputMessage = ResponseOutputMessage( + id="42", + type="message", + role="assistant", + status="completed", + content=[output_text, refusal], + ) + # Construct a function call item dict (as if returned from model) + func_item: ResponseFunctionToolCallParam = { + "id": "99", + "call_id": "abc", + "name": "math", + "arguments": "{}", + "type": "function_call", + } + items: list[TResponseInputItem] = [ + resp_msg.model_dump(), # type:ignore + func_item, + ] + messages = _Converter.items_to_messages(items) + # Should return a single assistant message + assert len(messages) == 1 + assistant = messages[0] + assert assistant["role"] == "assistant" + # Content combines text portions of the output message + assert "content" in assistant + assert assistant["content"] == "Part 1" + # Refusal in output message should be represented in assistant message + assert "refusal" in assistant + assert assistant["refusal"] == refusal.refusal + # Tool calls list should contain one ChatCompletionMessageToolCall dict + tool_calls = assistant.get("tool_calls") + assert isinstance(tool_calls, list) + assert len(tool_calls) == 1 + tool_call = tool_calls[0] + assert tool_call["type"] == "function" + assert tool_call["function"]["name"] == "math" + assert tool_call["function"]["arguments"] == "{}" + + +def test_convert_tool_choice_handles_standard_and_named_options() -> None: + """ + The `_Converter.convert_tool_choice` method should return NOT_GIVEN + if no choice is provided, pass through values like "auto", "required", + or "none" unchanged, and translate any other string into a function + selection dict. + """ + assert _Converter.convert_tool_choice(None).__class__.__name__ == "NotGiven" + assert _Converter.convert_tool_choice("auto") == "auto" + assert _Converter.convert_tool_choice("required") == "required" + assert _Converter.convert_tool_choice("none") == "none" + tool_choice_dict = _Converter.convert_tool_choice("mytool") + assert isinstance(tool_choice_dict, dict) + assert tool_choice_dict["type"] == "function" + assert tool_choice_dict["function"]["name"] == "mytool" + + +def test_convert_response_format_returns_not_given_for_plain_text_and_dict_for_schemas() -> None: + """ + The `_Converter.convert_response_format` method should return NOT_GIVEN + when no output schema is provided or if the output schema indicates + plain text. For structured output schemas, it should return a dict + with type `json_schema` and include the generated JSON schema and + strict flag from the provided `AgentOutputSchema`. + """ + # when output is plain text (schema None or output_type str), do not include response_format + assert _Converter.convert_response_format(None).__class__.__name__ == "NotGiven" + assert ( + _Converter.convert_response_format(AgentOutputSchema(str)).__class__.__name__ == "NotGiven" + ) + # For e.g. integer output, we expect a response_format dict + schema = AgentOutputSchema(int) + resp_format = _Converter.convert_response_format(schema) + assert isinstance(resp_format, dict) + assert resp_format["type"] == "json_schema" + assert resp_format["json_schema"]["name"] == "final_output" + assert "strict" in resp_format["json_schema"] + assert resp_format["json_schema"]["strict"] == schema.strict_json_schema + assert "schema" in resp_format["json_schema"] + assert resp_format["json_schema"]["schema"] == schema.json_schema() + + +def test_items_to_messages_with_function_output_item(): + """ + A function call output item should be converted into a tool role message + dict with the appropriate tool_call_id and content. + """ + func_output_item: FunctionCallOutput = { + "type": "function_call_output", + "call_id": "somecall", + "output": '{"foo": "bar"}', + } + messages = _Converter.items_to_messages([func_output_item]) + assert len(messages) == 1 + tool_msg = messages[0] + assert tool_msg["role"] == "tool" + assert tool_msg["tool_call_id"] == func_output_item["call_id"] + assert tool_msg["content"] == func_output_item["output"] + + +def test_extract_all_and_text_content_for_strings_and_lists(): + """ + The converter provides helpers for extracting user-supplied message content + either as a simple string or as a list of `input_text` dictionaries. + When passed a bare string, both `extract_all_content` and + `extract_text_content` should return the string unchanged. + When passed a list of input dictionaries, `extract_all_content` should + produce a list of `ChatCompletionContentPart` dicts, and `extract_text_content` + should filter to only the textual parts. + """ + prompt = "just text" + assert _Converter.extract_all_content(prompt) == prompt + assert _Converter.extract_text_content(prompt) == prompt + text1: ResponseInputTextParam = {"type": "input_text", "text": "one"} + text2: ResponseInputTextParam = {"type": "input_text", "text": "two"} + all_parts = _Converter.extract_all_content([text1, text2]) + assert isinstance(all_parts, list) + assert len(all_parts) == 2 + assert all_parts[0]["type"] == "text" and all_parts[0]["text"] == "one" + assert all_parts[1]["type"] == "text" and all_parts[1]["text"] == "two" + text_parts = _Converter.extract_text_content([text1, text2]) + assert isinstance(text_parts, list) + assert all(p["type"] == "text" for p in text_parts) + assert [p["text"] for p in text_parts] == ["one", "two"] + + +def test_items_to_messages_handles_system_and_developer_roles(): + """ + Roles other than `user` (e.g. `system` and `developer`) need to be + converted appropriately whether provided as simple dicts or as full + `message` typed dicts. + """ + sys_items: list[TResponseInputItem] = [{"role": "system", "content": "setup"}] + sys_msgs = _Converter.items_to_messages(sys_items) + assert len(sys_msgs) == 1 + assert sys_msgs[0]["role"] == "system" + assert sys_msgs[0]["content"] == "setup" + dev_items: list[TResponseInputItem] = [{"role": "developer", "content": "debug"}] + dev_msgs = _Converter.items_to_messages(dev_items) + assert len(dev_msgs) == 1 + assert dev_msgs[0]["role"] == "developer" + assert dev_msgs[0]["content"] == "debug" + + +def test_maybe_input_message_allows_message_typed_dict(): + """ + The `_Converter.maybe_input_message` should recognize a dict with + "type": "message" and a supported role as an input message. Ensure + that such dicts are passed through by `items_to_messages`. + """ + # Construct a dict with the proper required keys for a ResponseInputParam.Message + message_dict: TResponseInputItem = { + "type": "message", + "role": "user", + "content": "hi", + } + assert _Converter.maybe_input_message(message_dict) is not None + # items_to_messages should process this correctly + msgs = _Converter.items_to_messages([message_dict]) + assert len(msgs) == 1 + assert msgs[0]["role"] == "user" + assert msgs[0]["content"] == "hi" + + +def test_tool_call_conversion(): + """ + Test that tool calls are converted correctly. + """ + function_call = ResponseFunctionToolCallParam( + id="tool1", + call_id="abc", + name="math", + arguments="{}", + type="function_call", + ) + + messages = _Converter.items_to_messages([function_call]) + assert len(messages) == 1 + tool_msg = messages[0] + assert tool_msg["role"] == "assistant" + assert tool_msg.get("content") is None + tool_calls = list(tool_msg.get("tool_calls", [])) + assert len(tool_calls) == 1 + + tool_call = tool_calls[0] + assert tool_call["id"] == function_call["call_id"] + assert tool_call["function"]["name"] == function_call["name"] + assert tool_call["function"]["arguments"] == function_call["arguments"] + + +@pytest.mark.parametrize("role", ["user", "system", "developer"]) +def test_input_message_with_all_roles(role: str): + """ + The `_Converter.maybe_input_message` should recognize a dict with + "type": "message" and a supported role as an input message. Ensure + that such dicts are passed through by `items_to_messages`. + """ + # Construct a dict with the proper required keys for a ResponseInputParam.Message + casted_role = cast(Literal["user", "system", "developer"], role) + message_dict: TResponseInputItem = { + "type": "message", + "role": casted_role, + "content": "hi", + } + assert _Converter.maybe_input_message(message_dict) is not None + # items_to_messages should process this correctly + msgs = _Converter.items_to_messages([message_dict]) + assert len(msgs) == 1 + assert msgs[0]["role"] == casted_role + assert msgs[0]["content"] == "hi" + + +def test_item_reference_errors(): + """ + Test that item references are converted correctly. + """ + with pytest.raises(UserError): + _Converter.items_to_messages( + [ + { + "type": "item_reference", + "id": "item1", + } + ] + ) + + +class TestObject: + pass + + +def test_unknown_object_errors(): + """ + Test that unknown objects are converted correctly. + """ + with pytest.raises(UserError, match="Unhandled item type or structure"): + # Purposely ignore the type error + _Converter.items_to_messages([TestObject()]) # type: ignore diff --git a/tests/test_openai_chatcompletions_stream.py b/tests/test_openai_chatcompletions_stream.py new file mode 100644 index 0000000..2a15f7f --- /dev/null +++ b/tests/test_openai_chatcompletions_stream.py @@ -0,0 +1,278 @@ +from collections.abc import AsyncIterator + +import pytest +from openai.types.chat.chat_completion_chunk import ( + ChatCompletionChunk, + Choice, + ChoiceDelta, + ChoiceDeltaToolCall, + ChoiceDeltaToolCallFunction, +) +from openai.types.completion_usage import CompletionUsage +from openai.types.responses import ( + Response, + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputRefusal, + ResponseOutputText, +) + +from agents.model_settings import ModelSettings +from agents.models.interface import ModelTracing +from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel +from agents.models.openai_provider import OpenAIProvider + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_yields_events_for_text_content(monkeypatch) -> None: + """ + Validate that `stream_response` emits the correct sequence of events when + streaming a simple assistant message consisting of plain text content. + We simulate two chunks of text returned from the chat completion stream. + """ + # Create two chunks that will be emitted by the fake stream. + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="He"))], + ) + # Mark last chunk with usage so stream_response knows this is final. + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="llo"))], + usage=CompletionUsage(completion_tokens=5, prompt_tokens=7, total_tokens=12), + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + for c in (chunk1, chunk2): + yield c + + # Patch _fetch_response to inject our fake stream + async def patched_fetch_response(self, *args, **kwargs): + # `_fetch_response` is expected to return a Response skeleton and the async stream + resp = Response( + id="resp-id", + created_at=0, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + ) + return resp, fake_stream() + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + output_events = [] + async for event in model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ): + output_events.append(event) + # We expect a response.created, then a response.output_item.added, content part added, + # two content delta events (for "He" and "llo"), a content part done, the assistant message + # output_item.done, and finally response.completed. + # There should be 8 events in total. + assert len(output_events) == 8 + # First event indicates creation. + assert output_events[0].type == "response.created" + # The output item added and content part added events should mark the assistant message. + assert output_events[1].type == "response.output_item.added" + assert output_events[2].type == "response.content_part.added" + # Two text delta events. + assert output_events[3].type == "response.output_text.delta" + assert output_events[3].delta == "He" + assert output_events[4].type == "response.output_text.delta" + assert output_events[4].delta == "llo" + # After streaming, the content part and item should be marked done. + assert output_events[5].type == "response.content_part.done" + assert output_events[6].type == "response.output_item.done" + # Last event indicates completion of the stream. + assert output_events[7].type == "response.completed" + # The completed response should have one output message with full text. + completed_resp = output_events[7].response + assert isinstance(completed_resp.output[0], ResponseOutputMessage) + assert isinstance(completed_resp.output[0].content[0], ResponseOutputText) + assert completed_resp.output[0].content[0].text == "Hello" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_yields_events_for_refusal_content(monkeypatch) -> None: + """ + Validate that when the model streams a refusal string instead of normal content, + `stream_response` emits the appropriate sequence of events including + `response.refusal.delta` events for each chunk of the refusal message and + constructs a completed assistant message with a `ResponseOutputRefusal` part. + """ + # Simulate refusal text coming in two pieces, like content but using the `refusal` + # field on the delta rather than `content`. + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(refusal="No"))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(refusal="Thanks"))], + usage=CompletionUsage(completion_tokens=2, prompt_tokens=2, total_tokens=4), + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + for c in (chunk1, chunk2): + yield c + + async def patched_fetch_response(self, *args, **kwargs): + resp = Response( + id="resp-id", + created_at=0, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + ) + return resp, fake_stream() + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + output_events = [] + async for event in model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ): + output_events.append(event) + # Expect sequence similar to text: created, output_item.added, content part added, + # two refusal delta events, content part done, output_item.done, completed. + assert len(output_events) == 8 + assert output_events[0].type == "response.created" + assert output_events[1].type == "response.output_item.added" + assert output_events[2].type == "response.content_part.added" + assert output_events[3].type == "response.refusal.delta" + assert output_events[3].delta == "No" + assert output_events[4].type == "response.refusal.delta" + assert output_events[4].delta == "Thanks" + assert output_events[5].type == "response.content_part.done" + assert output_events[6].type == "response.output_item.done" + assert output_events[7].type == "response.completed" + completed_resp = output_events[7].response + assert isinstance(completed_resp.output[0], ResponseOutputMessage) + refusal_part = completed_resp.output[0].content[0] + assert isinstance(refusal_part, ResponseOutputRefusal) + assert refusal_part.refusal == "NoThanks" + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_yields_events_for_tool_call(monkeypatch) -> None: + """ + Validate that `stream_response` emits the correct sequence of events when + the model is streaming a function/tool call instead of plain text. + The function call will be split across two chunks. + """ + # Simulate a single tool call whose ID stays constant and function name/args built over chunks. + tool_call_delta1 = ChoiceDeltaToolCall( + index=0, + id="tool-id", + function=ChoiceDeltaToolCallFunction(name="my_", arguments="arg1"), + type="function", + ) + tool_call_delta2 = ChoiceDeltaToolCall( + index=0, + id="tool-id", + function=ChoiceDeltaToolCallFunction(name="func", arguments="arg2"), + type="function", + ) + chunk1 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta1]))], + ) + chunk2 = ChatCompletionChunk( + id="chunk-id", + created=1, + model="fake", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(tool_calls=[tool_call_delta2]))], + usage=CompletionUsage(completion_tokens=1, prompt_tokens=1, total_tokens=2), + ) + + async def fake_stream() -> AsyncIterator[ChatCompletionChunk]: + for c in (chunk1, chunk2): + yield c + + async def patched_fetch_response(self, *args, **kwargs): + resp = Response( + id="resp-id", + created_at=0, + model="fake-model", + object="response", + output=[], + tool_choice="none", + tools=[], + parallel_tool_calls=False, + ) + return resp, fake_stream() + + monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response) + model = OpenAIProvider(use_responses=False).get_model("gpt-4") + output_events = [] + async for event in model.stream_response( + system_instructions=None, + input="", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + ): + output_events.append(event) + # Sequence should be: response.created, then after loop we expect function call-related events: + # one response.output_item.added for function call, a response.function_call_arguments.delta, + # a response.output_item.done, and finally response.completed. + assert output_events[0].type == "response.created" + # The next three events are about the tool call. + assert output_events[1].type == "response.output_item.added" + # The added item should be a ResponseFunctionToolCall. + added_fn = output_events[1].item + assert isinstance(added_fn, ResponseFunctionToolCall) + assert added_fn.name == "my_func" # Name should be concatenation of both chunks. + assert added_fn.arguments == "arg1arg2" + assert output_events[2].type == "response.function_call_arguments.delta" + assert output_events[2].delta == "arg1arg2" + assert output_events[3].type == "response.output_item.done" + assert output_events[4].type == "response.completed" + assert output_events[2].delta == "arg1arg2" + assert output_events[3].type == "response.output_item.done" + assert output_events[4].type == "response.completed" + assert added_fn.name == "my_func" # Name should be concatenation of both chunks. + assert added_fn.arguments == "arg1arg2" + assert output_events[2].type == "response.function_call_arguments.delta" + assert output_events[2].delta == "arg1arg2" + assert output_events[3].type == "response.output_item.done" + assert output_events[4].type == "response.completed" diff --git a/tests/test_openai_responses_converter.py b/tests/test_openai_responses_converter.py new file mode 100644 index 0000000..5820426 --- /dev/null +++ b/tests/test_openai_responses_converter.py @@ -0,0 +1,205 @@ +# Copyright (c) OpenAI +# +# Licensed under the MIT License. +# See LICENSE file in the project root for full license information. + +""" +Unit tests for the `Converter` class defined in +`agents.models.openai_responses`. The converter is responsible for +translating various agent tool types and output schemas into the parameter +structures expected by the OpenAI Responses API. + +We test the following aspects: + +- `convert_tool_choice` correctly maps high-level tool choice strings into + the tool choice values accepted by the Responses API, including special types + like `file_search` and `web_search`, and falling back to function names + for arbitrary string values. +- `get_response_format` returns `openai.NOT_GIVEN` for plain-text response + formats and an appropriate format dict when a JSON-structured output schema + is provided. +- `convert_tools` maps our internal `Tool` dataclasses into the appropriate + request payloads and includes list, and enforces constraints like at most + one `ComputerTool`. +""" + +import pytest +from openai import NOT_GIVEN +from pydantic import BaseModel + +from agents import ( + Agent, + AgentOutputSchema, + Computer, + ComputerTool, + FileSearchTool, + Handoff, + Tool, + UserError, + WebSearchTool, + function_tool, + handoff, +) +from agents.models.openai_responses import Converter + + +def test_convert_tool_choice_standard_values(): + """ + Make sure that the standard tool_choice values map to themselves or + to "auto"/"required"/"none" as appropriate, and that special string + values map to the appropriate dicts. + """ + assert Converter.convert_tool_choice(None) is NOT_GIVEN + assert Converter.convert_tool_choice("auto") == "auto" + assert Converter.convert_tool_choice("required") == "required" + assert Converter.convert_tool_choice("none") == "none" + # Special tool types are represented as dicts of type only. + assert Converter.convert_tool_choice("file_search") == {"type": "file_search"} + assert Converter.convert_tool_choice("web_search_preview") == {"type": "web_search_preview"} + assert Converter.convert_tool_choice("computer_use_preview") == {"type": "computer_use_preview"} + # Arbitrary string should be interpreted as a function name. + assert Converter.convert_tool_choice("my_function") == { + "type": "function", + "name": "my_function", + } + + +def test_get_response_format_plain_text_and_json_schema(): + """ + For plain text output (default, or output type of `str`), the converter + should return NOT_GIVEN, indicating no special response format constraint. + If an output schema is provided for a structured type, the converter + should return a `format` dict with the schema and strictness. The exact + JSON schema depends on the output type; we just assert that required + keys are present and that we get back the original schema. + """ + # Default output (None) should be considered plain text. + assert Converter.get_response_format(None) is NOT_GIVEN + # An explicit plain-text schema (str) should also yield NOT_GIVEN. + assert Converter.get_response_format(AgentOutputSchema(str)) is NOT_GIVEN + + # A model-based schema should produce a format dict. + class OutModel(BaseModel): + foo: int + bar: str + + out_schema = AgentOutputSchema(OutModel) + fmt = Converter.get_response_format(out_schema) + assert isinstance(fmt, dict) + assert "format" in fmt + inner = fmt["format"] + assert inner.get("type") == "json_schema" + assert inner.get("name") == "final_output" + assert isinstance(inner.get("schema"), dict) + # Should include a strict flag matching the schema's strictness setting. + assert inner.get("strict") == out_schema.strict_json_schema + + +def test_convert_tools_basic_types_and_includes(): + """ + Construct a variety of tool types and make sure `convert_tools` returns + a matching list of tool param dicts and the expected includes. Also + check that only a single computer tool is allowed. + """ + # Simple function tool + tool_fn = function_tool(lambda a: "x", name_override="fn") + # File search tool with include_search_results set + file_tool = FileSearchTool( + max_num_results=3, vector_store_ids=["vs1"], include_search_results=True + ) + # Web search tool with custom params + web_tool = WebSearchTool(user_location=None, search_context_size="high") + + # Dummy computer tool subclassing the Computer ABC with minimal methods. + class DummyComputer(Computer): + @property + def environment(self): + return "mac" + + @property + def dimensions(self): + return (800, 600) + + def screenshot(self) -> str: + raise NotImplementedError + + def click(self, x: int, y: int, button: str) -> None: + raise NotImplementedError + + def double_click(self, x: int, y: int) -> None: + raise NotImplementedError + + def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + raise NotImplementedError + + def type(self, text: str) -> None: + raise NotImplementedError + + def wait(self) -> None: + raise NotImplementedError + + def move(self, x: int, y: int) -> None: + raise NotImplementedError + + def keypress(self, keys: list[str]) -> None: + raise NotImplementedError + + def drag(self, path: list[tuple[int, int]]) -> None: + raise NotImplementedError + + # Wrap our concrete computer in a ComputerTool for conversion. + comp_tool = ComputerTool(computer=DummyComputer()) + tools: list[Tool] = [tool_fn, file_tool, web_tool, comp_tool] + converted = Converter.convert_tools(tools, handoffs=[]) + assert isinstance(converted.tools, list) + assert isinstance(converted.includes, list) + # The includes list should have exactly the include for file search when include_search_results + # is True. + assert converted.includes == ["file_search_call.results"] + # There should be exactly four converted tool dicts. + assert len(converted.tools) == 4 + # Extract types and verify. + types = [ct["type"] for ct in converted.tools] + assert "function" in types + assert "file_search" in types + assert "web_search_preview" in types + assert "computer-preview" in types + # Verify file search tool contains max_num_results and vector_store_ids + file_params = next(ct for ct in converted.tools if ct["type"] == "file_search") + assert file_params.get("max_num_results") == file_tool.max_num_results + assert file_params.get("vector_store_ids") == file_tool.vector_store_ids + # Verify web search tool contains user_location and search_context_size + web_params = next(ct for ct in converted.tools if ct["type"] == "web_search_preview") + assert web_params.get("user_location") == web_tool.user_location + assert web_params.get("search_context_size") == web_tool.search_context_size + # Verify computer tool contains environment and computed dimensions + comp_params = next(ct for ct in converted.tools if ct["type"] == "computer-preview") + assert comp_params.get("environment") == "mac" + assert comp_params.get("display_width") == 800 + assert comp_params.get("display_height") == 600 + # The function tool dict should have name and description fields. + fn_params = next(ct for ct in converted.tools if ct["type"] == "function") + assert fn_params.get("name") == tool_fn.name + assert fn_params.get("description") == tool_fn.description + + # Only one computer tool should be allowed. + with pytest.raises(UserError): + Converter.convert_tools(tools=[comp_tool, comp_tool], handoffs=[]) + + +def test_convert_tools_includes_handoffs(): + """ + When handoff objects are included, `convert_tools` should append their + tool param dicts after tools and include appropriate descriptions. + """ + agent = Agent(name="support", handoff_description="Handles support") + handoff_obj = handoff(agent) + converted = Converter.convert_tools(tools=[], handoffs=[handoff_obj]) + assert isinstance(converted.tools, list) + assert len(converted.tools) == 1 + handoff_tool = converted.tools[0] + assert handoff_tool.get("type") == "function" + assert handoff_tool.get("name") == Handoff.default_tool_name(agent) + assert handoff_tool.get("description") == Handoff.default_tool_description(agent) + # No includes for handoffs by default. + assert converted.includes == [] diff --git a/tests/test_output_tool.py b/tests/test_output_tool.py new file mode 100644 index 0000000..31ac984 --- /dev/null +++ b/tests/test_output_tool.py @@ -0,0 +1,113 @@ +import json + +import pytest +from pydantic import BaseModel +from typing_extensions import TypedDict + +from agents import Agent, AgentOutputSchema, ModelBehaviorError, Runner, UserError, _utils +from agents.agent_output import _WRAPPER_DICT_KEY + + +def test_plain_text_output(): + agent = Agent(name="test") + output_schema = Runner._get_output_schema(agent) + assert not output_schema, "Shouldn't have an output tool config without an output type" + + agent = Agent(name="test", output_type=str) + assert not output_schema, "Shouldn't have an output tool config with str output type" + + +class Foo(BaseModel): + bar: str + + +def test_structured_output_pydantic(): + agent = Agent(name="test", output_type=Foo) + output_schema = Runner._get_output_schema(agent) + assert output_schema, "Should have an output tool config with a structured output type" + + assert output_schema.output_type == Foo, "Should have the correct output type" + assert not output_schema._is_wrapped, "Pydantic objects should not be wrapped" + for key, value in Foo.model_json_schema().items(): + assert output_schema.json_schema()[key] == value + + json_str = Foo(bar="baz").model_dump_json() + validated = output_schema.validate_json(json_str) + assert validated == Foo(bar="baz") + + +class Bar(TypedDict): + bar: str + + +def test_structured_output_typed_dict(): + agent = Agent(name="test", output_type=Bar) + output_schema = Runner._get_output_schema(agent) + assert output_schema, "Should have an output tool config with a structured output type" + assert output_schema.output_type == Bar, "Should have the correct output type" + assert not output_schema._is_wrapped, "TypedDicts should not be wrapped" + + json_str = json.dumps(Bar(bar="baz")) + validated = output_schema.validate_json(json_str) + assert validated == Bar(bar="baz") + + +def test_structured_output_list(): + agent = Agent(name="test", output_type=list[str]) + output_schema = Runner._get_output_schema(agent) + assert output_schema, "Should have an output tool config with a structured output type" + assert output_schema.output_type == list[str], "Should have the correct output type" + assert output_schema._is_wrapped, "Lists should be wrapped" + + # This is testing implementation details, but it's useful to make sure this doesn't break + json_str = json.dumps({_WRAPPER_DICT_KEY: ["foo", "bar"]}) + validated = output_schema.validate_json(json_str) + assert validated == ["foo", "bar"] + + +def test_bad_json_raises_error(mocker): + agent = Agent(name="test", output_type=Foo) + output_schema = Runner._get_output_schema(agent) + assert output_schema, "Should have an output tool config with a structured output type" + + with pytest.raises(ModelBehaviorError): + output_schema.validate_json("not valid json") + + agent = Agent(name="test", output_type=list[str]) + output_schema = Runner._get_output_schema(agent) + assert output_schema, "Should have an output tool config with a structured output type" + + mock_validate_json = mocker.patch.object(_utils, "validate_json") + mock_validate_json.return_value = ["foo"] + + with pytest.raises(ModelBehaviorError): + output_schema.validate_json(json.dumps(["foo"])) + + mock_validate_json.return_value = {"value": "foo"} + + with pytest.raises(ModelBehaviorError): + output_schema.validate_json(json.dumps(["foo"])) + + +def test_plain_text_obj_doesnt_produce_schema(): + output_wrapper = AgentOutputSchema(output_type=str) + with pytest.raises(UserError): + output_wrapper.json_schema() + + +def test_structured_output_is_strict(): + output_wrapper = AgentOutputSchema(output_type=Foo) + assert output_wrapper.strict_json_schema + for key, value in Foo.model_json_schema().items(): + assert output_wrapper.json_schema()[key] == value + + assert ( + "additionalProperties" in output_wrapper.json_schema() + and not output_wrapper.json_schema()["additionalProperties"] + ) + + +def test_setting_strict_false_works(): + output_wrapper = AgentOutputSchema(output_type=Foo, strict_json_schema=False) + assert not output_wrapper.strict_json_schema + assert output_wrapper.json_schema() == Foo.model_json_schema() diff --git a/tests/test_responses.py b/tests/test_responses.py new file mode 100644 index 0000000..6b91bf8 --- /dev/null +++ b/tests/test_responses.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import Any + +from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseOutputItem, + ResponseOutputMessage, + ResponseOutputText, +) + +from agents import ( + Agent, + FunctionTool, + Handoff, + TResponseInputItem, + default_tool_error_function, + function_tool, +) + + +def get_text_input_item(content: str) -> TResponseInputItem: + return { + "content": content, + "role": "user", + } + + +def get_text_message(content: str) -> ResponseOutputItem: + return ResponseOutputMessage( + id="1", + type="message", + role="assistant", + content=[ResponseOutputText(text=content, type="output_text", annotations=[])], + status="completed", + ) + + +def get_function_tool( + name: str | None = None, return_value: str | None = None, hide_errors: bool = False +) -> FunctionTool: + def _foo() -> str: + return return_value or "result_ok" + + return function_tool( + _foo, + name_override=name, + failure_error_function=None if hide_errors else default_tool_error_function, + ) + + +def get_function_tool_call(name: str, arguments: str | None = None) -> ResponseOutputItem: + return ResponseFunctionToolCall( + id="1", + call_id="2", + type="function_call", + name=name, + arguments=arguments or "", + ) + + +def get_handoff_tool_call( + to_agent: Agent[Any], override_name: str | None = None, args: str | None = None +) -> ResponseOutputItem: + name = override_name or Handoff.default_tool_name(to_agent) + return get_function_tool_call(name, args) + + +def get_final_output_message(args: str) -> ResponseOutputItem: + return ResponseOutputMessage( + id="1", + type="message", + role="assistant", + content=[ResponseOutputText(text=args, type="output_text", annotations=[])], + status="completed", + ) diff --git a/tests/test_responses_tracing.py b/tests/test_responses_tracing.py new file mode 100644 index 0000000..82b8e75 --- /dev/null +++ b/tests/test_responses_tracing.py @@ -0,0 +1,212 @@ +import pytest +from openai import AsyncOpenAI +from openai.types.responses import ResponseCompletedEvent + +from agents import ModelSettings, ModelTracing, OpenAIResponsesModel, trace +from agents.tracing.span_data import ResponseSpanData +from tests import fake_model + +from .testing_processor import fetch_ordered_spans + + +class DummyTracing: + def is_disabled(self): + return False + + +class DummyUsage: + def __init__(self, input_tokens=1, output_tokens=1, total_tokens=2): + self.input_tokens = input_tokens + self.output_tokens = output_tokens + self.total_tokens = total_tokens + + +class DummyResponse: + def __init__(self): + self.id = "dummy-id" + self.output = [] + self.usage = DummyUsage() + + def __aiter__(self): + yield ResponseCompletedEvent( + type="response.completed", + response=fake_model.get_response_obj(self.output), + ) + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_get_response_creates_trace(monkeypatch): + with trace(workflow_name="test"): + # Create an instance of the model + model = OpenAIResponsesModel(model="test-model", openai_client=AsyncOpenAI(api_key="test")) + + # Mock _fetch_response to return a dummy response with a known id + async def dummy_fetch_response( + system_instructions, input, model_settings, tools, output_schema, handoffs, stream + ): + return DummyResponse() + + monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) + + # Call get_response + await model.get_response( + "instr", "input", ModelSettings(), [], None, [], ModelTracing.ENABLED + ) + + 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 +async def test_non_data_tracing_doesnt_set_response_id(monkeypatch): + with trace(workflow_name="test"): + # Create an instance of the model + model = OpenAIResponsesModel(model="test-model", openai_client=AsyncOpenAI(api_key="test")) + + # Mock _fetch_response to return a dummy response with a known id + async def dummy_fetch_response( + system_instructions, input, model_settings, tools, output_schema, handoffs, stream + ): + return DummyResponse() + + monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) + + # Call get_response + await model.get_response( + "instr", "input", ModelSettings(), [], None, [], ModelTracing.ENABLED_WITHOUT_DATA + ) + + spans = fetch_ordered_spans() + assert len(spans) == 1 + assert spans[0].span_data.response is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_disable_tracing_does_not_create_span(monkeypatch): + with trace(workflow_name="test"): + # Create an instance of the model + model = OpenAIResponsesModel(model="test-model", openai_client=AsyncOpenAI(api_key="test")) + + # Mock _fetch_response to return a dummy response with a known id + async def dummy_fetch_response( + system_instructions, input, model_settings, tools, output_schema, handoffs, stream + ): + return DummyResponse() + + monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) + + # Call get_response + await model.get_response( + "instr", "input", ModelSettings(), [], None, [], ModelTracing.DISABLED + ) + + spans = fetch_ordered_spans() + assert len(spans) == 0 + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_response_creates_trace(monkeypatch): + with trace(workflow_name="test"): + # Create an instance of the model + model = OpenAIResponsesModel(model="test-model", openai_client=AsyncOpenAI(api_key="test")) + + # Define a dummy fetch function that returns an async stream with a dummy response + async def dummy_fetch_response( + system_instructions, input, model_settings, tools, output_schema, handoffs, stream + ): + class DummyStream: + async def __aiter__(self): + yield ResponseCompletedEvent( + type="response.completed", + response=fake_model.get_response_obj([], "dummy-id-123"), + ) + + return DummyStream() + + monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) + + # Consume the stream to trigger processing of the final response + async for _ in model.stream_response( + "instr", "input", ModelSettings(), [], None, [], ModelTracing.ENABLED + ): + pass + + 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 +async def test_stream_non_data_tracing_doesnt_set_response_id(monkeypatch): + with trace(workflow_name="test"): + # Create an instance of the model + model = OpenAIResponsesModel(model="test-model", openai_client=AsyncOpenAI(api_key="test")) + + # Define a dummy fetch function that returns an async stream with a dummy response + async def dummy_fetch_response( + system_instructions, input, model_settings, tools, output_schema, handoffs, stream + ): + class DummyStream: + async def __aiter__(self): + yield ResponseCompletedEvent( + type="response.completed", + response=fake_model.get_response_obj([], "dummy-id-123"), + ) + + return DummyStream() + + monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) + + # Consume the stream to trigger processing of the final response + async for _ in model.stream_response( + "instr", "input", ModelSettings(), [], None, [], ModelTracing.ENABLED_WITHOUT_DATA + ): + pass + + spans = fetch_ordered_spans() + assert len(spans) == 1 + assert isinstance(spans[0].span_data, ResponseSpanData) + assert spans[0].span_data.response is None + + +@pytest.mark.allow_call_model_methods +@pytest.mark.asyncio +async def test_stream_disabled_tracing_doesnt_create_span(monkeypatch): + with trace(workflow_name="test"): + # Create an instance of the model + model = OpenAIResponsesModel(model="test-model", openai_client=AsyncOpenAI(api_key="test")) + + # Define a dummy fetch function that returns an async stream with a dummy response + async def dummy_fetch_response( + system_instructions, input, model_settings, tools, output_schema, handoffs, stream + ): + class DummyStream: + async def __aiter__(self): + yield ResponseCompletedEvent( + type="response.completed", + response=fake_model.get_response_obj([], "dummy-id-123"), + ) + + return DummyStream() + + monkeypatch.setattr(model, "_fetch_response", dummy_fetch_response) + + # Consume the stream to trigger processing of the final response + async for _ in model.stream_response( + "instr", "input", ModelSettings(), [], None, [], ModelTracing.DISABLED + ): + pass + + spans = fetch_ordered_spans() + assert len(spans) == 0 diff --git a/tests/test_result_cast.py b/tests/test_result_cast.py new file mode 100644 index 0000000..ec17e32 --- /dev/null +++ b/tests/test_result_cast.py @@ -0,0 +1,58 @@ +from typing import Any + +import pytest +from pydantic import BaseModel + +from agents import Agent, RunResult + + +def create_run_result(final_output: Any) -> RunResult: + return RunResult( + input="test", + new_items=[], + raw_responses=[], + final_output=final_output, + input_guardrail_results=[], + output_guardrail_results=[], + _last_agent=Agent(name="test"), + ) + + +class Foo(BaseModel): + bar: int + + +def test_result_cast_typechecks(): + """Correct casts should work fine.""" + result = create_run_result(1) + assert result.final_output_as(int) == 1 + + result = create_run_result("test") + assert result.final_output_as(str) == "test" + + result = create_run_result(Foo(bar=1)) + assert result.final_output_as(Foo) == Foo(bar=1) + + +def test_bad_cast_doesnt_raise(): + """Bad casts shouldn't error unless we ask for it.""" + result = create_run_result(1) + result.final_output_as(str) + + result = create_run_result("test") + result.final_output_as(Foo) + + +def test_bad_cast_with_param_raises(): + """Bad casts should raise a TypeError when we ask for it.""" + result = create_run_result(1) + with pytest.raises(TypeError): + result.final_output_as(str, raise_if_incorrect_type=True) + + result = create_run_result("test") + with pytest.raises(TypeError): + result.final_output_as(Foo, raise_if_incorrect_type=True) + + result = create_run_result(Foo(bar=1)) + with pytest.raises(TypeError): + result.final_output_as(int, raise_if_incorrect_type=True) diff --git a/tests/test_run_config.py b/tests/test_run_config.py new file mode 100644 index 0000000..51835ab --- /dev/null +++ b/tests/test_run_config.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest + +from agents import Agent, RunConfig, Runner +from agents.models.interface import Model, ModelProvider + +from .fake_model import FakeModel +from .test_responses import get_text_message + + +class DummyProvider(ModelProvider): + """A simple model provider that always returns the same model, and + records the model name it was asked to provide.""" + + def __init__(self, model_to_return: Model | None = None) -> None: + self.last_requested: str | None = None + self.model_to_return: Model = model_to_return or FakeModel() + + def get_model(self, model_name: str | None) -> Model: + # record the requested model name and return our test model + self.last_requested = model_name + return self.model_to_return + + +@pytest.mark.asyncio +async def test_model_provider_on_run_config_is_used_for_agent_model_name() -> None: + """ + When the agent's ``model`` attribute is a string and no explicit model override is + provided in the ``RunConfig``, the ``Runner`` should resolve the model using the + ``model_provider`` on the ``RunConfig``. + """ + fake_model = FakeModel(initial_output=[get_text_message("from-provider")]) + provider = DummyProvider(model_to_return=fake_model) + agent = Agent(name="test", model="test-model") + run_config = RunConfig(model_provider=provider) + result = await Runner.run(agent, input="any", run_config=run_config) + # We picked up the model from our dummy provider + assert provider.last_requested == "test-model" + assert result.final_output == "from-provider" + + +@pytest.mark.asyncio +async def test_run_config_model_name_override_takes_precedence() -> None: + """ + When a model name string is set on the RunConfig, then that name should be looked up + using the RunConfig's model_provider, and should override any model on the agent. + """ + fake_model = FakeModel(initial_output=[get_text_message("override-name")]) + provider = DummyProvider(model_to_return=fake_model) + agent = Agent(name="test", model="agent-model") + run_config = RunConfig(model="override-name", model_provider=provider) + result = await Runner.run(agent, input="any", run_config=run_config) + # We should have requested the override name, not the agent.model + assert provider.last_requested == "override-name" + assert result.final_output == "override-name" + + +@pytest.mark.asyncio +async def test_run_config_model_override_object_takes_precedence() -> None: + """ + When a concrete Model instance is set on the RunConfig, then that instance should be + returned by Runner._get_model regardless of the agent's model. + """ + fake_model = FakeModel(initial_output=[get_text_message("override-object")]) + agent = Agent(name="test", model="agent-model") + run_config = RunConfig(model=fake_model) + result = await Runner.run(agent, input="any", run_config=run_config) + # Our FakeModel on the RunConfig should have been used. + assert result.final_output == "override-object" + + +@pytest.mark.asyncio +async def test_agent_model_object_is_used_when_present() -> None: + """ + If the agent has a concrete Model object set as its model, and the RunConfig does + not specify a model override, then that object should be used directly without + consulting the RunConfig's model_provider. + """ + fake_model = FakeModel(initial_output=[get_text_message("from-agent-object")]) + provider = DummyProvider() + agent = Agent(name="test", model=fake_model) + run_config = RunConfig(model_provider=provider) + result = await Runner.run(agent, input="any", run_config=run_config) + # The dummy provider should never have been called, and the output should come from + # the FakeModel on the agent. + assert provider.last_requested is None + assert result.final_output == "from-agent-object" diff --git a/tests/test_run_step_execution.py b/tests/test_run_step_execution.py new file mode 100644 index 0000000..2d581bf --- /dev/null +++ b/tests/test_run_step_execution.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import BaseModel + +from agents import ( + Agent, + MessageOutputItem, + ModelResponse, + RunConfig, + RunContextWrapper, + RunHooks, + RunItem, + Runner, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, + Usage, +) +from agents._run_impl import ( + NextStepFinalOutput, + NextStepHandoff, + NextStepRunAgain, + RunImpl, + SingleStepResult, +) + +from .test_responses import ( + get_final_output_message, + get_function_tool, + get_function_tool_call, + get_handoff_tool_call, + get_text_input_item, + get_text_message, +) + + +@pytest.mark.asyncio +async def test_empty_response_is_final_output(): + agent = Agent[None](name="test") + response = ModelResponse( + output=[], + usage=Usage(), + referenceable_id=None, + ) + result = await get_execute_result(agent, response) + + assert result.original_input == "hello" + assert result.generated_items == [] + assert isinstance(result.next_step, NextStepFinalOutput) + assert result.next_step.output == "" + + +@pytest.mark.asyncio +async def test_plaintext_agent_no_tool_calls_is_final_output(): + agent = Agent(name="test") + response = ModelResponse( + output=[get_text_message("hello_world")], + usage=Usage(), + referenceable_id=None, + ) + result = await get_execute_result(agent, response) + + assert result.original_input == "hello" + assert len(result.generated_items) == 1 + assert_item_is_message(result.generated_items[0], "hello_world") + assert isinstance(result.next_step, NextStepFinalOutput) + assert result.next_step.output == "hello_world" + + +@pytest.mark.asyncio +async def test_plaintext_agent_no_tool_calls_multiple_messages_is_final_output(): + agent = Agent(name="test") + response = ModelResponse( + output=[ + get_text_message("hello_world"), + get_text_message("bye"), + ], + usage=Usage(), + referenceable_id=None, + ) + result = await get_execute_result( + agent, + response, + original_input=[ + get_text_input_item("test"), + get_text_input_item("test2"), + ], + ) + + assert len(result.original_input) == 2 + assert len(result.generated_items) == 2 + assert_item_is_message(result.generated_items[0], "hello_world") + assert_item_is_message(result.generated_items[1], "bye") + + assert isinstance(result.next_step, NextStepFinalOutput) + assert result.next_step.output == "bye" + + +@pytest.mark.asyncio +async def test_plaintext_agent_with_tool_call_is_run_again(): + agent = Agent(name="test", tools=[get_function_tool(name="test", return_value="123")]) + response = ModelResponse( + output=[get_text_message("hello_world"), get_function_tool_call("test", "")], + usage=Usage(), + referenceable_id=None, + ) + result = await get_execute_result(agent, response) + + assert result.original_input == "hello" + + # 3 items: new message, tool call, tool result + assert len(result.generated_items) == 3 + assert isinstance(result.next_step, NextStepRunAgain) + + items = result.generated_items + assert_item_is_message(items[0], "hello_world") + assert_item_is_function_tool_call(items[1], "test", None) + assert_item_is_function_tool_call_output(items[2], "123") + + assert isinstance(result.next_step, NextStepRunAgain) + + +@pytest.mark.asyncio +async def test_multiple_tool_calls(): + agent = Agent( + name="test", + tools=[ + get_function_tool(name="test_1", return_value="123"), + get_function_tool(name="test_2", return_value="456"), + get_function_tool(name="test_3", return_value="789"), + ], + ) + response = ModelResponse( + output=[ + get_text_message("Hello, world!"), + get_function_tool_call("test_1"), + get_function_tool_call("test_2"), + ], + usage=Usage(), + referenceable_id=None, + ) + + result = await get_execute_result(agent, response) + assert result.original_input == "hello" + + # 5 items: new message, 2 tool calls, 2 tool call outputs + assert len(result.generated_items) == 5 + assert isinstance(result.next_step, NextStepRunAgain) + + items = result.generated_items + assert_item_is_message(items[0], "Hello, world!") + assert_item_is_function_tool_call(items[1], "test_1", None) + assert_item_is_function_tool_call(items[2], "test_2", None) + + assert isinstance(result.next_step, NextStepRunAgain) + + +@pytest.mark.asyncio +async def test_handoff_output_leads_to_handoff_next_step(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2") + agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2]) + response = ModelResponse( + output=[get_text_message("Hello, world!"), get_handoff_tool_call(agent_1)], + usage=Usage(), + referenceable_id=None, + ) + result = await get_execute_result(agent_3, response) + + assert isinstance(result.next_step, NextStepHandoff) + assert result.next_step.new_agent == agent_1 + + assert len(result.generated_items) == 3 + + +class Foo(BaseModel): + bar: str + + +@pytest.mark.asyncio +async def test_final_output_without_tool_runs_again(): + agent = Agent(name="test", output_type=Foo, tools=[get_function_tool("tool_1", "result")]) + response = ModelResponse( + output=[get_function_tool_call("tool_1")], + usage=Usage(), + referenceable_id=None, + ) + result = await get_execute_result(agent, response) + + assert isinstance(result.next_step, NextStepRunAgain) + assert len(result.generated_items) == 2, "expected 2 items: tool call, tool call output" + + +@pytest.mark.asyncio +async def test_final_output_leads_to_final_output_next_step(): + agent = Agent(name="test", output_type=Foo) + response = ModelResponse( + output=[ + get_text_message("Hello, world!"), + get_final_output_message(Foo(bar="123").model_dump_json()), + ], + usage=Usage(), + referenceable_id=None, + ) + result = await get_execute_result(agent, response) + + assert isinstance(result.next_step, NextStepFinalOutput) + assert result.next_step.output == Foo(bar="123") + + +@pytest.mark.asyncio +async def test_handoff_and_final_output_leads_to_handoff_next_step(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2") + agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2], output_type=Foo) + response = ModelResponse( + output=[ + get_final_output_message(Foo(bar="123").model_dump_json()), + get_handoff_tool_call(agent_1), + ], + usage=Usage(), + referenceable_id=None, + ) + result = await get_execute_result(agent_3, response) + + assert isinstance(result.next_step, NextStepHandoff) + assert result.next_step.new_agent == agent_1 + + +@pytest.mark.asyncio +async def test_multiple_final_output_leads_to_final_output_next_step(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2") + agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2], output_type=Foo) + response = ModelResponse( + output=[ + get_final_output_message(Foo(bar="123").model_dump_json()), + get_final_output_message(Foo(bar="456").model_dump_json()), + ], + usage=Usage(), + referenceable_id=None, + ) + result = await get_execute_result(agent_3, response) + + assert isinstance(result.next_step, NextStepFinalOutput) + assert result.next_step.output == Foo(bar="456") + + +# === Helpers === + + +def assert_item_is_message(item: RunItem, text: str) -> None: + assert isinstance(item, MessageOutputItem) + assert item.raw_item.type == "message" + assert item.raw_item.role == "assistant" + assert item.raw_item.content[0].type == "output_text" + assert item.raw_item.content[0].text == text + + +def assert_item_is_function_tool_call( + item: RunItem, name: str, arguments: str | None = None +) -> None: + assert isinstance(item, ToolCallItem) + assert item.raw_item.type == "function_call" + assert item.raw_item.name == name + assert not arguments or item.raw_item.arguments == arguments + + +def assert_item_is_function_tool_call_output(item: RunItem, output: str) -> None: + assert isinstance(item, ToolCallOutputItem) + assert item.raw_item["type"] == "function_call_output" + assert item.raw_item["output"] == output + + +async def get_execute_result( + agent: Agent[Any], + response: ModelResponse, + *, + original_input: str | list[TResponseInputItem] | None = None, + generated_items: list[RunItem] | None = None, + hooks: RunHooks[Any] | None = None, + context_wrapper: RunContextWrapper[Any] | None = None, + run_config: RunConfig | None = None, +) -> SingleStepResult: + output_schema = Runner._get_output_schema(agent) + handoffs = Runner._get_handoffs(agent) + + processed_response = RunImpl.process_model_response( + agent=agent, + response=response, + output_schema=output_schema, + handoffs=handoffs, + ) + return await RunImpl.execute_tools_and_side_effects( + agent=agent, + original_input=original_input or "hello", + new_response=response, + pre_step_items=generated_items or [], + processed_response=processed_response, + output_schema=output_schema, + hooks=hooks or RunHooks(), + context_wrapper=context_wrapper or RunContextWrapper(None), + run_config=run_config or RunConfig(), + ) diff --git a/tests/test_run_step_processing.py b/tests/test_run_step_processing.py new file mode 100644 index 0000000..41f65c4 --- /dev/null +++ b/tests/test_run_step_processing.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import pytest +from openai.types.responses import ( + ResponseComputerToolCall, + ResponseFileSearchToolCall, + ResponseFunctionWebSearch, +) +from openai.types.responses.response_computer_tool_call import ActionClick +from openai.types.responses.response_output_item import Reasoning, ReasoningContent +from pydantic import BaseModel + +from agents import ( + Agent, + Computer, + ComputerTool, + Handoff, + ModelBehaviorError, + ModelResponse, + ReasoningItem, + RunContextWrapper, + Runner, + ToolCallItem, + Usage, +) +from agents._run_impl import RunImpl + +from .test_responses import ( + get_final_output_message, + get_function_tool, + get_function_tool_call, + get_handoff_tool_call, + get_text_message, +) + + +def test_empty_response(): + agent = Agent(name="test") + response = ModelResponse( + output=[], + usage=Usage(), + referenceable_id=None, + ) + + result = RunImpl.process_model_response( + agent=agent, response=response, output_schema=None, handoffs=[] + ) + assert not result.handoffs + assert not result.functions + + +def test_no_tool_calls(): + agent = Agent(name="test") + response = ModelResponse( + output=[get_text_message("Hello, world!")], + usage=Usage(), + referenceable_id=None, + ) + result = RunImpl.process_model_response( + agent=agent, response=response, output_schema=None, handoffs=[] + ) + assert not result.handoffs + assert not result.functions + + +def test_single_tool_call(): + agent = Agent(name="test", tools=[get_function_tool(name="test")]) + response = ModelResponse( + output=[ + get_text_message("Hello, world!"), + get_function_tool_call("test", ""), + ], + usage=Usage(), + referenceable_id=None, + ) + result = RunImpl.process_model_response( + agent=agent, response=response, output_schema=None, handoffs=[] + ) + assert not result.handoffs + assert result.functions and len(result.functions) == 1 + + func = result.functions[0] + assert func.tool_call.name == "test" + assert func.tool_call.arguments == "" + + +def test_missing_tool_call_raises_error(): + agent = Agent(name="test", tools=[get_function_tool(name="test")]) + response = ModelResponse( + output=[ + get_text_message("Hello, world!"), + get_function_tool_call("missing", ""), + ], + usage=Usage(), + referenceable_id=None, + ) + + with pytest.raises(ModelBehaviorError): + RunImpl.process_model_response( + agent=agent, response=response, output_schema=None, handoffs=[] + ) + + +def test_multiple_tool_calls(): + agent = Agent( + name="test", + tools=[ + get_function_tool(name="test_1"), + get_function_tool(name="test_2"), + get_function_tool(name="test_3"), + ], + ) + response = ModelResponse( + output=[ + get_text_message("Hello, world!"), + get_function_tool_call("test_1", "abc"), + get_function_tool_call("test_2", "xyz"), + ], + usage=Usage(), + referenceable_id=None, + ) + + result = RunImpl.process_model_response( + agent=agent, response=response, output_schema=None, handoffs=[] + ) + assert not result.handoffs + assert result.functions and len(result.functions) == 2 + + func_1 = result.functions[0] + assert func_1.tool_call.name == "test_1" + assert func_1.tool_call.arguments == "abc" + + func_2 = result.functions[1] + assert func_2.tool_call.name == "test_2" + assert func_2.tool_call.arguments == "xyz" + + +@pytest.mark.asyncio +async def test_handoffs_parsed_correctly(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2") + agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2]) + response = ModelResponse( + output=[get_text_message("Hello, world!")], + usage=Usage(), + referenceable_id=None, + ) + result = RunImpl.process_model_response( + agent=agent_3, response=response, output_schema=None, handoffs=[] + ) + assert not result.handoffs, "Shouldn't have a handoff here" + + response = ModelResponse( + output=[get_text_message("Hello, world!"), get_handoff_tool_call(agent_1)], + usage=Usage(), + referenceable_id=None, + ) + result = RunImpl.process_model_response( + agent=agent_3, + response=response, + output_schema=None, + handoffs=Runner._get_handoffs(agent_3), + ) + assert len(result.handoffs) == 1, "Should have a handoff here" + handoff = result.handoffs[0] + assert handoff.handoff.tool_name == Handoff.default_tool_name(agent_1) + assert handoff.handoff.tool_description == Handoff.default_tool_description(agent_1) + assert handoff.handoff.agent_name == agent_1.name + + handoff_agent = await handoff.handoff.on_invoke_handoff( + RunContextWrapper(None), handoff.tool_call.arguments + ) + assert handoff_agent == agent_1 + + +@pytest.mark.asyncio +async def test_missing_handoff_fails(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2") + agent_3 = Agent(name="test_3", handoffs=[agent_1]) + response = ModelResponse( + output=[get_text_message("Hello, world!"), get_handoff_tool_call(agent_2)], + usage=Usage(), + referenceable_id=None, + ) + with pytest.raises(ModelBehaviorError): + RunImpl.process_model_response( + agent=agent_3, + response=response, + output_schema=None, + handoffs=Runner._get_handoffs(agent_3), + ) + + +def test_multiple_handoffs_doesnt_error(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2") + agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2]) + response = ModelResponse( + output=[ + get_text_message("Hello, world!"), + get_handoff_tool_call(agent_1), + get_handoff_tool_call(agent_2), + ], + usage=Usage(), + referenceable_id=None, + ) + result = RunImpl.process_model_response( + agent=agent_3, + response=response, + output_schema=None, + handoffs=Runner._get_handoffs(agent_3), + ) + assert len(result.handoffs) == 2, "Should have multiple handoffs here" + + +class Foo(BaseModel): + bar: str + + +def test_final_output_parsed_correctly(): + agent = Agent(name="test", output_type=Foo) + response = ModelResponse( + output=[ + get_text_message("Hello, world!"), + get_final_output_message(Foo(bar="123").model_dump_json()), + ], + usage=Usage(), + referenceable_id=None, + ) + + RunImpl.process_model_response( + agent=agent, + response=response, + output_schema=Runner._get_output_schema(agent), + handoffs=[], + ) + + +def test_file_search_tool_call_parsed_correctly(): + # Ensure that a ResponseFileSearchToolCall output is parsed into a ToolCallItem and that no tool + # runs are scheduled. + + agent = Agent(name="test") + file_search_call = ResponseFileSearchToolCall( + id="fs1", + queries=["query"], + status="completed", + type="file_search_call", + ) + response = ModelResponse( + output=[get_text_message("hello"), file_search_call], + usage=Usage(), + referenceable_id=None, + ) + result = RunImpl.process_model_response( + agent=agent, response=response, output_schema=None, handoffs=[] + ) + # The final item should be a ToolCallItem for the file search call + assert any( + isinstance(item, ToolCallItem) and item.raw_item is file_search_call + for item in result.new_items + ) + assert not result.functions + assert not result.handoffs + + +def test_function_web_search_tool_call_parsed_correctly(): + agent = Agent(name="test") + web_search_call = ResponseFunctionWebSearch(id="w1", status="completed", type="web_search_call") + response = ModelResponse( + output=[get_text_message("hello"), web_search_call], + usage=Usage(), + referenceable_id=None, + ) + result = RunImpl.process_model_response( + agent=agent, response=response, output_schema=None, handoffs=[] + ) + assert any( + isinstance(item, ToolCallItem) and item.raw_item is web_search_call + for item in result.new_items + ) + assert not result.functions + assert not result.handoffs + + +def test_reasoning_item_parsed_correctly(): + # Verify that a Reasoning output item is converted into a ReasoningItem. + + reasoning = Reasoning( + id="r1", type="reasoning", content=[ReasoningContent(text="why", type="reasoning_summary")] + ) + response = ModelResponse( + output=[reasoning], + usage=Usage(), + referenceable_id=None, + ) + result = RunImpl.process_model_response( + agent=Agent(name="test"), response=response, output_schema=None, handoffs=[] + ) + assert any( + isinstance(item, ReasoningItem) and item.raw_item is reasoning for item in result.new_items + ) + + +class DummyComputer(Computer): + """Minimal computer implementation for testing.""" + + @property + def environment(self): + return "mac" # pragma: no cover + + @property + def dimensions(self): + return (0, 0) # pragma: no cover + + def screenshot(self) -> str: + return "" # pragma: no cover + + def click(self, x: int, y: int, button: str) -> None: + return None # pragma: no cover + + def double_click(self, x: int, y: int) -> None: + return None # pragma: no cover + + def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None: + return None # pragma: no cover + + def type(self, text: str) -> None: + return None # pragma: no cover + + def wait(self) -> None: + return None # pragma: no cover + + def move(self, x: int, y: int) -> None: + return None # pragma: no cover + + def keypress(self, keys: list[str]) -> None: + return None # pragma: no cover + + def drag(self, path: list[tuple[int, int]]) -> None: + return None # pragma: no cover + + +def test_computer_tool_call_without_computer_tool_raises_error(): + # If the agent has no ComputerTool in its tools, process_model_response should raise a + # ModelBehaviorError when encountering a ResponseComputerToolCall. + computer_call = ResponseComputerToolCall( + id="c1", + type="computer_call", + action=ActionClick(type="click", x=1, y=2, button="left"), + call_id="c1", + pending_safety_checks=[], + status="completed", + ) + response = ModelResponse( + output=[computer_call], + usage=Usage(), + referenceable_id=None, + ) + with pytest.raises(ModelBehaviorError): + RunImpl.process_model_response( + agent=Agent(name="test"), response=response, output_schema=None, handoffs=[] + ) + + +def test_computer_tool_call_with_computer_tool_parsed_correctly(): + # If the agent contains a ComputerTool, ensure that a ResponseComputerToolCall is parsed into a + # ToolCallItem and scheduled to run in computer_actions. + dummy_computer = DummyComputer() + agent = Agent(name="test", tools=[ComputerTool(computer=dummy_computer)]) + computer_call = ResponseComputerToolCall( + id="c1", + type="computer_call", + action=ActionClick(type="click", x=1, y=2, button="left"), + call_id="c1", + pending_safety_checks=[], + status="completed", + ) + response = ModelResponse( + output=[computer_call], + usage=Usage(), + referenceable_id=None, + ) + result = RunImpl.process_model_response( + agent=agent, response=response, output_schema=None, handoffs=[] + ) + assert any( + isinstance(item, ToolCallItem) and item.raw_item is computer_call + for item in result.new_items + ) + assert result.computer_actions and result.computer_actions[0].tool_call == computer_call + + +def test_tool_and_handoff_parsed_correctly(): + agent_1 = Agent(name="test_1") + agent_2 = Agent(name="test_2") + agent_3 = Agent( + name="test_3", tools=[get_function_tool(name="test")], handoffs=[agent_1, agent_2] + ) + response = ModelResponse( + output=[ + get_text_message("Hello, world!"), + get_function_tool_call("test", "abc"), + get_handoff_tool_call(agent_1), + ], + usage=Usage(), + referenceable_id=None, + ) + + result = RunImpl.process_model_response( + agent=agent_3, + response=response, + output_schema=None, + handoffs=Runner._get_handoffs(agent_3), + ) + assert result.functions and len(result.functions) == 1 + assert len(result.handoffs) == 1, "Should have a handoff here" + handoff = result.handoffs[0] + assert handoff.handoff.tool_name == Handoff.default_tool_name(agent_1) + assert handoff.handoff.tool_description == Handoff.default_tool_description(agent_1) + assert handoff.handoff.agent_name == agent_1.name diff --git a/tests/test_strict_schema.py b/tests/test_strict_schema.py new file mode 100644 index 0000000..c35e9ad --- /dev/null +++ b/tests/test_strict_schema.py @@ -0,0 +1,126 @@ +import pytest + +from agents.exceptions import UserError +from agents.strict_schema import ensure_strict_json_schema + + +def test_empty_schema_has_additional_properties_false(): + strict_schema = ensure_strict_json_schema({}) + assert strict_schema["additionalProperties"] is False + + +def test_non_dict_schema_errors(): + with pytest.raises(TypeError): + ensure_strict_json_schema([]) # type: ignore + + +def test_object_without_additional_properties(): + # When an object type schema has properties but no additionalProperties, + # it should be added and the "required" list set from the property keys. + schema = {"type": "object", "properties": {"a": {"type": "string"}}} + result = ensure_strict_json_schema(schema) + assert result["type"] == "object" + assert result["additionalProperties"] is False + assert result["required"] == ["a"] + # The inner property remains unchanged (no additionalProperties is added for non-object types) + assert result["properties"]["a"] == {"type": "string"} + + +def test_object_with_true_additional_properties(): + # If additionalProperties is explicitly set to True for an object, a UserError should be raised. + schema = { + "type": "object", + "properties": {"a": {"type": "number"}}, + "additionalProperties": True, + } + with pytest.raises(UserError): + ensure_strict_json_schema(schema) + + +def test_array_items_processing_and_default_removal(): + # When processing an array, the items schema is processed recursively. + # Also, any "default": None should be removed. + schema = { + "type": "array", + "items": {"type": "number", "default": None}, + } + result = ensure_strict_json_schema(schema) + # "default" should be stripped from the items schema. + assert "default" not in result["items"] + assert result["items"]["type"] == "number" + + +def test_anyOf_processing(): + # Test that anyOf schemas are processed. + schema = { + "anyOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "number", "default": None}, + ] + } + result = ensure_strict_json_schema(schema) + # For the first variant: object type should get additionalProperties and required keys set. + variant0 = result["anyOf"][0] + assert variant0["type"] == "object" + assert variant0["additionalProperties"] is False + assert variant0["required"] == ["a"] + + # For the second variant: the "default": None should be removed. + variant1 = result["anyOf"][1] + assert variant1["type"] == "number" + assert "default" not in variant1 + + +def test_allOf_single_entry_merging(): + # When an allOf list has a single entry, its content should be merged into the parent. + schema = { + "type": "object", + "allOf": [{"properties": {"a": {"type": "boolean"}}}], + } + result = ensure_strict_json_schema(schema) + # allOf should be removed and merged. + assert "allOf" not in result + # The object should now have additionalProperties set and required set. + assert result["additionalProperties"] is False + assert result["required"] == ["a"] + assert "a" in result["properties"] + assert result["properties"]["a"]["type"] == "boolean" + + +def test_default_removal_on_non_object(): + # Test that "default": None is stripped from schemas that are not objects. + schema = {"type": "string", "default": None} + result = ensure_strict_json_schema(schema) + assert result["type"] == "string" + assert "default" not in result + + +def test_ref_expansion(): + # Construct a schema with a definitions section and a property with a $ref. + schema = { + "definitions": {"refObj": {"type": "string", "default": None}}, + "type": "object", + "properties": {"a": {"$ref": "#/definitions/refObj", "description": "desc"}}, + } + result = ensure_strict_json_schema(schema) + a_schema = result["properties"]["a"] + # The $ref should be expanded so that the type is from the referenced definition, + # the description from the original takes precedence, and default is removed. + assert a_schema["type"] == "string" + assert a_schema["description"] == "desc" + assert "default" not in a_schema + + +def test_ref_no_expansion_when_alone(): + # If the schema only contains a $ref key, it should not be expanded. + schema = {"$ref": "#/definitions/refObj"} + result = ensure_strict_json_schema(schema) + # Because there is only one key, the $ref remains unchanged. + assert result == {"$ref": "#/definitions/refObj"} + + +def test_invalid_ref_format(): + # A $ref that does not start with "#/" should trigger a ValueError when resolved. + schema = {"type": "object", "properties": {"a": {"$ref": "invalid", "description": "desc"}}} + with pytest.raises(ValueError): + ensure_strict_json_schema(schema) diff --git a/tests/test_tool_converter.py b/tests/test_tool_converter.py new file mode 100644 index 0000000..1b6ebcf --- /dev/null +++ b/tests/test_tool_converter.py @@ -0,0 +1,54 @@ +import pytest +from pydantic import BaseModel + +from agents import Agent, Handoff, function_tool, handoff +from agents.exceptions import UserError +from agents.models.openai_chatcompletions import ToolConverter +from agents.tool import FileSearchTool, WebSearchTool + + +def some_function(a: str, b: list[int]) -> str: + return "hello" + + +def test_to_openai_with_function_tool(): + some_function(a="foo", b=[1, 2, 3]) + + tool = function_tool(some_function) + result = ToolConverter.to_openai(tool) + + assert result["type"] == "function" + assert result["function"]["name"] == "some_function" + params = result.get("function", {}).get("parameters") + assert params is not None + properties = params.get("properties", {}) + assert isinstance(properties, dict) + assert properties.keys() == {"a", "b"} + + +class Foo(BaseModel): + a: str + b: list[int] + + +def test_convert_handoff_tool(): + agent = Agent(name="test_1", handoff_description="test_2") + handoff_obj = handoff(agent=agent) + result = ToolConverter.convert_handoff_tool(handoff_obj) + + assert result["type"] == "function" + assert result["function"]["name"] == Handoff.default_tool_name(agent) + assert result["function"].get("description") == Handoff.default_tool_description(agent) + params = result.get("function", {}).get("parameters") + assert params is not None + + for key, value in handoff_obj.input_json_schema.items(): + assert params[key] == value + + +def test_tool_converter_hosted_tools_errors(): + with pytest.raises(UserError): + ToolConverter.to_openai(WebSearchTool()) + + with pytest.raises(UserError): + ToolConverter.to_openai(FileSearchTool(vector_store_ids=["abc"], max_num_results=1)) diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py new file mode 100644 index 0000000..72318ca --- /dev/null +++ b/tests/test_trace_processor.py @@ -0,0 +1,276 @@ +import os +import time +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from agents.tracing.processor_interface import TracingProcessor +from agents.tracing.processors import BackendSpanExporter, BatchTraceProcessor +from agents.tracing.span_data import AgentSpanData +from agents.tracing.spans import SpanImpl +from agents.tracing.traces import TraceImpl + + +def get_span(processor: TracingProcessor) -> SpanImpl[AgentSpanData]: + """Create a minimal agent span for testing processors.""" + return SpanImpl( + trace_id="test_trace_id", + span_id="test_span_id", + parent_id=None, + processor=processor, + span_data=AgentSpanData(name="test_agent"), + ) + + +def get_trace(processor: TracingProcessor) -> TraceImpl: + """Create a minimal trace.""" + return TraceImpl( + name="test_trace", + trace_id="test_trace_id", + group_id="test_session_id", + metadata={}, + processor=processor, + ) + + +@pytest.fixture +def mocked_exporter(): + exporter = MagicMock() + exporter.export = MagicMock() + return exporter + + +def test_batch_trace_processor_on_trace_start(mocked_exporter): + processor = BatchTraceProcessor(exporter=mocked_exporter, schedule_delay=0.1) + test_trace = get_trace(processor) + + processor.on_trace_start(test_trace) + assert processor._queue.qsize() == 1, "Trace should be added to the queue" + + # Shutdown to clean up the worker thread + processor.shutdown() + + +def test_batch_trace_processor_on_span_end(mocked_exporter): + processor = BatchTraceProcessor(exporter=mocked_exporter, schedule_delay=0.1) + test_span = get_span(processor) + + processor.on_span_end(test_span) + assert processor._queue.qsize() == 1, "Span should be added to the queue" + + # Shutdown to clean up the worker thread + processor.shutdown() + + +def test_batch_trace_processor_queue_full(mocked_exporter): + processor = BatchTraceProcessor(exporter=mocked_exporter, max_queue_size=2, schedule_delay=0.1) + # Fill the queue + processor.on_trace_start(get_trace(processor)) + processor.on_trace_start(get_trace(processor)) + assert processor._queue.full() is True + + # Next item should not be queued + processor.on_trace_start(get_trace(processor)) + assert processor._queue.qsize() == 2, "Queue should not exceed max_queue_size" + + processor.on_span_end(get_span(processor)) + assert processor._queue.qsize() == 2, "Queue should not exceed max_queue_size" + + processor.shutdown() + + +def test_batch_processor_doesnt_enqueue_on_trace_end_or_span_start(mocked_exporter): + processor = BatchTraceProcessor(exporter=mocked_exporter) + + processor.on_trace_start(get_trace(processor)) + assert processor._queue.qsize() == 1, "Trace should be queued" + + processor.on_span_start(get_span(processor)) + assert processor._queue.qsize() == 1, "Span should not be queued" + + processor.on_span_end(get_span(processor)) + assert processor._queue.qsize() == 2, "Span should be queued" + + processor.on_trace_end(get_trace(processor)) + assert processor._queue.qsize() == 2, "Nothing new should be queued" + + processor.shutdown() + + +def test_batch_trace_processor_force_flush(mocked_exporter): + processor = BatchTraceProcessor(exporter=mocked_exporter, max_batch_size=2, schedule_delay=5.0) + + processor.on_trace_start(get_trace(processor)) + processor.on_span_end(get_span(processor)) + processor.on_span_end(get_span(processor)) + + processor.force_flush() + + # Ensure exporter.export was called with all items + # Because max_batch_size=2, it may have been called multiple times + total_exported = 0 + for call_args in mocked_exporter.export.call_args_list: + batch = call_args[0][0] # first positional arg to export() is the items list + total_exported += len(batch) + + # We pushed 3 items; ensure they all got exported + assert total_exported == 3 + + processor.shutdown() + + +def test_batch_trace_processor_shutdown_flushes(mocked_exporter): + processor = BatchTraceProcessor(exporter=mocked_exporter, schedule_delay=5.0) + processor.on_trace_start(get_trace(processor)) + processor.on_span_end(get_span(processor)) + qsize_before = processor._queue.qsize() + assert qsize_before == 2 + + processor.shutdown() + + # Ensure everything was exported after shutdown + total_exported = 0 + for call_args in mocked_exporter.export.call_args_list: + batch = call_args[0][0] + total_exported += len(batch) + + assert total_exported == 2, "All items in the queue should be exported upon shutdown" + + +def test_batch_trace_processor_scheduled_export(mocked_exporter): + """ + Tests that items are automatically exported when the schedule_delay expires. + We mock time.time() so we can trigger the condition without waiting in real time. + """ + with patch("time.time") as mock_time: + base_time = 1000.0 + mock_time.return_value = base_time + + processor = BatchTraceProcessor(exporter=mocked_exporter, schedule_delay=1.0) + + processor.on_span_end(get_span(processor)) # queue size = 1 + + # Now artificially advance time beyond the next export time + mock_time.return_value = base_time + 2.0 # > base_time + schedule_delay + # Let the background thread run a bit + time.sleep(0.3) + + # Check that exporter.export was eventually called + # Because the background thread runs, we might need a small sleep + processor.shutdown() + + total_exported = 0 + for call_args in mocked_exporter.export.call_args_list: + batch = call_args[0][0] + total_exported += len(batch) + + assert total_exported == 1, "Item should be exported after scheduled delay" + + +@pytest.fixture +def patched_time_sleep(): + """ + Fixture to replace time.sleep with a no-op to speed up tests + that rely on retry/backoff logic. + """ + with patch("time.sleep") as mock_sleep: + yield mock_sleep + + +def mock_processor(): + processor = MagicMock() + processor.on_trace_start = MagicMock() + processor.on_span_end = MagicMock() + return processor + + +@patch("httpx.Client") +def test_backend_span_exporter_no_items(mock_client): + exporter = BackendSpanExporter(api_key="test_key") + exporter.export([]) + # No calls should be made if there are no items + mock_client.return_value.post.assert_not_called() + exporter.close() + + +@patch("httpx.Client") +def test_backend_span_exporter_no_api_key(mock_client): + # Ensure that os.environ is empty (sometimes devs have the openai api key set in their env) + + with patch.dict(os.environ, {}, clear=True): + exporter = BackendSpanExporter(api_key=None) + exporter.export([get_span(mock_processor())]) + + # Should log an error and return without calling post + mock_client.return_value.post.assert_not_called() + exporter.close() + + +@patch("httpx.Client") +def test_backend_span_exporter_2xx_success(mock_client): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.return_value.post.return_value = mock_response + + exporter = BackendSpanExporter(api_key="test_key") + exporter.export([get_span(mock_processor()), get_trace(mock_processor())]) + + # Should have called post exactly once + mock_client.return_value.post.assert_called_once() + exporter.close() + + +@patch("httpx.Client") +def test_backend_span_exporter_4xx_client_error(mock_client): + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "Bad Request" + mock_client.return_value.post.return_value = mock_response + + exporter = BackendSpanExporter(api_key="test_key") + exporter.export([get_span(mock_processor())]) + + # 4xx should not be retried + mock_client.return_value.post.assert_called_once() + exporter.close() + + +@patch("httpx.Client") +def test_backend_span_exporter_5xx_retry(mock_client, patched_time_sleep): + mock_response = MagicMock() + mock_response.status_code = 500 + + # Make post() return 500 every time + mock_client.return_value.post.return_value = mock_response + + exporter = BackendSpanExporter(api_key="test_key", max_retries=3, base_delay=0.1, max_delay=0.2) + exporter.export([get_span(mock_processor())]) + + # Should retry up to max_retries times + assert mock_client.return_value.post.call_count == 3 + + exporter.close() + + +@patch("httpx.Client") +def test_backend_span_exporter_request_error(mock_client, patched_time_sleep): + # Make post() raise a RequestError each time + mock_client.return_value.post.side_effect = httpx.RequestError("Network error") + + exporter = BackendSpanExporter(api_key="test_key", max_retries=2, base_delay=0.1, max_delay=0.2) + exporter.export([get_span(mock_processor())]) + + # Should retry up to max_retries times + assert mock_client.return_value.post.call_count == 2 + + exporter.close() + + +@patch("httpx.Client") +def test_backend_span_exporter_close(mock_client): + exporter = BackendSpanExporter(api_key="test_key") + exporter.close() + + # Ensure underlying http client is closed + mock_client.return_value.close.assert_called_once() diff --git a/tests/test_tracing.py b/tests/test_tracing.py new file mode 100644 index 0000000..c54c3d8 --- /dev/null +++ b/tests/test_tracing.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from agents.tracing import ( + Span, + Trace, + agent_span, + custom_span, + function_span, + generation_span, + handoff_span, + trace, +) +from agents.tracing.spans import SpanError + +from .testing_processor import fetch_events, fetch_ordered_spans, fetch_traces + +### HELPERS + + +def standard_span_checks( + span: Span[Any], trace_id: str, parent_id: str | None, span_type: str +) -> None: + assert span.span_id is not None + assert span.trace_id == trace_id + assert span.parent_id == parent_id + assert span.started_at is not None + assert span.ended_at is not None + assert span.span_data.type == span_type + + +def standard_trace_checks(trace: Trace, name_check: str | None = None) -> None: + assert trace.trace_id is not None + + if name_check: + assert trace.name == name_check + + +### TESTS + + +def simple_tracing(): + x = trace("test") + x.start() + + span_1 = agent_span(name="agent_1", parent=x) + span_1.start() + span_1.finish() + + span_2 = custom_span(name="custom_1", span_id="span_2", parent=x) + span_2.start() + + span_3 = custom_span(name="custom_2", span_id="span_3", parent=span_2) + span_3.start() + span_3.finish() + + span_2.finish() + + x.finish() + + +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 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 custom_span(name="custom_1", span_id="span_1"): + with custom_span(name="custom_2", span_id="span_1_inner"): + pass + + with custom_span(name="custom_2", span_id="span_2"): + pass + + +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 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: + with generation_span(span_id=span_id): + await asyncio.sleep(0.01) + + +async def simple_async_tracing(): + with trace(workflow_name="test", trace_id="123", group_id="456"): + await run_subtask(span_id="span_1") + await run_subtask(span_id="span_2") + + +@pytest.mark.asyncio +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 + + +async def run_tasks_parallel(span_ids: list[str]) -> None: + await asyncio.gather( + *[run_subtask(span_id=span_id) for span_id in span_ids], + ) + + +async def run_tasks_as_children(first_span_id: str, second_span_id: str) -> None: + with generation_span(span_id=first_span_id): + await run_subtask(span_id=second_span_id) + + +async def complex_async_tracing(): + with trace(workflow_name="test", trace_id="123", group_id="456"): + await asyncio.sleep(0.01) + 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"), + ) + + +@pytest.mark.asyncio +async def test_complex_async_tracing() -> None: + 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") + + +def spans_with_setters(): + with trace(workflow_name="test", trace_id="123", group_id="456"): + with agent_span(name="agent_1") as span_a: + span_a.span_data.name = "agent_2" + + with function_span(name="function_1") as span_b: + span_b.span_data.input = "i" + span_b.span_data.output = "o" + + with generation_span() as span_c: + span_c.span_data.input = [{"foo": "bar"}] + + with handoff_span(from_agent="agent_1", to_agent="agent_2"): + pass + + +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" + ) + + +def disabled_tracing(): + with trace(workflow_name="test", trace_id="123", group_id="456", disabled=True): + with agent_span(name="agent_1"): + with function_span(name="function_1"): + pass + + +def test_disabled_tracing(): + disabled_tracing() + + spans, traces = fetch_ordered_spans(), fetch_traces() + assert len(spans) == 0 + assert len(traces) == 0 + + +def enabled_trace_disabled_span(): + with trace(workflow_name="test", trace_id="123"): + with agent_span(name="agent_1"): + with function_span(name="function_1", disabled=True): + with generation_span(): + pass + + +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" + + +def test_start_and_end_called_manual(): + simple_tracing() + + events = fetch_events() + + assert events == [ + "trace_start", + "span_start", # span_1 + "span_end", # span_1 + "span_start", # span_2 + "span_start", # span_3 + "span_end", # span_3 + "span_end", # span_2 + "trace_end", + ] + + +def test_start_and_end_called_ctxmanager(): + with trace(workflow_name="test", trace_id="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 + + with custom_span(name="custom_2", span_id="span_2"): + pass + + events = fetch_events() + + assert events == [ + "trace_start", + "span_start", # span_1 + "span_start", # span_1_inner + "span_end", # span_1_inner + "span_end", # span_1 + "span_start", # span_2 + "span_end", # span_2 + "trace_end", + ] + + +@pytest.mark.asyncio +async def test_start_and_end_called_async_ctxmanager(): + await simple_async_tracing() + + events = fetch_events() + + assert events == [ + "trace_start", + "span_start", # span_1 + "span_end", # span_1 + "span_start", # span_2 + "span_end", # span_2 + "trace_end", + ] + + +async def test_noop_span_doesnt_record(): + with trace(workflow_name="test", disabled=True) as t: + 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 t.export() is None + assert span.export() is None + assert span.started_at is None + assert span.ended_at is None + assert span.error is None + + +async def test_multiple_span_start_finish_doesnt_crash(): + with trace(workflow_name="test", trace_id="123", group_id="456"): + with custom_span(name="span_1") as span: + span.start() + + span.finish() + + +async def test_noop_parent_is_noop_child(): + tr = trace(workflow_name="test", disabled=True) + + span = custom_span(name="span_1", parent=tr) + span.start() + span.finish() + + assert span.export() is None + + span_2 = custom_span(name="span_2", parent=span) + span_2.start() + span_2.finish() + + assert span_2.export() is None diff --git a/tests/test_tracing_errors.py b/tests/test_tracing_errors.py new file mode 100644 index 0000000..d57e1a8 --- /dev/null +++ b/tests/test_tracing_errors.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest +from typing_extensions import TypedDict + +from agents import ( + Agent, + GuardrailFunctionOutput, + InputGuardrail, + InputGuardrailTripwireTriggered, + MaxTurnsExceeded, + ModelBehaviorError, + RunContextWrapper, + Runner, + TResponseInputItem, +) +from agents.tracing import AgentSpanData, FunctionSpanData, GenerationSpanData + +from .fake_model import FakeModel +from .test_responses import ( + get_final_output_message, + get_function_tool, + get_function_tool_call, + get_handoff_tool_call, + get_text_message, +) +from .testing_processor import fetch_ordered_spans, fetch_traces + + +@pytest.mark.asyncio +async def test_single_turn_model_error(): + model = FakeModel(tracing_enabled=True) + model.set_next_output(ValueError("test error")) + + agent = Agent( + name="test_agent", + model=model, + ) + 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)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent = Agent( + name="test_agent", + model=model, + tools=[get_function_tool("foo", "tool_result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], + # Second turn: error + ValueError("test error"), + # Third turn: text message + [get_text_message("done")], + ] + ) + + 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)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent = Agent( + name="test_agent", + model=model, + tools=[get_function_tool("foo", "tool_result", hide_errors=True)], + ) + + model.set_next_output( + [get_text_message("a_message"), get_function_tool_call("foo", "bad_json")], + ) + + 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)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent_1 = Agent( + name="test", + model=model, + ) + agent_2 = Agent( + name="test", + model=model, + ) + agent_3 = Agent( + name="test", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and 2 handoff + [ + get_text_message("a_message"), + get_handoff_tool_call(agent_1), + get_handoff_tool_call(agent_2), + ], + # Third turn: text message + [get_text_message("done")], + ] + ) + 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)}" + + 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 + + +@pytest.mark.asyncio +async def test_multiple_final_output_doesnt_error(): + model = FakeModel(tracing_enabled=True) + + agent_1 = Agent( + name="test", + model=model, + output_type=Foo, + ) + + model.set_next_output( + [ + get_final_output_message(json.dumps(Foo(bar="baz"))), + get_final_output_message(json.dumps(Foo(bar="abc"))), + ] + ) + + 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)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent_1 = Agent( + name="test_agent_1", + model=model, + tools=[get_function_tool("some_function", "result")], + ) + agent_2 = Agent( + name="test_agent_2", + model=model, + handoffs=[agent_1], + tools=[get_function_tool("some_function", "result")], + ) + agent_3 = Agent( + name="test_agent_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + ) + + agent_1.handoffs.append(agent_3) + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and 2 handoff + [ + get_text_message("a_message"), + get_handoff_tool_call(agent_1), + get_handoff_tool_call(agent_2), + ], + # Third turn: tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Fourth turn: handoff + [get_handoff_tool_call(agent_3)], + # Fifth turn: text message + [get_text_message("done")], + ] + ) + result = await Runner.run(agent_3, input="user_message") + + assert result.last_agent == agent_3, ( + 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)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent = Agent( + name="test", + model=model, + output_type=Foo, + tools=[get_function_tool("foo", "result")], + ) + + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("foo")], + [get_function_tool_call("foo")], + [get_function_tool_call("foo")], + [get_function_tool_call("foo")], + [get_function_tool_call("foo")], + ] + ) + + 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)}" + + 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] +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=True, + ) + + +@pytest.mark.asyncio +async def test_guardrail_error(): + agent = Agent( + name="test", input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)] + ) + model = FakeModel() + model.set_next_output([get_text_message("some_message")]) + + 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)}" + + 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 new file mode 100644 index 0000000..00f440e --- /dev/null +++ b/tests/test_tracing_errors_streamed.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest +from typing_extensions import TypedDict + +from agents import ( + Agent, + AgentSpanData, + FunctionSpanData, + GenerationSpanData, + GuardrailFunctionOutput, + InputGuardrail, + InputGuardrailTripwireTriggered, + MaxTurnsExceeded, + ModelBehaviorError, + OutputGuardrail, + OutputGuardrailTripwireTriggered, + RunContextWrapper, + Runner, + TResponseInputItem, +) + +from .fake_model import FakeModel +from .test_responses import ( + get_final_output_message, + get_function_tool, + get_function_tool_call, + get_handoff_tool_call, + get_text_message, +) +from .testing_processor import fetch_ordered_spans, fetch_traces + + +@pytest.mark.asyncio +async def test_single_turn_model_error(): + model = FakeModel(tracing_enabled=True) + model.set_next_output(ValueError("test error")) + + agent = Agent( + name="test_agent", + model=model, + ) + with pytest.raises(ValueError): + result = Runner.run_streamed(agent, input="first_test") + async for _ in result.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent = Agent( + name="test_agent", + model=model, + tools=[get_function_tool("foo", "tool_result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a message and tool call + [get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))], + # Second turn: error + ValueError("test error"), + # Third turn: text message + [get_text_message("done")], + ] + ) + + with pytest.raises(ValueError): + result = Runner.run_streamed(agent, input="first_test") + async for _ in result.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent = Agent( + name="test_agent", + model=model, + tools=[get_function_tool("foo", "tool_result", hide_errors=True)], + ) + + model.set_next_output( + [get_text_message("a_message"), get_function_tool_call("foo", "bad_json")], + ) + + with pytest.raises(ModelBehaviorError): + result = Runner.run_streamed(agent, input="first_test") + async for _ in result.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent_1 = Agent( + name="test", + model=model, + ) + agent_2 = Agent( + name="test", + model=model, + ) + agent_3 = Agent( + name="test", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + ) + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and 2 handoff + [ + get_text_message("a_message"), + get_handoff_tool_call(agent_1), + get_handoff_tool_call(agent_2), + ], + # Third turn: text message + [get_text_message("done")], + ] + ) + result = Runner.run_streamed(agent_3, input="user_message") + async for _ in result.stream_events(): + pass + + 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)}" + + 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 + + +@pytest.mark.asyncio +async def test_multiple_final_output_no_error(): + model = FakeModel(tracing_enabled=True) + + agent_1 = Agent( + name="test", + model=model, + output_type=Foo, + ) + + model.set_next_output( + [ + get_final_output_message(json.dumps(Foo(bar="baz"))), + get_final_output_message(json.dumps(Foo(bar="abc"))), + ] + ) + + result = Runner.run_streamed(agent_1, input="user_message") + async for _ in result.stream_events(): + pass + + 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)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent_1 = Agent( + name="test_agent_1", + model=model, + tools=[get_function_tool("some_function", "result")], + ) + agent_2 = Agent( + name="test_agent_2", + model=model, + handoffs=[agent_1], + tools=[get_function_tool("some_function", "result")], + ) + agent_3 = Agent( + name="test_agent_3", + model=model, + handoffs=[agent_1, agent_2], + tools=[get_function_tool("some_function", "result")], + ) + + agent_1.handoffs.append(agent_3) + + model.add_multiple_turn_outputs( + [ + # First turn: a tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Second turn: a message and 2 handoff + [ + get_text_message("a_message"), + get_handoff_tool_call(agent_1), + get_handoff_tool_call(agent_2), + ], + # Third turn: tool call + [get_function_tool_call("some_function", json.dumps({"a": "b"}))], + # Fourth turn: handoff + [get_handoff_tool_call(agent_3)], + # Fifth turn: text message + [get_text_message("done")], + ] + ) + result = Runner.run_streamed(agent_3, input="user_message") + async for _ in result.stream_events(): + pass + + assert result.last_agent == agent_3, ( + 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)}" + + 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(): + model = FakeModel(tracing_enabled=True) + + agent = Agent( + name="test", + model=model, + output_type=Foo, + tools=[get_function_tool("foo", "result")], + ) + + model.add_multiple_turn_outputs( + [ + [get_function_tool_call("foo")], + [get_function_tool_call("foo")], + [get_function_tool_call("foo")], + [get_function_tool_call("foo")], + [get_function_tool_call("foo")], + ] + ) + + with pytest.raises(MaxTurnsExceeded): + result = Runner.run_streamed(agent, input="user_message", max_turns=2) + async for _ in result.stream_events(): + pass + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + 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] +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=True, + ) + + +@pytest.mark.asyncio +async def test_input_guardrail_error(): + model = FakeModel() + + agent = Agent( + name="test", + model=model, + input_guardrails=[InputGuardrail(guardrail_function=input_guardrail_function)], + ) + model.set_next_output([get_text_message("some_message")]) + + with pytest.raises(InputGuardrailTripwireTriggered): + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + + await asyncio.sleep(1) + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + 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 +) -> GuardrailFunctionOutput: + return GuardrailFunctionOutput( + output_info=None, + tripwire_triggered=True, + ) + + +@pytest.mark.asyncio +async def test_output_guardrail_error(): + model = FakeModel() + + agent = Agent( + name="test", + model=model, + output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail_function)], + ) + model.set_next_output([get_text_message("some_message")]) + + with pytest.raises(OutputGuardrailTripwireTriggered): + result = Runner.run_streamed(agent, input="user_message") + async for _ in result.stream_events(): + pass + + await asyncio.sleep(1) + + traces = fetch_traces() + assert len(traces) == 1, f"Expected 1 trace, got {len(traces)}" + + 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/testing_processor.py b/tests/testing_processor.py new file mode 100644 index 0000000..258a08d --- /dev/null +++ b/tests/testing_processor.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import threading +from typing import Any, Literal + +from agents.tracing import Span, Trace, TracingProcessor + +TestSpanProcessorEvent = Literal["trace_start", "trace_end", "span_start", "span_end"] + + +class SpanProcessorForTests(TracingProcessor): + """ + A simple processor that stores finished spans in memory. + This is thread-safe and suitable for tests or basic usage. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + # Dictionary of trace_id -> list of spans + self._spans: list[Span[Any]] = [] + self._traces: list[Trace] = [] + self._events: list[TestSpanProcessorEvent] = [] + + def on_trace_start(self, trace: Trace) -> None: + with self._lock: + self._traces.append(trace) + self._events.append("trace_start") + + def on_trace_end(self, trace: Trace) -> None: + with self._lock: + # We don't append the trace here, we want to do that in on_trace_start + self._events.append("trace_end") + + def on_span_start(self, span: Span[Any]) -> None: + with self._lock: + # Purposely not appending the span here, we want to do that in on_span_end + self._events.append("span_start") + + def on_span_end(self, span: Span[Any]) -> None: + with self._lock: + self._events.append("span_end") + self._spans.append(span) + + def get_ordered_spans(self, including_empty: bool = False) -> list[Span[Any]]: + with self._lock: + spans = [x for x in self._spans if including_empty or x.export()] + return sorted(spans, key=lambda x: x.started_at or 0) + + def get_traces(self, including_empty: bool = False) -> list[Trace]: + with self._lock: + traces = [x for x in self._traces if including_empty or x.export()] + return traces + + def clear(self) -> None: + with self._lock: + self._spans.clear() + self._traces.clear() + self._events.clear() + + def shutdown(self) -> None: + pass + + def force_flush(self) -> None: + pass + + +SPAN_PROCESSOR_TESTING = SpanProcessorForTests() + + +def fetch_ordered_spans() -> list[Span[Any]]: + return SPAN_PROCESSOR_TESTING.get_ordered_spans() + + +def fetch_traces() -> list[Trace]: + return SPAN_PROCESSOR_TESTING.get_traces() + + +def fetch_events() -> list[TestSpanProcessorEvent]: + return SPAN_PROCESSOR_TESTING._events diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..141e15b --- /dev/null +++ b/uv.lock @@ -0,0 +1,1360 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { 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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, +] + +[[package]] +name = "backrefs" +version = "5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/46/caba1eb32fa5784428ab401a5487f73db4104590ecd939ed9daaf18b47e0/backrefs-5.8.tar.gz", hash = "sha256:2cab642a205ce966af3dd4b38ee36009b31fa9502a35fd61d59ccc116e40a6bd", size = 6773994 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/cb/d019ab87fe70e0fe3946196d50d6a4428623dc0c38a6669c8cae0320fbf3/backrefs-5.8-py310-none-any.whl", hash = "sha256:c67f6638a34a5b8730812f5101376f9d41dc38c43f1fdc35cb54700f6ed4465d", size = 380337 }, + { url = "https://files.pythonhosted.org/packages/a9/86/abd17f50ee21b2248075cb6924c6e7f9d23b4925ca64ec660e869c2633f1/backrefs-5.8-py311-none-any.whl", hash = "sha256:2e1c15e4af0e12e45c8701bd5da0902d326b2e200cafcd25e49d9f06d44bb61b", size = 392142 }, + { url = "https://files.pythonhosted.org/packages/b3/04/7b415bd75c8ab3268cc138c76fa648c19495fcc7d155508a0e62f3f82308/backrefs-5.8-py312-none-any.whl", hash = "sha256:bbef7169a33811080d67cdf1538c8289f76f0942ff971222a16034da88a73486", size = 398021 }, + { url = "https://files.pythonhosted.org/packages/04/b8/60dcfb90eb03a06e883a92abbc2ab95c71f0d8c9dd0af76ab1d5ce0b1402/backrefs-5.8-py313-none-any.whl", hash = "sha256:e3a63b073867dbefd0536425f43db618578528e3896fb77be7141328642a1585", size = 399915 }, + { url = "https://files.pythonhosted.org/packages/0c/37/fb6973edeb700f6e3d6ff222400602ab1830446c25c7b4676d8de93e65b8/backrefs-5.8-py39-none-any.whl", hash = "sha256:a66851e4533fb5b371aa0628e1fee1af05135616b86140c9d787a2ffdf4b8fdc", size = 380336 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +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 = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, + { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, + { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, + { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, + { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, + { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, + { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, + { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, + { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, + { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, + { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, + { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 }, + { url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 }, + { url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 }, + { url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 }, + { url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 }, + { url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 }, + { url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 }, + { url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 }, + { url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 }, + { url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 }, + { url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 }, + { url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 }, + { url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.6.12" +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 } +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 }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +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 = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, +] + +[[package]] +name = "greenlet" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/90/5234a78dc0ef6496a6eb97b67a42a8e96742a56f7dc808cb954a85390448/greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563", size = 271235 }, + { url = "https://files.pythonhosted.org/packages/7c/16/cd631fa0ab7d06ef06387135b7549fdcc77d8d859ed770a0d28e47b20972/greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83", size = 637168 }, + { url = "https://files.pythonhosted.org/packages/2f/b1/aed39043a6fec33c284a2c9abd63ce191f4f1a07319340ffc04d2ed3256f/greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0", size = 648826 }, + { url = "https://files.pythonhosted.org/packages/76/25/40e0112f7f3ebe54e8e8ed91b2b9f970805143efef16d043dfc15e70f44b/greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120", size = 644443 }, + { url = "https://files.pythonhosted.org/packages/fb/2f/3850b867a9af519794784a7eeed1dd5bc68ffbcc5b28cef703711025fd0a/greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc", size = 643295 }, + { url = "https://files.pythonhosted.org/packages/cf/69/79e4d63b9387b48939096e25115b8af7cd8a90397a304f92436bcb21f5b2/greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617", size = 599544 }, + { url = "https://files.pythonhosted.org/packages/46/1d/44dbcb0e6c323bd6f71b8c2f4233766a5faf4b8948873225d34a0b7efa71/greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7", size = 1125456 }, + { url = "https://files.pythonhosted.org/packages/e0/1d/a305dce121838d0278cee39d5bb268c657f10a5363ae4b726848f833f1bb/greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6", size = 1149111 }, + { url = "https://files.pythonhosted.org/packages/96/28/d62835fb33fb5652f2e98d34c44ad1a0feacc8b1d3f1aecab035f51f267d/greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80", size = 298392 }, + { url = "https://files.pythonhosted.org/packages/28/62/1c2665558618553c42922ed47a4e6d6527e2fa3516a8256c2f431c5d0441/greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70", size = 272479 }, + { url = "https://files.pythonhosted.org/packages/76/9d/421e2d5f07285b6e4e3a676b016ca781f63cfe4a0cd8eaecf3fd6f7a71ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159", size = 640404 }, + { url = "https://files.pythonhosted.org/packages/e5/de/6e05f5c59262a584e502dd3d261bbdd2c97ab5416cc9c0b91ea38932a901/greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e", size = 652813 }, + { url = "https://files.pythonhosted.org/packages/49/93/d5f93c84241acdea15a8fd329362c2c71c79e1a507c3f142a5d67ea435ae/greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1", size = 648517 }, + { url = "https://files.pythonhosted.org/packages/15/85/72f77fc02d00470c86a5c982b8daafdf65d38aefbbe441cebff3bf7037fc/greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383", size = 647831 }, + { url = "https://files.pythonhosted.org/packages/f7/4b/1c9695aa24f808e156c8f4813f685d975ca73c000c2a5056c514c64980f6/greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a", size = 602413 }, + { url = "https://files.pythonhosted.org/packages/76/70/ad6e5b31ef330f03b12559d19fda2606a522d3849cde46b24f223d6d1619/greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511", size = 1129619 }, + { url = "https://files.pythonhosted.org/packages/f4/fb/201e1b932e584066e0f0658b538e73c459b34d44b4bd4034f682423bc801/greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395", size = 1155198 }, + { url = "https://files.pythonhosted.org/packages/12/da/b9ed5e310bb8b89661b80cbcd4db5a067903bbcd7fc854923f5ebb4144f0/greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39", size = 298930 }, + { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260 }, + { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064 }, + { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420 }, + { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035 }, + { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105 }, + { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077 }, + { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975 }, + { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955 }, + { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655 }, + { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 }, + { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 }, + { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 }, + { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 }, + { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 }, + { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 }, + { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 }, + { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 }, + { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 }, + { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 }, + { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 }, + { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 }, + { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 }, + { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 }, + { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 }, + { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, + { url = "https://files.pythonhosted.org/packages/8c/82/8051e82af6d6b5150aacb6789a657a8afd48f0a44d8e91cb72aaaf28553a/greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3", size = 270027 }, + { url = "https://files.pythonhosted.org/packages/f9/74/f66de2785880293780eebd18a2958aeea7cbe7814af1ccef634f4701f846/greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42", size = 634822 }, + { url = "https://files.pythonhosted.org/packages/68/23/acd9ca6bc412b02b8aa755e47b16aafbe642dde0ad2f929f836e57a7949c/greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f", size = 646866 }, + { url = "https://files.pythonhosted.org/packages/a9/ab/562beaf8a53dc9f6b2459f200e7bc226bb07e51862a66351d8b7817e3efd/greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437", size = 641985 }, + { url = "https://files.pythonhosted.org/packages/03/d3/1006543621f16689f6dc75f6bcf06e3c23e044c26fe391c16c253623313e/greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145", size = 641268 }, + { url = "https://files.pythonhosted.org/packages/2f/c1/ad71ce1b5f61f900593377b3f77b39408bce5dc96754790311b49869e146/greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c", size = 597376 }, + { url = "https://files.pythonhosted.org/packages/f7/ff/183226685b478544d61d74804445589e069d00deb8ddef042699733950c7/greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e", size = 1123359 }, + { url = "https://files.pythonhosted.org/packages/c0/8b/9b3b85a89c22f55f315908b94cd75ab5fed5973f7393bbef000ca8b2c5c1/greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e", size = 1147458 }, + { url = "https://files.pythonhosted.org/packages/b8/1c/248fadcecd1790b0ba793ff81fa2375c9ad6442f4c748bf2cc2e6563346a/greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c", size = 281131 }, + { url = "https://files.pythonhosted.org/packages/ae/02/e7d0aef2354a38709b764df50b2b83608f0621493e47f47694eb80922822/greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22", size = 298306 }, +] + +[[package]] +name = "griffe" +version = "1.6.0" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/02/5a22bc98d0aebb68c15ba70d2da1c84a5ef56048d79634e5f96cd2ba96e9/griffe-1.6.0-py3-none-any.whl", hash = "sha256:9f1dfe035d4715a244ed2050dfbceb05b1f470809ed4f6bb10ece5a7302f8dd1", size = 128470 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "jiter" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/e4562507f52f0af7036da125bb699602ead37a2332af0788f8e0a3417f36/jiter-0.9.0.tar.gz", hash = "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", size = 162604 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/82/39f7c9e67b3b0121f02a0b90d433626caa95a565c3d2449fea6bcfa3f5f5/jiter-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:816ec9b60fdfd1fec87da1d7ed46c66c44ffec37ab2ef7de5b147b2fce3fd5ad", size = 314540 }, + { url = "https://files.pythonhosted.org/packages/01/07/7bf6022c5a152fca767cf5c086bb41f7c28f70cf33ad259d023b53c0b858/jiter-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b1d3086f8a3ee0194ecf2008cf81286a5c3e540d977fa038ff23576c023c0ea", size = 321065 }, + { url = "https://files.pythonhosted.org/packages/6c/b2/de3f3446ecba7c48f317568e111cc112613da36c7b29a6de45a1df365556/jiter-0.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1339f839b91ae30b37c409bf16ccd3dc453e8b8c3ed4bd1d6a567193651a4a51", size = 341664 }, + { url = "https://files.pythonhosted.org/packages/13/cf/6485a4012af5d407689c91296105fcdb080a3538e0658d2abf679619c72f/jiter-0.9.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ffba79584b3b670fefae66ceb3a28822365d25b7bf811e030609a3d5b876f538", size = 364635 }, + { url = "https://files.pythonhosted.org/packages/0d/f7/4a491c568f005553240b486f8e05c82547340572d5018ef79414b4449327/jiter-0.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cfc7d0a8e899089d11f065e289cb5b2daf3d82fbe028f49b20d7b809193958d", size = 406288 }, + { url = "https://files.pythonhosted.org/packages/d3/ca/f4263ecbce7f5e6bded8f52a9f1a66540b270c300b5c9f5353d163f9ac61/jiter-0.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e00a1a2bbfaaf237e13c3d1592356eab3e9015d7efd59359ac8b51eb56390a12", size = 397499 }, + { url = "https://files.pythonhosted.org/packages/ac/a2/522039e522a10bac2f2194f50e183a49a360d5f63ebf46f6d890ef8aa3f9/jiter-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1d9870561eb26b11448854dce0ff27a9a27cb616b632468cafc938de25e9e51", size = 352926 }, + { url = "https://files.pythonhosted.org/packages/b1/67/306a5c5abc82f2e32bd47333a1c9799499c1c3a415f8dde19dbf876f00cb/jiter-0.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9872aeff3f21e437651df378cb75aeb7043e5297261222b6441a620218b58708", size = 384506 }, + { url = "https://files.pythonhosted.org/packages/0f/89/c12fe7b65a4fb74f6c0d7b5119576f1f16c79fc2953641f31b288fad8a04/jiter-0.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1fd19112d1049bdd47f17bfbb44a2c0001061312dcf0e72765bfa8abd4aa30e5", size = 520621 }, + { url = "https://files.pythonhosted.org/packages/c4/2b/d57900c5c06e6273fbaa76a19efa74dbc6e70c7427ab421bf0095dfe5d4a/jiter-0.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6ef5da104664e526836070e4a23b5f68dec1cc673b60bf1edb1bfbe8a55d0678", size = 512613 }, + { url = "https://files.pythonhosted.org/packages/89/05/d8b90bfb21e58097d5a4e0224f2940568366f68488a079ae77d4b2653500/jiter-0.9.0-cp310-cp310-win32.whl", hash = "sha256:cb12e6d65ebbefe5518de819f3eda53b73187b7089040b2d17f5b39001ff31c4", size = 206613 }, + { url = "https://files.pythonhosted.org/packages/2c/1d/5767f23f88e4f885090d74bbd2755518050a63040c0f59aa059947035711/jiter-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:c43ca669493626d8672be3b645dbb406ef25af3f4b6384cfd306da7eb2e70322", size = 208371 }, + { url = "https://files.pythonhosted.org/packages/23/44/e241a043f114299254e44d7e777ead311da400517f179665e59611ab0ee4/jiter-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6c4d99c71508912a7e556d631768dcdef43648a93660670986916b297f1c54af", size = 314654 }, + { url = "https://files.pythonhosted.org/packages/fb/1b/a7e5e42db9fa262baaa9489d8d14ca93f8663e7f164ed5e9acc9f467fc00/jiter-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f60fb8ce7df529812bf6c625635a19d27f30806885139e367af93f6e734ef58", size = 320909 }, + { url = "https://files.pythonhosted.org/packages/60/bf/8ebdfce77bc04b81abf2ea316e9c03b4a866a7d739cf355eae4d6fd9f6fe/jiter-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51c4e1a4f8ea84d98b7b98912aa4290ac3d1eabfde8e3c34541fae30e9d1f08b", size = 341733 }, + { url = "https://files.pythonhosted.org/packages/a8/4e/754ebce77cff9ab34d1d0fa0fe98f5d42590fd33622509a3ba6ec37ff466/jiter-0.9.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f4c677c424dc76684fea3e7285a7a2a7493424bea89ac441045e6a1fb1d7b3b", size = 365097 }, + { url = "https://files.pythonhosted.org/packages/32/2c/6019587e6f5844c612ae18ca892f4cd7b3d8bbf49461ed29e384a0f13d98/jiter-0.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2221176dfec87f3470b21e6abca056e6b04ce9bff72315cb0b243ca9e835a4b5", size = 406603 }, + { url = "https://files.pythonhosted.org/packages/da/e9/c9e6546c817ab75a1a7dab6dcc698e62e375e1017113e8e983fccbd56115/jiter-0.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c7adb66f899ffa25e3c92bfcb593391ee1947dbdd6a9a970e0d7e713237d572", size = 396625 }, + { url = "https://files.pythonhosted.org/packages/be/bd/976b458add04271ebb5a255e992bd008546ea04bb4dcadc042a16279b4b4/jiter-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98d27330fdfb77913c1097a7aab07f38ff2259048949f499c9901700789ac15", size = 351832 }, + { url = "https://files.pythonhosted.org/packages/07/51/fe59e307aaebec9265dbad44d9d4381d030947e47b0f23531579b9a7c2df/jiter-0.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eda3f8cc74df66892b1d06b5d41a71670c22d95a1ca2cbab73654745ce9d0419", size = 384590 }, + { url = "https://files.pythonhosted.org/packages/db/55/5dcd2693794d8e6f4889389ff66ef3be557a77f8aeeca8973a97a7c00557/jiter-0.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dd5ab5ddc11418dce28343123644a100f487eaccf1de27a459ab36d6cca31043", size = 520690 }, + { url = "https://files.pythonhosted.org/packages/54/d5/9f51dc90985e9eb251fbbb747ab2b13b26601f16c595a7b8baba964043bd/jiter-0.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:42f8a68a69f047b310319ef8e2f52fdb2e7976fb3313ef27df495cf77bcad965", size = 512649 }, + { url = "https://files.pythonhosted.org/packages/a6/e5/4e385945179bcf128fa10ad8dca9053d717cbe09e258110e39045c881fe5/jiter-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a25519efb78a42254d59326ee417d6f5161b06f5da827d94cf521fed961b1ff2", size = 206920 }, + { url = "https://files.pythonhosted.org/packages/4c/47/5e0b94c603d8e54dd1faab439b40b832c277d3b90743e7835879ab663757/jiter-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:923b54afdd697dfd00d368b7ccad008cccfeb1efb4e621f32860c75e9f25edbd", size = 210119 }, + { url = "https://files.pythonhosted.org/packages/af/d7/c55086103d6f29b694ec79156242304adf521577530d9031317ce5338c59/jiter-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7b46249cfd6c48da28f89eb0be3f52d6fdb40ab88e2c66804f546674e539ec11", size = 309203 }, + { url = "https://files.pythonhosted.org/packages/b0/01/f775dfee50beb420adfd6baf58d1c4d437de41c9b666ddf127c065e5a488/jiter-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:609cf3c78852f1189894383cf0b0b977665f54cb38788e3e6b941fa6d982c00e", size = 319678 }, + { url = "https://files.pythonhosted.org/packages/ab/b8/09b73a793714726893e5d46d5c534a63709261af3d24444ad07885ce87cb/jiter-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d726a3890a54561e55a9c5faea1f7655eda7f105bd165067575ace6e65f80bb2", size = 341816 }, + { url = "https://files.pythonhosted.org/packages/35/6f/b8f89ec5398b2b0d344257138182cc090302854ed63ed9c9051e9c673441/jiter-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e89dc075c1fef8fa9be219e249f14040270dbc507df4215c324a1839522ea75", size = 364152 }, + { url = "https://files.pythonhosted.org/packages/9b/ca/978cc3183113b8e4484cc7e210a9ad3c6614396e7abd5407ea8aa1458eef/jiter-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04e8ffa3c353b1bc4134f96f167a2082494351e42888dfcf06e944f2729cbe1d", size = 406991 }, + { url = "https://files.pythonhosted.org/packages/13/3a/72861883e11a36d6aa314b4922125f6ae90bdccc225cd96d24cc78a66385/jiter-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:203f28a72a05ae0e129b3ed1f75f56bc419d5f91dfacd057519a8bd137b00c42", size = 395824 }, + { url = "https://files.pythonhosted.org/packages/87/67/22728a86ef53589c3720225778f7c5fdb617080e3deaed58b04789418212/jiter-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca1a02ad60ec30bb230f65bc01f611c8608b02d269f998bc29cca8619a919dc", size = 351318 }, + { url = "https://files.pythonhosted.org/packages/69/b9/f39728e2e2007276806d7a6609cda7fac44ffa28ca0d02c49a4f397cc0d9/jiter-0.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:237e5cee4d5d2659aaf91bbf8ec45052cc217d9446070699441a91b386ae27dc", size = 384591 }, + { url = "https://files.pythonhosted.org/packages/eb/8f/8a708bc7fd87b8a5d861f1c118a995eccbe6d672fe10c9753e67362d0dd0/jiter-0.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:528b6b71745e7326eed73c53d4aa57e2a522242320b6f7d65b9c5af83cf49b6e", size = 520746 }, + { url = "https://files.pythonhosted.org/packages/95/1e/65680c7488bd2365dbd2980adaf63c562d3d41d3faac192ebc7ef5b4ae25/jiter-0.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9f48e86b57bc711eb5acdfd12b6cb580a59cc9a993f6e7dcb6d8b50522dcd50d", size = 512754 }, + { url = "https://files.pythonhosted.org/packages/78/f3/fdc43547a9ee6e93c837685da704fb6da7dba311fc022e2766d5277dfde5/jiter-0.9.0-cp312-cp312-win32.whl", hash = "sha256:699edfde481e191d81f9cf6d2211debbfe4bd92f06410e7637dffb8dd5dfde06", size = 207075 }, + { url = "https://files.pythonhosted.org/packages/cd/9d/742b289016d155f49028fe1bfbeb935c9bf0ffeefdf77daf4a63a42bb72b/jiter-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:099500d07b43f61d8bd780466d429c45a7b25411b334c60ca875fa775f68ccb0", size = 207999 }, + { url = "https://files.pythonhosted.org/packages/e7/1b/4cd165c362e8f2f520fdb43245e2b414f42a255921248b4f8b9c8d871ff1/jiter-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2764891d3f3e8b18dce2cff24949153ee30c9239da7c00f032511091ba688ff7", size = 308197 }, + { url = "https://files.pythonhosted.org/packages/13/aa/7a890dfe29c84c9a82064a9fe36079c7c0309c91b70c380dc138f9bea44a/jiter-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:387b22fbfd7a62418d5212b4638026d01723761c75c1c8232a8b8c37c2f1003b", size = 318160 }, + { url = "https://files.pythonhosted.org/packages/6a/38/5888b43fc01102f733f085673c4f0be5a298f69808ec63de55051754e390/jiter-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d8da8629ccae3606c61d9184970423655fb4e33d03330bcdfe52d234d32f69", size = 341259 }, + { url = "https://files.pythonhosted.org/packages/3d/5e/bbdbb63305bcc01006de683b6228cd061458b9b7bb9b8d9bc348a58e5dc2/jiter-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1be73d8982bdc278b7b9377426a4b44ceb5c7952073dd7488e4ae96b88e1103", size = 363730 }, + { url = "https://files.pythonhosted.org/packages/75/85/53a3edc616992fe4af6814c25f91ee3b1e22f7678e979b6ea82d3bc0667e/jiter-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2228eaaaa111ec54b9e89f7481bffb3972e9059301a878d085b2b449fbbde635", size = 405126 }, + { url = "https://files.pythonhosted.org/packages/ae/b3/1ee26b12b2693bd3f0b71d3188e4e5d817b12e3c630a09e099e0a89e28fa/jiter-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11509bfecbc319459647d4ac3fd391d26fdf530dad00c13c4dadabf5b81f01a4", size = 393668 }, + { url = "https://files.pythonhosted.org/packages/11/87/e084ce261950c1861773ab534d49127d1517b629478304d328493f980791/jiter-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f22238da568be8bbd8e0650e12feeb2cfea15eda4f9fc271d3b362a4fa0604d", size = 352350 }, + { url = "https://files.pythonhosted.org/packages/f0/06/7dca84b04987e9df563610aa0bc154ea176e50358af532ab40ffb87434df/jiter-0.9.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17f5d55eb856597607562257c8e36c42bc87f16bef52ef7129b7da11afc779f3", size = 384204 }, + { url = "https://files.pythonhosted.org/packages/16/2f/82e1c6020db72f397dd070eec0c85ebc4df7c88967bc86d3ce9864148f28/jiter-0.9.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6a99bed9fbb02f5bed416d137944419a69aa4c423e44189bc49718859ea83bc5", size = 520322 }, + { url = "https://files.pythonhosted.org/packages/36/fd/4f0cd3abe83ce208991ca61e7e5df915aa35b67f1c0633eb7cf2f2e88ec7/jiter-0.9.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e057adb0cd1bd39606100be0eafe742de2de88c79df632955b9ab53a086b3c8d", size = 512184 }, + { url = "https://files.pythonhosted.org/packages/a0/3c/8a56f6d547731a0b4410a2d9d16bf39c861046f91f57c98f7cab3d2aa9ce/jiter-0.9.0-cp313-cp313-win32.whl", hash = "sha256:f7e6850991f3940f62d387ccfa54d1a92bd4bb9f89690b53aea36b4364bcab53", size = 206504 }, + { url = "https://files.pythonhosted.org/packages/f4/1c/0c996fd90639acda75ed7fa698ee5fd7d80243057185dc2f63d4c1c9f6b9/jiter-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:c8ae3bf27cd1ac5e6e8b7a27487bf3ab5f82318211ec2e1346a5b058756361f7", size = 204943 }, + { url = "https://files.pythonhosted.org/packages/78/0f/77a63ca7aa5fed9a1b9135af57e190d905bcd3702b36aca46a01090d39ad/jiter-0.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0b2827fb88dda2cbecbbc3e596ef08d69bda06c6f57930aec8e79505dc17001", size = 317281 }, + { url = "https://files.pythonhosted.org/packages/f9/39/a3a1571712c2bf6ec4c657f0d66da114a63a2e32b7e4eb8e0b83295ee034/jiter-0.9.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062b756ceb1d40b0b28f326cba26cfd575a4918415b036464a52f08632731e5a", size = 350273 }, + { url = "https://files.pythonhosted.org/packages/ee/47/3729f00f35a696e68da15d64eb9283c330e776f3b5789bac7f2c0c4df209/jiter-0.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", size = 206867 }, + { url = "https://files.pythonhosted.org/packages/aa/2c/9bee940db68d8cefb84178f8b15220c836276db8c6e09cbd422071c01c33/jiter-0.9.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:9ef340fae98065071ccd5805fe81c99c8f80484e820e40043689cf97fb66b3e2", size = 315246 }, + { url = "https://files.pythonhosted.org/packages/d0/9b/42d5d59585d9af4fe207e96c6edac2a62bca26d76e2471e78c2f5da28bb8/jiter-0.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:efb767d92c63b2cd9ec9f24feeb48f49574a713870ec87e9ba0c2c6e9329c3e2", size = 312621 }, + { url = "https://files.pythonhosted.org/packages/2e/a5/a64de757516e5531f8d147a32251905f0e23641738d3520a0a0724fe9651/jiter-0.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:113f30f87fb1f412510c6d7ed13e91422cfd329436364a690c34c8b8bd880c42", size = 343006 }, + { url = "https://files.pythonhosted.org/packages/89/be/08d2bae711200d558ab8c5771f05f47cd09b82b2258a8d6fad0ee2c6a1f3/jiter-0.9.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8793b6df019b988526f5a633fdc7456ea75e4a79bd8396a3373c371fc59f5c9b", size = 365099 }, + { url = "https://files.pythonhosted.org/packages/03/9e/d137a0088be90ba5081f7d5d2383374bd77a1447158e44c3ec4e142f902c/jiter-0.9.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7a9aaa5102dba4e079bb728076fadd5a2dca94c05c04ce68004cfd96f128ea34", size = 407834 }, + { url = "https://files.pythonhosted.org/packages/04/4c/b6bee52a5b327830abea13eba4222f33f88895a1055eff8870ab3ebbde41/jiter-0.9.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d838650f6ebaf4ccadfb04522463e74a4c378d7e667e0eb1865cfe3990bfac49", size = 399255 }, + { url = "https://files.pythonhosted.org/packages/12/b7/364b615a35f99d01cc27d3caea8c3a3ac5451bd5cadf8e5dc4355b102aba/jiter-0.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0194f813efdf4b8865ad5f5c5f50f8566df7d770a82c51ef593d09e0b347020", size = 354142 }, + { url = "https://files.pythonhosted.org/packages/65/cc/5156f75c496aac65080e2995910104d0e46644df1452c20d963cb904b4b1/jiter-0.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7954a401d0a8a0b8bc669199db78af435aae1e3569187c2939c477c53cb6a0a", size = 385142 }, + { url = "https://files.pythonhosted.org/packages/46/cf/370be59c38e56a6fed0308ca266b12d8178b8d6630284cc88ae5af110764/jiter-0.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4feafe787eb8a8d98168ab15637ca2577f6ddf77ac6c8c66242c2d028aa5420e", size = 522035 }, + { url = "https://files.pythonhosted.org/packages/ff/f5/c462d994dcbff43de8a3c953548d609c73a5db8138182408944fce2b68c1/jiter-0.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:27cd1f2e8bb377f31d3190b34e4328d280325ad7ef55c6ac9abde72f79e84d2e", size = 513844 }, + { url = "https://files.pythonhosted.org/packages/15/39/60d8f17de27586fa1e7c8215ead8222556d40a6b96b20f1ad70528961f99/jiter-0.9.0-cp39-cp39-win32.whl", hash = "sha256:161d461dcbe658cf0bd0aa375b30a968b087cdddc624fc585f3867c63c6eca95", size = 207147 }, + { 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 = "markdown" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +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]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, + { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 }, + { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 }, + { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 }, + { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 }, + { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 }, + { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 }, + { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 }, + { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 }, + { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 }, + { 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 = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/44/140469d87379c02f1e1870315f3143718036a983dd0416650827b8883192/mkdocs_autorefs-1.4.1.tar.gz", hash = "sha256:4b5b6235a4becb2b10425c2fa191737e415b37aa3418919db33e5d774c9db079", size = 4131355 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/29/1125f7b11db63e8e32bcfa0752a4eea30abff3ebd0796f808e14571ddaa2/mkdocs_autorefs-1.4.1-py3-none-any.whl", hash = "sha256:9793c5ac06a6ebbe52ec0f8439256e66187badf4b5334b5fde0b128ec134df4f", size = 5782047 }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, +] + +[[package]] +name = "mkdocs-material" +version = "9.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { 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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/d3/12f22de41bdd9e576ddc459b38c651d68edfb840b32acaa1f46ae36845e3/mkdocs_material-9.6.7-py3-none-any.whl", hash = "sha256:8a159e45e80fcaadd9fbeef62cbf928569b93df954d4dc5ba76d46820caf7b47", size = 8696755 }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, +] + +[[package]] +name = "mkdocstrings" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/4d/a9484dc5d926295bdf308f1f6c4f07fcc99735b970591edc414d401fcc91/mkdocstrings-0.29.0.tar.gz", hash = "sha256:3657be1384543ce0ee82112c3e521bbf48e41303aa0c229b9ffcccba057d922e", size = 1212185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/47/eb876dfd84e48f31ff60897d161b309cf6a04ca270155b0662aae562b3fb/mkdocstrings-0.29.0-py3-none-any.whl", hash = "sha256:8ea98358d2006f60befa940fdebbbc88a26b37ecbcded10be726ba359284f73d", size = 1630824 }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { 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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/27/42f8a520111a4dde9722f08ca75d761b68722158b2232b63def061de12a8/mkdocstrings_python-1.16.5-py3-none-any.whl", hash = "sha256:0899a12e356eab8e83720c63e15d0ff51cd96603216c837618de346e086b39ba", size = 451550 }, +] + +[[package]] +name = "mypy" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433 }, + { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472 }, + { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424 }, + { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450 }, + { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765 }, + { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701 }, + { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338 }, + { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540 }, + { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051 }, + { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751 }, + { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783 }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618 }, + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, + { url = "https://files.pythonhosted.org/packages/5a/fa/79cf41a55b682794abe71372151dbbf856e3008f6767057229e6649d294a/mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078", size = 10737129 }, + { url = "https://files.pythonhosted.org/packages/d3/33/dd8feb2597d648de29e3da0a8bf4e1afbda472964d2a4a0052203a6f3594/mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba", size = 9856335 }, + { url = "https://files.pythonhosted.org/packages/e4/b5/74508959c1b06b96674b364ffeb7ae5802646b32929b7701fc6b18447592/mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5", size = 11611935 }, + { url = "https://files.pythonhosted.org/packages/6c/53/da61b9d9973efcd6507183fdad96606996191657fe79701b2c818714d573/mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b", size = 12365827 }, + { url = "https://files.pythonhosted.org/packages/c1/72/965bd9ee89540c79a25778cc080c7e6ef40aa1eeac4d52cec7eae6eb5228/mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2", size = 12541924 }, + { url = "https://files.pythonhosted.org/packages/46/d0/f41645c2eb263e6c77ada7d76f894c580c9ddb20d77f0c24d34273a4dab2/mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980", size = 9271176 }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +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 = "openai" +version = "1.66.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/c5/3c422ca3ccc81c063955e7c20739d7f8f37fea0af865c4a60c81e6225e14/openai-1.66.0.tar.gz", hash = "sha256:8a9e672bc6eadec60a962f0b40d7d1c09050010179c919ed65322e433e2d1025", size = 396819 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/f1/d52960dac9519c9de64593460826a0fe2e19159389ec97ecf3e931d2e6a3/openai-1.66.0-py3-none-any.whl", hash = "sha256:43e4a3c0c066cc5809be4e6aac456a3ebc4ec1848226ef9d1340859ac130d45a", size = 566389 }, +] + +[[package]] +name = "openai-agents" +version = "0.0.1" +source = { editable = "." } +dependencies = [ + { name = "griffe" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "types-requests" }, + { name = "typing-extensions" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "mypy" }, + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, + { name = "rich" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "griffe", specifier = ">=1.5.6,<2" }, + { name = "openai", specifier = ">=1.66.0" }, + { 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" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", specifier = ">=7.6.12" }, + { name = "mkdocs", specifier = ">=1.6.0" }, + { name = "mkdocs-material", specifier = ">=9.6.0" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.28.0" }, + { name = "mypy" }, + { name = "playwright", specifier = "==1.50.0" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "rich" }, + { name = "ruff", specifier = "==0.9.2" }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "playwright" +version = "1.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/5e/068dea3c96e9c09929b45c92cf7e573403b52a89aa463f89b9da9b87b7a4/playwright-1.50.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:f36d754a6c5bd9bf7f14e8f57a2aea6fd08f39ca4c8476481b9c83e299531148", size = 40277564 }, + { url = "https://files.pythonhosted.org/packages/78/85/b3deb3d2add00d2a6ee74bf6f57ccefb30efc400fd1b7b330ba9a3626330/playwright-1.50.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:40f274384591dfd27f2b014596250b2250c843ed1f7f4ef5d2960ecb91b4961e", size = 39521844 }, + { url = "https://files.pythonhosted.org/packages/f3/f6/002b3d98df9c84296fea84f070dc0d87c2270b37f423cf076a913370d162/playwright-1.50.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9922ef9bcd316995f01e220acffd2d37a463b4ad10fd73e388add03841dfa230", size = 40277563 }, + { url = "https://files.pythonhosted.org/packages/b9/63/c9a73736e434df894e484278dddc0bf154312ff8d0f16d516edb790a7d42/playwright-1.50.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:8fc628c492d12b13d1f347137b2ac6c04f98197ff0985ef0403a9a9ee0d39131", size = 45076712 }, + { url = "https://files.pythonhosted.org/packages/bd/2c/a54b5a64cc7d1a62f2d944c5977fb3c88e74d76f5cdc7966e717426bce66/playwright-1.50.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcff35f72db2689a79007aee78f1b0621a22e6e3d6c1f58aaa9ac805bf4497c", size = 44493111 }, + { url = "https://files.pythonhosted.org/packages/2b/4a/047cbb2ffe1249bd7a56441fc3366fb4a8a1f44bc36a9061d10edfda2c86/playwright-1.50.0-py3-none-win32.whl", hash = "sha256:3b906f4d351260016a8c5cc1e003bb341651ae682f62213b50168ed581c7558a", size = 34784543 }, + { url = "https://files.pythonhosted.org/packages/bc/2b/e944e10c9b18e77e43d3bb4d6faa323f6cc27597db37b75bc3fd796adfd5/playwright-1.50.0-py3-none-win_amd64.whl", hash = "sha256:1859423da82de631704d5e3d88602d755462b0906824c1debe140979397d2e8d", size = 34784546 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +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 = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475 }, + { url = "https://files.pythonhosted.org/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279 }, + { url = "https://files.pythonhosted.org/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112 }, + { url = "https://files.pythonhosted.org/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780 }, + { url = "https://files.pythonhosted.org/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943 }, + { url = "https://files.pythonhosted.org/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492 }, + { url = "https://files.pythonhosted.org/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714 }, + { url = "https://files.pythonhosted.org/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163 }, + { url = "https://files.pythonhosted.org/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217 }, + { url = "https://files.pythonhosted.org/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899 }, + { url = "https://files.pythonhosted.org/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726 }, + { url = "https://files.pythonhosted.org/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219 }, + { url = "https://files.pythonhosted.org/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, + { url = "https://files.pythonhosted.org/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733 }, + { url = "https://files.pythonhosted.org/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375 }, + { url = "https://files.pythonhosted.org/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307 }, + { url = "https://files.pythonhosted.org/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971 }, + { url = "https://files.pythonhosted.org/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616 }, + { url = "https://files.pythonhosted.org/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943 }, + { url = "https://files.pythonhosted.org/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654 }, + { url = "https://files.pythonhosted.org/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292 }, + { url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961 }, +] + +[[package]] +name = "pyee" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/37/8fb6e653597b2b67ef552ed49b438d5398ba3b85a9453f8ada0fd77d455c/pyee-12.1.1.tar.gz", hash = "sha256:bbc33c09e2ff827f74191e3e5bbc6be7da02f627b7ec30d86f5ce1a6fb2424a3", size = 30915 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/68/7e150cba9eeffdeb3c5cecdb6896d70c8edd46ce41c0491e12fb2b2256ff/pyee-12.1.1-py3-none-any.whl", hash = "sha256:18a19c650556bb6b32b406d7f017c8f513aceed1ef7ca618fb65de7bd2d347ef", size = 15527 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/44/e6de2fdc880ad0ec7547ca2e087212be815efbc9a425a8d5ba9ede602cbb/pymdown_extensions-10.14.3.tar.gz", hash = "sha256:41e576ce3f5d650be59e900e4ceff231e0aed2a88cf30acaee41e02f063a061b", size = 846846 } +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 = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +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 = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "ruff" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/63/77ecca9d21177600f551d1c58ab0e5a0b260940ea7312195bd2a4798f8a8/ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0", size = 3553799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b9/0e168e4e7fb3af851f739e8f07889b91d1a33a30fca8c29fa3149d6b03ec/ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347", size = 11652408 }, + { url = "https://files.pythonhosted.org/packages/2c/22/08ede5db17cf701372a461d1cb8fdde037da1d4fa622b69ac21960e6237e/ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00", size = 11587553 }, + { url = "https://files.pythonhosted.org/packages/42/05/dedfc70f0bf010230229e33dec6e7b2235b2a1b8cbb2a991c710743e343f/ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4", size = 11020755 }, + { url = "https://files.pythonhosted.org/packages/df/9b/65d87ad9b2e3def67342830bd1af98803af731243da1255537ddb8f22209/ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d", size = 11826502 }, + { url = "https://files.pythonhosted.org/packages/93/02/f2239f56786479e1a89c3da9bc9391120057fc6f4a8266a5b091314e72ce/ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c", size = 11390562 }, + { url = "https://files.pythonhosted.org/packages/c9/37/d3a854dba9931f8cb1b2a19509bfe59e00875f48ade632e95aefcb7a0aee/ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f", size = 12548968 }, + { url = "https://files.pythonhosted.org/packages/fa/c3/c7b812bb256c7a1d5553433e95980934ffa85396d332401f6b391d3c4569/ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684", size = 13187155 }, + { url = "https://files.pythonhosted.org/packages/bd/5a/3c7f9696a7875522b66aa9bba9e326e4e5894b4366bd1dc32aa6791cb1ff/ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d", size = 12704674 }, + { url = "https://files.pythonhosted.org/packages/be/d6/d908762257a96ce5912187ae9ae86792e677ca4f3dc973b71e7508ff6282/ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df", size = 14529328 }, + { url = "https://files.pythonhosted.org/packages/2d/c2/049f1e6755d12d9cd8823242fa105968f34ee4c669d04cac8cea51a50407/ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247", size = 12385955 }, + { url = "https://files.pythonhosted.org/packages/91/5a/a9bdb50e39810bd9627074e42743b00e6dc4009d42ae9f9351bc3dbc28e7/ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e", size = 11810149 }, + { url = "https://files.pythonhosted.org/packages/e5/fd/57df1a0543182f79a1236e82a79c68ce210efb00e97c30657d5bdb12b478/ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe", size = 11479141 }, + { url = "https://files.pythonhosted.org/packages/dc/16/bc3fd1d38974f6775fc152a0554f8c210ff80f2764b43777163c3c45d61b/ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb", size = 12014073 }, + { url = "https://files.pythonhosted.org/packages/47/6b/e4ca048a8f2047eb652e1e8c755f384d1b7944f69ed69066a37acd4118b0/ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a", size = 12435758 }, + { url = "https://files.pythonhosted.org/packages/c2/40/4d3d6c979c67ba24cf183d29f706051a53c36d78358036a9cd21421582ab/ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145", size = 9796916 }, + { url = "https://files.pythonhosted.org/packages/c3/ef/7f548752bdb6867e6939489c87fe4da489ab36191525fadc5cede2a6e8e2/ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5", size = 10773080 }, + { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +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 = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +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-requests" +version = "2.32.0.20250306" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/1a/beaeff79ef9efd186566ba5f0d95b44ae21f6d31e9413bcfbef3489b6ae3/types_requests-2.32.0.20250306.tar.gz", hash = "sha256:0962352694ec5b2f95fda877ee60a159abdf84a0fc6fdace599f20acb41a03d1", size = 23012 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/26/645d89f56004aa0ba3b96fec27793e3c7e62b40982ee069e52568922b6db/types_requests-2.32.0.20250306-py3-none-any.whl", hash = "sha256:25f2cbb5c8710b2022f8bbee7b2b66f319ef14aeea2f35d80f18c9dbf3b60a0b", size = 20673 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +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 = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390 }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389 }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020 }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, + { url = "https://files.pythonhosted.org/packages/05/52/7223011bb760fce8ddc53416beb65b83a3ea6d7d13738dde75eeb2c89679/watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8", size = 96390 }, + { url = "https://files.pythonhosted.org/packages/9c/62/d2b21bc4e706d3a9d467561f487c2938cbd881c69f3808c43ac1ec242391/watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a", size = 88386 }, + { url = "https://files.pythonhosted.org/packages/ea/22/1c90b20eda9f4132e4603a26296108728a8bfe9584b006bd05dd94548853/watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", size = 89017 }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902 }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380 }, + { url = "https://files.pythonhosted.org/packages/5b/79/69f2b0e8d3f2afd462029031baafb1b75d11bb62703f0e1022b2e54d49ee/watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa", size = 87903 }, + { url = "https://files.pythonhosted.org/packages/e2/2b/dc048dd71c2e5f0f7ebc04dd7912981ec45793a03c0dc462438e0591ba5d/watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e", size = 88381 }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { 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 = "zipp" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, +]