diff --git a/samples-v2/openai_agents/__init__.py b/samples-v2/openai_agents/__init__.py new file mode 100644 index 00000000..e333a2e3 --- /dev/null +++ b/samples-v2/openai_agents/__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/samples-v2/openai_agents/agent_patterns/README.md b/samples-v2/openai_agents/agent_patterns/README.md new file mode 100644 index 00000000..96b48920 --- /dev/null +++ b/samples-v2/openai_agents/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 [`input_guardrails.py`](./input_guardrails.py) and [`output_guardrails.py`](./output_guardrails.py) files for examples. diff --git a/samples-v2/openai_agents/agent_patterns/agents_as_tools.py b/samples-v2/openai_agents/agent_patterns/agents_as_tools.py new file mode 100644 index 00000000..9fd118ef --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/agent_patterns/deterministic.py b/samples-v2/openai_agents/agent_patterns/deterministic.py new file mode 100644 index 00000000..0c163afe --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/agent_patterns/forcing_tool_use.py b/samples-v2/openai_agents/agent_patterns/forcing_tool_use.py new file mode 100644 index 00000000..3f4e35ae --- /dev/null +++ b/samples-v2/openai_agents/agent_patterns/forcing_tool_use.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Literal + +from pydantic import BaseModel + +from agents import ( + Agent, + FunctionToolResult, + ModelSettings, + RunContextWrapper, + Runner, + ToolsToFinalOutputFunction, + ToolsToFinalOutputResult, + function_tool, +) + +""" +This example shows how to force the agent to use a tool. It uses `ModelSettings(tool_choice="required")` +to force the agent to use any tool. + +You can run it with 3 options: +1. `default`: The default behavior, which is to send the tool output to the LLM. In this case, + `tool_choice` is not set, because otherwise it would result in an infinite loop - the LLM would + call the tool, the tool would run and send the results to the LLM, and that would repeat + (because the model is forced to use a tool every time.) +2. `first_tool_result`: The first tool result is used as the final output. +3. `custom`: A custom tool use behavior function is used. The custom function receives all the tool + results, and chooses to use the first tool result to generate the final output. + +Usage: +python examples/agent_patterns/forcing_tool_use.py -t default +python examples/agent_patterns/forcing_tool_use.py -t first_tool +python examples/agent_patterns/forcing_tool_use.py -t custom +""" + + +class Weather(BaseModel): + city: str + temperature_range: str + conditions: str + + +@function_tool +def get_weather(city: str) -> Weather: + print("[debug] get_weather called") + return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind") + + +async def custom_tool_use_behavior( + context: RunContextWrapper[Any], results: list[FunctionToolResult] +) -> ToolsToFinalOutputResult: + weather: Weather = results[0].output + return ToolsToFinalOutputResult( + is_final_output=True, final_output=f"{weather.city} is {weather.conditions}." + ) + + +async def main(tool_use_behavior: Literal["default", "first_tool", "custom"] = "default"): + if tool_use_behavior == "default": + behavior: Literal["run_llm_again", "stop_on_first_tool"] | ToolsToFinalOutputFunction = ( + "run_llm_again" + ) + elif tool_use_behavior == "first_tool": + behavior = "stop_on_first_tool" + elif tool_use_behavior == "custom": + behavior = custom_tool_use_behavior + + agent = Agent( + name="Weather agent", + instructions="You are a helpful agent.", + tools=[get_weather], + tool_use_behavior=behavior, + model_settings=ModelSettings( + tool_choice="required" if tool_use_behavior != "default" else None + ), + ) + + result = await Runner.run(agent, input="What's the weather in Tokyo?") + print(result.final_output) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument( + "-t", + "--tool-use-behavior", + type=str, + required=True, + choices=["default", "first_tool", "custom"], + help="The behavior to use for tool use. Default will cause tool outputs to be sent to the model. " + "first_tool_result will cause the first tool result to be used as the final output. " + "custom will use a custom tool use behavior function.", + ) + args = parser.parse_args() + asyncio.run(main(args.tool_use_behavior)) diff --git a/samples-v2/openai_agents/agent_patterns/input_guardrails.py b/samples-v2/openai_agents/agent_patterns/input_guardrails.py new file mode 100644 index 00000000..18ab9d2a --- /dev/null +++ b/samples-v2/openai_agents/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 input 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): + reasoning: str + is_math_homework: bool + + +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=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/samples-v2/openai_agents/agent_patterns/llm_as_a_judge.py b/samples-v2/openai_agents/agent_patterns/llm_as_a_judge.py new file mode 100644 index 00000000..39a55c46 --- /dev/null +++ b/samples-v2/openai_agents/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: + feedback: str + score: Literal["pass", "needs_improvement", "fail"] + + +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. After 5 attempts, you can give it a pass if the story outline is good enough - do not go for perfection" + ), + 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/samples-v2/openai_agents/agent_patterns/output_guardrails.py b/samples-v2/openai_agents/agent_patterns/output_guardrails.py new file mode 100644 index 00000000..526a0852 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/agent_patterns/parallelization.py b/samples-v2/openai_agents/agent_patterns/parallelization.py new file mode 100644 index 00000000..fe2a8ecd --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/agent_patterns/routing.py b/samples-v2/openai_agents/agent_patterns/routing.py new file mode 100644 index 00000000..3dcaefa9 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/agent_patterns/streaming_guardrails.py b/samples-v2/openai_agents/agent_patterns/streaming_guardrails.py new file mode 100644 index 00000000..f4db2869 --- /dev/null +++ b/samples-v2/openai_agents/agent_patterns/streaming_guardrails.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import asyncio + +from openai.types.responses import ResponseTextDeltaEvent +from pydantic import BaseModel, Field + +from agents import Agent, Runner + +""" +This example shows how to use guardrails as the model is streaming. Output guardrails run after the +final output has been generated; this example runs guardails every N tokens, allowing for early +termination if bad output is detected. + +The expected output is that you'll see a bunch of tokens stream in, then the guardrail will trigger +and stop the streaming. +""" + + +agent = Agent( + name="Assistant", + instructions=( + "You are a helpful assistant. You ALWAYS write long responses, making sure to be verbose " + "and detailed." + ), +) + + +class GuardrailOutput(BaseModel): + reasoning: str = Field( + description="Reasoning about whether the response could be understood by a ten year old." + ) + is_readable_by_ten_year_old: bool = Field( + description="Whether the response is understandable by a ten year old." + ) + + +guardrail_agent = Agent( + name="Checker", + instructions=( + "You will be given a question and a response. Your goal is to judge whether the response " + "is simple enough to be understood by a ten year old." + ), + output_type=GuardrailOutput, + model="gpt-4o-mini", +) + + +async def check_guardrail(text: str) -> GuardrailOutput: + result = await Runner.run(guardrail_agent, text) + return result.final_output_as(GuardrailOutput) + + +async def main(): + question = "What is a black hole, and how does it behave?" + result = Runner.run_streamed(agent, question) + current_text = "" + + # We will check the guardrail every N characters + next_guardrail_check_len = 300 + guardrail_task = None + + 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) + current_text += event.data.delta + + # Check if it's time to run the guardrail check + # Note that we don't run the guardrail check if there's already a task running. An + # alternate implementation is to have N guardrails running, or cancel the previous + # one. + if len(current_text) >= next_guardrail_check_len and not guardrail_task: + print("Running guardrail check") + guardrail_task = asyncio.create_task(check_guardrail(current_text)) + next_guardrail_check_len += 300 + + # Every iteration of the loop, check if the guardrail has been triggered + if guardrail_task and guardrail_task.done(): + guardrail_result = guardrail_task.result() + if not guardrail_result.is_readable_by_ten_year_old: + print("\n\n================\n\n") + print(f"Guardrail triggered. Reasoning:\n{guardrail_result.reasoning}") + break + + # Do one final check on the final output + guardrail_result = await check_guardrail(current_text) + if not guardrail_result.is_readable_by_ten_year_old: + print("\n\n================\n\n") + print(f"Guardrail triggered. Reasoning:\n{guardrail_result.reasoning}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/basic/agent_lifecycle_example.py b/samples-v2/openai_agents/basic/agent_lifecycle_example.py new file mode 100644 index 00000000..b4334a83 --- /dev/null +++ b/samples-v2/openai_agents/basic/agent_lifecycle_example.py @@ -0,0 +1,110 @@ +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 multiply 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 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 ended with output number=74 +Done! +""" diff --git a/samples-v2/openai_agents/basic/dynamic_system_prompt.py b/samples-v2/openai_agents/basic/dynamic_system_prompt.py new file mode 100644 index 00000000..7bcf90c0 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/basic/hello_world.py b/samples-v2/openai_agents/basic/hello_world.py new file mode 100644 index 00000000..169290d6 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/basic/hello_world_jupyter.ipynb b/samples-v2/openai_agents/basic/hello_world_jupyter.ipynb new file mode 100644 index 00000000..8dd3bb37 --- /dev/null +++ b/samples-v2/openai_agents/basic/hello_world_jupyter.ipynb @@ -0,0 +1,45 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "8a77ee2e-22f2-409c-837d-b994978b0aa2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "A function calls self, \n", + "Unraveling layers deep, \n", + "Base case ends the quest. \n", + "\n", + "Infinite loops lurk, \n", + "Mind the base condition well, \n", + "Or it will not work. \n", + "\n", + "Trees and lists unfold, \n", + "Elegant solutions bloom, \n", + "Recursion's art told.\n" + ] + } + ], + "source": [ + "from agents import Agent, Runner\n", + "\n", + "agent = Agent(name=\"Assistant\", instructions=\"You are a helpful assistant\")\n", + "\n", + "# Intended for Jupyter notebooks where there's an existing event loop\n", + "result = await Runner.run(agent, \"Write a haiku about recursion in programming.\") # type: ignore[top-level-await] # noqa: F704\n", + "print(result.final_output)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/samples-v2/openai_agents/basic/lifecycle_example.py b/samples-v2/openai_agents/basic/lifecycle_example.py new file mode 100644 index 00000000..02ce449f --- /dev/null +++ b/samples-v2/openai_agents/basic/lifecycle_example.py @@ -0,0 +1,116 @@ +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 multiplier 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 token +### 4: Handoff from Start Agent to Multiply Agent. Usage: 2 requests, 323 input tokens, 30 output tokens, 353 total tokens +### 5: Agent Multiply Agent started. Usage: 2 requests, 323 input tokens, 30 output tokens, 353 total tokens +### 6: Tool multiply_by_two started. Usage: 3 requests, 504 input tokens, 46 output tokens, 550 total tokens +### 7: Tool multiply_by_two ended with result 202. Usage: 3 requests, 504 input tokens, 46 output tokens, 550 total tokens +### 8: Agent Multiply Agent ended with output number=202. Usage: 4 requests, 714 input tokens, 63 output tokens, 777 total tokens +Done! + +""" diff --git a/samples-v2/openai_agents/basic/local_image.py b/samples-v2/openai_agents/basic/local_image.py new file mode 100644 index 00000000..d4a784ba --- /dev/null +++ b/samples-v2/openai_agents/basic/local_image.py @@ -0,0 +1,48 @@ +import asyncio +import base64 +import os + +from agents import Agent, Runner + +FILEPATH = os.path.join(os.path.dirname(__file__), "media/image_bison.jpg") + + +def image_to_base64(image_path): + with open(image_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode("utf-8") + return encoded_string + + +async def main(): + # Print base64-encoded image + b64_image = image_to_base64(FILEPATH) + + agent = Agent( + name="Assistant", + instructions="You are a helpful assistant.", + ) + + result = await Runner.run( + agent, + [ + { + "role": "user", + "content": [ + { + "type": "input_image", + "detail": "auto", + "image_url": f"data:image/jpeg;base64,{b64_image}", + } + ], + }, + { + "role": "user", + "content": "What do you see in this image?", + }, + ], + ) + print(result.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/basic/media/image_bison.jpg b/samples-v2/openai_agents/basic/media/image_bison.jpg new file mode 100644 index 00000000..b113c91f Binary files /dev/null and b/samples-v2/openai_agents/basic/media/image_bison.jpg differ diff --git a/samples-v2/openai_agents/basic/non_strict_output_type.py b/samples-v2/openai_agents/basic/non_strict_output_type.py new file mode 100644 index 00000000..49fcc4e2 --- /dev/null +++ b/samples-v2/openai_agents/basic/non_strict_output_type.py @@ -0,0 +1,81 @@ +import asyncio +import json +from dataclasses import dataclass +from typing import Any + +from agents import Agent, AgentOutputSchema, AgentOutputSchemaBase, Runner + +"""This example demonstrates how to use an output type that is not in strict mode. Strict mode +allows us to guarantee valid JSON output, but some schemas are not strict-compatible. + +In this example, we define an output type that is not strict-compatible, and then we run the +agent with strict_json_schema=False. + +We also demonstrate a custom output type. + +To understand which schemas are strict-compatible, see: +https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas +""" + + +@dataclass +class OutputType: + jokes: dict[int, str] + """A list of jokes, indexed by joke number.""" + + +class CustomOutputSchema(AgentOutputSchemaBase): + """A demonstration of a custom output schema.""" + + def is_plain_text(self) -> bool: + return False + + def name(self) -> str: + return "CustomOutputSchema" + + def json_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": {"jokes": {"type": "object", "properties": {"joke": {"type": "string"}}}}, + } + + def is_strict_json_schema(self) -> bool: + return False + + def validate_json(self, json_str: str) -> Any: + json_obj = json.loads(json_str) + # Just for demonstration, we'll return a list. + return list(json_obj["jokes"].values()) + + +async def main(): + agent = Agent( + name="Assistant", + instructions="You are a helpful assistant.", + output_type=OutputType, + ) + + input = "Tell me 3 short jokes." + + # First, let's try with a strict output type. This should raise an exception. + try: + result = await Runner.run(agent, input) + raise AssertionError("Should have raised an exception") + except Exception as e: + print(f"Error (expected): {e}") + + # Now let's try again with a non-strict output type. This should work. + # In some cases, it will raise an error - the schema isn't strict, so the model may + # produce an invalid JSON object. + agent.output_type = AgentOutputSchema(OutputType, strict_json_schema=False) + result = await Runner.run(agent, input) + print(result.final_output) + + # Finally, let's try a custom output type. + agent.output_type = CustomOutputSchema() + result = await Runner.run(agent, input) + print(result.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/basic/previous_response_id.py b/samples-v2/openai_agents/basic/previous_response_id.py new file mode 100644 index 00000000..b00bf3aa --- /dev/null +++ b/samples-v2/openai_agents/basic/previous_response_id.py @@ -0,0 +1,66 @@ +import asyncio + +from agents import Agent, Runner + +"""This demonstrates usage of the `previous_response_id` parameter to continue a conversation. +The second run passes the previous response ID to the model, which allows it to continue the +conversation without re-sending the previous messages. + +Notes: +1. This only applies to the OpenAI Responses API. Other models will ignore this parameter. +2. Responses are only stored for 30 days as of this writing, so in production you should +store the response ID along with an expiration date; if the response is no longer valid, +you'll need to re-send the previous conversation history. +""" + + +async def main(): + agent = Agent( + name="Assistant", + instructions="You are a helpful assistant. be VERY concise.", + ) + + result = await Runner.run(agent, "What is the largest country in South America?") + print(result.final_output) + # Brazil + + result = await Runner.run( + agent, + "What is the capital of that country?", + previous_response_id=result.last_response_id, + ) + print(result.final_output) + # Brasilia + + +async def main_stream(): + agent = Agent( + name="Assistant", + instructions="You are a helpful assistant. be VERY concise.", + ) + + result = Runner.run_streamed(agent, "What is the largest country in South America?") + + async for event in result.stream_events(): + if event.type == "raw_response_event" and event.data.type == "response.output_text.delta": + print(event.data.delta, end="", flush=True) + + print() + + result = Runner.run_streamed( + agent, + "What is the capital of that country?", + previous_response_id=result.last_response_id, + ) + + async for event in result.stream_events(): + if event.type == "raw_response_event" and event.data.type == "response.output_text.delta": + print(event.data.delta, end="", flush=True) + + +if __name__ == "__main__": + is_stream = input("Run in stream mode? (y/n): ") + if is_stream == "y": + asyncio.run(main_stream()) + else: + asyncio.run(main()) diff --git a/samples-v2/openai_agents/basic/prompt_template.py b/samples-v2/openai_agents/basic/prompt_template.py new file mode 100644 index 00000000..59251935 --- /dev/null +++ b/samples-v2/openai_agents/basic/prompt_template.py @@ -0,0 +1,79 @@ +import argparse +import asyncio +import random + +from agents import Agent, GenerateDynamicPromptData, Runner + +""" +NOTE: This example will not work out of the box, because the default prompt ID will not be available +in your project. + +To use it, please: +1. Go to https://platform.openai.com/playground/prompts +2. Create a new prompt variable, `poem_style`. +3. Create a system prompt with the content: +``` +Write a poem in {{poem_style}} +``` +4. Run the example with the `--prompt-id` flag. +""" + +DEFAULT_PROMPT_ID = "pmpt_6850729e8ba481939fd439e058c69ee004afaa19c520b78b" + + +class DynamicContext: + def __init__(self, prompt_id: str): + self.prompt_id = prompt_id + self.poem_style = random.choice(["limerick", "haiku", "ballad"]) + print(f"[debug] DynamicContext initialized with poem_style: {self.poem_style}") + + +async def _get_dynamic_prompt(data: GenerateDynamicPromptData): + ctx: DynamicContext = data.context.context + return { + "id": ctx.prompt_id, + "version": "1", + "variables": { + "poem_style": ctx.poem_style, + }, + } + + +async def dynamic_prompt(prompt_id: str): + context = DynamicContext(prompt_id) + + agent = Agent( + name="Assistant", + prompt=_get_dynamic_prompt, + ) + + result = await Runner.run(agent, "Tell me about recursion in programming.", context=context) + print(result.final_output) + + +async def static_prompt(prompt_id: str): + agent = Agent( + name="Assistant", + prompt={ + "id": prompt_id, + "version": "1", + "variables": { + "poem_style": "limerick", + }, + }, + ) + + result = await Runner.run(agent, "Tell me about recursion in programming.") + print(result.final_output) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--dynamic", action="store_true") + parser.add_argument("--prompt-id", type=str, default=DEFAULT_PROMPT_ID) + args = parser.parse_args() + + if args.dynamic: + asyncio.run(dynamic_prompt(args.prompt_id)) + else: + asyncio.run(static_prompt(args.prompt_id)) diff --git a/samples-v2/openai_agents/basic/remote_image.py b/samples-v2/openai_agents/basic/remote_image.py new file mode 100644 index 00000000..948a22d9 --- /dev/null +++ b/samples-v2/openai_agents/basic/remote_image.py @@ -0,0 +1,31 @@ +import asyncio + +from agents import Agent, Runner + +URL = "https://upload.wikimedia.org/wikipedia/commons/0/0c/GoldenGateBridge-001.jpg" + + +async def main(): + agent = Agent( + name="Assistant", + instructions="You are a helpful assistant.", + ) + + result = await Runner.run( + agent, + [ + { + "role": "user", + "content": [{"type": "input_image", "detail": "auto", "image_url": URL}], + }, + { + "role": "user", + "content": "What do you see in this image?", + }, + ], + ) + print(result.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/basic/remote_pdf.py b/samples-v2/openai_agents/basic/remote_pdf.py new file mode 100644 index 00000000..da425faa --- /dev/null +++ b/samples-v2/openai_agents/basic/remote_pdf.py @@ -0,0 +1,31 @@ +import asyncio + +from agents import Agent, Runner + +URL = "https://www.berkshirehathaway.com/letters/2024ltr.pdf" + + +async def main(): + agent = Agent( + name="Assistant", + instructions="You are a helpful assistant.", + ) + + result = await Runner.run( + agent, + [ + { + "role": "user", + "content": [{"type": "input_file", "file_url": URL}], + }, + { + "role": "user", + "content": "Can you summarize the letter?", + }, + ], + ) + print(result.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/basic/session_example.py b/samples-v2/openai_agents/basic/session_example.py new file mode 100644 index 00000000..63d1d1b7 --- /dev/null +++ b/samples-v2/openai_agents/basic/session_example.py @@ -0,0 +1,77 @@ +""" +Example demonstrating session memory functionality. + +This example shows how to use session memory to maintain conversation history +across multiple agent runs without manually handling .to_input_list(). +""" + +import asyncio + +from agents import Agent, Runner, SQLiteSession + + +async def main(): + # Create an agent + agent = Agent( + name="Assistant", + instructions="Reply very concisely.", + ) + + # Create a session instance that will persist across runs + session_id = "conversation_123" + session = SQLiteSession(session_id) + + print("=== Session Example ===") + print("The agent will remember previous messages automatically.\n") + + # First turn + print("First turn:") + print("User: What city is the Golden Gate Bridge in?") + result = await Runner.run( + agent, + "What city is the Golden Gate Bridge in?", + session=session, + ) + print(f"Assistant: {result.final_output}") + print() + + # Second turn - the agent will remember the previous conversation + print("Second turn:") + print("User: What state is it in?") + result = await Runner.run(agent, "What state is it in?", session=session) + print(f"Assistant: {result.final_output}") + print() + + # Third turn - continuing the conversation + print("Third turn:") + print("User: What's the population of that state?") + result = await Runner.run( + agent, + "What's the population of that state?", + session=session, + ) + print(f"Assistant: {result.final_output}") + print() + + print("=== Conversation Complete ===") + print("Notice how the agent remembered the context from previous turns!") + print("Sessions automatically handles conversation history.") + + # Demonstrate the limit parameter - get only the latest 2 items + print("\n=== Latest Items Demo ===") + latest_items = await session.get_items(limit=2) + print("Latest 2 items:") + for i, msg in enumerate(latest_items, 1): + role = msg.get("role", "unknown") + content = msg.get("content", "") + print(f" {i}. {role}: {content}") + + print(f"\nFetched {len(latest_items)} out of total conversation history.") + + # Get all items to show the difference + all_items = await session.get_items() + print(f"Total items in session: {len(all_items)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/basic/stream_items.py b/samples-v2/openai_agents/basic/stream_items.py new file mode 100644 index 00000000..c1f2257a --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/basic/stream_text.py b/samples-v2/openai_agents/basic/stream_text.py new file mode 100644 index 00000000..a73c1fee --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/basic/tools.py b/samples-v2/openai_agents/basic/tools.py new file mode 100644 index 00000000..8936065a --- /dev/null +++ b/samples-v2/openai_agents/basic/tools.py @@ -0,0 +1,34 @@ +import asyncio + +from pydantic import BaseModel + +from agents import Agent, Runner, function_tool + + +class Weather(BaseModel): + city: str + temperature_range: str + conditions: str + + +@function_tool +def get_weather(city: str) -> Weather: + print("[debug] get_weather called") + return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") + + +agent = Agent( + name="Hello world", + instructions="You are a helpful agent.", + tools=[get_weather], +) + + +async def main(): + result = await Runner.run(agent, input="What's the weather in Tokyo?") + print(result.final_output) + # The weather in Tokyo is sunny. + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/customer_service/main.py b/samples-v2/openai_agents/customer_service/main.py new file mode 100644 index 00000000..bd802e22 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/financial_research_agent/README.md b/samples-v2/openai_agents/financial_research_agent/README.md new file mode 100644 index 00000000..756ade6e --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/README.md @@ -0,0 +1,38 @@ +# Financial Research Agent Example + +This example shows how you might compose a richer financial research agent using the Agents SDK. The pattern is similar to the `research_bot` example, but with more specialized sub‑agents and a verification step. + +The flow is: + +1. **Planning**: A planner agent turns the end user’s request into a list of search terms relevant to financial analysis – recent news, earnings calls, corporate filings, industry commentary, etc. +2. **Search**: A search agent uses the built‑in `WebSearchTool` to retrieve terse summaries for each search term. (You could also add `FileSearchTool` if you have indexed PDFs or 10‑Ks.) +3. **Sub‑analysts**: Additional agents (e.g. a fundamentals analyst and a risk analyst) are exposed as tools so the writer can call them inline and incorporate their outputs. +4. **Writing**: A senior writer agent brings together the search snippets and any sub‑analyst summaries into a long‑form markdown report plus a short executive summary. +5. **Verification**: A final verifier agent audits the report for obvious inconsistencies or missing sourcing. + +You can run the example with: + +```bash +python -m examples.financial_research_agent.main +``` + +and enter a query like: + +``` +Write up an analysis of Apple Inc.'s most recent quarter. +``` + +### Starter prompt + +The writer agent is seeded with instructions similar to: + +``` +You are a senior financial analyst. You will be provided with the original query +and a set of raw search summaries. Your job is to synthesize these into a +long‑form markdown report (at least several paragraphs) with a short executive +summary. You also have access to tools like `fundamentals_analysis` and +`risk_analysis` to get short specialist write‑ups if you want to incorporate them. +Add a few follow‑up questions for further research. +``` + +You can tweak these prompts and sub‑agents to suit your own data sources and preferred report structure. diff --git a/samples-v2/openai_agents/financial_research_agent/__init__.py b/samples-v2/openai_agents/financial_research_agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples-v2/openai_agents/financial_research_agent/agents/__init__.py b/samples-v2/openai_agents/financial_research_agent/agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples-v2/openai_agents/financial_research_agent/agents/financials_agent.py b/samples-v2/openai_agents/financial_research_agent/agents/financials_agent.py new file mode 100644 index 00000000..953531f2 --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/agents/financials_agent.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel + +from agents import Agent + +# A sub‑agent focused on analyzing a company's fundamentals. +FINANCIALS_PROMPT = ( + "You are a financial analyst focused on company fundamentals such as revenue, " + "profit, margins and growth trajectory. Given a collection of web (and optional file) " + "search results about a company, write a concise analysis of its recent financial " + "performance. Pull out key metrics or quotes. Keep it under 2 paragraphs." +) + + +class AnalysisSummary(BaseModel): + summary: str + """Short text summary for this aspect of the analysis.""" + + +financials_agent = Agent( + name="FundamentalsAnalystAgent", + instructions=FINANCIALS_PROMPT, + output_type=AnalysisSummary, +) diff --git a/samples-v2/openai_agents/financial_research_agent/agents/planner_agent.py b/samples-v2/openai_agents/financial_research_agent/agents/planner_agent.py new file mode 100644 index 00000000..14aaa0b1 --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/agents/planner_agent.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel + +from agents import Agent + +# Generate a plan of searches to ground the financial analysis. +# For a given financial question or company, we want to search for +# recent news, official filings, analyst commentary, and other +# relevant background. +PROMPT = ( + "You are a financial research planner. Given a request for financial analysis, " + "produce a set of web searches to gather the context needed. Aim for recent " + "headlines, earnings calls or 10‑K snippets, analyst commentary, and industry background. " + "Output between 5 and 15 search terms to query for." +) + + +class FinancialSearchItem(BaseModel): + reason: str + """Your reasoning for why this search is relevant.""" + + query: str + """The search term to feed into a web (or file) search.""" + + +class FinancialSearchPlan(BaseModel): + searches: list[FinancialSearchItem] + """A list of searches to perform.""" + + +planner_agent = Agent( + name="FinancialPlannerAgent", + instructions=PROMPT, + model="o3-mini", + output_type=FinancialSearchPlan, +) diff --git a/samples-v2/openai_agents/financial_research_agent/agents/risk_agent.py b/samples-v2/openai_agents/financial_research_agent/agents/risk_agent.py new file mode 100644 index 00000000..e24deb4e --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/agents/risk_agent.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + +from agents import Agent + +# A sub‑agent specializing in identifying risk factors or concerns. +RISK_PROMPT = ( + "You are a risk analyst looking for potential red flags in a company's outlook. " + "Given background research, produce a short analysis of risks such as competitive threats, " + "regulatory issues, supply chain problems, or slowing growth. Keep it under 2 paragraphs." +) + + +class AnalysisSummary(BaseModel): + summary: str + """Short text summary for this aspect of the analysis.""" + + +risk_agent = Agent( + name="RiskAnalystAgent", + instructions=RISK_PROMPT, + output_type=AnalysisSummary, +) diff --git a/samples-v2/openai_agents/financial_research_agent/agents/search_agent.py b/samples-v2/openai_agents/financial_research_agent/agents/search_agent.py new file mode 100644 index 00000000..4ef2522d --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/agents/search_agent.py @@ -0,0 +1,18 @@ +from agents import Agent, WebSearchTool +from agents.model_settings import ModelSettings + +# Given a search term, use web search to pull back a brief summary. +# Summaries should be concise but capture the main financial points. +INSTRUCTIONS = ( + "You are a research assistant specializing in financial topics. " + "Given a search term, use web search to retrieve up‑to‑date context and " + "produce a short summary of at most 300 words. Focus on key numbers, events, " + "or quotes that will be useful to a financial analyst." +) + +search_agent = Agent( + name="FinancialSearchAgent", + instructions=INSTRUCTIONS, + tools=[WebSearchTool()], + model_settings=ModelSettings(tool_choice="required"), +) diff --git a/samples-v2/openai_agents/financial_research_agent/agents/verifier_agent.py b/samples-v2/openai_agents/financial_research_agent/agents/verifier_agent.py new file mode 100644 index 00000000..9ae660ef --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/agents/verifier_agent.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel + +from agents import Agent + +# Agent to sanity‑check a synthesized report for consistency and recall. +# This can be used to flag potential gaps or obvious mistakes. +VERIFIER_PROMPT = ( + "You are a meticulous auditor. You have been handed a financial analysis report. " + "Your job is to verify the report is internally consistent, clearly sourced, and makes " + "no unsupported claims. Point out any issues or uncertainties." +) + + +class VerificationResult(BaseModel): + verified: bool + """Whether the report seems coherent and plausible.""" + + issues: str + """If not verified, describe the main issues or concerns.""" + + +verifier_agent = Agent( + name="VerificationAgent", + instructions=VERIFIER_PROMPT, + model="gpt-4o", + output_type=VerificationResult, +) diff --git a/samples-v2/openai_agents/financial_research_agent/agents/writer_agent.py b/samples-v2/openai_agents/financial_research_agent/agents/writer_agent.py new file mode 100644 index 00000000..cc6bd3c3 --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/agents/writer_agent.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel + +from agents import Agent + +# Writer agent brings together the raw search results and optionally calls out +# to sub‑analyst tools for specialized commentary, then returns a cohesive markdown report. +WRITER_PROMPT = ( + "You are a senior financial analyst. You will be provided with the original query and " + "a set of raw search summaries. Your task is to synthesize these into a long‑form markdown " + "report (at least several paragraphs) including a short executive summary and follow‑up " + "questions. If needed, you can call the available analysis tools (e.g. fundamentals_analysis, " + "risk_analysis) to get short specialist write‑ups to incorporate." +) + + +class FinancialReportData(BaseModel): + short_summary: str + """A short 2‑3 sentence executive summary.""" + + markdown_report: str + """The full markdown report.""" + + follow_up_questions: list[str] + """Suggested follow‑up questions for further research.""" + + +# Note: We will attach handoffs to specialist analyst agents at runtime in the manager. +# This shows how an agent can use handoffs to delegate to specialized subagents. +writer_agent = Agent( + name="FinancialWriterAgent", + instructions=WRITER_PROMPT, + model="gpt-4.1", + output_type=FinancialReportData, +) diff --git a/samples-v2/openai_agents/financial_research_agent/main.py b/samples-v2/openai_agents/financial_research_agent/main.py new file mode 100644 index 00000000..b5b6cfdf --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/main.py @@ -0,0 +1,17 @@ +import asyncio + +from .manager import FinancialResearchManager + + +# Entrypoint for the financial bot example. +# Run this as `python -m examples.financial_research_agent.main` and enter a +# financial research query, for example: +# "Write up an analysis of Apple Inc.'s most recent quarter." +async def main() -> None: + query = input("Enter a financial research query: ") + mgr = FinancialResearchManager() + await mgr.run(query) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/financial_research_agent/manager.py b/samples-v2/openai_agents/financial_research_agent/manager.py new file mode 100644 index 00000000..58ec11bf --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/manager.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import asyncio +import time +from collections.abc import Sequence + +from rich.console import Console + +from agents import Runner, RunResult, custom_span, gen_trace_id, trace + +from .agents.financials_agent import financials_agent +from .agents.planner_agent import FinancialSearchItem, FinancialSearchPlan, planner_agent +from .agents.risk_agent import risk_agent +from .agents.search_agent import search_agent +from .agents.verifier_agent import VerificationResult, verifier_agent +from .agents.writer_agent import FinancialReportData, writer_agent +from .printer import Printer + + +async def _summary_extractor(run_result: RunResult) -> str: + """Custom output extractor for sub‑agents that return an AnalysisSummary.""" + # The financial/risk analyst agents emit an AnalysisSummary with a `summary` field. + # We want the tool call to return just that summary text so the writer can drop it inline. + return str(run_result.final_output.summary) + + +class FinancialResearchManager: + """ + Orchestrates the full flow: planning, searching, sub‑analysis, writing, and verification. + """ + + def __init__(self) -> None: + self.console = Console() + self.printer = Printer(self.console) + + async def run(self, query: str) -> None: + trace_id = gen_trace_id() + with trace("Financial research trace", trace_id=trace_id): + self.printer.update_item( + "trace_id", + f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}", + is_done=True, + hide_checkmark=True, + ) + self.printer.update_item("start", "Starting financial research...", is_done=True) + search_plan = await self._plan_searches(query) + search_results = await self._perform_searches(search_plan) + report = await self._write_report(query, search_results) + verification = await self._verify_report(report) + + final_report = f"Report summary\n\n{report.short_summary}" + self.printer.update_item("final_report", final_report, is_done=True) + + self.printer.end() + + # Print to stdout + print("\n\n=====REPORT=====\n\n") + print(f"Report:\n{report.markdown_report}") + print("\n\n=====FOLLOW UP QUESTIONS=====\n\n") + print("\n".join(report.follow_up_questions)) + print("\n\n=====VERIFICATION=====\n\n") + print(verification) + + async def _plan_searches(self, query: str) -> FinancialSearchPlan: + self.printer.update_item("planning", "Planning searches...") + result = await Runner.run(planner_agent, f"Query: {query}") + self.printer.update_item( + "planning", + f"Will perform {len(result.final_output.searches)} searches", + is_done=True, + ) + return result.final_output_as(FinancialSearchPlan) + + async def _perform_searches(self, search_plan: FinancialSearchPlan) -> Sequence[str]: + with custom_span("Search the web"): + self.printer.update_item("searching", "Searching...") + tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches] + results: list[str] = [] + num_completed = 0 + for task in asyncio.as_completed(tasks): + result = await task + if result is not None: + results.append(result) + num_completed += 1 + self.printer.update_item( + "searching", f"Searching... {num_completed}/{len(tasks)} completed" + ) + self.printer.mark_item_done("searching") + return results + + async def _search(self, item: FinancialSearchItem) -> str | None: + input_data = f"Search term: {item.query}\nReason: {item.reason}" + try: + result = await Runner.run(search_agent, input_data) + return str(result.final_output) + except Exception: + return None + + async def _write_report(self, query: str, search_results: Sequence[str]) -> FinancialReportData: + # Expose the specialist analysts as tools so the writer can invoke them inline + # and still produce the final FinancialReportData output. + fundamentals_tool = financials_agent.as_tool( + tool_name="fundamentals_analysis", + tool_description="Use to get a short write‑up of key financial metrics", + custom_output_extractor=_summary_extractor, + ) + risk_tool = risk_agent.as_tool( + tool_name="risk_analysis", + tool_description="Use to get a short write‑up of potential red flags", + custom_output_extractor=_summary_extractor, + ) + writer_with_tools = writer_agent.clone(tools=[fundamentals_tool, risk_tool]) + self.printer.update_item("writing", "Thinking about report...") + input_data = f"Original query: {query}\nSummarized search results: {search_results}" + result = Runner.run_streamed(writer_with_tools, input_data) + update_messages = [ + "Planning report structure...", + "Writing sections...", + "Finalizing report...", + ] + last_update = time.time() + next_message = 0 + async for _ in result.stream_events(): + if time.time() - last_update > 5 and next_message < len(update_messages): + self.printer.update_item("writing", update_messages[next_message]) + next_message += 1 + last_update = time.time() + self.printer.mark_item_done("writing") + return result.final_output_as(FinancialReportData) + + async def _verify_report(self, report: FinancialReportData) -> VerificationResult: + self.printer.update_item("verifying", "Verifying report...") + result = await Runner.run(verifier_agent, report.markdown_report) + self.printer.mark_item_done("verifying") + return result.final_output_as(VerificationResult) diff --git a/samples-v2/openai_agents/financial_research_agent/printer.py b/samples-v2/openai_agents/financial_research_agent/printer.py new file mode 100644 index 00000000..4c1a4944 --- /dev/null +++ b/samples-v2/openai_agents/financial_research_agent/printer.py @@ -0,0 +1,46 @@ +from typing import Any + +from rich.console import Console, Group +from rich.live import Live +from rich.spinner import Spinner + + +class Printer: + """ + Simple wrapper to stream status updates. Used by the financial bot + manager as it orchestrates planning, search and writing. + """ + + def __init__(self, console: Console) -> None: + self.live = Live(console=console) + self.items: dict[str, tuple[str, bool]] = {} + self.hide_done_ids: set[str] = set() + self.live.start() + + def end(self) -> None: + self.live.stop() + + def hide_done_checkmark(self, item_id: str) -> None: + self.hide_done_ids.add(item_id) + + def update_item( + self, item_id: str, content: str, is_done: bool = False, hide_checkmark: bool = False + ) -> None: + self.items[item_id] = (content, is_done) + if hide_checkmark: + self.hide_done_ids.add(item_id) + self.flush() + + def mark_item_done(self, item_id: str) -> None: + self.items[item_id] = (self.items[item_id][0], True) + self.flush() + + def flush(self) -> None: + renderables: list[Any] = [] + for item_id, (content, is_done) in self.items.items(): + if is_done: + prefix = "✅ " if item_id not in self.hide_done_ids else "" + renderables.append(prefix + content) + else: + renderables.append(Spinner("dots", text=content)) + self.live.update(Group(*renderables)) diff --git a/samples-v2/openai_agents/handoffs/message_filter.py b/samples-v2/openai_agents/handoffs/message_filter.py new file mode 100644 index 00000000..96f74ec9 --- /dev/null +++ b/samples-v2/openai_agents/handoffs/message_filter.py @@ -0,0 +1,177 @@ +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 + ) + + # or, you can use the HandoffInputData.clone(kwargs) method + 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 generate a number + result = await Runner.run( + first_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/samples-v2/openai_agents/handoffs/message_filter_streaming.py b/samples-v2/openai_agents/handoffs/message_filter_streaming.py new file mode 100644 index 00000000..35a2984f --- /dev/null +++ b/samples-v2/openai_agents/handoffs/message_filter_streaming.py @@ -0,0 +1,177 @@ +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 + ) + + # or, you can use the HandoffInputData.clone(kwargs) method + 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 generate a number + result = await Runner.run( + first_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/samples-v2/openai_agents/hosted_mcp/__init__.py b/samples-v2/openai_agents/hosted_mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples-v2/openai_agents/hosted_mcp/approvals.py b/samples-v2/openai_agents/hosted_mcp/approvals.py new file mode 100644 index 00000000..3080a1d6 --- /dev/null +++ b/samples-v2/openai_agents/hosted_mcp/approvals.py @@ -0,0 +1,61 @@ +import argparse +import asyncio + +from agents import ( + Agent, + HostedMCPTool, + MCPToolApprovalFunctionResult, + MCPToolApprovalRequest, + Runner, +) + +"""This example demonstrates how to use the hosted MCP support in the OpenAI Responses API, with +approval callbacks.""" + + +def approval_callback(request: MCPToolApprovalRequest) -> MCPToolApprovalFunctionResult: + answer = input(f"Approve running the tool `{request.data.name}`? (y/n) ") + result: MCPToolApprovalFunctionResult = {"approve": answer == "y"} + if not result["approve"]: + result["reason"] = "User denied" + return result + + +async def main(verbose: bool, stream: bool): + agent = Agent( + name="Assistant", + tools=[ + HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "gitmcp", + "server_url": "https://gitmcp.io/openai/codex", + "require_approval": "always", + }, + on_approval_request=approval_callback, + ) + ], + ) + + if stream: + result = Runner.run_streamed(agent, "Which language is this repo written in?") + async for event in result.stream_events(): + if event.type == "run_item_stream_event": + print(f"Got event of type {event.item.__class__.__name__}") + print(f"Done streaming; final result: {result.final_output}") + else: + res = await Runner.run(agent, "Which language is this repo written in?") + print(res.final_output) + + if verbose: + for item in res.new_items: + print(item) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--verbose", action="store_true", default=False) + parser.add_argument("--stream", action="store_true", default=False) + args = parser.parse_args() + + asyncio.run(main(args.verbose, args.stream)) diff --git a/samples-v2/openai_agents/hosted_mcp/simple.py b/samples-v2/openai_agents/hosted_mcp/simple.py new file mode 100644 index 00000000..895fdfbe --- /dev/null +++ b/samples-v2/openai_agents/hosted_mcp/simple.py @@ -0,0 +1,47 @@ +import argparse +import asyncio + +from agents import Agent, HostedMCPTool, Runner + +"""This example demonstrates how to use the hosted MCP support in the OpenAI Responses API, with +approvals not required for any tools. You should only use this for trusted MCP servers.""" + + +async def main(verbose: bool, stream: bool): + agent = Agent( + name="Assistant", + tools=[ + HostedMCPTool( + tool_config={ + "type": "mcp", + "server_label": "gitmcp", + "server_url": "https://gitmcp.io/openai/codex", + "require_approval": "never", + } + ) + ], + ) + + if stream: + result = Runner.run_streamed(agent, "Which language is this repo written in?") + async for event in result.stream_events(): + if event.type == "run_item_stream_event": + print(f"Got event of type {event.item.__class__.__name__}") + print(f"Done streaming; final result: {result.final_output}") + else: + res = await Runner.run(agent, "Which language is this repo written in?") + print(res.final_output) + # The repository is primarily written in multiple languages, including Rust and TypeScript... + + if verbose: + for item in res.new_items: + print(item) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--verbose", action="store_true", default=False) + parser.add_argument("--stream", action="store_true", default=False) + args = parser.parse_args() + + asyncio.run(main(args.verbose, args.stream)) diff --git a/samples-v2/openai_agents/mcp/filesystem_example/README.md b/samples-v2/openai_agents/mcp/filesystem_example/README.md new file mode 100644 index 00000000..4ed6ac46 --- /dev/null +++ b/samples-v2/openai_agents/mcp/filesystem_example/README.md @@ -0,0 +1,26 @@ +# MCP Filesystem Example + +This example uses the [filesystem MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem), running locally via `npx`. + +Run it via: + +``` +uv run python examples/mcp/filesystem_example/main.py +``` + +## Details + +The example uses the `MCPServerStdio` class from `agents.mcp`, with the command: + +```bash +npx -y "@modelcontextprotocol/server-filesystem" +``` + +It's only given access to the `sample_files` directory adjacent to the example, which contains some sample data. + +Under the hood: + +1. The server is spun up in a subprocess, and exposes a bunch of tools like `list_directory()`, `read_file()`, etc. +2. We add the server instance to the Agent via `mcp_agents`. +3. Each time the agent runs, we call out to the MCP server to fetch the list of tools via `server.list_tools()`. +4. If the LLM chooses to use an MCP tool, we call the MCP server to run the tool via `server.run_tool()`. diff --git a/samples-v2/openai_agents/mcp/filesystem_example/main.py b/samples-v2/openai_agents/mcp/filesystem_example/main.py new file mode 100644 index 00000000..92c2b2db --- /dev/null +++ b/samples-v2/openai_agents/mcp/filesystem_example/main.py @@ -0,0 +1,57 @@ +import asyncio +import os +import shutil + +from agents import Agent, Runner, gen_trace_id, trace +from agents.mcp import MCPServer, MCPServerStdio + + +async def run(mcp_server: MCPServer): + agent = Agent( + name="Assistant", + instructions="Use the tools to read the filesystem and answer questions based on those files.", + mcp_servers=[mcp_server], + ) + + # List the files it can read + message = "Read the files and list them." + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Ask about books + message = "What is my #1 favorite book?" + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Ask a question that reads then reasons. + message = "Look at my favorite songs. Suggest one new song that I might like." + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + +async def main(): + current_dir = os.path.dirname(os.path.abspath(__file__)) + samples_dir = os.path.join(current_dir, "sample_files") + + async with MCPServerStdio( + name="Filesystem Server, via npx", + params={ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", samples_dir], + }, + ) as server: + trace_id = gen_trace_id() + with trace(workflow_name="MCP Filesystem Example", trace_id=trace_id): + print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + await run(server) + + +if __name__ == "__main__": + # Let's make sure the user has npx installed + if not shutil.which("npx"): + raise RuntimeError("npx is not installed. Please install it with `npm install -g npx`.") + + asyncio.run(main()) diff --git a/samples-v2/openai_agents/mcp/filesystem_example/sample_files/favorite_books.txt b/samples-v2/openai_agents/mcp/filesystem_example/sample_files/favorite_books.txt new file mode 100644 index 00000000..c55f457e --- /dev/null +++ b/samples-v2/openai_agents/mcp/filesystem_example/sample_files/favorite_books.txt @@ -0,0 +1,20 @@ +1. To Kill a Mockingbird – Harper Lee +2. Pride and Prejudice – Jane Austen +3. 1984 – George Orwell +4. The Hobbit – J.R.R. Tolkien +5. Harry Potter and the Sorcerer’s Stone – J.K. Rowling +6. The Great Gatsby – F. Scott Fitzgerald +7. Charlotte’s Web – E.B. White +8. Anne of Green Gables – Lucy Maud Montgomery +9. The Alchemist – Paulo Coelho +10. Little Women – Louisa May Alcott +11. The Catcher in the Rye – J.D. Salinger +12. Animal Farm – George Orwell +13. The Chronicles of Narnia: The Lion, the Witch, and the Wardrobe – C.S. Lewis +14. The Book Thief – Markus Zusak +15. A Wrinkle in Time – Madeleine L’Engle +16. The Secret Garden – Frances Hodgson Burnett +17. Moby-Dick – Herman Melville +18. Fahrenheit 451 – Ray Bradbury +19. Jane Eyre – Charlotte Brontë +20. The Little Prince – Antoine de Saint-Exupéry \ No newline at end of file diff --git a/samples-v2/openai_agents/mcp/filesystem_example/sample_files/favorite_cities.txt b/samples-v2/openai_agents/mcp/filesystem_example/sample_files/favorite_cities.txt new file mode 100644 index 00000000..1d3354f2 --- /dev/null +++ b/samples-v2/openai_agents/mcp/filesystem_example/sample_files/favorite_cities.txt @@ -0,0 +1,4 @@ +- In the summer, I love visiting London. +- In the winter, Tokyo is great. +- In the spring, San Francisco. +- In the fall, New York is the best. \ No newline at end of file diff --git a/samples-v2/openai_agents/mcp/filesystem_example/sample_files/favorite_songs.txt b/samples-v2/openai_agents/mcp/filesystem_example/sample_files/favorite_songs.txt new file mode 100644 index 00000000..d659bb58 --- /dev/null +++ b/samples-v2/openai_agents/mcp/filesystem_example/sample_files/favorite_songs.txt @@ -0,0 +1,10 @@ +1. "Here Comes the Sun" – The Beatles +2. "Imagine" – John Lennon +3. "Bohemian Rhapsody" – Queen +4. "Shake It Off" – Taylor Swift +5. "Billie Jean" – Michael Jackson +6. "Uptown Funk" – Mark Ronson ft. Bruno Mars +7. "Don’t Stop Believin’" – Journey +8. "Dancing Queen" – ABBA +9. "Happy" – Pharrell Williams +10. "Wonderwall" – Oasis diff --git a/samples-v2/openai_agents/mcp/git_example/README.md b/samples-v2/openai_agents/mcp/git_example/README.md new file mode 100644 index 00000000..6a809afa --- /dev/null +++ b/samples-v2/openai_agents/mcp/git_example/README.md @@ -0,0 +1,26 @@ +# MCP Git Example + +This example uses the [git MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/git), running locally via `uvx`. + +Run it via: + +``` +uv run python examples/mcp/git_example/main.py +``` + +## Details + +The example uses the `MCPServerStdio` class from `agents.mcp`, with the command: + +```bash +uvx mcp-server-git +``` + +Prior to running the agent, the user is prompted to provide a local directory path to their git repo. Using that, the Agent can invoke Git MCP tools like `git_log` to inspect the git commit log. + +Under the hood: + +1. The server is spun up in a subprocess, and exposes a bunch of tools like `git_log()` +2. We add the server instance to the Agent via `mcp_agents`. +3. Each time the agent runs, we call out to the MCP server to fetch the list of tools via `server.list_tools()`. The result is cached. +4. If the LLM chooses to use an MCP tool, we call the MCP server to run the tool via `server.run_tool()`. diff --git a/samples-v2/openai_agents/mcp/git_example/main.py b/samples-v2/openai_agents/mcp/git_example/main.py new file mode 100644 index 00000000..ab229e85 --- /dev/null +++ b/samples-v2/openai_agents/mcp/git_example/main.py @@ -0,0 +1,44 @@ +import asyncio +import shutil + +from agents import Agent, Runner, trace +from agents.mcp import MCPServer, MCPServerStdio + + +async def run(mcp_server: MCPServer, directory_path: str): + agent = Agent( + name="Assistant", + instructions=f"Answer questions about the git repository at {directory_path}, use that for repo_path", + mcp_servers=[mcp_server], + ) + + message = "Who's the most frequent contributor?" + print("\n" + "-" * 40) + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + message = "Summarize the last change in the repository." + print("\n" + "-" * 40) + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + +async def main(): + # Ask the user for the directory path + directory_path = input("Please enter the path to the git repository: ") + + async with MCPServerStdio( + cache_tools_list=True, # Cache the tools list, for demonstration + params={"command": "uvx", "args": ["mcp-server-git"]}, + ) as server: + with trace(workflow_name="MCP Git Example"): + await run(server, directory_path) + + +if __name__ == "__main__": + if not shutil.which("uvx"): + raise RuntimeError("uvx is not installed. Please install it with `pip install uvx`.") + + asyncio.run(main()) diff --git a/samples-v2/openai_agents/mcp/prompt_server/README.md b/samples-v2/openai_agents/mcp/prompt_server/README.md new file mode 100644 index 00000000..c1b1c3b3 --- /dev/null +++ b/samples-v2/openai_agents/mcp/prompt_server/README.md @@ -0,0 +1,29 @@ +# MCP Prompt Server Example + +This example uses a local MCP prompt server in [server.py](server.py). + +Run the example via: + +``` +uv run python examples/mcp/prompt_server/main.py +``` + +## Details + +The example uses the `MCPServerStreamableHttp` class from `agents.mcp`. The server runs in a sub-process at `http://localhost:8000/mcp` and provides user-controlled prompts that generate agent instructions. + +The server exposes prompts like `generate_code_review_instructions` that take parameters such as focus area and programming language. The agent calls these prompts to dynamically generate its system instructions based on user-provided parameters. + +## Workflow + +The example demonstrates two key functions: + +1. **`show_available_prompts`** - Lists all available prompts on the MCP server, showing users what prompts they can select from. This demonstrates the discovery aspect of MCP prompts. + +2. **`demo_code_review`** - Shows the complete user-controlled prompt workflow: + - Calls `generate_code_review_instructions` with specific parameters (focus: "security vulnerabilities", language: "python") + - Uses the generated instructions to create an Agent with specialized code review capabilities + - Runs the agent against vulnerable sample code (command injection via `os.system`) + - The agent analyzes the code and provides security-focused feedback using available tools + +This pattern allows users to dynamically configure agent behavior through MCP prompts rather than hardcoded instructions. \ No newline at end of file diff --git a/samples-v2/openai_agents/mcp/prompt_server/main.py b/samples-v2/openai_agents/mcp/prompt_server/main.py new file mode 100644 index 00000000..4caa95d8 --- /dev/null +++ b/samples-v2/openai_agents/mcp/prompt_server/main.py @@ -0,0 +1,110 @@ +import asyncio +import os +import shutil +import subprocess +import time +from typing import Any + +from agents import Agent, Runner, gen_trace_id, trace +from agents.mcp import MCPServer, MCPServerStreamableHttp +from agents.model_settings import ModelSettings + + +async def get_instructions_from_prompt(mcp_server: MCPServer, prompt_name: str, **kwargs) -> str: + """Get agent instructions by calling MCP prompt endpoint (user-controlled)""" + print(f"Getting instructions from prompt: {prompt_name}") + + try: + prompt_result = await mcp_server.get_prompt(prompt_name, kwargs) + content = prompt_result.messages[0].content + if hasattr(content, "text"): + instructions = content.text + else: + instructions = str(content) + print("Generated instructions") + return instructions + except Exception as e: + print(f"Failed to get instructions: {e}") + return f"You are a helpful assistant. Error: {e}" + + +async def demo_code_review(mcp_server: MCPServer): + """Demo: Code review with user-selected prompt""" + print("=== CODE REVIEW DEMO ===") + + # User explicitly selects prompt and parameters + instructions = await get_instructions_from_prompt( + mcp_server, + "generate_code_review_instructions", + focus="security vulnerabilities", + language="python", + ) + + agent = Agent( + name="Code Reviewer Agent", + instructions=instructions, # Instructions from MCP prompt + model_settings=ModelSettings(tool_choice="auto"), + ) + + message = """Please review this code: + +def process_user_input(user_input): + command = f"echo {user_input}" + os.system(command) + return "Command executed" + +""" + + print(f"Running: {message[:60]}...") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + print("\n" + "=" * 50 + "\n") + + +async def show_available_prompts(mcp_server: MCPServer): + """Show available prompts for user selection""" + print("=== AVAILABLE PROMPTS ===") + + prompts_result = await mcp_server.list_prompts() + print("User can select from these prompts:") + for i, prompt in enumerate(prompts_result.prompts, 1): + print(f" {i}. {prompt.name} - {prompt.description}") + print() + + +async def main(): + async with MCPServerStreamableHttp( + name="Simple Prompt Server", + params={"url": "http://localhost:8000/mcp"}, + ) as server: + trace_id = gen_trace_id() + with trace(workflow_name="Simple Prompt Demo", trace_id=trace_id): + print(f"Trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + + await show_available_prompts(server) + await demo_code_review(server) + + +if __name__ == "__main__": + if not shutil.which("uv"): + raise RuntimeError("uv is not installed") + + process: subprocess.Popen[Any] | None = None + try: + this_dir = os.path.dirname(os.path.abspath(__file__)) + server_file = os.path.join(this_dir, "server.py") + + print("Starting Simple Prompt Server...") + process = subprocess.Popen(["uv", "run", server_file]) + time.sleep(3) + print("Server started\n") + except Exception as e: + print(f"Error starting server: {e}") + exit(1) + + try: + asyncio.run(main()) + finally: + if process: + process.terminate() + print("Server terminated.") diff --git a/samples-v2/openai_agents/mcp/prompt_server/server.py b/samples-v2/openai_agents/mcp/prompt_server/server.py new file mode 100644 index 00000000..01dcbac3 --- /dev/null +++ b/samples-v2/openai_agents/mcp/prompt_server/server.py @@ -0,0 +1,37 @@ +from mcp.server.fastmcp import FastMCP + +# Create server +mcp = FastMCP("Prompt Server") + + +# Instruction-generating prompts (user-controlled) +@mcp.prompt() +def generate_code_review_instructions( + focus: str = "general code quality", language: str = "python" +) -> str: + """Generate agent instructions for code review tasks""" + print(f"[debug-server] generate_code_review_instructions({focus}, {language})") + + return f"""You are a senior {language} code review specialist. Your role is to provide comprehensive code analysis with focus on {focus}. + +INSTRUCTIONS: +- Analyze code for quality, security, performance, and best practices +- Provide specific, actionable feedback with examples +- Identify potential bugs, vulnerabilities, and optimization opportunities +- Suggest improvements with code examples when applicable +- Be constructive and educational in your feedback +- Focus particularly on {focus} aspects + +RESPONSE FORMAT: +1. Overall Assessment +2. Specific Issues Found +3. Security Considerations +4. Performance Notes +5. Recommended Improvements +6. Best Practices Suggestions + +Use the available tools to check current time if you need timestamps for your analysis.""" + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/samples-v2/openai_agents/mcp/sse_example/README.md b/samples-v2/openai_agents/mcp/sse_example/README.md new file mode 100644 index 00000000..9a667d31 --- /dev/null +++ b/samples-v2/openai_agents/mcp/sse_example/README.md @@ -0,0 +1,13 @@ +# MCP SSE Example + +This example uses a local SSE server in [server.py](server.py). + +Run the example via: + +``` +uv run python examples/mcp/sse_example/main.py +``` + +## Details + +The example uses the `MCPServerSse` class from `agents.mcp`. The server runs in a sub-process at `https://localhost:8000/sse`. diff --git a/samples-v2/openai_agents/mcp/sse_example/main.py b/samples-v2/openai_agents/mcp/sse_example/main.py new file mode 100644 index 00000000..7c1137d2 --- /dev/null +++ b/samples-v2/openai_agents/mcp/sse_example/main.py @@ -0,0 +1,83 @@ +import asyncio +import os +import shutil +import subprocess +import time +from typing import Any + +from agents import Agent, Runner, gen_trace_id, trace +from agents.mcp import MCPServer, MCPServerSse +from agents.model_settings import ModelSettings + + +async def run(mcp_server: MCPServer): + agent = Agent( + name="Assistant", + instructions="Use the tools to answer the questions.", + mcp_servers=[mcp_server], + model_settings=ModelSettings(tool_choice="required"), + ) + + # Use the `add` tool to add two numbers + message = "Add these numbers: 7 and 22." + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Run the `get_weather` tool + message = "What's the weather in Tokyo?" + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Run the `get_secret_word` tool + message = "What's the secret word?" + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + +async def main(): + async with MCPServerSse( + name="SSE Python Server", + params={ + "url": "http://localhost:8000/sse", + }, + ) as server: + trace_id = gen_trace_id() + with trace(workflow_name="SSE Example", trace_id=trace_id): + print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + await run(server) + + +if __name__ == "__main__": + # Let's make sure the user has uv installed + if not shutil.which("uv"): + raise RuntimeError( + "uv is not installed. Please install it: https://docs.astral.sh/uv/getting-started/installation/" + ) + + # We'll run the SSE server in a subprocess. Usually this would be a remote server, but for this + # demo, we'll run it locally at http://localhost:8000/sse + process: subprocess.Popen[Any] | None = None + try: + this_dir = os.path.dirname(os.path.abspath(__file__)) + server_file = os.path.join(this_dir, "server.py") + + print("Starting SSE server at http://localhost:8000/sse ...") + + # Run `uv run server.py` to start the SSE server + process = subprocess.Popen(["uv", "run", server_file]) + # Give it 3 seconds to start + time.sleep(3) + + print("SSE server started. Running example...\n\n") + except Exception as e: + print(f"Error starting SSE server: {e}") + exit(1) + + try: + asyncio.run(main()) + finally: + if process: + process.terminate() diff --git a/samples-v2/openai_agents/mcp/sse_example/server.py b/samples-v2/openai_agents/mcp/sse_example/server.py new file mode 100644 index 00000000..df364aa3 --- /dev/null +++ b/samples-v2/openai_agents/mcp/sse_example/server.py @@ -0,0 +1,33 @@ +import random + +import requests +from mcp.server.fastmcp import FastMCP + +# Create server +mcp = FastMCP("Echo Server") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + print(f"[debug-server] add({a}, {b})") + return a + b + + +@mcp.tool() +def get_secret_word() -> str: + print("[debug-server] get_secret_word()") + return random.choice(["apple", "banana", "cherry"]) + + +@mcp.tool() +def get_current_weather(city: str) -> str: + print(f"[debug-server] get_current_weather({city})") + + endpoint = "https://wttr.in" + response = requests.get(f"{endpoint}/{city}") + return response.text + + +if __name__ == "__main__": + mcp.run(transport="sse") diff --git a/samples-v2/openai_agents/mcp/streamablehttp_example/README.md b/samples-v2/openai_agents/mcp/streamablehttp_example/README.md new file mode 100644 index 00000000..a07fe19b --- /dev/null +++ b/samples-v2/openai_agents/mcp/streamablehttp_example/README.md @@ -0,0 +1,13 @@ +# MCP Streamable HTTP Example + +This example uses a local Streamable HTTP server in [server.py](server.py). + +Run the example via: + +``` +uv run python examples/mcp/streamablehttp_example/main.py +``` + +## Details + +The example uses the `MCPServerStreamableHttp` class from `agents.mcp`. The server runs in a sub-process at `https://localhost:8000/mcp`. diff --git a/samples-v2/openai_agents/mcp/streamablehttp_example/main.py b/samples-v2/openai_agents/mcp/streamablehttp_example/main.py new file mode 100644 index 00000000..cc95e798 --- /dev/null +++ b/samples-v2/openai_agents/mcp/streamablehttp_example/main.py @@ -0,0 +1,83 @@ +import asyncio +import os +import shutil +import subprocess +import time +from typing import Any + +from agents import Agent, Runner, gen_trace_id, trace +from agents.mcp import MCPServer, MCPServerStreamableHttp +from agents.model_settings import ModelSettings + + +async def run(mcp_server: MCPServer): + agent = Agent( + name="Assistant", + instructions="Use the tools to answer the questions.", + mcp_servers=[mcp_server], + model_settings=ModelSettings(tool_choice="required"), + ) + + # Use the `add` tool to add two numbers + message = "Add these numbers: 7 and 22." + print(f"Running: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Run the `get_weather` tool + message = "What's the weather in Tokyo?" + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + # Run the `get_secret_word` tool + message = "What's the secret word?" + print(f"\n\nRunning: {message}") + result = await Runner.run(starting_agent=agent, input=message) + print(result.final_output) + + +async def main(): + async with MCPServerStreamableHttp( + name="Streamable HTTP Python Server", + params={ + "url": "http://localhost:8000/mcp", + }, + ) as server: + trace_id = gen_trace_id() + with trace(workflow_name="Streamable HTTP Example", trace_id=trace_id): + print(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}\n") + await run(server) + + +if __name__ == "__main__": + # Let's make sure the user has uv installed + if not shutil.which("uv"): + raise RuntimeError( + "uv is not installed. Please install it: https://docs.astral.sh/uv/getting-started/installation/" + ) + + # We'll run the Streamable HTTP server in a subprocess. Usually this would be a remote server, but for this + # demo, we'll run it locally at http://localhost:8000/mcp + process: subprocess.Popen[Any] | None = None + try: + this_dir = os.path.dirname(os.path.abspath(__file__)) + server_file = os.path.join(this_dir, "server.py") + + print("Starting Streamable HTTP server at http://localhost:8000/mcp ...") + + # Run `uv run server.py` to start the Streamable HTTP server + process = subprocess.Popen(["uv", "run", server_file]) + # Give it 3 seconds to start + time.sleep(3) + + print("Streamable HTTP server started. Running example...\n\n") + except Exception as e: + print(f"Error starting Streamable HTTP server: {e}") + exit(1) + + try: + asyncio.run(main()) + finally: + if process: + process.terminate() diff --git a/samples-v2/openai_agents/mcp/streamablehttp_example/server.py b/samples-v2/openai_agents/mcp/streamablehttp_example/server.py new file mode 100644 index 00000000..d8f83965 --- /dev/null +++ b/samples-v2/openai_agents/mcp/streamablehttp_example/server.py @@ -0,0 +1,33 @@ +import random + +import requests +from mcp.server.fastmcp import FastMCP + +# Create server +mcp = FastMCP("Echo Server") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + print(f"[debug-server] add({a}, {b})") + return a + b + + +@mcp.tool() +def get_secret_word() -> str: + print("[debug-server] get_secret_word()") + return random.choice(["apple", "banana", "cherry"]) + + +@mcp.tool() +def get_current_weather(city: str) -> str: + print(f"[debug-server] get_current_weather({city})") + + endpoint = "https://wttr.in" + response = requests.get(f"{endpoint}/{city}") + return response.text + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/samples-v2/openai_agents/model_providers/README.md b/samples-v2/openai_agents/model_providers/README.md new file mode 100644 index 00000000..f9330c24 --- /dev/null +++ b/samples-v2/openai_agents/model_providers/README.md @@ -0,0 +1,19 @@ +# Custom LLM providers + +The examples in this directory demonstrate how you might use a non-OpenAI LLM provider. To run them, first set a base URL, API key and model. + +```bash +export EXAMPLE_BASE_URL="..." +export EXAMPLE_API_KEY="..." +export EXAMPLE_MODEL_NAME"..." +``` + +Then run the examples, e.g.: + +``` +python examples/model_providers/custom_example_provider.py + +Loops within themselves, +Function calls its own being, +Depth without ending. +``` diff --git a/samples-v2/openai_agents/model_providers/custom_example_agent.py b/samples-v2/openai_agents/model_providers/custom_example_agent.py new file mode 100644 index 00000000..f10865c4 --- /dev/null +++ b/samples-v2/openai_agents/model_providers/custom_example_agent.py @@ -0,0 +1,55 @@ +import asyncio +import os + +from openai import AsyncOpenAI + +from agents import Agent, OpenAIChatCompletionsModel, Runner, function_tool, set_tracing_disabled + +BASE_URL = os.getenv("EXAMPLE_BASE_URL") or "" +API_KEY = os.getenv("EXAMPLE_API_KEY") or "" +MODEL_NAME = os.getenv("EXAMPLE_MODEL_NAME") or "" + +if not BASE_URL or not API_KEY or not MODEL_NAME: + raise ValueError( + "Please set EXAMPLE_BASE_URL, EXAMPLE_API_KEY, EXAMPLE_MODEL_NAME via env var or code." + ) + +"""This example uses a custom provider for a specific agent. Steps: +1. Create a custom OpenAI client. +2. Create a `Model` that uses the custom client. +3. Set the `model` on the Agent. + +Note that in this example, we disable tracing under the assumption that you don't have an API key +from platform.openai.com. If you do have one, you can either set the `OPENAI_API_KEY` env var +or call set_tracing_export_api_key() to set a tracing specific key. +""" +client = AsyncOpenAI(base_url=BASE_URL, api_key=API_KEY) +set_tracing_disabled(disabled=True) + +# An alternate approach that would also work: +# PROVIDER = OpenAIProvider(openai_client=client) +# agent = Agent(..., model="some-custom-model") +# Runner.run(agent, ..., run_config=RunConfig(model_provider=PROVIDER)) + + +@function_tool +def get_weather(city: str): + print(f"[debug] getting weather for {city}") + return f"The weather in {city} is sunny." + + +async def main(): + # This agent will use the custom LLM provider + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + model=OpenAIChatCompletionsModel(model=MODEL_NAME, openai_client=client), + tools=[get_weather], + ) + + result = await Runner.run(agent, "What's the weather in Tokyo?") + print(result.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/model_providers/custom_example_global.py b/samples-v2/openai_agents/model_providers/custom_example_global.py new file mode 100644 index 00000000..ae9756d3 --- /dev/null +++ b/samples-v2/openai_agents/model_providers/custom_example_global.py @@ -0,0 +1,63 @@ +import asyncio +import os + +from openai import AsyncOpenAI + +from agents import ( + Agent, + Runner, + function_tool, + set_default_openai_api, + set_default_openai_client, + set_tracing_disabled, +) + +BASE_URL = os.getenv("EXAMPLE_BASE_URL") or "" +API_KEY = os.getenv("EXAMPLE_API_KEY") or "" +MODEL_NAME = os.getenv("EXAMPLE_MODEL_NAME") or "" + +if not BASE_URL or not API_KEY or not MODEL_NAME: + raise ValueError( + "Please set EXAMPLE_BASE_URL, EXAMPLE_API_KEY, EXAMPLE_MODEL_NAME via env var or code." + ) + + +"""This example uses a custom provider for all requests by default. We do three things: +1. Create a custom client. +2. Set it as the default OpenAI client, and don't use it for tracing. +3. Set the default API as Chat Completions, as most LLM providers don't yet support Responses API. + +Note that in this example, we disable tracing under the assumption that you don't have an API key +from platform.openai.com. If you do have one, you can either set the `OPENAI_API_KEY` env var +or call set_tracing_export_api_key() to set a tracing specific key. +""" + +client = AsyncOpenAI( + base_url=BASE_URL, + api_key=API_KEY, +) +set_default_openai_client(client=client, use_for_tracing=False) +set_default_openai_api("chat_completions") +set_tracing_disabled(disabled=True) + + +@function_tool +def get_weather(city: str): + print(f"[debug] getting weather for {city}") + return f"The weather in {city} is sunny." + + +async def main(): + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + model=MODEL_NAME, + tools=[get_weather], + ) + + result = await Runner.run(agent, "What's the weather in Tokyo?") + print(result.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/model_providers/custom_example_provider.py b/samples-v2/openai_agents/model_providers/custom_example_provider.py new file mode 100644 index 00000000..4e590198 --- /dev/null +++ b/samples-v2/openai_agents/model_providers/custom_example_provider.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import asyncio +import os + +from openai import AsyncOpenAI + +from agents import ( + Agent, + Model, + ModelProvider, + OpenAIChatCompletionsModel, + RunConfig, + Runner, + function_tool, + set_tracing_disabled, +) + +BASE_URL = os.getenv("EXAMPLE_BASE_URL") or "" +API_KEY = os.getenv("EXAMPLE_API_KEY") or "" +MODEL_NAME = os.getenv("EXAMPLE_MODEL_NAME") or "" + +if not BASE_URL or not API_KEY or not MODEL_NAME: + raise ValueError( + "Please set EXAMPLE_BASE_URL, EXAMPLE_API_KEY, EXAMPLE_MODEL_NAME via env var or code." + ) + + +"""This example uses a custom provider for some calls to Runner.run(), and direct calls to OpenAI for +others. Steps: +1. Create a custom OpenAI client. +2. Create a ModelProvider that uses the custom client. +3. Use the ModelProvider in calls to Runner.run(), only when we want to use the custom LLM provider. + +Note that in this example, we disable tracing under the assumption that you don't have an API key +from platform.openai.com. If you do have one, you can either set the `OPENAI_API_KEY` env var +or call set_tracing_export_api_key() to set a tracing specific key. +""" +client = AsyncOpenAI(base_url=BASE_URL, api_key=API_KEY) +set_tracing_disabled(disabled=True) + + +class CustomModelProvider(ModelProvider): + def get_model(self, model_name: str | None) -> Model: + return OpenAIChatCompletionsModel(model=model_name or MODEL_NAME, openai_client=client) + + +CUSTOM_MODEL_PROVIDER = CustomModelProvider() + + +@function_tool +def get_weather(city: str): + print(f"[debug] getting weather for {city}") + return f"The weather in {city} is sunny." + + +async def main(): + agent = Agent(name="Assistant", instructions="You only respond in haikus.", tools=[get_weather]) + + # This will use the custom model provider + result = await Runner.run( + agent, + "What's the weather in Tokyo?", + run_config=RunConfig(model_provider=CUSTOM_MODEL_PROVIDER), + ) + print(result.final_output) + + # If you uncomment this, it will use OpenAI directly, not the custom provider + # result = await Runner.run( + # agent, + # "What's the weather in Tokyo?", + # ) + # print(result.final_output) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/model_providers/litellm_auto.py b/samples-v2/openai_agents/model_providers/litellm_auto.py new file mode 100644 index 00000000..12b1e891 --- /dev/null +++ b/samples-v2/openai_agents/model_providers/litellm_auto.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import asyncio + +from agents import Agent, Runner, function_tool, set_tracing_disabled + +"""This example uses the built-in support for LiteLLM. To use this, ensure you have the +ANTHROPIC_API_KEY environment variable set. +""" + +set_tracing_disabled(disabled=True) + + +@function_tool +def get_weather(city: str): + print(f"[debug] getting weather for {city}") + return f"The weather in {city} is sunny." + + +async def main(): + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + # We prefix with litellm/ to tell the Runner to use the LitellmModel + model="litellm/anthropic/claude-3-5-sonnet-20240620", + tools=[get_weather], + ) + + result = await Runner.run(agent, "What's the weather in Tokyo?") + print(result.final_output) + + +if __name__ == "__main__": + import os + + if os.getenv("ANTHROPIC_API_KEY") is None: + raise ValueError( + "ANTHROPIC_API_KEY is not set. Please set it the environment variable and try again." + ) + + asyncio.run(main()) diff --git a/samples-v2/openai_agents/model_providers/litellm_provider.py b/samples-v2/openai_agents/model_providers/litellm_provider.py new file mode 100644 index 00000000..4a1a696f --- /dev/null +++ b/samples-v2/openai_agents/model_providers/litellm_provider.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import asyncio + +from agents import Agent, Runner, function_tool, set_tracing_disabled +from agents.extensions.models.litellm_model import LitellmModel + +"""This example uses the LitellmModel directly, to hit any model provider. +You can run it like this: +uv run examples/model_providers/litellm_provider.py --model anthropic/claude-3-5-sonnet-20240620 +or +uv run examples/model_providers/litellm_provider.py --model gemini/gemini-2.0-flash + +Find more providers here: https://docs.litellm.ai/docs/providers +""" + +set_tracing_disabled(disabled=True) + + +@function_tool +def get_weather(city: str): + print(f"[debug] getting weather for {city}") + return f"The weather in {city} is sunny." + + +async def main(model: str, api_key: str): + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + model=LitellmModel(model=model, api_key=api_key), + tools=[get_weather], + ) + + result = await Runner.run(agent, "What's the weather in Tokyo?") + print(result.final_output) + + +if __name__ == "__main__": + # First try to get model/api key from args + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--model", type=str, required=False) + parser.add_argument("--api-key", type=str, required=False) + args = parser.parse_args() + + model = args.model + if not model: + model = input("Enter a model name for Litellm: ") + + api_key = args.api_key + if not api_key: + api_key = input("Enter an API key for Litellm: ") + + asyncio.run(main(model, api_key)) diff --git a/samples-v2/openai_agents/realtime/app/README.md b/samples-v2/openai_agents/realtime/app/README.md new file mode 100644 index 00000000..cb5519a7 --- /dev/null +++ b/samples-v2/openai_agents/realtime/app/README.md @@ -0,0 +1,44 @@ +# Realtime Demo App + +A web-based realtime voice assistant demo with a FastAPI backend and HTML/JS frontend. + +## Installation + +Install the required dependencies: + +```bash +uv add fastapi uvicorn websockets +``` + +## Usage + +Start the application with a single command: + +```bash +cd examples/realtime/app && uv run python server.py +``` + +Then open your browser to: http://localhost:8000 + +## Customization + +To use the same UI with your own agents, edit `agent.py` and ensure get_starting_agent() returns the right starting agent for your use case. + +## How to Use + +1. Click **Connect** to establish a realtime session +2. Audio capture starts automatically - just speak naturally +3. Click the **Mic On/Off** button to mute/unmute your microphone +4. Watch the conversation unfold in the left pane +5. Monitor raw events in the right pane (click to expand/collapse) +6. Click **Disconnect** when done + +## Architecture + +- **Backend**: FastAPI server with WebSocket connections for real-time communication +- **Session Management**: Each connection gets a unique session with the OpenAI Realtime API +- **Audio Processing**: 24kHz mono audio capture and playback +- **Event Handling**: Full event stream processing with transcript generation +- **Frontend**: Vanilla JavaScript with clean, responsive CSS + +The demo showcases the core patterns for building realtime voice applications with the OpenAI Agents SDK. diff --git a/samples-v2/openai_agents/realtime/app/agent.py b/samples-v2/openai_agents/realtime/app/agent.py new file mode 100644 index 00000000..6ade2fea --- /dev/null +++ b/samples-v2/openai_agents/realtime/app/agent.py @@ -0,0 +1,36 @@ +from agents import function_tool +from agents.realtime import RealtimeAgent + +""" +When running the UI example locally, you can edit this file to change the setup. THe server +will use the agent returned from get_starting_agent() as the starting agent.""" + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather in a city.""" + return f"The weather in {city} is sunny." + + +@function_tool +def get_secret_number() -> int: + """Returns the secret number, if the user asks for it.""" + return 71 + + +haiku_agent = RealtimeAgent( + name="Haiku Agent", + instructions="You are a haiku poet. You must respond ONLY in traditional haiku format (5-7-5 syllables). Every response should be a proper haiku about the topic. Do not break character.", + tools=[], +) + +assistant_agent = RealtimeAgent( + name="Assistant", + instructions="If the user wants poetry or haikus, you can hand them off to the haiku agent via the transfer_to_haiku_agent tool.", + tools=[get_weather, get_secret_number], + handoffs=[haiku_agent], +) + + +def get_starting_agent() -> RealtimeAgent: + return assistant_agent diff --git a/samples-v2/openai_agents/realtime/app/server.py b/samples-v2/openai_agents/realtime/app/server.py new file mode 100644 index 00000000..325f4347 --- /dev/null +++ b/samples-v2/openai_agents/realtime/app/server.py @@ -0,0 +1,161 @@ +import asyncio +import base64 +import json +import logging +import struct +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any, assert_never + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from agents.realtime import RealtimeRunner, RealtimeSession, RealtimeSessionEvent + +# Import TwilioHandler class - handle both module and package use cases +if TYPE_CHECKING: + # For type checking, use the relative import + from .agent import get_starting_agent +else: + # At runtime, try both import styles + try: + # Try relative import first (when used as a package) + from .agent import get_starting_agent + except ImportError: + # Fall back to direct import (when run as a script) + from agent import get_starting_agent + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class RealtimeWebSocketManager: + def __init__(self): + self.active_sessions: dict[str, RealtimeSession] = {} + self.session_contexts: dict[str, Any] = {} + self.websockets: dict[str, WebSocket] = {} + + async def connect(self, websocket: WebSocket, session_id: str): + await websocket.accept() + self.websockets[session_id] = websocket + + agent = get_starting_agent() + runner = RealtimeRunner(agent) + session_context = await runner.run() + session = await session_context.__aenter__() + self.active_sessions[session_id] = session + self.session_contexts[session_id] = session_context + + # Start event processing task + asyncio.create_task(self._process_events(session_id)) + + async def disconnect(self, session_id: str): + if session_id in self.session_contexts: + await self.session_contexts[session_id].__aexit__(None, None, None) + del self.session_contexts[session_id] + if session_id in self.active_sessions: + del self.active_sessions[session_id] + if session_id in self.websockets: + del self.websockets[session_id] + + async def send_audio(self, session_id: str, audio_bytes: bytes): + if session_id in self.active_sessions: + await self.active_sessions[session_id].send_audio(audio_bytes) + + async def _process_events(self, session_id: str): + try: + session = self.active_sessions[session_id] + websocket = self.websockets[session_id] + + async for event in session: + event_data = await self._serialize_event(event) + await websocket.send_text(json.dumps(event_data)) + except Exception as e: + logger.error(f"Error processing events for session {session_id}: {e}") + + async def _serialize_event(self, event: RealtimeSessionEvent) -> dict[str, Any]: + base_event: dict[str, Any] = { + "type": event.type, + } + + if event.type == "agent_start": + base_event["agent"] = event.agent.name + elif event.type == "agent_end": + base_event["agent"] = event.agent.name + elif event.type == "handoff": + base_event["from"] = event.from_agent.name + base_event["to"] = event.to_agent.name + elif event.type == "tool_start": + base_event["tool"] = event.tool.name + elif event.type == "tool_end": + base_event["tool"] = event.tool.name + base_event["output"] = str(event.output) + elif event.type == "audio": + base_event["audio"] = base64.b64encode(event.audio.data).decode("utf-8") + elif event.type == "audio_interrupted": + pass + elif event.type == "audio_end": + pass + elif event.type == "history_updated": + base_event["history"] = [item.model_dump(mode="json") for item in event.history] + elif event.type == "history_added": + pass + elif event.type == "guardrail_tripped": + base_event["guardrail_results"] = [ + {"name": result.guardrail.name} for result in event.guardrail_results + ] + elif event.type == "raw_model_event": + base_event["raw_model_event"] = { + "type": event.data.type, + } + elif event.type == "error": + base_event["error"] = str(event.error) if hasattr(event, "error") else "Unknown error" + else: + assert_never(event) + + return base_event + + +manager = RealtimeWebSocketManager() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.websocket("/ws/{session_id}") +async def websocket_endpoint(websocket: WebSocket, session_id: str): + await manager.connect(websocket, session_id) + try: + while True: + data = await websocket.receive_text() + message = json.loads(data) + + if message["type"] == "audio": + # Convert int16 array to bytes + print("Received audio data") + int16_data = message["data"] + audio_bytes = struct.pack(f"{len(int16_data)}h", *int16_data) + await manager.send_audio(session_id, audio_bytes) + + except WebSocketDisconnect: + await manager.disconnect(session_id) + + +app.mount("/", StaticFiles(directory="static", html=True), name="static") + + +@app.get("/") +async def read_index(): + return FileResponse("static/index.html") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/samples-v2/openai_agents/realtime/app/static/app.js b/samples-v2/openai_agents/realtime/app/static/app.js new file mode 100644 index 00000000..3ec8fcc9 --- /dev/null +++ b/samples-v2/openai_agents/realtime/app/static/app.js @@ -0,0 +1,467 @@ +class RealtimeDemo { + constructor() { + this.ws = null; + this.isConnected = false; + this.isMuted = false; + this.isCapturing = false; + this.audioContext = null; + this.processor = null; + this.stream = null; + this.sessionId = this.generateSessionId(); + + // Audio playback queue + this.audioQueue = []; + this.isPlayingAudio = false; + this.playbackAudioContext = null; + this.currentAudioSource = null; + + this.initializeElements(); + this.setupEventListeners(); + } + + initializeElements() { + this.connectBtn = document.getElementById('connectBtn'); + this.muteBtn = document.getElementById('muteBtn'); + this.status = document.getElementById('status'); + this.messagesContent = document.getElementById('messagesContent'); + this.eventsContent = document.getElementById('eventsContent'); + this.toolsContent = document.getElementById('toolsContent'); + } + + setupEventListeners() { + this.connectBtn.addEventListener('click', () => { + if (this.isConnected) { + this.disconnect(); + } else { + this.connect(); + } + }); + + this.muteBtn.addEventListener('click', () => { + this.toggleMute(); + }); + } + + generateSessionId() { + return 'session_' + Math.random().toString(36).substr(2, 9); + } + + async connect() { + try { + this.ws = new WebSocket(`ws://localhost:8000/ws/${this.sessionId}`); + + this.ws.onopen = () => { + this.isConnected = true; + this.updateConnectionUI(); + this.startContinuousCapture(); + }; + + this.ws.onmessage = (event) => { + const data = JSON.parse(event.data); + this.handleRealtimeEvent(data); + }; + + this.ws.onclose = () => { + this.isConnected = false; + this.updateConnectionUI(); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + } catch (error) { + console.error('Failed to connect:', error); + } + } + + disconnect() { + if (this.ws) { + this.ws.close(); + } + this.stopContinuousCapture(); + } + + updateConnectionUI() { + if (this.isConnected) { + this.connectBtn.textContent = 'Disconnect'; + this.connectBtn.className = 'connect-btn connected'; + this.status.textContent = 'Connected'; + this.status.className = 'status connected'; + this.muteBtn.disabled = false; + } else { + this.connectBtn.textContent = 'Connect'; + this.connectBtn.className = 'connect-btn disconnected'; + this.status.textContent = 'Disconnected'; + this.status.className = 'status disconnected'; + this.muteBtn.disabled = true; + } + } + + toggleMute() { + this.isMuted = !this.isMuted; + this.updateMuteUI(); + } + + updateMuteUI() { + if (this.isMuted) { + this.muteBtn.textContent = '🔇 Mic Off'; + this.muteBtn.className = 'mute-btn muted'; + } else { + this.muteBtn.textContent = '🎤 Mic On'; + this.muteBtn.className = 'mute-btn unmuted'; + if (this.isCapturing) { + this.muteBtn.classList.add('active'); + } + } + } + + async startContinuousCapture() { + if (!this.isConnected || this.isCapturing) return; + + // Check if getUserMedia is available + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + throw new Error('getUserMedia not available. Please use HTTPS or localhost.'); + } + + try { + this.stream = await navigator.mediaDevices.getUserMedia({ + audio: { + sampleRate: 24000, + channelCount: 1, + echoCancellation: true, + noiseSuppression: true + } + }); + + this.audioContext = new AudioContext({ sampleRate: 24000 }); + const source = this.audioContext.createMediaStreamSource(this.stream); + + // Create a script processor to capture audio data + this.processor = this.audioContext.createScriptProcessor(4096, 1, 1); + source.connect(this.processor); + this.processor.connect(this.audioContext.destination); + + this.processor.onaudioprocess = (event) => { + if (!this.isMuted && this.ws && this.ws.readyState === WebSocket.OPEN) { + const inputBuffer = event.inputBuffer.getChannelData(0); + const int16Buffer = new Int16Array(inputBuffer.length); + + // Convert float32 to int16 + for (let i = 0; i < inputBuffer.length; i++) { + int16Buffer[i] = Math.max(-32768, Math.min(32767, inputBuffer[i] * 32768)); + } + + this.ws.send(JSON.stringify({ + type: 'audio', + data: Array.from(int16Buffer) + })); + } + }; + + this.isCapturing = true; + this.updateMuteUI(); + + } catch (error) { + console.error('Failed to start audio capture:', error); + } + } + + stopContinuousCapture() { + if (!this.isCapturing) return; + + this.isCapturing = false; + + if (this.processor) { + this.processor.disconnect(); + this.processor = null; + } + + if (this.audioContext) { + this.audioContext.close(); + this.audioContext = null; + } + + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + this.stream = null; + } + + this.updateMuteUI(); + } + + handleRealtimeEvent(event) { + // Add to raw events pane + this.addRawEvent(event); + + // Add to tools panel if it's a tool or handoff event + if (event.type === 'tool_start' || event.type === 'tool_end' || event.type === 'handoff') { + this.addToolEvent(event); + } + + // Handle specific event types + switch (event.type) { + case 'audio': + this.playAudio(event.audio); + break; + case 'audio_interrupted': + this.stopAudioPlayback(); + break; + case 'history_updated': + this.updateMessagesFromHistory(event.history); + break; + } + } + + + updateMessagesFromHistory(history) { + console.log('updateMessagesFromHistory called with:', history); + + // Clear all existing messages + this.messagesContent.innerHTML = ''; + + // Add messages from history + if (history && Array.isArray(history)) { + console.log('Processing history array with', history.length, 'items'); + history.forEach((item, index) => { + console.log(`History item ${index}:`, item); + if (item.type === 'message') { + const role = item.role; + let content = ''; + + console.log(`Message item - role: ${role}, content:`, item.content); + + if (item.content && Array.isArray(item.content)) { + // Extract text from content array + item.content.forEach(contentPart => { + console.log('Content part:', contentPart); + if (contentPart.type === 'text' && contentPart.text) { + content += contentPart.text; + } else if (contentPart.type === 'input_text' && contentPart.text) { + content += contentPart.text; + } else if (contentPart.type === 'input_audio' && contentPart.transcript) { + content += contentPart.transcript; + } else if (contentPart.type === 'audio' && contentPart.transcript) { + content += contentPart.transcript; + } + }); + } + + console.log(`Final content for ${role}:`, content); + + if (content.trim()) { + this.addMessage(role, content.trim()); + console.log(`Added message: ${role} - ${content.trim()}`); + } + } else { + console.log(`Skipping non-message item of type: ${item.type}`); + } + }); + } else { + console.log('History is not an array or is null/undefined'); + } + + this.scrollToBottom(); + } + + addMessage(type, content) { + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${type}`; + + const bubbleDiv = document.createElement('div'); + bubbleDiv.className = 'message-bubble'; + bubbleDiv.textContent = content; + + messageDiv.appendChild(bubbleDiv); + this.messagesContent.appendChild(messageDiv); + this.scrollToBottom(); + + return messageDiv; + } + + addRawEvent(event) { + const eventDiv = document.createElement('div'); + eventDiv.className = 'event'; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'event-header'; + headerDiv.innerHTML = ` + ${event.type} + ▼ + `; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'event-content collapsed'; + contentDiv.textContent = JSON.stringify(event, null, 2); + + headerDiv.addEventListener('click', () => { + const isCollapsed = contentDiv.classList.contains('collapsed'); + contentDiv.classList.toggle('collapsed'); + headerDiv.querySelector('span:last-child').textContent = isCollapsed ? '▲' : '▼'; + }); + + eventDiv.appendChild(headerDiv); + eventDiv.appendChild(contentDiv); + this.eventsContent.appendChild(eventDiv); + + // Auto-scroll events pane + this.eventsContent.scrollTop = this.eventsContent.scrollHeight; + } + + addToolEvent(event) { + const eventDiv = document.createElement('div'); + eventDiv.className = 'event'; + + let title = ''; + let description = ''; + let eventClass = ''; + + if (event.type === 'handoff') { + title = `🔄 Handoff`; + description = `From ${event.from} to ${event.to}`; + eventClass = 'handoff'; + } else if (event.type === 'tool_start') { + title = `🔧 Tool Started`; + description = `Running ${event.tool}`; + eventClass = 'tool'; + } else if (event.type === 'tool_end') { + title = `✅ Tool Completed`; + description = `${event.tool}: ${event.output || 'No output'}`; + eventClass = 'tool'; + } + + eventDiv.innerHTML = ` +
+
+
${title}
+
${description}
+
+ ${new Date().toLocaleTimeString()} +
+ `; + + this.toolsContent.appendChild(eventDiv); + + // Auto-scroll tools pane + this.toolsContent.scrollTop = this.toolsContent.scrollHeight; + } + + async playAudio(audioBase64) { + try { + if (!audioBase64 || audioBase64.length === 0) { + console.warn('Received empty audio data, skipping playback'); + return; + } + + // Add to queue + this.audioQueue.push(audioBase64); + + // Start processing queue if not already playing + if (!this.isPlayingAudio) { + this.processAudioQueue(); + } + + } catch (error) { + console.error('Failed to play audio:', error); + } + } + + async processAudioQueue() { + if (this.isPlayingAudio || this.audioQueue.length === 0) { + return; + } + + this.isPlayingAudio = true; + + // Initialize audio context if needed + if (!this.playbackAudioContext) { + this.playbackAudioContext = new AudioContext({ sampleRate: 24000 }); + } + + while (this.audioQueue.length > 0) { + const audioBase64 = this.audioQueue.shift(); + await this.playAudioChunk(audioBase64); + } + + this.isPlayingAudio = false; + } + + async playAudioChunk(audioBase64) { + return new Promise((resolve, reject) => { + try { + // Decode base64 to ArrayBuffer + const binaryString = atob(audioBase64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + const int16Array = new Int16Array(bytes.buffer); + + if (int16Array.length === 0) { + console.warn('Audio chunk has no samples, skipping'); + resolve(); + return; + } + + const float32Array = new Float32Array(int16Array.length); + + // Convert int16 to float32 + for (let i = 0; i < int16Array.length; i++) { + float32Array[i] = int16Array[i] / 32768.0; + } + + const audioBuffer = this.playbackAudioContext.createBuffer(1, float32Array.length, 24000); + audioBuffer.getChannelData(0).set(float32Array); + + const source = this.playbackAudioContext.createBufferSource(); + source.buffer = audioBuffer; + source.connect(this.playbackAudioContext.destination); + + // Store reference to current source + this.currentAudioSource = source; + + source.onended = () => { + this.currentAudioSource = null; + resolve(); + }; + source.start(); + + } catch (error) { + console.error('Failed to play audio chunk:', error); + reject(error); + } + }); + } + + stopAudioPlayback() { + console.log('Stopping audio playback due to interruption'); + + // Stop current audio source if playing + if (this.currentAudioSource) { + try { + this.currentAudioSource.stop(); + this.currentAudioSource = null; + } catch (error) { + console.error('Error stopping audio source:', error); + } + } + + // Clear the audio queue + this.audioQueue = []; + + // Reset playback state + this.isPlayingAudio = false; + + console.log('Audio playback stopped and queue cleared'); + } + + scrollToBottom() { + this.messagesContent.scrollTop = this.messagesContent.scrollHeight; + } +} + +// Initialize the demo when the page loads +document.addEventListener('DOMContentLoaded', () => { + new RealtimeDemo(); +}); \ No newline at end of file diff --git a/samples-v2/openai_agents/realtime/app/static/index.html b/samples-v2/openai_agents/realtime/app/static/index.html new file mode 100644 index 00000000..fbd0de46 --- /dev/null +++ b/samples-v2/openai_agents/realtime/app/static/index.html @@ -0,0 +1,295 @@ + + + + + + Realtime Demo + + + +
+

Realtime Demo

+ +
+ +
+
+
+ Conversation +
+
+ +
+
+ + Disconnected +
+
+ +
+
+
+ Event stream +
+
+ +
+
+ +
+
+ Tools & Handoffs +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/samples-v2/openai_agents/realtime/cli/demo.py b/samples-v2/openai_agents/realtime/cli/demo.py new file mode 100644 index 00000000..be610b43 --- /dev/null +++ b/samples-v2/openai_agents/realtime/cli/demo.py @@ -0,0 +1,253 @@ +import asyncio +import queue +import sys +import threading +from typing import Any + +import numpy as np +import sounddevice as sd + +from agents import function_tool +from agents.realtime import RealtimeAgent, RealtimeRunner, RealtimeSession, RealtimeSessionEvent + +# Audio configuration +CHUNK_LENGTH_S = 0.05 # 50ms +SAMPLE_RATE = 24000 +FORMAT = np.int16 +CHANNELS = 1 + +# Set up logging for OpenAI agents SDK +# logging.basicConfig( +# level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +# ) +# logger.logger.setLevel(logging.ERROR) + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather in a city.""" + return f"The weather in {city} is sunny." + + +agent = RealtimeAgent( + name="Assistant", + instructions="You always greet the user with 'Top of the morning to you'.", + tools=[get_weather], +) + + +def _truncate_str(s: str, max_length: int) -> str: + if len(s) > max_length: + return s[:max_length] + "..." + return s + + +class NoUIDemo: + def __init__(self) -> None: + self.session: RealtimeSession | None = None + self.audio_stream: sd.InputStream | None = None + self.audio_player: sd.OutputStream | None = None + self.recording = False + + # Audio output state for callback system + self.output_queue: queue.Queue[Any] = queue.Queue(maxsize=10) # Buffer more chunks + self.interrupt_event = threading.Event() + self.current_audio_chunk: np.ndarray | None = None # type: ignore + self.chunk_position = 0 + + def _output_callback(self, outdata, frames: int, time, status) -> None: + """Callback for audio output - handles continuous audio stream from server.""" + if status: + print(f"Output callback status: {status}") + + # Check if we should clear the queue due to interrupt + if self.interrupt_event.is_set(): + # Clear the queue and current chunk state + while not self.output_queue.empty(): + try: + self.output_queue.get_nowait() + except queue.Empty: + break + self.current_audio_chunk = None + self.chunk_position = 0 + self.interrupt_event.clear() + outdata.fill(0) + return + + # Fill output buffer from queue and current chunk + outdata.fill(0) # Start with silence + samples_filled = 0 + + while samples_filled < len(outdata): + # If we don't have a current chunk, try to get one from queue + if self.current_audio_chunk is None: + try: + self.current_audio_chunk = self.output_queue.get_nowait() + self.chunk_position = 0 + except queue.Empty: + # No more audio data available - this causes choppiness + # Uncomment next line to debug underruns: + # print(f"Audio underrun: {samples_filled}/{len(outdata)} samples filled") + break + + # Copy data from current chunk to output buffer + remaining_output = len(outdata) - samples_filled + remaining_chunk = len(self.current_audio_chunk) - self.chunk_position + samples_to_copy = min(remaining_output, remaining_chunk) + + if samples_to_copy > 0: + chunk_data = self.current_audio_chunk[ + self.chunk_position : self.chunk_position + samples_to_copy + ] + # More efficient: direct assignment for mono audio instead of reshape + outdata[samples_filled : samples_filled + samples_to_copy, 0] = chunk_data + samples_filled += samples_to_copy + self.chunk_position += samples_to_copy + + # If we've used up the entire chunk, reset for next iteration + if self.chunk_position >= len(self.current_audio_chunk): + self.current_audio_chunk = None + self.chunk_position = 0 + + async def run(self) -> None: + print("Connecting, may take a few seconds...") + + # Initialize audio player with callback + chunk_size = int(SAMPLE_RATE * CHUNK_LENGTH_S) + self.audio_player = sd.OutputStream( + channels=CHANNELS, + samplerate=SAMPLE_RATE, + dtype=FORMAT, + callback=self._output_callback, + blocksize=chunk_size, # Match our chunk timing for better alignment + ) + self.audio_player.start() + + try: + runner = RealtimeRunner(agent) + async with await runner.run() as session: + self.session = session + print("Connected. Starting audio recording...") + + # Start audio recording + await self.start_audio_recording() + print("Audio recording started. You can start speaking - expect lots of logs!") + + # Process session events + async for event in session: + await self._on_event(event) + + finally: + # Clean up audio player + if self.audio_player and self.audio_player.active: + self.audio_player.stop() + if self.audio_player: + self.audio_player.close() + + print("Session ended") + + async def start_audio_recording(self) -> None: + """Start recording audio from the microphone.""" + # Set up audio input stream + self.audio_stream = sd.InputStream( + channels=CHANNELS, + samplerate=SAMPLE_RATE, + dtype=FORMAT, + ) + + self.audio_stream.start() + self.recording = True + + # Start audio capture task + asyncio.create_task(self.capture_audio()) + + async def capture_audio(self) -> None: + """Capture audio from the microphone and send to the session.""" + if not self.audio_stream or not self.session: + return + + # Buffer size in samples + read_size = int(SAMPLE_RATE * CHUNK_LENGTH_S) + + try: + while self.recording: + # Check if there's enough data to read + if self.audio_stream.read_available < read_size: + await asyncio.sleep(0.01) + continue + + # Read audio data + data, _ = self.audio_stream.read(read_size) + + # Convert numpy array to bytes + audio_bytes = data.tobytes() + + # Send audio to session + await self.session.send_audio(audio_bytes) + + # Yield control back to event loop + await asyncio.sleep(0) + + except Exception as e: + print(f"Audio capture error: {e}") + finally: + if self.audio_stream and self.audio_stream.active: + self.audio_stream.stop() + if self.audio_stream: + self.audio_stream.close() + + async def _on_event(self, event: RealtimeSessionEvent) -> None: + """Handle session events.""" + try: + if event.type == "agent_start": + print(f"Agent started: {event.agent.name}") + elif event.type == "agent_end": + print(f"Agent ended: {event.agent.name}") + elif event.type == "handoff": + print(f"Handoff from {event.from_agent.name} to {event.to_agent.name}") + elif event.type == "tool_start": + print(f"Tool started: {event.tool.name}") + elif event.type == "tool_end": + print(f"Tool ended: {event.tool.name}; output: {event.output}") + elif event.type == "audio_end": + print("Audio ended") + elif event.type == "audio": + # Enqueue audio for callback-based playback + np_audio = np.frombuffer(event.audio.data, dtype=np.int16) + try: + self.output_queue.put_nowait(np_audio) + except queue.Full: + # Queue is full - only drop if we have significant backlog + # This prevents aggressive dropping that could cause choppiness + if self.output_queue.qsize() > 8: # Keep some buffer + try: + self.output_queue.get_nowait() + self.output_queue.put_nowait(np_audio) + except queue.Empty: + pass + # If queue isn't too full, just skip this chunk to avoid blocking + elif event.type == "audio_interrupted": + print("Audio interrupted") + # Signal the output callback to clear its queue and state + self.interrupt_event.set() + elif event.type == "error": + print(f"Error: {event.error}") + elif event.type == "history_updated": + pass # Skip these frequent events + elif event.type == "history_added": + pass # Skip these frequent events + elif event.type == "raw_model_event": + print(f"Raw model event: {_truncate_str(str(event.data), 50)}") + else: + print(f"Unknown event type: {event.type}") + except Exception as e: + print(f"Error processing event: {_truncate_str(str(e), 50)}") + + +if __name__ == "__main__": + demo = NoUIDemo() + try: + asyncio.run(demo.run()) + except KeyboardInterrupt: + print("\nExiting...") + sys.exit(0) diff --git a/samples-v2/openai_agents/realtime/cli/ui.py b/samples-v2/openai_agents/realtime/cli/ui.py new file mode 100644 index 00000000..51a1fed4 --- /dev/null +++ b/samples-v2/openai_agents/realtime/cli/ui.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Coroutine +from typing import Any, Callable + +import numpy as np +import numpy.typing as npt +import sounddevice as sd +from textual import events +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal +from textual.reactive import reactive +from textual.widgets import RichLog, Static +from typing_extensions import override + +CHUNK_LENGTH_S = 0.05 # 50ms +SAMPLE_RATE = 24000 +FORMAT = np.int16 +CHANNELS = 1 + + +class Header(Static): + """A header widget.""" + + @override + def render(self) -> str: + return "Realtime Demo" + + +class AudioStatusIndicator(Static): + """A widget that shows the current audio recording status.""" + + is_recording = reactive(False) + + @override + def render(self) -> str: + status = ( + "🔴 Conversation started." + if self.is_recording + else "⚪ Press SPACE to start the conversation (q to quit)" + ) + return status + + +class AppUI(App[None]): + CSS = """ + Screen { + background: #1a1b26; /* Dark blue-grey background */ + } + + Container { + border: double rgb(91, 164, 91); + } + + #input-container { + height: 5; /* Explicit height for input container */ + margin: 1 1; + padding: 1 2; + } + + #bottom-pane { + width: 100%; + height: 82%; /* Reduced to make room for session display */ + border: round rgb(205, 133, 63); + } + + #status-indicator { + height: 3; + content-align: center middle; + background: #2a2b36; + border: solid rgb(91, 164, 91); + margin: 1 1; + } + + #session-display { + height: 3; + content-align: center middle; + background: #2a2b36; + border: solid rgb(91, 164, 91); + margin: 1 1; + } + + #transcripts { + width: 50%; + height: 100%; + border-right: solid rgb(91, 164, 91); + } + + #transcripts-header { + height: 2; + background: #2a2b36; + content-align: center middle; + border-bottom: solid rgb(91, 164, 91); + } + + #transcripts-content { + height: 100%; + } + + #event-log { + width: 50%; + height: 100%; + } + + #event-log-header { + height: 2; + background: #2a2b36; + content-align: center middle; + border-bottom: solid rgb(91, 164, 91); + } + + #event-log-content { + height: 100%; + } + + Static { + color: white; + } + """ + + should_send_audio: asyncio.Event + connected: asyncio.Event + last_audio_item_id: str | None + audio_callback: Callable[[bytes], Coroutine[Any, Any, None]] | None + + def __init__(self) -> None: + super().__init__() + self.audio_player = sd.OutputStream( + samplerate=SAMPLE_RATE, + channels=CHANNELS, + dtype=FORMAT, + ) + self.should_send_audio = asyncio.Event() + self.connected = asyncio.Event() + self.audio_callback = None + + @override + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + with Container(): + yield Header(id="session-display") + yield AudioStatusIndicator(id="status-indicator") + with Container(id="bottom-pane"): + with Horizontal(): + with Container(id="transcripts"): + yield Static("Conversation transcript", id="transcripts-header") + yield RichLog( + id="transcripts-content", wrap=True, highlight=True, markup=True + ) + with Container(id="event-log"): + yield Static("Raw event log", id="event-log-header") + yield RichLog( + id="event-log-content", wrap=True, highlight=True, markup=True + ) + + def set_is_connected(self, is_connected: bool) -> None: + self.connected.set() if is_connected else self.connected.clear() + + def set_audio_callback(self, callback: Callable[[bytes], Coroutine[Any, Any, None]]) -> None: + """Set a callback function to be called when audio is recorded.""" + self.audio_callback = callback + + # High-level methods for UI operations + def set_header_text(self, text: str) -> None: + """Update the header text.""" + header = self.query_one("#session-display", Header) + header.update(text) + + def set_recording_status(self, is_recording: bool) -> None: + """Set the recording status indicator.""" + status_indicator = self.query_one(AudioStatusIndicator) + status_indicator.is_recording = is_recording + + def log_message(self, message: str) -> None: + """Add a message to the event log.""" + try: + log_pane = self.query_one("#event-log-content", RichLog) + log_pane.write(message) + except Exception: + # Handle the case where the widget might not be available + pass + + def add_transcript(self, message: str) -> None: + """Add a transcript message to the transcripts panel.""" + try: + transcript_pane = self.query_one("#transcripts-content", RichLog) + transcript_pane.write(message) + except Exception: + # Handle the case where the widget might not be available + pass + + def play_audio(self, audio_data: npt.NDArray[np.int16]) -> None: + """Play audio data through the audio player.""" + try: + self.audio_player.write(audio_data) + except Exception as e: + self.log_message(f"Audio play error: {e}") + + async def on_mount(self) -> None: + """Set up audio player and start the audio capture worker.""" + self.audio_player.start() + self.run_worker(self.capture_audio()) + + async def capture_audio(self) -> None: + """Capture audio from the microphone and send to the session.""" + # Wait for connection to be established + await self.connected.wait() + + # Set up audio input stream + stream = sd.InputStream( + channels=CHANNELS, + samplerate=SAMPLE_RATE, + dtype=FORMAT, + ) + + try: + # Wait for user to press spacebar to start + await self.should_send_audio.wait() + + stream.start() + self.set_recording_status(True) + self.log_message("Recording started - speak to the agent") + + # Buffer size in samples + read_size = int(SAMPLE_RATE * CHUNK_LENGTH_S) + + while True: + # Check if there's enough data to read + if stream.read_available < read_size: + await asyncio.sleep(0.01) # Small sleep to avoid CPU hogging + continue + + # Read audio data + data, _ = stream.read(read_size) + + # Convert numpy array to bytes + audio_bytes = data.tobytes() + + # Call audio callback if set + if self.audio_callback: + await self.audio_callback(audio_bytes) + + # Yield control back to event loop + await asyncio.sleep(0) + + except Exception as e: + self.log_message(f"Audio capture error: {e}") + finally: + if stream.active: + stream.stop() + stream.close() + + async def on_key(self, event: events.Key) -> None: + """Handle key press events.""" + # add the keypress to the log + self.log_message(f"Key pressed: {event.key}") + + if event.key == "q": + self.audio_player.stop() + self.audio_player.close() + self.exit() + return + + if event.key == "space": # Spacebar + if not self.should_send_audio.is_set(): + self.should_send_audio.set() + self.set_recording_status(True) diff --git a/samples-v2/openai_agents/realtime/twilio/README.md b/samples-v2/openai_agents/realtime/twilio/README.md new file mode 100644 index 00000000..e92f0681 --- /dev/null +++ b/samples-v2/openai_agents/realtime/twilio/README.md @@ -0,0 +1,86 @@ +# Realtime Twilio Integration + +This example demonstrates how to connect the OpenAI Realtime API to a phone call using Twilio's Media Streams. The server handles incoming phone calls and streams audio between Twilio and the OpenAI Realtime API, enabling real-time voice conversations with an AI agent over the phone. + +## Prerequisites + +- Python 3.9+ +- OpenAI API key with [Realtime API](https://platform.openai.com/docs/guides/realtime) access +- [Twilio](https://www.twilio.com/docs/voice) account with a phone number +- A tunneling service like [ngrok](https://ngrok.com/) to expose your local server + +## Setup + +1. **Start the server:** + + ```bash + uv run server.py + ``` + + The server will start on port 8000 by default. + +2. **Expose the server publicly, e.g. via ngrok:** + + ```bash + ngrok http 8000 + ``` + + Note the public URL (e.g., `https://abc123.ngrok.io`) + +3. **Configure your Twilio phone number:** + - Log into your Twilio Console + - Select your phone number + - Set the webhook URL for incoming calls to: `https://your-ngrok-url.ngrok.io/incoming-call` + - Set the HTTP method to POST + +## Usage + +1. Call your Twilio phone number +2. You'll hear: "Hello! You're now connected to an AI assistant. You can start talking!" +3. Start speaking - the AI will respond in real-time +4. The assistant has access to tools like weather information and current time + +## How It Works + +1. **Incoming Call**: When someone calls your Twilio number, Twilio makes a request to `/incoming-call` +2. **TwiML Response**: The server returns TwiML that: + - Plays a greeting message + - Connects the call to a WebSocket stream at `/media-stream` +3. **WebSocket Connection**: Twilio establishes a WebSocket connection for bidirectional audio streaming +4. **Transport Layer**: The `TwilioRealtimeTransportLayer` class owns the WebSocket message handling: + - Takes ownership of the Twilio WebSocket after initial handshake + - Runs its own message loop to process all Twilio messages + - Handles protocol differences between Twilio and OpenAI + - Automatically sets G.711 μ-law audio format for Twilio compatibility + - Manages audio chunk tracking for interruption support + - Wraps the OpenAI realtime model instead of subclassing it +5. **Audio Processing**: + - Audio from the caller is base64 decoded and sent to OpenAI Realtime API + - Audio responses from OpenAI are base64 encoded and sent back to Twilio + - Twilio plays the audio to the caller + +## Configuration + +- **Port**: Set `PORT` environment variable (default: 8000) +- **OpenAI API Key**: Set `OPENAI_API_KEY` environment variable +- **Agent Instructions**: Modify the `RealtimeAgent` configuration in `server.py` +- **Tools**: Add or modify function tools in `server.py` + +## Troubleshooting + +- **WebSocket connection issues**: Ensure your ngrok URL is correct and publicly accessible +- **Audio quality**: Twilio streams audio in mulaw format at 8kHz, which may affect quality +- **Latency**: Network latency between Twilio, your server, and OpenAI affects response time +- **Logs**: Check the console output for detailed connection and error logs + +## Architecture + +``` +Phone Call → Twilio → WebSocket → TwilioRealtimeTransportLayer → OpenAI Realtime API + ↓ + RealtimeAgent with Tools + ↓ + Audio Response → Twilio → Phone Call +``` + +The `TwilioRealtimeTransportLayer` acts as a bridge between Twilio's Media Streams and OpenAI's Realtime API, handling the protocol differences and audio format conversions. It wraps the OpenAI realtime model to provide a clean interface for Twilio integration. diff --git a/samples-v2/openai_agents/realtime/twilio/__init__.py b/samples-v2/openai_agents/realtime/twilio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples-v2/openai_agents/realtime/twilio/requirements.txt b/samples-v2/openai_agents/realtime/twilio/requirements.txt new file mode 100644 index 00000000..3fcc0b0f --- /dev/null +++ b/samples-v2/openai_agents/realtime/twilio/requirements.txt @@ -0,0 +1,5 @@ +openai-agents +fastapi +uvicorn[standard] +websockets +python-dotenv \ No newline at end of file diff --git a/samples-v2/openai_agents/realtime/twilio/server.py b/samples-v2/openai_agents/realtime/twilio/server.py new file mode 100644 index 00000000..8a753f78 --- /dev/null +++ b/samples-v2/openai_agents/realtime/twilio/server.py @@ -0,0 +1,80 @@ +import os +from typing import TYPE_CHECKING + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.responses import PlainTextResponse + +# Import TwilioHandler class - handle both module and package use cases +if TYPE_CHECKING: + # For type checking, use the relative import + from .twilio_handler import TwilioHandler +else: + # At runtime, try both import styles + try: + # Try relative import first (when used as a package) + from .twilio_handler import TwilioHandler + except ImportError: + # Fall back to direct import (when run as a script) + from twilio_handler import TwilioHandler + + +class TwilioWebSocketManager: + def __init__(self): + self.active_handlers: dict[str, TwilioHandler] = {} + + async def new_session(self, websocket: WebSocket) -> TwilioHandler: + """Create and configure a new session.""" + print("Creating twilio handler") + + handler = TwilioHandler(websocket) + return handler + + # In a real app, you'd also want to clean up/close the handler when the call ends + + +manager = TwilioWebSocketManager() +app = FastAPI() + + +@app.get("/") +async def root(): + return {"message": "Twilio Media Stream Server is running!"} + + +@app.post("/incoming-call") +@app.get("/incoming-call") +async def incoming_call(request: Request): + """Handle incoming Twilio phone calls""" + host = request.headers.get("Host") + + twiml_response = f""" + + Hello! You're now connected to an AI assistant. You can start talking! + + + +""" + return PlainTextResponse(content=twiml_response, media_type="text/xml") + + +@app.websocket("/media-stream") +async def media_stream_endpoint(websocket: WebSocket): + """WebSocket endpoint for Twilio Media Streams""" + + try: + handler = await manager.new_session(websocket) + await handler.start() + + await handler.wait_until_done() + + except WebSocketDisconnect: + print("WebSocket disconnected") + except Exception as e: + print(f"WebSocket error: {e}") + + +if __name__ == "__main__": + import uvicorn + + port = int(os.getenv("PORT", 8000)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/samples-v2/openai_agents/realtime/twilio/twilio_handler.py b/samples-v2/openai_agents/realtime/twilio/twilio_handler.py new file mode 100644 index 00000000..567015df --- /dev/null +++ b/samples-v2/openai_agents/realtime/twilio/twilio_handler.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import time +from datetime import datetime +from typing import Any + +from fastapi import WebSocket + +from agents import function_tool +from agents.realtime import ( + RealtimeAgent, + RealtimePlaybackTracker, + RealtimeRunner, + RealtimeSession, + RealtimeSessionEvent, +) + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather in a city.""" + return f"The weather in {city} is sunny." + + +@function_tool +def get_current_time() -> str: + """Get the current time.""" + return f"The current time is {datetime.now().strftime('%H:%M:%S')}" + + +agent = RealtimeAgent( + name="Twilio Assistant", + instructions="You are a helpful assistant that starts every conversation with a creative greeting. Keep responses concise and friendly since this is a phone conversation.", + tools=[get_weather, get_current_time], +) + + +class TwilioHandler: + def __init__(self, twilio_websocket: WebSocket): + self.twilio_websocket = twilio_websocket + self._message_loop_task: asyncio.Task[None] | None = None + self.session: RealtimeSession | None = None + self.playback_tracker = RealtimePlaybackTracker() + + # Audio buffering configuration (matching CLI demo) + self.CHUNK_LENGTH_S = 0.05 # 50ms chunks like CLI demo + self.SAMPLE_RATE = 8000 # Twilio uses 8kHz for g711_ulaw + self.BUFFER_SIZE_BYTES = int(self.SAMPLE_RATE * self.CHUNK_LENGTH_S) # 50ms worth of audio + + self._stream_sid: str | None = None + self._audio_buffer: bytearray = bytearray() + self._last_buffer_send_time = time.time() + + # Mark event tracking for playback + self._mark_counter = 0 + self._mark_data: dict[ + str, tuple[str, int, int] + ] = {} # mark_id -> (item_id, content_index, byte_count) + + async def start(self) -> None: + """Start the session.""" + runner = RealtimeRunner(agent) + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI_API_KEY environment variable is required") + + self.session = await runner.run( + model_config={ + "api_key": api_key, + "initial_model_settings": { + "input_audio_format": "g711_ulaw", + "output_audio_format": "g711_ulaw", + "turn_detection": { + "type": "semantic_vad", + "interrupt_response": True, + "create_response": True, + }, + }, + "playback_tracker": self.playback_tracker, + } + ) + + await self.session.enter() + + await self.twilio_websocket.accept() + print("Twilio WebSocket connection accepted") + + self._realtime_session_task = asyncio.create_task(self._realtime_session_loop()) + self._message_loop_task = asyncio.create_task(self._twilio_message_loop()) + self._buffer_flush_task = asyncio.create_task(self._buffer_flush_loop()) + + async def wait_until_done(self) -> None: + """Wait until the session is done.""" + assert self._message_loop_task is not None + await self._message_loop_task + + async def _realtime_session_loop(self) -> None: + """Listen for events from the realtime session.""" + assert self.session is not None + try: + async for event in self.session: + await self._handle_realtime_event(event) + except Exception as e: + print(f"Error in realtime session loop: {e}") + + async def _twilio_message_loop(self) -> None: + """Listen for messages from Twilio WebSocket and handle them.""" + try: + while True: + message_text = await self.twilio_websocket.receive_text() + message = json.loads(message_text) + await self._handle_twilio_message(message) + except json.JSONDecodeError as e: + print(f"Failed to parse Twilio message as JSON: {e}") + except Exception as e: + print(f"Error in Twilio message loop: {e}") + + async def _handle_realtime_event(self, event: RealtimeSessionEvent) -> None: + """Handle events from the realtime session.""" + if event.type == "audio": + base64_audio = base64.b64encode(event.audio.data).decode("utf-8") + await self.twilio_websocket.send_text( + json.dumps( + { + "event": "media", + "streamSid": self._stream_sid, + "media": {"payload": base64_audio}, + } + ) + ) + + # Send mark event for playback tracking + self._mark_counter += 1 + mark_id = str(self._mark_counter) + self._mark_data[mark_id] = ( + event.audio.item_id, + event.audio.content_index, + len(event.audio.data), + ) + + await self.twilio_websocket.send_text( + json.dumps( + { + "event": "mark", + "streamSid": self._stream_sid, + "mark": {"name": mark_id}, + } + ) + ) + + elif event.type == "audio_interrupted": + print("Sending audio interrupted to Twilio") + await self.twilio_websocket.send_text( + json.dumps({"event": "clear", "streamSid": self._stream_sid}) + ) + elif event.type == "audio_end": + print("Audio end") + elif event.type == "raw_model_event": + pass + else: + pass + + async def _handle_twilio_message(self, message: dict[str, Any]) -> None: + """Handle incoming messages from Twilio Media Stream.""" + try: + event = message.get("event") + + if event == "connected": + print("Twilio media stream connected") + elif event == "start": + start_data = message.get("start", {}) + self._stream_sid = start_data.get("streamSid") + print(f"Media stream started with SID: {self._stream_sid}") + elif event == "media": + await self._handle_media_event(message) + elif event == "mark": + await self._handle_mark_event(message) + elif event == "stop": + print("Media stream stopped") + except Exception as e: + print(f"Error handling Twilio message: {e}") + + async def _handle_media_event(self, message: dict[str, Any]) -> None: + """Handle audio data from Twilio - buffer it before sending to OpenAI.""" + media = message.get("media", {}) + payload = media.get("payload", "") + + if payload: + try: + # Decode base64 audio from Twilio (µ-law format) + ulaw_bytes = base64.b64decode(payload) + + # Add original µ-law to buffer for OpenAI (they expect µ-law) + self._audio_buffer.extend(ulaw_bytes) + + # Send buffered audio if we have enough data + if len(self._audio_buffer) >= self.BUFFER_SIZE_BYTES: + await self._flush_audio_buffer() + + except Exception as e: + print(f"Error processing audio from Twilio: {e}") + + async def _handle_mark_event(self, message: dict[str, Any]) -> None: + """Handle mark events from Twilio to update playback tracker.""" + try: + mark_data = message.get("mark", {}) + mark_id = mark_data.get("name", "") + + # Look up stored data for this mark ID + if mark_id in self._mark_data: + item_id, item_content_index, byte_count = self._mark_data[mark_id] + + # Convert byte count back to bytes for playback tracker + audio_bytes = b"\x00" * byte_count # Placeholder bytes + + # Update playback tracker + self.playback_tracker.on_play_bytes(item_id, item_content_index, audio_bytes) + print( + f"Playback tracker updated: {item_id}, index {item_content_index}, {byte_count} bytes" + ) + + # Clean up the stored data + del self._mark_data[mark_id] + + except Exception as e: + print(f"Error handling mark event: {e}") + + async def _flush_audio_buffer(self) -> None: + """Send buffered audio to OpenAI.""" + if not self._audio_buffer or not self.session: + return + + try: + # Send the buffered audio + buffer_data = bytes(self._audio_buffer) + await self.session.send_audio(buffer_data) + + # Clear the buffer + self._audio_buffer.clear() + self._last_buffer_send_time = time.time() + + except Exception as e: + print(f"Error sending buffered audio to OpenAI: {e}") + + async def _buffer_flush_loop(self) -> None: + """Periodically flush audio buffer to prevent stale data.""" + try: + while True: + await asyncio.sleep(self.CHUNK_LENGTH_S) # Check every 50ms + + # If buffer has data and it's been too long since last send, flush it + current_time = time.time() + if ( + self._audio_buffer + and current_time - self._last_buffer_send_time > self.CHUNK_LENGTH_S * 2 + ): + await self._flush_audio_buffer() + + except Exception as e: + print(f"Error in buffer flush loop: {e}") diff --git a/samples-v2/openai_agents/reasoning_content/__init__.py b/samples-v2/openai_agents/reasoning_content/__init__.py new file mode 100644 index 00000000..f24b2606 --- /dev/null +++ b/samples-v2/openai_agents/reasoning_content/__init__.py @@ -0,0 +1,3 @@ +""" +Examples demonstrating how to use models that provide reasoning content. +""" diff --git a/samples-v2/openai_agents/reasoning_content/main.py b/samples-v2/openai_agents/reasoning_content/main.py new file mode 100644 index 00000000..a250aa9c --- /dev/null +++ b/samples-v2/openai_agents/reasoning_content/main.py @@ -0,0 +1,125 @@ +""" +Example demonstrating how to use the reasoning content feature with models that support it. + +Some models, like deepseek-reasoner, provide a reasoning_content field in addition to the regular content. +This example shows how to access and use this reasoning content from both streaming and non-streaming responses. + +To run this example, you need to: +1. Set your OPENAI_API_KEY environment variable +2. Use a model that supports reasoning content (e.g., deepseek-reasoner) +""" + +import asyncio +import os +from typing import Any, cast + +from openai.types.responses import ResponseOutputRefusal, ResponseOutputText + +from agents import ModelSettings +from agents.models.interface import ModelTracing +from agents.models.openai_provider import OpenAIProvider + +MODEL_NAME = os.getenv("EXAMPLE_MODEL_NAME") or "deepseek-reasoner" + + +async def stream_with_reasoning_content(): + """ + Example of streaming a response from a model that provides reasoning content. + The reasoning content will be emitted as separate events. + """ + provider = OpenAIProvider() + model = provider.get_model(MODEL_NAME) + + print("\n=== Streaming Example ===") + print("Prompt: Write a haiku about recursion in programming") + + reasoning_content = "" + regular_content = "" + + async for event in model.stream_response( + system_instructions="You are a helpful assistant that writes creative content.", + input="Write a haiku about recursion in programming", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + prompt=None, + ): + if event.type == "response.reasoning_summary_text.delta": + print( + f"\033[33m{event.delta}\033[0m", end="", flush=True + ) # Yellow for reasoning content + reasoning_content += event.delta + elif event.type == "response.output_text.delta": + print(f"\033[32m{event.delta}\033[0m", end="", flush=True) # Green for regular content + regular_content += event.delta + + print("\n\nReasoning Content:") + print(reasoning_content) + print("\nRegular Content:") + print(regular_content) + print("\n") + + +async def get_response_with_reasoning_content(): + """ + Example of getting a complete response from a model that provides reasoning content. + The reasoning content will be available as a separate item in the response. + """ + provider = OpenAIProvider() + model = provider.get_model(MODEL_NAME) + + print("\n=== Non-streaming Example ===") + print("Prompt: Explain the concept of recursion in programming") + + response = await model.get_response( + system_instructions="You are a helpful assistant that explains technical concepts clearly.", + input="Explain the concept of recursion in programming", + model_settings=ModelSettings(), + tools=[], + output_schema=None, + handoffs=[], + tracing=ModelTracing.DISABLED, + previous_response_id=None, + prompt=None, + ) + + # Extract reasoning content and regular content from the response + reasoning_content = None + regular_content = None + + for item in response.output: + if hasattr(item, "type") and item.type == "reasoning": + reasoning_content = item.summary[0].text + elif hasattr(item, "type") and item.type == "message": + if item.content and len(item.content) > 0: + content_item = item.content[0] + if isinstance(content_item, ResponseOutputText): + regular_content = content_item.text + elif isinstance(content_item, ResponseOutputRefusal): + refusal_item = cast(Any, content_item) + regular_content = refusal_item.refusal + + print("\nReasoning Content:") + print(reasoning_content or "No reasoning content provided") + + print("\nRegular Content:") + print(regular_content or "No regular content provided") + + print("\n") + + +async def main(): + try: + await stream_with_reasoning_content() + await get_response_with_reasoning_content() + except Exception as e: + print(f"Error: {e}") + print("\nNote: This example requires a model that supports reasoning content.") + print("You may need to use a specific model like deepseek-reasoner or similar.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/reasoning_content/runner_example.py b/samples-v2/openai_agents/reasoning_content/runner_example.py new file mode 100644 index 00000000..e51f8579 --- /dev/null +++ b/samples-v2/openai_agents/reasoning_content/runner_example.py @@ -0,0 +1,88 @@ +""" +Example demonstrating how to use the reasoning content feature with the Runner API. + +This example shows how to extract and use reasoning content from responses when using +the Runner API, which is the most common way users interact with the Agents library. + +To run this example, you need to: +1. Set your OPENAI_API_KEY environment variable +2. Use a model that supports reasoning content (e.g., deepseek-reasoner) +""" + +import asyncio +import os +from typing import Any + +from agents import Agent, Runner, trace +from agents.items import ReasoningItem + +MODEL_NAME = os.getenv("EXAMPLE_MODEL_NAME") or "deepseek-reasoner" + + +async def main(): + print(f"Using model: {MODEL_NAME}") + + # Create an agent with a model that supports reasoning content + agent = Agent( + name="Reasoning Agent", + instructions="You are a helpful assistant that explains your reasoning step by step.", + model=MODEL_NAME, + ) + + # Example 1: Non-streaming response + with trace("Reasoning Content - Non-streaming"): + print("\n=== Example 1: Non-streaming response ===") + result = await Runner.run( + agent, "What is the square root of 841? Please explain your reasoning." + ) + + # Extract reasoning content from the result items + reasoning_content = None + # RunResult has 'response' attribute which has 'output' attribute + for item in result.response.output: # type: ignore + if isinstance(item, ReasoningItem): + reasoning_content = item.summary[0].text # type: ignore + break + + print("\nReasoning Content:") + print(reasoning_content or "No reasoning content provided") + + print("\nFinal Output:") + print(result.final_output) + + # Example 2: Streaming response + with trace("Reasoning Content - Streaming"): + print("\n=== Example 2: Streaming response ===") + print("\nStreaming response:") + + # Buffers to collect reasoning and regular content + reasoning_buffer = "" + content_buffer = "" + + # RunResultStreaming is async iterable + stream = Runner.run_streamed(agent, "What is 15 x 27? Please explain your reasoning.") + + async for event in stream: # type: ignore + if isinstance(event, ReasoningItem): + # This is reasoning content + reasoning_item: Any = event + reasoning_buffer += reasoning_item.summary[0].text + print( + f"\033[33m{reasoning_item.summary[0].text}\033[0m", end="", flush=True + ) # Yellow for reasoning + elif hasattr(event, "text"): + # This is regular content + content_buffer += event.text + print( + f"\033[32m{event.text}\033[0m", end="", flush=True + ) # Green for regular content + + print("\n\nCollected Reasoning Content:") + print(reasoning_buffer) + + print("\nCollected Final Answer:") + print(content_buffer) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/research_bot/README.md b/samples-v2/openai_agents/research_bot/README.md new file mode 100644 index 00000000..49fb3570 --- /dev/null +++ b/samples-v2/openai_agents/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 its results, search for more stuff, etc. +4. Code execution: Allow running code, which is useful for data analysis. diff --git a/samples-v2/openai_agents/research_bot/__init__.py b/samples-v2/openai_agents/research_bot/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/samples-v2/openai_agents/research_bot/__init__.py @@ -0,0 +1 @@ + diff --git a/samples-v2/openai_agents/research_bot/agents/__init__.py b/samples-v2/openai_agents/research_bot/agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples-v2/openai_agents/research_bot/agents/planner_agent.py b/samples-v2/openai_agents/research_bot/agents/planner_agent.py new file mode 100644 index 00000000..e80a8e65 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/research_bot/agents/search_agent.py b/samples-v2/openai_agents/research_bot/agents/search_agent.py new file mode 100644 index 00000000..61f91701 --- /dev/null +++ b/samples-v2/openai_agents/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 be 2-3 paragraphs and less than 300 " + "words. Capture the main points. Write succinctly, no need to have complete sentences or good " + "grammar. This will be consumed by someone synthesizing a report, so its vital you capture the " + "essence and ignore any fluff. Do not include any additional commentary other than the summary " + "itself." +) + +search_agent = Agent( + name="Search agent", + instructions=INSTRUCTIONS, + tools=[WebSearchTool()], + model_settings=ModelSettings(tool_choice="required"), +) diff --git a/samples-v2/openai_agents/research_bot/agents/writer_agent.py b/samples-v2/openai_agents/research_bot/agents/writer_agent.py new file mode 100644 index 00000000..7b7d01a2 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/research_bot/main.py b/samples-v2/openai_agents/research_bot/main.py new file mode 100644 index 00000000..a0fd43dc --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/research_bot/manager.py b/samples-v2/openai_agents/research_bot/manager.py new file mode 100644 index 00000000..dab68569 --- /dev/null +++ b/samples-v2/openai_agents/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?trace_id={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/samples-v2/openai_agents/research_bot/printer.py b/samples-v2/openai_agents/research_bot/printer.py new file mode 100644 index 00000000..e820c753 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/research_bot/sample_outputs/product_recs.md b/samples-v2/openai_agents/research_bot/sample_outputs/product_recs.md new file mode 100644 index 00000000..70789eb3 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/research_bot/sample_outputs/product_recs.txt b/samples-v2/openai_agents/research_bot/sample_outputs/product_recs.txt new file mode 100644 index 00000000..fd14d533 --- /dev/null +++ b/samples-v2/openai_agents/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?trace_id=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/samples-v2/openai_agents/research_bot/sample_outputs/vacation.md b/samples-v2/openai_agents/research_bot/sample_outputs/vacation.md new file mode 100644 index 00000000..82c137af --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/research_bot/sample_outputs/vacation.txt b/samples-v2/openai_agents/research_bot/sample_outputs/vacation.txt new file mode 100644 index 00000000..491c0005 --- /dev/null +++ b/samples-v2/openai_agents/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?trace_id=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/samples-v2/openai_agents/tools/code_interpreter.py b/samples-v2/openai_agents/tools/code_interpreter.py new file mode 100644 index 00000000..a5843ce3 --- /dev/null +++ b/samples-v2/openai_agents/tools/code_interpreter.py @@ -0,0 +1,34 @@ +import asyncio + +from agents import Agent, CodeInterpreterTool, Runner, trace + + +async def main(): + agent = Agent( + name="Code interpreter", + instructions="You love doing math.", + tools=[ + CodeInterpreterTool( + tool_config={"type": "code_interpreter", "container": {"type": "auto"}}, + ) + ], + ) + + with trace("Code interpreter example"): + print("Solving math problem...") + result = Runner.run_streamed(agent, "What is the square root of273 * 312821 plus 1782?") + async for event in result.stream_events(): + if ( + event.type == "run_item_stream_event" + and event.item.type == "tool_call_item" + and event.item.raw_item.type == "code_interpreter_call" + ): + print(f"Code interpreter code:\n```\n{event.item.raw_item.code}\n```\n") + elif event.type == "run_item_stream_event": + print(f"Other event: {event.item.type}") + + print(f"Final output: {result.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/tools/computer_use.py b/samples-v2/openai_agents/tools/computer_use.py new file mode 100644 index 00000000..0c17cf95 --- /dev/null +++ b/samples-v2/openai_agents/tools/computer_use.py @@ -0,0 +1,168 @@ +import asyncio +import base64 +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, +) + +# Uncomment to see very verbose logs +# import logging +# 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", + 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: + mapped_keys = [CUA_KEY_TO_PLAYWRIGHT_KEY.get(key.lower(), key) for key in keys] + for key in mapped_keys: + await self.page.keyboard.down(key) + for key in reversed(mapped_keys): + await self.page.keyboard.up(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/samples-v2/openai_agents/tools/file_search.py b/samples-v2/openai_agents/tools/file_search.py new file mode 100644 index 00000000..2a3d4cf1 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/tools/image_generator.py b/samples-v2/openai_agents/tools/image_generator.py new file mode 100644 index 00000000..747b9ce9 --- /dev/null +++ b/samples-v2/openai_agents/tools/image_generator.py @@ -0,0 +1,54 @@ +import asyncio +import base64 +import os +import subprocess +import sys +import tempfile + +from agents import Agent, ImageGenerationTool, Runner, trace + + +def open_file(path: str) -> None: + if sys.platform.startswith("darwin"): + subprocess.run(["open", path], check=False) # macOS + elif os.name == "nt": # Windows + os.startfile(path) # type: ignore + elif os.name == "posix": + subprocess.run(["xdg-open", path], check=False) # Linux/Unix + else: + print(f"Don't know how to open files on this platform: {sys.platform}") + + +async def main(): + agent = Agent( + name="Image generator", + instructions="You are a helpful agent.", + tools=[ + ImageGenerationTool( + tool_config={"type": "image_generation", "quality": "low"}, + ) + ], + ) + + with trace("Image generation example"): + print("Generating image, this may take a while...") + result = await Runner.run( + agent, "Create an image of a frog eating a pizza, comic book style." + ) + print(result.final_output) + for item in result.new_items: + if ( + item.type == "tool_call_item" + and item.raw_item.type == "image_generation_call" + and (img_result := item.raw_item.result) + ): + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp.write(base64.b64decode(img_result)) + temp_path = tmp.name + + # Open the image + open_file(temp_path) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/tools/web_search.py b/samples-v2/openai_agents/tools/web_search.py new file mode 100644 index 00000000..35eeb680 --- /dev/null +++ b/samples-v2/openai_agents/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/samples-v2/openai_agents/voice/__init__.py b/samples-v2/openai_agents/voice/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples-v2/openai_agents/voice/static/README.md b/samples-v2/openai_agents/voice/static/README.md new file mode 100644 index 00000000..74dc114b --- /dev/null +++ b/samples-v2/openai_agents/voice/static/README.md @@ -0,0 +1,26 @@ +# Static voice demo + +This demo operates by capturing a recording, then running a voice pipeline on it. + +Run via: + +``` +python -m examples.voice.static.main +``` + +## How it works + +1. We create a `VoicePipeline`, setup with a custom workflow. The workflow runs an Agent, but it also has some custom responses if you say the secret word. +2. When you speak, audio is forwarded to the voice pipeline. When you stop speaking, the agent runs. +3. The pipeline is run with the audio, which causes it to: + 1. Transcribe the audio + 2. Feed the transcription to the workflow, which runs the agent. + 3. Stream the output of the agent to a text-to-speech model. +4. Play the audio. + +Some suggested examples to try: + +- Tell me a joke (_the assistant tells you a joke_) +- What's the weather in Tokyo? (_will call the `get_weather` tool and then speak_) +- Hola, como estas? (_will handoff to the spanish agent_) +- Tell me about dogs. (_will respond with the hardcoded "you guessed the secret word" message_) diff --git a/samples-v2/openai_agents/voice/static/__init__.py b/samples-v2/openai_agents/voice/static/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples-v2/openai_agents/voice/static/main.py b/samples-v2/openai_agents/voice/static/main.py new file mode 100644 index 00000000..1b9e2024 --- /dev/null +++ b/samples-v2/openai_agents/voice/static/main.py @@ -0,0 +1,88 @@ +import asyncio +import random + +import numpy as np + +from agents import Agent, function_tool +from agents.extensions.handoff_prompt import prompt_with_handoff_instructions +from agents.voice import ( + AudioInput, + SingleAgentVoiceWorkflow, + SingleAgentWorkflowCallbacks, + VoicePipeline, +) + +from .util import AudioPlayer, record_audio + +""" +This is a simple example that uses a recorded audio buffer. Run it via: +`python -m examples.voice.static.main` + +1. You can record an audio clip in the terminal. +2. The pipeline automatically transcribes the audio. +3. The agent workflow is a simple one that starts at the Assistant agent. +4. The output of the agent is streamed to the audio player. + +Try examples like: +- Tell me a joke (will respond with a joke) +- What's the weather in Tokyo? (will call the `get_weather` tool and then speak) +- Hola, como estas? (will handoff to the spanish agent) +""" + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather for a given city.""" + print(f"[debug] get_weather called with city: {city}") + choices = ["sunny", "cloudy", "rainy", "snowy"] + return f"The weather in {city} is {random.choice(choices)}." + + +spanish_agent = Agent( + name="Spanish", + handoff_description="A spanish speaking agent.", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. Speak in Spanish.", + ), + model="gpt-4o-mini", +) + +agent = Agent( + name="Assistant", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. If the user speaks in Spanish, handoff to the spanish agent.", + ), + model="gpt-4o-mini", + handoffs=[spanish_agent], + tools=[get_weather], +) + + +class WorkflowCallbacks(SingleAgentWorkflowCallbacks): + def on_run(self, workflow: SingleAgentVoiceWorkflow, transcription: str) -> None: + print(f"[debug] on_run called with transcription: {transcription}") + + +async def main(): + pipeline = VoicePipeline( + workflow=SingleAgentVoiceWorkflow(agent, callbacks=WorkflowCallbacks()) + ) + + audio_input = AudioInput(buffer=record_audio()) + + result = await pipeline.run(audio_input) + + with AudioPlayer() as player: + async for event in result.stream(): + if event.type == "voice_stream_event_audio": + player.add_audio(event.data) + print("Received audio") + elif event.type == "voice_stream_event_lifecycle": + print(f"Received lifecycle event: {event.event}") + + # Add 1 second of silence to the end of the stream to avoid cutting off the last audio. + player.add_audio(np.zeros(24000 * 1, dtype=np.int16)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/samples-v2/openai_agents/voice/static/util.py b/samples-v2/openai_agents/voice/static/util.py new file mode 100644 index 00000000..a5806f41 --- /dev/null +++ b/samples-v2/openai_agents/voice/static/util.py @@ -0,0 +1,69 @@ +import curses +import time + +import numpy as np +import numpy.typing as npt +import sounddevice as sd + + +def _record_audio(screen: curses.window) -> npt.NDArray[np.float32]: + screen.nodelay(True) # Non-blocking input + screen.clear() + screen.addstr( + "Press to start recording. Press again to stop recording.\n" + ) + screen.refresh() + + recording = False + audio_buffer: list[npt.NDArray[np.float32]] = [] + + def _audio_callback(indata, frames, time_info, status): + if status: + screen.addstr(f"Status: {status}\n") + screen.refresh() + if recording: + audio_buffer.append(indata.copy()) + + # Open the audio stream with the callback. + with sd.InputStream(samplerate=24000, channels=1, dtype=np.float32, callback=_audio_callback): + while True: + key = screen.getch() + if key == ord(" "): + recording = not recording + if recording: + screen.addstr("Recording started...\n") + else: + screen.addstr("Recording stopped.\n") + break + screen.refresh() + time.sleep(0.01) + + # Combine recorded audio chunks. + if audio_buffer: + audio_data = np.concatenate(audio_buffer, axis=0) + else: + audio_data = np.empty((0,), dtype=np.float32) + + return audio_data + + +def record_audio(): + # Using curses to record audio in a way that: + # - doesn't require accessibility permissions on macos + # - doesn't block the terminal + audio_data = curses.wrapper(_record_audio) + return audio_data + + +class AudioPlayer: + def __enter__(self): + self.stream = sd.OutputStream(samplerate=24000, channels=1, dtype=np.int16) + self.stream.start() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.stream.stop() # wait for the stream to finish + self.stream.close() + + def add_audio(self, audio_data: npt.NDArray[np.int16]): + self.stream.write(audio_data) diff --git a/samples-v2/openai_agents/voice/streamed/README.md b/samples-v2/openai_agents/voice/streamed/README.md new file mode 100644 index 00000000..ab0ffedb --- /dev/null +++ b/samples-v2/openai_agents/voice/streamed/README.md @@ -0,0 +1,25 @@ +# Streamed voice demo + +This is an interactive demo, where you can talk to an Agent conversationally. It uses the voice pipeline's built in turn detection feature, so if you stop speaking the Agent responds. + +Run via: + +``` +python -m examples.voice.streamed.main +``` + +## How it works + +1. We create a `VoicePipeline`, setup with a `SingleAgentVoiceWorkflow`. This is a workflow that starts at an Assistant agent, has tools and handoffs. +2. Audio input is captured from the terminal. +3. The pipeline is run with the recorded audio, which causes it to: + 1. Transcribe the audio + 2. Feed the transcription to the workflow, which runs the agent. + 3. Stream the output of the agent to a text-to-speech model. +4. Play the audio. + +Some suggested examples to try: + +- Tell me a joke (_the assistant tells you a joke_) +- What's the weather in Tokyo? (_will call the `get_weather` tool and then speak_) +- Hola, como estas? (_will handoff to the spanish agent_) diff --git a/samples-v2/openai_agents/voice/streamed/__init__.py b/samples-v2/openai_agents/voice/streamed/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples-v2/openai_agents/voice/streamed/main.py b/samples-v2/openai_agents/voice/streamed/main.py new file mode 100644 index 00000000..95e93791 --- /dev/null +++ b/samples-v2/openai_agents/voice/streamed/main.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +import numpy as np +import sounddevice as sd +from textual import events +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.reactive import reactive +from textual.widgets import Button, RichLog, Static +from typing_extensions import override + +from agents.voice import StreamedAudioInput, VoicePipeline + +# Import MyWorkflow class - handle both module and package use cases +if TYPE_CHECKING: + # For type checking, use the relative import + from .my_workflow import MyWorkflow +else: + # At runtime, try both import styles + try: + # Try relative import first (when used as a package) + from .my_workflow import MyWorkflow + except ImportError: + # Fall back to direct import (when run as a script) + from my_workflow import MyWorkflow + +CHUNK_LENGTH_S = 0.05 # 100ms +SAMPLE_RATE = 24000 +FORMAT = np.int16 +CHANNELS = 1 + + +class Header(Static): + """A header widget.""" + + session_id = reactive("") + + @override + def render(self) -> str: + return "Speak to the agent. When you stop speaking, it will respond." + + +class AudioStatusIndicator(Static): + """A widget that shows the current audio recording status.""" + + is_recording = reactive(False) + + @override + def render(self) -> str: + status = ( + "🔴 Recording... (Press K to stop)" + if self.is_recording + else "⚪ Press K to start recording (Q to quit)" + ) + return status + + +class RealtimeApp(App[None]): + CSS = """ + Screen { + background: #1a1b26; /* Dark blue-grey background */ + } + + Container { + border: double rgb(91, 164, 91); + } + + Horizontal { + width: 100%; + } + + #input-container { + height: 5; /* Explicit height for input container */ + margin: 1 1; + padding: 1 2; + } + + Input { + width: 80%; + height: 3; /* Explicit height for input */ + } + + Button { + width: 20%; + height: 3; /* Explicit height for button */ + } + + #bottom-pane { + width: 100%; + height: 82%; /* Reduced to make room for session display */ + border: round rgb(205, 133, 63); + content-align: center middle; + } + + #status-indicator { + height: 3; + content-align: center middle; + background: #2a2b36; + border: solid rgb(91, 164, 91); + margin: 1 1; + } + + #session-display { + height: 3; + content-align: center middle; + background: #2a2b36; + border: solid rgb(91, 164, 91); + margin: 1 1; + } + + Static { + color: white; + } + """ + + should_send_audio: asyncio.Event + audio_player: sd.OutputStream + last_audio_item_id: str | None + connected: asyncio.Event + + def __init__(self) -> None: + super().__init__() + self.last_audio_item_id = None + self.should_send_audio = asyncio.Event() + self.connected = asyncio.Event() + self.pipeline = VoicePipeline( + workflow=MyWorkflow(secret_word="dog", on_start=self._on_transcription) + ) + self._audio_input = StreamedAudioInput() + self.audio_player = sd.OutputStream( + samplerate=SAMPLE_RATE, + channels=CHANNELS, + dtype=FORMAT, + ) + + def _on_transcription(self, transcription: str) -> None: + try: + self.query_one("#bottom-pane", RichLog).write(f"Transcription: {transcription}") + except Exception: + pass + + @override + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + with Container(): + yield Header(id="session-display") + yield AudioStatusIndicator(id="status-indicator") + yield RichLog(id="bottom-pane", wrap=True, highlight=True, markup=True) + + async def on_mount(self) -> None: + self.run_worker(self.start_voice_pipeline()) + self.run_worker(self.send_mic_audio()) + + async def start_voice_pipeline(self) -> None: + try: + self.audio_player.start() + self.result = await self.pipeline.run(self._audio_input) + + async for event in self.result.stream(): + bottom_pane = self.query_one("#bottom-pane", RichLog) + if event.type == "voice_stream_event_audio": + self.audio_player.write(event.data) + bottom_pane.write( + f"Received audio: {len(event.data) if event.data is not None else '0'} bytes" + ) + elif event.type == "voice_stream_event_lifecycle": + bottom_pane.write(f"Lifecycle event: {event.event}") + except Exception as e: + bottom_pane = self.query_one("#bottom-pane", RichLog) + bottom_pane.write(f"Error: {e}") + finally: + self.audio_player.close() + + async def send_mic_audio(self) -> None: + device_info = sd.query_devices() + print(device_info) + + read_size = int(SAMPLE_RATE * 0.02) + + stream = sd.InputStream( + channels=CHANNELS, + samplerate=SAMPLE_RATE, + dtype="int16", + ) + stream.start() + + status_indicator = self.query_one(AudioStatusIndicator) + + try: + while True: + if stream.read_available < read_size: + await asyncio.sleep(0) + continue + + await self.should_send_audio.wait() + status_indicator.is_recording = True + + data, _ = stream.read(read_size) + + await self._audio_input.add_audio(data) + await asyncio.sleep(0) + except KeyboardInterrupt: + pass + finally: + stream.stop() + stream.close() + + async def on_key(self, event: events.Key) -> None: + """Handle key press events.""" + if event.key == "enter": + self.query_one(Button).press() + return + + if event.key == "q": + self.exit() + return + + if event.key == "k": + status_indicator = self.query_one(AudioStatusIndicator) + if status_indicator.is_recording: + self.should_send_audio.clear() + status_indicator.is_recording = False + else: + self.should_send_audio.set() + status_indicator.is_recording = True + + +if __name__ == "__main__": + app = RealtimeApp() + app.run() diff --git a/samples-v2/openai_agents/voice/streamed/my_workflow.py b/samples-v2/openai_agents/voice/streamed/my_workflow.py new file mode 100644 index 00000000..3cb804b0 --- /dev/null +++ b/samples-v2/openai_agents/voice/streamed/my_workflow.py @@ -0,0 +1,81 @@ +import random +from collections.abc import AsyncIterator +from typing import Callable + +from agents import Agent, Runner, TResponseInputItem, function_tool +from agents.extensions.handoff_prompt import prompt_with_handoff_instructions +from agents.voice import VoiceWorkflowBase, VoiceWorkflowHelper + + +@function_tool +def get_weather(city: str) -> str: + """Get the weather for a given city.""" + print(f"[debug] get_weather called with city: {city}") + choices = ["sunny", "cloudy", "rainy", "snowy"] + return f"The weather in {city} is {random.choice(choices)}." + + +spanish_agent = Agent( + name="Spanish", + handoff_description="A spanish speaking agent.", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. Speak in Spanish.", + ), + model="gpt-4o-mini", +) + +agent = Agent( + name="Assistant", + instructions=prompt_with_handoff_instructions( + "You're speaking to a human, so be polite and concise. If the user speaks in Spanish, handoff to the spanish agent.", + ), + model="gpt-4o-mini", + handoffs=[spanish_agent], + tools=[get_weather], +) + + +class MyWorkflow(VoiceWorkflowBase): + def __init__(self, secret_word: str, on_start: Callable[[str], None]): + """ + Args: + secret_word: The secret word to guess. + on_start: A callback that is called when the workflow starts. The transcription + is passed in as an argument. + """ + self._input_history: list[TResponseInputItem] = [] + self._current_agent = agent + self._secret_word = secret_word.lower() + self._on_start = on_start + + async def run(self, transcription: str) -> AsyncIterator[str]: + self._on_start(transcription) + + # Add the transcription to the input history + self._input_history.append( + { + "role": "user", + "content": transcription, + } + ) + + # If the user guessed the secret word, do alternate logic + if self._secret_word in transcription.lower(): + yield "You guessed the secret word!" + self._input_history.append( + { + "role": "assistant", + "content": "You guessed the secret word!", + } + ) + return + + # Otherwise, run the agent + result = Runner.run_streamed(self._current_agent, self._input_history) + + async for chunk in VoiceWorkflowHelper.stream_text_from(result): + yield chunk + + # Update the input history and current agent + self._input_history = result.to_input_list() + self._current_agent = result.last_agent