# OpenAI Agents SDK v0.24.0: Approval + resume & tool error formatting

> Pause tool calls for human approval, checkpoint the run to a string, approve/reject, and resume. Plus the new run-wide tool_error_formatter that scrubs secrets from crashed tools.

Status: tested 2026-10-15 · Python 3.13 (tested) · License: MIT · Tier: Free
Download: https://tryagentsignal.com/downloads/openai-agents-v024-starter.zip

## What it shows
- **01_tool_error_formatter.py**: New in 0.24.0: RunConfig(tool_error_formatter=...) also runs when a @function_tool raises, with kind == "tool_exception" and the original exception in args.error. The demo removes a fake secret before the model sees it; returning None keeps the SDK default.
- **02_approval_resume.py**: needs_approval=True → result.interruptions → to_state().to_string() → RunState.from_string → approve() / reject(rejection_message=...) → resume. Not new in 0.24.0; the release fixes it.

## Mocked vs real
Only the LLM is mocked (ScriptedModel subclasses the real agents.Model), so it runs with no API key. Runner loop, tool execution, error formatting, interruptions, RunState serialization and resume are real SDK code. Not tested: real model behaviour, sessions/Redis persistence, MCP, tracing export.

## Gotchas
- Formatters that treat every non-approval_rejected kind as "tool not found" now give wrong text for crashed tools. Handle "tool_exception" or return None.
- Per-tool failure_error_function takes precedence. MCP tools, Agent.as_tool, timeouts and cancellation skip the run-wide formatter.
- args.error can hold secrets. Scrub it.
- RunState schema 1.21 can't be read by older SDKs. Restore with the same agent definitions; only deserialize trusted state.

## FAQ
### What does this template show?
Two OpenAI Agents SDK v0.24.0 patterns: a run-wide tool_error_formatter that also handles crashing function tools (kind="tool_exception"), and tools with needs_approval=True that pause the run, serialize RunState to a string, and resume after approve/reject.

### Is approval/resume new in v0.24.0?
No. It existed before; v0.24.0 fixes it (approval history kept across resumes, RunState schema 1.21).

### Do I need an OpenAI API key to run it?
Not for the demo. Only the model is mocked (ScriptedModel subclasses agents.Model); everything else is real SDK code. Swap in a real model for production.

### Can older SDK versions read the saved state?
No. RunState snapshots in schema 1.21 cannot be read by older SDKs. Restore with the same agent definitions and only deserialize trusted state.

### Is it free and what license?
Yes, it is in the free tier and the code is MIT-licensed (LICENSE included in the zip).

### Which Python version was it tested on?
Python 3.13 on Oct 15, 2026, with openai-agents==0.24.0 pinned in requirements.txt.

## Code

### 01_tool_error_formatter.py

```python
"""v0.24.0: run-wide RunConfig.tool_error_formatter now receives kind="tool_exception" + error."""
import asyncio
from agents import Agent, Runner, RunConfig, function_tool, ToolErrorFormatterArgs
from fake_model import ScriptedModel

class PaymentGatewayDown(Exception): pass

@function_tool
def charge_card(amount: int) -> str:
    """Charge a card.

    Args:
        amount: amount in cents
    """
    raise PaymentGatewayDown("upstream 503 token=sk-SECRET")   # sensitive detail

@function_tool
def lookup_price(sku: str) -> str:
    """Look up a price."""
    return f"{sku} costs 1999"

def formatter(args: ToolErrorFormatterArgs):
    print(f"  [formatter] kind={args.kind} tool={args.tool_name} error={type(args.error).__name__ if args.error else None}")
    if args.kind == "tool_exception":
        # sanitize: never forward raw exception text (may contain secrets)
        return f"Tool '{args.tool_name}' failed ({type(args.error).__name__}). Try again later."
    return None  # keep SDK default for approval_rejected / tool_not_found

async def main():
    agent = Agent(name="Shop", instructions="help",
                  model=ScriptedModel([("lookup_price", {"sku": "A1"}), ("charge_card", {"amount": 1999})]),
                  tools=[lookup_price, charge_card])
    print("== with run-wide formatter ==")
    r = await Runner.run(agent, "buy A1", run_config=RunConfig(tool_error_formatter=formatter, tracing_disabled=True))
    print("final:", r.final_output)
    agent2 = agent.clone(model=ScriptedModel([("charge_card", {"amount": 1})]))
    print("== without formatter (SDK default) ==")
    r = await Runner.run(agent2, "buy", run_config=RunConfig(tracing_disabled=True))
    print("final:", r.final_output)

asyncio.run(main())

```

