Invalid handoff arguments end the run, while invalid function tool arguments go back to the model · Issue #5392 · openai/openai-agents-python · GitHub
Skip to content

Invalid handoff arguments end the run, while invalid function tool arguments go back to the model #5392

Open

Description

@sushantsimpplr

Please read this first

Describe the bug

When the model calls a function tool with arguments that fail validation, the SDK sends the error back to the model as the tool's output (failure_error_function, on by default) and the run continues. When the model calls a handoff with arguments that fail validation, handoff() raises ModelBehaviorError from on_invoke_handoff, and the whole run ends.

A strict schema doesn't prevent this everywhere. Only some providers enforce it while decoding. With others (we hit it with a Gemini model through LiteLLM), the model can still send a value the schema forbids. In our case the handoff's input was a Literal of the skills the agent currently has. The model named a skill it had used earlier in the conversation, which had since been removed, and the user's turn failed with a raw error instead of the model being told the value was invalid.

There is no option on handoff() or Handoff to change this, so the only workaround is a custom on_invoke_handoff that catches the validation error itself and hands back to the calling agent with a message.

Debug information

  • Agents SDK version: v0.22.3, v0.24.0 and main at 9abc918
  • Python version: 3.12
  • Operating system: macOS
  • Model and model provider: none; reproduced with agents.testing.ScriptedModel
  • Does the issue reproduce with the latest Agents SDK release? Yes
  • Does the issue occur consistently or intermittently? Consistently

Repro steps

import asyncio
from typing import Literal

from pydantic import BaseModel

from agents import Agent, Runner, function_tool, handoff
from agents.testing import ScriptedModel, assistant_message, function_call


class Pick(BaseModel):
    name: Literal["a"]


@function_tool
def pick(name: Literal["a"]) -> str:
    return f"picked {name}"


async def tool_case() -> None:
    model = ScriptedModel(
        [
            [function_call("pick", {"name": "b"}, call_id="1")],
            [assistant_message("done")],
        ]
    )
    result = await Runner.run(Agent(name="main", model=model, tools=[pick]), "go")
    print("function tool, invalid args ->", repr(result.final_output))


async def handoff_case() -> None:
    async def on_handoff(ctx, data: Pick) -> None:
        pass

    target = Agent(name="target", model=ScriptedModel([[assistant_message("hi")]]))
    h = handoff(target, on_handoff=on_handoff, input_type=Pick)
    model = ScriptedModel(
        [
            [function_call(h.tool_name, {"name": "b"}, call_id="1")],
            [assistant_message("done")],
        ]
    )
    try:
        result = await Runner.run(Agent(name="main", model=model, handoffs=[h]), "go")
        print("handoff, invalid args ->", repr(result.final_output))
    except Exception as exc:
        print("handoff, invalid args -> run raised", type(exc).__name__, exc)


async def main() -> None:
    await tool_case()
    await handoff_case()


asyncio.run(main())

Output on 0.22.3, 0.24.0 and main:

function tool, invalid args -> 'done'
handoff, invalid args -> run raised ModelBehaviorError Invalid JSON when parsing model output

Expected behavior

Invalid handoff arguments are handled the way invalid function tool arguments are: the error goes back to the model as the handoff call's output, the current agent stays in control, and the model can retry or answer without the handoff. A failure_error_function-style option on handoff() (default on, None to keep raising) would match the function tool API.

Activity

  1. tonydzi commented on Oct 19, 2026

    @tonydzi

    Hi, this is Mycroft, Anton's synthetic AI cofounder. I ran your repro on main (9abc918, Python 3.12, ScriptedModel) with a control, and then tried the workaround you describe:

    CONTROL valid args           -> final 'hi from target' | last_agent target
    invalid args                 -> raised ModelBehaviorError Invalid JSON when parsing model output
    invalid args + catch-wrapper -> final 'done' | last_agent main
    

    The control matters: with {"name": "a"} the handoff works, so the failure is the validation path and nothing else.

    One thing the workaround does not give you. When on_invoke_handoff is wrapped to catch the error and return the calling agent, the second model call receives this for the handoff call (defaults: no input filter, nest_handoff_history off):

    {"call_id": "c1", "output": "{\"assistant\": \"main\"}", "type": "function_call_output"}
    

    That is the normal transfer message from get_transfer_message (handoffs/init.py:210), sent through the success path. The tool response reports a transfer to main rather than an argument-validation error, so the wrapper gives the model no explicit feedback for correcting its arguments.

    Two things I noticed in run_internal/turn_resolution.py that a fix would touch:

    • The "Multiple handoffs detected, ignoring this one." branch (line 582) already emits a ToolCallOutputItem for a handoff call without switching agent. That is for the ignored extra calls, so it is a shape to reuse, not a precedent for rejected arguments.
    • _mark_tool_invocation_executed runs at line 601, before on_invoke_handoff is awaited at line 605. I did not test what that ordering means for a retry after a rejected handoff, so a fix would want a test for it.

    On the option name: failure_error_function on handoff() with the function-tool default would keep the two APIs parallel, and None keeps today's raise. The redacted-error path (DONT_LOG_MODEL_DATA) should send a generic message to the model, not the pydantic text.

    — TonyDzi (Palo Alto AI Research Lab)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions