Skip to content

Finished tool call is dropped when a parallel tool's guardrail trips #5327

Closed

Description

@thuongvu

Please read this first

Describe the bug

When parallel tool calls run and one of them ends the run with an exception, a call that already finished is dropped from the history.

For example, the model calls create_ticket and send_email together. create_ticket finishes. Then a tool input guardrail on send_email trips, and the run raises ToolInputGuardrailTripwireTriggered. The Session ends up with only the user message, so on the next turn the model creates the ticket again. gpt-5-mini did this 10 of 10 times. With a fix, it was 0 of 10.

The input was accepted and create_ticket really ran, so this isn't speculative work. The guardrails docs keep "already-completed tool call and tool output items" when an output guardrail trips. The same loss also happens with a tool output guardrail, with failure_error_function=None, and when on_tool_end raises.

A fix that worked for me: when the tool batch raises, record the outputs of calls that already finished before re-raising, and keep any reasoning item that comes before a kept call.

Debug information

  • Agents SDK version: 0.23.1, and main at 71c2da4
  • Python 3.12.13, macOS 15.6.1, openai 3.24.0
  • Model: none, the repro uses ScriptedModel
  • Reproduces on the latest release, every time

Repro steps

import asyncio

from agents import (Agent, RunHooks, Runner, SQLiteSession, ToolGuardrailFunctionOutput,
                    function_tool, set_tracing_disabled, tool_input_guardrail)
from agents.testing import ScriptedModel, assistant_message, function_call

ticket_created = asyncio.Event()


@function_tool
def create_ticket(order_id: str) -> str:
    return f"ticket T-1 for {order_id}"  # The ticket now exists.


@tool_input_guardrail
async def block_email(data) -> ToolGuardrailFunctionOutput:
    await ticket_created.wait()  # Trip only after create_ticket has finished.
    return ToolGuardrailFunctionOutput.raise_exception(output_info="blocked")


@function_tool(tool_input_guardrails=[block_email])
def send_email(order_id: str) -> str:
    return "sent"


class Hooks(RunHooks):
    async def on_tool_end(self, context, agent, tool, result) -> None:
        ticket_created.set()


def show(items: list) -> list[str]:
    return [f"{i['type']}:{i['call_id']}" if "call_id" in i else i.get("role", "?") for i in items]


async def main() -> None:
    calls = [function_call(n, {"order_id": "A1"}, call_id=f"call_{n}") for n in ("send_email", "create_ticket")]
    model = ScriptedModel([calls, [assistant_message("ok")]])
    agent = Agent(name="support", model=model, tools=[send_email, create_ticket])
    session = SQLiteSession("demo")
    try:
        await Runner.run(agent, "Open a ticket for order A1 and email the customer.", session=session, hooks=Hooks())
    except Exception as exc:
        print("raised:      ", type(exc).__name__, "| run_data.new_items:", len(exc.run_data.new_items))
    print("session:     ", show(await session.get_items()))
    await Runner.run(agent, "Please finish the task.", session=session)
    print("next request:", show(model.last_call.input))


set_tracing_disabled(True)
asyncio.run(main())

Output on 0.23.1 and on main:

raised:       ToolInputGuardrailTripwireTriggered | run_data.new_items: 0
session:      ['user']
next request: ['user', 'user']

Expected behavior

The run still raises, but the finished call stays in the Session, run_data, to_input_list() and to_state(), so the next turn sees it:

raised:       ToolInputGuardrailTripwireTriggered | run_data.new_items: 2
session:      ['user', 'function_call:call_create_ticket', 'function_call_output:call_create_ticket']
next request: ['user', 'function_call:call_create_ticket', 'function_call_output:call_create_ticket', 'user']

A reasoning item before a kept call has to stay too, or the Responses API rejects the next request.

I ran the repro on 0.23.1 and on main.

Activity

  1. sattyamjjain commented on Oct 8, 2026

    @sattyamjjain

    Clean repro, and +1 for keeping finished calls in the session when the batch raises.

    One gap the fix can't close on its own: if the process dies after create_ticket returns but before the session is written, the next turn still won't see the ticket. For tools with side effects, I'd also send an idempotency key to the backend built from the business input (here, order_id plus the action), not from tool_call_id, since the model sends a new call ID on the next turn. Then a repeat is a no-op on the ticketing side, regardless of what the history says.

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