From 7dcc5797c5f44594d52267cb8efe812c66ba4d4f Mon Sep 17 00:00:00 2001 From: Tahir Khan Date: Sun, 21 Jun 2026 20:52:21 +0530 Subject: [PATCH 1/2] examples: add ModelBehaviorError handling patterns and self-healing agent Adds two new examples to examples/agent_patterns/ that address the common question of what to do when the model calls a nonexistent tool or produces invalid output (ModelBehaviorError), referenced in issue 325. model_behavior_error_handling.py: Three practical patterns for handling ModelBehaviorError: 1. Return a safe fallback value for best-effort tasks. 2. Log and re-raise with enriched context for production pipelines. 3. Retry the same task with a more reliable fallback agent. self_healing_agent.py: A self-healing loop that catches ModelBehaviorError, appends the error as a correction to the conversation, and retries up to MAX_SELF_HEALS times. This gives the model a chance to fix its own mistake without restarting the full run from scratch. Also updates examples/agent_patterns/README.md to document both patterns. --- examples/agent_patterns/README.md | 17 ++ .../model_behavior_error_handling.py | 158 ++++++++++++++++++ examples/agent_patterns/self_healing_agent.py | 118 +++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 examples/agent_patterns/model_behavior_error_handling.py create mode 100644 examples/agent_patterns/self_healing_agent.py diff --git a/examples/agent_patterns/README.md b/examples/agent_patterns/README.md index 2cd34561c5..9848c40532 100644 --- a/examples/agent_patterns/README.md +++ b/examples/agent_patterns/README.md @@ -60,3 +60,20 @@ See the [`input_guardrails.py`](./input_guardrails.py) and [`output_guardrails.p You can pause runs for manual approval before executing sensitive tools. This is useful for operations like sending money, deleting data, or running destructive commands. See [`human_in_the_loop.py`](./human_in_the_loop.py) for the base approval flow and [`human_in_the_loop_custom_rejection.py`](./human_in_the_loop_custom_rejection.py) for run-level tool error formatting when approvals are rejected. + +## Handling ModelBehaviorError + +`ModelBehaviorError` is raised when the model does something unexpected — calling a tool that doesn't exist, returning malformed JSON, or failing schema validation. Rather than always letting the exception crash your run, you have several options. + +See [`model_behavior_error_handling.py`](./model_behavior_error_handling.py) for three patterns: +1. **Return a safe fallback value** — useful for best-effort tasks. +2. **Log and re-raise with enriched context** — useful in production pipelines that need structured error reporting. +3. **Retry with a fallback agent** — hand the same task to a more reliable model when the primary one misbehaves. + +## Self-healing agent + +A more advanced pattern: when `ModelBehaviorError` is caught, the error description is appended to the conversation as a correction and the run is retried, giving the model a chance to fix its own mistake without a full restart. + +This is useful when the task is long-running, restarting from scratch is expensive, or you are using a smaller model that occasionally calls wrong tools. + +See [`self_healing_agent.py`](./self_healing_agent.py) for an example. diff --git a/examples/agent_patterns/model_behavior_error_handling.py b/examples/agent_patterns/model_behavior_error_handling.py new file mode 100644 index 0000000000..b238797581 --- /dev/null +++ b/examples/agent_patterns/model_behavior_error_handling.py @@ -0,0 +1,158 @@ +"""Handling ModelBehaviorError — three patterns. + +``ModelBehaviorError`` is raised when the model does something the SDK cannot +handle: calling a tool that does not exist, returning malformed JSON for a +structured output, or producing an output that fails schema validation. + +This file demonstrates three practical ways to deal with it, in increasing +order of complexity. + +Pattern 1 — Ignore and return a fallback value. + Useful when the task is best-effort and a graceful default is acceptable. + +Pattern 2 — Log and re-raise with context. + Useful in production pipelines where you want structured error reporting + without losing the original exception. + +Pattern 3 — Retry with a fallback agent. + When the primary model misbehaves, hand the same task to a more reliable + (often larger) fallback model. + +Run with: + python -m examples.agent_patterns.model_behavior_error_handling +""" + +import asyncio +from dataclasses import dataclass + +from agents import Agent, ModelBehaviorError, Runner, function_tool + + +@function_tool +def get_stock_price(ticker: str) -> str: + """Return a (mocked) stock price for the given ticker symbol. + + Args: + ticker: Stock ticker symbol, e.g. AAPL. + """ + prices = {"AAPL": "$189.30", "GOOGL": "$175.20", "MSFT": "$415.50"} + return prices.get(ticker.upper(), f"Ticker '{ticker}' not found.") + + +primary_agent = Agent( + name="Stock Assistant", + instructions=( + "You are a stock price assistant. Use the get_stock_price tool to answer questions. " + "Do not call any other tools." + ), + tools=[get_stock_price], +) + +# A more conservative fallback agent using explicit instructions to reduce errors. +fallback_agent = Agent( + name="Stock Assistant (fallback)", + instructions=( + "You are a stock price assistant. You have ONE tool: get_stock_price(ticker). " + "Call it exactly once with the ticker symbol the user mentioned. " + "Do not call any other tool. Do not make up data." + ), + tools=[get_stock_price], +) + + +# --------------------------------------------------------------------------- +# Pattern 1 — Return a safe default on error +# --------------------------------------------------------------------------- + + +async def pattern_1_fallback_value(task: str) -> str: + """Run the agent; return a safe default string if ModelBehaviorError occurs.""" + try: + result = await Runner.run(primary_agent, task) + return result.final_output + except ModelBehaviorError as exc: + print(f"[pattern-1] ModelBehaviorError: {exc.message}") + return "Sorry, I could not retrieve that information right now." + + +# --------------------------------------------------------------------------- +# Pattern 2 — Log and re-raise with enriched context +# --------------------------------------------------------------------------- + + +@dataclass +class EnrichedModelError(Exception): + original: ModelBehaviorError + task: str + agent_name: str + + def __str__(self) -> str: + return ( + f"Agent '{self.agent_name}' failed on task: {self.task!r}\n" + f"Reason: {self.original.message}" + ) + + +async def pattern_2_log_and_reraise(task: str) -> str: + """Run the agent; re-raise ModelBehaviorError with structured context.""" + try: + result = await Runner.run(primary_agent, task) + return result.final_output + except ModelBehaviorError as exc: + raise EnrichedModelError( + original=exc, + task=task, + agent_name=primary_agent.name, + ) from exc + + +# --------------------------------------------------------------------------- +# Pattern 3 — Retry with a fallback agent +# --------------------------------------------------------------------------- + + +async def pattern_3_fallback_agent(task: str) -> str: + """Try the primary agent; fall back to a more reliable agent on error.""" + try: + result = await Runner.run(primary_agent, task) + print("[pattern-3] Primary agent succeeded.") + return result.final_output + except ModelBehaviorError as exc: + print(f"[pattern-3] Primary agent failed ({exc.message}), trying fallback agent...") + result = await Runner.run(fallback_agent, task) + print("[pattern-3] Fallback agent succeeded.") + return result.final_output + + +# --------------------------------------------------------------------------- +# Demo +# --------------------------------------------------------------------------- + + +async def main() -> None: + task = "What is the current price of AAPL stock?" + + print("=" * 60) + print("Pattern 1 — fallback value") + print("=" * 60) + output = await pattern_1_fallback_value(task) + print(f"Output: {output}\n") + + print("=" * 60) + print("Pattern 2 — log and re-raise") + print("=" * 60) + try: + output = await pattern_2_log_and_reraise(task) + print(f"Output: {output}\n") + except EnrichedModelError as exc: + print(f"Caught enriched error:\n{exc}\n") + + print("=" * 60) + print("Pattern 3 — fallback agent") + print("=" * 60) + output = await pattern_3_fallback_agent(task) + print(f"Output: {output}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agent_patterns/self_healing_agent.py b/examples/agent_patterns/self_healing_agent.py new file mode 100644 index 0000000000..10cb16ca45 --- /dev/null +++ b/examples/agent_patterns/self_healing_agent.py @@ -0,0 +1,118 @@ +"""Self-healing agent pattern. + +When a model calls a tool that does not exist, or produces malformed JSON for +a structured output, the SDK raises ``ModelBehaviorError``. The default +behaviour is to let that exception propagate and crash the run. + +This example shows a loop that catches ``ModelBehaviorError``, appends the +error text to the conversation as a user-turn correction, and retries — giving +the model a chance to fix its own mistake before giving up. + +The pattern is useful when: +- You are using a smaller/cheaper model that occasionally calls wrong tools. +- You want resilience without replacing the model or hard-coding fallbacks. +- The task is long-running and restarting from scratch is expensive. + +Run with: + python -m examples.agent_patterns.self_healing_agent +""" + +import asyncio + +from agents import Agent, ModelBehaviorError, Runner, function_tool + +MAX_SELF_HEALS = 3 + + +@function_tool +def add(a: int, b: int) -> int: + """Return the sum of two integers. + + Args: + a: First integer. + b: Second integer. + """ + return a + b + + +@function_tool +def multiply(a: int, b: int) -> int: + """Return the product of two integers. + + Args: + a: First integer. + b: Second integer. + """ + return a * b + + +agent = Agent( + name="Math Assistant", + instructions=( + "You are a math assistant. Use the available tools to answer questions. " + "Only call tools that exist: add, multiply." + ), + tools=[add, multiply], +) + + +async def run_with_self_healing(task: str) -> str: + """Run an agent task, retrying up to MAX_SELF_HEALS times on ModelBehaviorError. + + On each retry the error description is appended to the conversation so the + model can understand what went wrong and correct itself. + + Args: + task: The initial user message to send to the agent. + + Returns: + The final output string from the agent. + + Raises: + ModelBehaviorError: If the model keeps misbehaving after all retries. + """ + messages: str | list = task + heals_remaining = MAX_SELF_HEALS + + while True: + try: + result = await Runner.run(agent, messages) + return result.final_output + except ModelBehaviorError as exc: + if heals_remaining <= 0: + print(f"[self-heal] Giving up after {MAX_SELF_HEALS} attempts.") + raise + + heals_remaining -= 1 + print( + f"[self-heal] ModelBehaviorError caught " + f"({MAX_SELF_HEALS - heals_remaining}/{MAX_SELF_HEALS}): {exc.message}" + ) + + # Feed the error back so the model can self-correct on the next turn. + correction = ( + f"Your previous response caused an error: {exc.message}\n" + "Please correct your approach and try again using only the tools available." + ) + + # Build a fresh input that includes the correction. + # We restart from the original task plus the correction note so the + # model has full context without the broken tool call in its history. + messages = f"{task}\n\n[System note: {correction}]" + print("[self-heal] Retrying with correction appended to input...") + + +async def main() -> None: + task = "What is (7 + 3) multiplied by 4?" + + print(f"Task: {task}") + print("-" * 60) + + output = await run_with_self_healing(task) + + print("-" * 60) + print(f"Answer: {output}") + + +if __name__ == "__main__": + asyncio.run(main()) From e7d840a5be4633216b6438b933ae40c249422e21 Mon Sep 17 00:00:00 2001 From: Tahir Khan Date: Sun, 21 Jun 2026 22:17:18 +0530 Subject: [PATCH 2/2] fix(self_healing_agent): resume from completed turns via exc.run_data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the retry loop discarded all completed turns and restarted from the original task string, causing prior tool calls to be re-run on every heal attempt (including any tool side effects). Now the retry builds the next input from exc.run_data.new_items — the list of RunItems completed before the error — and appends only the correction message. This preserves the conversation history and avoids re-running prior work, which is the main point of the self-healing pattern for long-running tasks. Falls back to full restart if exc.run_data is None (should not occur in practice, but defensive). --- examples/agent_patterns/self_healing_agent.py | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/examples/agent_patterns/self_healing_agent.py b/examples/agent_patterns/self_healing_agent.py index 10cb16ca45..b0fc162140 100644 --- a/examples/agent_patterns/self_healing_agent.py +++ b/examples/agent_patterns/self_healing_agent.py @@ -5,8 +5,12 @@ behaviour is to let that exception propagate and crash the run. This example shows a loop that catches ``ModelBehaviorError``, appends the -error text to the conversation as a user-turn correction, and retries — giving -the model a chance to fix its own mistake before giving up. +error text to the conversation as a correction, and retries — giving the +model a chance to fix its own mistake before giving up. + +Crucially, the retry resumes from ``exc.run_data.new_items``, which contains +all the turns the agent completed before the error occurred. This avoids +re-running prior work (and re-triggering tool side effects) on every heal. The pattern is useful when: - You are using a smaller/cheaper model that occasionally calls wrong tools. @@ -18,8 +22,9 @@ """ import asyncio +from typing import Union -from agents import Agent, ModelBehaviorError, Runner, function_tool +from agents import Agent, ModelBehaviorError, Runner, TResponseInputItem, function_tool MAX_SELF_HEALS = 3 @@ -59,8 +64,9 @@ def multiply(a: int, b: int) -> int: async def run_with_self_healing(task: str) -> str: """Run an agent task, retrying up to MAX_SELF_HEALS times on ModelBehaviorError. - On each retry the error description is appended to the conversation so the - model can understand what went wrong and correct itself. + On each retry the completed turns from ``exc.run_data.new_items`` are + preserved and a correction note is appended, so the model continues from + where it left off rather than restarting the full task. Args: task: The initial user message to send to the agent. @@ -71,12 +77,12 @@ async def run_with_self_healing(task: str) -> str: Raises: ModelBehaviorError: If the model keeps misbehaving after all retries. """ - messages: str | list = task + input: Union[str, list[TResponseInputItem]] = task heals_remaining = MAX_SELF_HEALS while True: try: - result = await Runner.run(agent, messages) + result = await Runner.run(agent, input) return result.final_output except ModelBehaviorError as exc: if heals_remaining <= 0: @@ -89,17 +95,34 @@ async def run_with_self_healing(task: str) -> str: f"({MAX_SELF_HEALS - heals_remaining}/{MAX_SELF_HEALS}): {exc.message}" ) - # Feed the error back so the model can self-correct on the next turn. - correction = ( - f"Your previous response caused an error: {exc.message}\n" - "Please correct your approach and try again using only the tools available." + # Build the correction message to append. + correction: TResponseInputItem = { + "role": "user", + "content": ( + f"Your previous response caused an error: {exc.message}\n" + "Please correct your approach and try again using only the tools available." + ), + } + + # Resume from the completed turns rather than restarting from scratch. + # exc.run_data.new_items holds every RunItem produced before the error, + # so prior tool calls and their results are preserved and not re-run. + completed_items: list[TResponseInputItem] = ( + [item.to_input_item() for item in exc.run_data.new_items] + if exc.run_data is not None + else [] ) - # Build a fresh input that includes the correction. - # We restart from the original task plus the correction note so the - # model has full context without the broken tool call in its history. - messages = f"{task}\n\n[System note: {correction}]" - print("[self-heal] Retrying with correction appended to input...") + if completed_items: + input = completed_items + [correction] + print( + f"[self-heal] Resuming from {len(completed_items)} completed turn(s) " + "with correction appended..." + ) + else: + # No completed turns to preserve — fall back to the original task. + input = f"{task}\n\n{correction['content']}" + print("[self-heal] No prior turns to resume from. Retrying from scratch...") async def main() -> None: