---
name: openai-agents
description: Use when building or debugging OpenAI Agents SDK workflows — defining agents with tools and handoffs, wiring typed context, streaming responses, adding guardrails, or integrating with the Agentex ADK.
---

# OpenAI Agents SDK Patterns

The OpenAI Agents SDK (`openai-agents`) orchestrates LLM agents with tools, handoffs, and tracing.

## When to Activate

- Defining agents with system prompts, tools, and handoffs
- Writing `@function_tool` decorators and tool schemas
- Running agents with `Runner.run()` or streaming with `Runner.run_streamed()`
- Implementing multi-agent handoffs (triage → specialist)
- Debugging tool call errors, context leaks, or infinite loops
- Integrating with Agentex ADK via `adk.providers.openai`
- Adding tracing spans for observability

---

## Core Concepts

```
Agent
├── name, instructions (system prompt)
├── tools     — functions the agent can call
├── handoffs  — other agents it can delegate to
├── model     — LLM to use (default: gpt-4o)
└── output_type — structured Pydantic output (optional)

Runner
├── .run()          — async, returns final output
├── .run_streamed() — async generator, streams events
└── .run_sync()     — sync wrapper (testing/scripts)
```

---

## Minimal Agent

```python
from agents import Agent, Runner, function_tool

@function_tool
def get_weather(city: str) -> str:
    """Get current weather for a city."""
    return f"It's sunny and 72°F in {city}."

agent = Agent(
    name="Weather Agent",
    instructions="You help users check weather. Always use the get_weather tool.",
    tools=[get_weather],
    model="gpt-4o-mini",
)

# Run
result = await Runner.run(agent, "What's the weather in Tokyo?")
print(result.final_output)
```

---

## Defining Tools

```python
from agents import function_tool
from pydantic import BaseModel

# Simple tool — docstring becomes the tool description
@function_tool
def search_web(query: str) -> str:
    """Search the web for current information. Returns the top results."""
    return web_search_api(query)

# Tool with multiple typed params
# NOTE: `eval()` on a tool argument is RCE — the model can pass `__import__(...)`.
# Use a constrained evaluator like `simpleeval` (or `ast.literal_eval` for literals).
from simpleeval import simple_eval

@function_tool
def calculate(expression: str, precision: int = 2) -> str:
    """Evaluate a mathematical expression and return the result."""
    result = simple_eval(expression)
    return str(round(result, precision))

# Tool returning structured data
class SearchResult(BaseModel):
    title: str
    url: str
    snippet: str

@function_tool
def search_docs(query: str, limit: int = 5) -> list[SearchResult]:
    """Search the documentation. Returns matching articles."""
    return [SearchResult(...) for r in docs_search(query, limit)]

# Async tool
@function_tool
async def fetch_user(user_id: str) -> dict:
    """Fetch user profile from the database."""
    user = await db.get_user(user_id)
    return user.model_dump()
```

**Tool naming:** the function name becomes the tool name. Keep names short and action-oriented (`search_web`, not `search_the_web_for_information`).

---

## Context (passing data to tools without LLM)

```python
from agents import Agent, Runner, RunContextWrapper, function_tool
from dataclasses import dataclass

@dataclass
class AppContext:
    user_id: str
    db_session: AsyncSession

# Tools receive context as first param (not exposed to LLM)
@function_tool
async def get_my_orders(ctx: RunContextWrapper[AppContext]) -> list[dict]:
    """Get the current user's orders."""
    orders = await OrderCRUD(ctx.context.db_session).list_for_user(ctx.context.user_id)
    return [o.model_dump() for o in orders]

agent = Agent[AppContext](
    name="Order Agent",
    instructions="Help users check their orders.",
    tools=[get_my_orders],
)

context = AppContext(user_id="u-123", db_session=session)
result = await Runner.run(agent, "Show my recent orders", context=context)
```

---

## Structured Output

```python
from pydantic import BaseModel
from agents import Agent, Runner

class EmailDraft(BaseModel):
    subject: str
    body: str
    tone: Literal["formal", "casual", "urgent"]

agent = Agent(
    name="Email Writer",
    instructions="Draft professional emails based on user requests.",
    output_type=EmailDraft,   # forces structured JSON response
)

result = await Runner.run(agent, "Write a follow-up email for a job interview")
email: EmailDraft = result.final_output   # typed, validated by Pydantic
print(email.subject)
```

