From 658ed681659a487c92128b65038fb6c78b8af7ec Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 6 Aug 2026 18:30:17 -0500 Subject: [PATCH] fix(sandbox): keep programmatic tool calling items in memory rollouts _INCLUDED_MEMORY_ITEM_TYPES lists both halves of every call/output pair in _TOOL_CALL_TO_OUTPUT_TYPE, plus web_search_call, mcp_call and the MCP approval items. The program/program_output pair was the only one missing: the set was written in #2889 and Programmatic Tool Calling introduced those two types later in #3833 without updating this call site. A run that uses ProgrammaticToolCallingTool therefore wrote a rollout with the program code and the program result stripped, while the function_call items the program issued were retained -- each still carrying a caller reference to a program item that was no longer in the rollout. --- src/agents/sandbox/memory/rollouts.py | 2 + tests/sandbox/test_memory.py | 118 ++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/agents/sandbox/memory/rollouts.py b/src/agents/sandbox/memory/rollouts.py index 2ca64dd596..1c0904932b 100644 --- a/src/agents/sandbox/memory/rollouts.py +++ b/src/agents/sandbox/memory/rollouts.py @@ -38,6 +38,8 @@ "mcp_approval_request", "mcp_approval_response", "mcp_call", + "program", + "program_output", "shell_call", "shell_call_output", "tool_search_call", diff --git a/tests/sandbox/test_memory.py b/tests/sandbox/test_memory.py index 1a8ed9a560..48a2ff7541 100644 --- a/tests/sandbox/test_memory.py +++ b/tests/sandbox/test_memory.py @@ -11,6 +11,8 @@ import pytest from openai.types.responses import ResponseCustomToolCall, ResponseFunctionToolCall +from openai.types.responses.response_function_tool_call import CallerProgram +from openai.types.responses.response_output_item import Program, ProgramOutput from openai.types.responses.response_output_message import ResponseOutputMessage from openai.types.responses.response_reasoning_item import ResponseReasoningItem @@ -33,6 +35,8 @@ CompactionItem, MessageOutputItem, ToolApprovalItem, + ToolCallItem, + ToolCallOutputItem, TResponseOutputItem, ) from agents.result import RunResult, RunResultStreaming @@ -279,6 +283,120 @@ def test_build_rollout_payload_filters_developer_and_noisy_items() -> None: assert payload["final_output"] == "done" +def test_build_rollout_payload_keeps_programmatic_tool_calling_items() -> None: + agent = Agent(name="test") + program = Program( + id="program_item", + call_id="call_prog_1", + code='lookup_inventory(sku="A-1")', + fingerprint="fingerprint", + type="program", + ) + function_call = ResponseFunctionToolCall( + id="function_item", + call_id="call_fn_1", + name="lookup_inventory", + arguments='{"sku":"A-1"}', + caller=CallerProgram(type="program", caller_id="call_prog_1"), + type="function_call", + ) + function_call_output = cast( + TResponseInputItem, + { + "type": "function_call_output", + "call_id": "call_fn_1", + "output": '{"available_units":42}', + }, + ) + program_output = ProgramOutput( + id="program_output_item", + call_id="call_prog_1", + result='{"sku":"A-1","available_units":42}', + status="completed", + type="program_output", + ) + + payload = build_rollout_payload( + input="what is in stock?", + new_items=[ + ToolCallItem(agent=agent, raw_item=program), + ToolCallItem(agent=agent, raw_item=function_call), + ToolCallOutputItem(agent=agent, raw_item=function_call_output, output="42"), + ToolCallOutputItem(agent=agent, raw_item=program_output, output="42"), + ], + final_output="done", + interruptions=[], + terminal_metadata=RolloutTerminalMetadata( + terminal_state="completed", + has_final_output=True, + ), + ) + + generated_items = payload["generated_items"] + assert [item["type"] for item in generated_items] == [ + "program", + "function_call", + "function_call_output", + "program_output", + ] + # The retained function call points back at the program that issued it, so the program + # it names has to survive alongside it. + assert generated_items[1]["caller"] == {"type": "program", "caller_id": "call_prog_1"} + assert generated_items[0]["call_id"] == "call_prog_1" + assert generated_items[0]["code"] == 'lookup_inventory(sku="A-1")' + assert generated_items[3]["call_id"] == "call_prog_1" + assert generated_items[3]["result"] == '{"sku":"A-1","available_units":42}' + + +def test_build_rollout_payload_keeps_program_items_from_input() -> None: + payload = build_rollout_payload( + input=[ + cast( + TResponseInputItem, + { + "type": "program", + "call_id": "call_prog_1", + "code": 'lookup_inventory(sku="A-1")', + "fingerprint": "fingerprint", + }, + ), + cast( + TResponseInputItem, + { + "type": "program_output", + "call_id": "call_prog_1", + "result": '{"available_units":42}', + "status": "completed", + }, + ), + ], + new_items=[], + final_output=None, + interruptions=[], + terminal_metadata=RolloutTerminalMetadata(terminal_state="completed"), + ) + + assert [item["type"] for item in payload["input"]] == ["program", "program_output"] + + +def test_build_rollout_payload_still_drops_hosted_items_outside_the_included_set() -> None: + """Program items are included because every other call/output pair is; hosted tool calls + with no output half stay out.""" + payload = build_rollout_payload( + input=[ + cast(TResponseInputItem, {"type": "file_search_call", "id": "fs_1", "queries": []}), + cast(TResponseInputItem, {"type": "image_generation_call", "id": "ig_1"}), + cast(TResponseInputItem, {"type": "program", "call_id": "call_prog_1", "code": "x()"}), + ], + new_items=[], + final_output=None, + interruptions=[], + terminal_metadata=RolloutTerminalMetadata(terminal_state="completed"), + ) + + assert [item["type"] for item in payload["input"]] == ["program"] + + def test_build_rollout_payload_serializes_model_interruptions_as_dicts() -> None: agent = Agent(name="test") raw = ResponseFunctionToolCall(