Repository navigation
Description
Please read this first
- Have you read the docs? Yes.
- Have you searched for related issues? Yes. Output guardrail trip leaves orphaned tool call on streamed resume #4125 and fix: preserve tool outputs after callback failures #5318 fixed similar losses, but this case still happens on
main.
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
mainat 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
sattyamjjain commentedon Oct 8, 2026
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.