---

## Handoffs (Multi-Agent)

Handoffs let one agent delegate to another specialized agent. The triage agent decides which specialist handles the task.

```python
from agents import Agent, handoff, Runner

coding_agent = Agent(
    name="Coding Assistant",
    instructions="You solve programming problems. Write clean, working code.",
    tools=[search_docs, run_code],
)

writing_agent = Agent(
    name="Writing Assistant",
    instructions="You help with writing, editing, and proofreading.",
)

triage_agent = Agent(
    name="Triage",
    instructions="""Route the user to the right specialist:
    - For code/programming questions → coding_assistant
    - For writing/editing requests → writing_assistant
    - Handle simple questions yourself.""",
    handoffs=[coding_agent, writing_agent],
)

result = await Runner.run(triage_agent, "Fix this Python bug: ...")
# triage_agent may hand off to coding_agent, which runs to completion
print(result.final_output)
```

**Customizing handoff behavior:**
```python
from agents import handoff

def on_handoff_to_billing(ctx: RunContextWrapper[AppContext]):
    # Called when handoff happens — log, update state, etc.
    logger.info(f"Handing off to billing for user {ctx.context.user_id}")

billing_agent = Agent(name="Billing", instructions="...")

triage_agent = Agent(
    handoffs=[
        handoff(billing_agent, on_handoff=on_handoff_to_billing),
    ]
)
```

---

## Streaming

```python
from agents import Runner
from agents.stream_events import RunItemStreamEvent, AgentUpdatedStreamEvent

async def stream_agent(agent, prompt: str):
    stream = Runner.run_streamed(agent, prompt)
    async for event in stream.stream_events():
        if isinstance(event, RunItemStreamEvent):
            # Each completed item (tool call, tool result, message)
            item = event.item
            if hasattr(item, "raw_item"):
                raw = item.raw_item
                if raw.get("type") == "response.output_text.delta":
                    print(raw["delta"], end="", flush=True)

    return await stream.get_final_output()

# FastAPI SSE endpoint
@router.get("/stream")
async def stream_response(prompt: str):
    async def generate():
        stream = Runner.run_streamed(agent, prompt)
        async for event in stream.stream_events():
            if isinstance(event, RunItemStreamEvent):
                item = event.item
                if hasattr(item, "raw_item"):
                    delta = item.raw_item.get("delta", "")
                    if delta:
                        yield f"data: {delta}\n\n"
    return StreamingResponse(generate(), media_type="text/event-stream")
```

---

## Guardrails

Guardrails validate input/output before the agent processes or responds.

```python
from agents import Agent, input_guardrail, output_guardrail, GuardrailFunctionOutput

@input_guardrail
async def no_pii(ctx, agent, input) -> GuardrailFunctionOutput:
    text = input if isinstance(input, str) else str(input)
    if contains_pii(text):
        return GuardrailFunctionOutput(
            output_info="PII detected",
            tripwire_triggered=True,    # stops the agent
        )
    return GuardrailFunctionOutput(output_info="clean", tripwire_triggered=False)

@output_guardrail
async def no_harmful_content(ctx, agent, output) -> GuardrailFunctionOutput:
    if is_harmful(str(output)):
        return GuardrailFunctionOutput(
            output_info="harmful content",
            tripwire_triggered=True,
        )
    return GuardrailFunctionOutput(output_info="safe", tripwire_triggered=False)

agent = Agent(
    name="Safe Agent",
    instructions="...",
    input_guardrails=[no_pii],
    output_guardrails=[no_harmful_content],
)
```

---

## Tracing

```python
from agents import Agent, Runner
from agents.tracing import trace, custom_span

# Runner automatically creates a root trace
result = await Runner.run(agent, "Hello", run_config=RunConfig(
    trace_id="my-trace-123",        # link to your own tracing system
    trace_metadata={"user_id": "u-123"},
))

# Add custom spans inside tools
@function_tool
async def complex_search(query: str) -> str:
    """Search across multiple sources."""
    with custom_span("db_search"):
        db_results = await db.search(query)
    with custom_span("web_search"):
        web_results = await web.search(query)
    return combine(db_results, web_results)
```

---

## Agentex ADK Integration

In Agentex Temporal agents, use `adk.providers.openai` instead of calling `Runner` directly — it handles message streaming to the UI automatically.

```python
from agentex.lib import adk
from agents import Agent, function_tool

@function_tool
async def search_web(query: str) -> str:
    """Search the web for information."""
    return await web_search(query)

agent = Agent(
    name="Research Agent",
    instructions="Research topics thoroughly using web search.",
    tools=[search_web],
    model="gpt-4o",
)

# In a Temporal activity:
async def run_research_agent(params: AgentParams) -> str:
    result = await adk.providers.openai.run_agent_streamed_auto_send(
        agent=agent,
        task_id=params.task_id,
        input=params.user_message,
        # context=AppContext(...)  if using typed context
    )
    return result.final_output

# run_agent_streamed_auto_send:
# - streams each token to the Agentex UI via adk.messages
# - wraps Runner.run_streamed internally
# - handles tracing integration
```

---

## RunConfig Options

```python
from agents import RunConfig

result = await Runner.run(
    agent,
    "Hello",
    run_config=RunConfig(
        model="gpt-4o",                    # override agent's model
        model_settings=ModelSettings(
            temperature=0.2,
            max_tokens=2000,
        ),
        max_turns=10,                       # prevent infinite agent loops
        trace_id="req-abc-123",
        workflow_name="my-workflow",        # appears in traces
    ),
)
```

---

## Common Errors

| Error | Cause | Fix |
|---|---|---|
| `MaxTurnsExceeded` | Agent looping (tool → agent → tool) | Set `max_turns`, check for circular handoffs |
| Tool not called | Weak system prompt | Be explicit: "You MUST use X tool to answer" |
| Wrong handoff | Ambiguous triage instructions | List exact conditions for each handoff |
| `ValidationError` in tool | Pydantic type mismatch in return | Ensure return type matches annotation |
| Context `None` in tool | Forgot to pass `context=` to Runner | Pass `context=your_context` in `Runner.run()` |

---

## Red Flags

- **No `max_turns` set** — without a turn limit an agent that calls a tool whose result triggers another tool call can loop indefinitely; always pass `RunConfig(max_turns=N)` to cap runaway execution
- **Passing app state (DB session, user ID) through the LLM** — including session objects or sensitive IDs in the prompt or tool return values exposes them to the model and wastes tokens; use typed `context` (`RunContextWrapper`) so tools receive state without the LLM ever seeing it
- **Vague tool docstrings** — the docstring is the only description the LLM sees; "Does stuff with the database" gives the model no signal on when to call it; write one sentence that says exactly what the tool returns and when to use it
- **Vague handoff instructions in the triage agent** — "Route to the right agent" with no criteria leads to random or wrong handoffs; list the exact conditions for each handoff in the triage agent's instructions
- **Using `Runner.run()` directly inside an Agentex activity** — `Runner.run()` doesn't stream tokens to the Agentex UI; use `adk.providers.openai.run_agent_streamed_auto_send()` which wraps `Runner.run_streamed()` and handles token delivery automatically
- **No input/output guardrails on user-facing agents** — agents that handle user-supplied text without guardrails can be prompted to leak context, call wrong tools, or produce harmful output; add `@input_guardrail` and `@output_guardrail` for sensitive deployments
- **Tool names that are long or vague** — the model uses the tool name as a primary signal; `search_the_web_for_current_information` is worse than `search_web`; keep tool names short, lowercase, and verb-noun

## Checklist

- [ ] Tool docstrings are clear — they become the LLM-facing description
- [ ] Context used for app state (DB session, user ID) — not passed through LLM
- [ ] `output_type` set for structured outputs — avoids parsing LLM text
- [ ] `max_turns` set to prevent runaway agent loops
- [ ] Handoff instructions are specific — vague routing leads to wrong agent
- [ ] `run_agent_streamed_auto_send` used in Agentex activities (not `Runner` directly)
- [ ] Guardrails added for user-facing agents handling sensitive data
- [ ] Tool names are short verb-noun: `get_user`, `search_docs`, `send_email`