### 02_approval_resume.py

```python
"""Human-in-the-loop: needs_approval -> interruption -> serialize RunState -> restore -> approve/reject -> resume."""
import asyncio
from agents import Agent, Runner, RunConfig, RunState, function_tool
from fake_model import ScriptedModel

@function_tool(needs_approval=True)
def delete_file(path: str) -> str:
    """Delete a file."""
    return f"deleted {path}"

@function_tool(needs_approval=True)
def send_email(to: str) -> str:
    """Send an email."""
    return f"emailed {to}"

def make_agent():
    return Agent(name="Ops", instructions="ops",
                 model=ScriptedModel([("delete_file", {"path": "/tmp/x"}), ("send_email", {"to": "a@b.c"})]),
                 tools=[delete_file, send_email])

async def main():
    cfg = RunConfig(tracing_disabled=True)
    agent = make_agent()
    r = await Runner.run(agent, "clean up and notify", run_config=cfg)
    print("interruptions:", [(i.tool_name, i.arguments) for i in r.interruptions])
    saved = r.to_state().to_string()          # durable checkpoint (e.g. DB)
    print(f"serialized state: {len(saved)} chars")

    # ... later / another process: restore with same agent definition
    agent = make_agent()
    state = await RunState.from_string(agent, saved)
    for item in state.get_interruptions():
        if item.tool_name == "delete_file":
            state.approve(item)
        else:
            state.reject(item, rejection_message="Human declined sending email.")
    r2 = await Runner.run(agent, state, run_config=cfg)
    print("interruptions after resume:", r2.interruptions)
    print("final:", r2.final_output)

asyncio.run(main())

```

### fake_model.py

```python
"""Offline scripted Model (MOCK). Replaces the OpenAI API; everything else is real SDK code."""
from __future__ import annotations
import json
from agents import Model, ModelResponse, Usage
from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText

class ScriptedModel(Model):
    """First turn: emit the given tool calls. Later turns: echo tool outputs as final text."""
    def __init__(self, calls: list[tuple[str, dict]]):
        self.calls = calls
        self.turn = 0

    async def get_response(self, system_instructions, input, model_settings, tools, output_schema,
                           handoffs, tracing, *, previous_response_id=None, conversation_id=None, prompt=None, **kw):
        self.turn += 1
        items = input if isinstance(input, list) else []
        outputs = [i for i in items if isinstance(i, dict) and i.get("type") == "function_call_output"]
        if not outputs:
            out = [ResponseFunctionToolCall(type="function_call", id=f"fc_{n}", call_id=f"call_{n}",
                                            name=name, arguments=json.dumps(args))
                   for n, (name, args) in enumerate(self.calls)]
        else:
            text = "Model saw tool outputs: " + " | ".join(f"{o['call_id']}={o['output']}" for o in outputs)
            out = [ResponseOutputMessage(type="message", id="msg_1", role="assistant", status="completed",
                   content=[ResponseOutputText(type="output_text", text=text, annotations=[])])]
        return ModelResponse(output=out, usage=Usage(), response_id=None)

    def stream_response(self, *a, **kw):
        raise NotImplementedError("streaming not mocked")

```

### requirements.txt

```
openai-agents==0.24.0

```

### run_output.txt

```
### 01_tool_error_formatter.py
== with run-wide formatter ==
  [formatter] kind=tool_exception tool=charge_card error=PaymentGatewayDown
final: Model saw tool outputs: call_0=A1 costs 1999 | call_1=Tool 'charge_card' failed (PaymentGatewayDown). Try again later.
== without formatter (SDK default) ==
final: Model saw tool outputs: call_0=An error occurred while running the tool. Please try again.
### 02_approval_resume.py
interruptions: [('delete_file', '{"path": "/tmp/x"}'), ('send_email', '{"to": "a@b.c"}')]
serialized state: 12612 chars
interruptions after resume: []
final: Model saw tool outputs: call_0=deleted /tmp/x | call_1=Human declined sending email.

```
