From 581856b725933ccafa4edb84a19a435b257e86eb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 14 Aug 2025 16:36:42 +0900 Subject: [PATCH 1/5] Fix a bug where this SDK's internals fail to handle items restored using Pydantic etc. --- src/agents/items.py | 4 +- src/agents/run.py | 10 +- src/agents/util/_safe_copy.py | 102 ++++++++++++++++++ tests/test_items_helpers.py | 28 +++++ tests/utils/test_safe_copy.py | 191 ++++++++++++++++++++++++++++++++++ 5 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 src/agents/util/_safe_copy.py create mode 100644 tests/utils/test_safe_copy.py diff --git a/src/agents/items.py b/src/agents/items.py index c43e9f856..6ceaa52a8 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -1,7 +1,6 @@ from __future__ import annotations import abc -import copy from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, Union @@ -41,6 +40,7 @@ from .exceptions import AgentsException, ModelBehaviorError from .usage import Usage +from .util._safe_copy import safe_copy if TYPE_CHECKING: from .agent import Agent @@ -277,7 +277,7 @@ def input_to_new_input_list( "role": "user", } ] - return copy.deepcopy(input) + return safe_copy(input) @classmethod def text_message_outputs(cls, items: list[RunItem]) -> str: diff --git a/src/agents/run.py b/src/agents/run.py index 5f9ec10ac..fab03cfac 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import copy import inspect from dataclasses import dataclass, field from typing import Any, Callable, Generic, cast @@ -56,6 +55,7 @@ from .tracing.span_data import AgentSpanData from .usage import Usage from .util import _coro, _error_tracing +from .util._safe_copy import safe_copy from .util._types import MaybeAwaitable DEFAULT_MAX_TURNS = 10 @@ -387,7 +387,7 @@ async def run( disabled=run_config.tracing_disabled, ): current_turn = 0 - original_input: str | list[TResponseInputItem] = copy.deepcopy(prepared_input) + original_input: str | list[TResponseInputItem] = safe_copy(prepared_input) generated_items: list[RunItem] = [] model_responses: list[ModelResponse] = [] @@ -446,7 +446,7 @@ async def run( starting_agent, starting_agent.input_guardrails + (run_config.input_guardrails or []), - copy.deepcopy(prepared_input), + safe_copy(prepared_input), context_wrapper, ), self._run_single_turn( @@ -594,7 +594,7 @@ def run_streamed( ) streamed_result = RunResultStreaming( - input=copy.deepcopy(input), + input=safe_copy(input), new_items=[], current_agent=starting_agent, raw_responses=[], @@ -786,7 +786,7 @@ async def _start_streaming( cls._run_input_guardrails_with_queue( starting_agent, starting_agent.input_guardrails + (run_config.input_guardrails or []), - copy.deepcopy(ItemHelpers.input_to_new_input_list(prepared_input)), + safe_copy(ItemHelpers.input_to_new_input_list(prepared_input)), context_wrapper, streamed_result, current_span, diff --git a/src/agents/util/_safe_copy.py b/src/agents/util/_safe_copy.py new file mode 100644 index 000000000..d5d5b43db --- /dev/null +++ b/src/agents/util/_safe_copy.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import copy +import datetime as _dt +from decimal import Decimal +from fractions import Fraction +from pathlib import PurePath +from typing import Any +from uuid import UUID + + +def safe_copy(obj: Any) -> Any: + """ + Copy 'obj' without triggering deepcopy on complex/fragile objects. + + Rules: + - Primitive/simple atoms (ints, strs, datetimes, etc.): deepcopy (cheap and safe). + - Built-in containers (dict, list, tuple, set, frozenset): recurse element-wise. + - Everything else (framework objects, iterators, models, file handles, etc.): + shallow copy if possible; otherwise return as-is. + + This avoids failures like: + TypeError: cannot pickle '...ValidatorIterator' object + because we never call deepcopy() on non-trivial objects. + """ + memo: dict[int, Any] = {} + return _safe_copy_internal(obj, memo) + + +_SIMPLE_ATOMS = ( + # basics + type(None), + bool, + int, + float, + complex, + str, + bytes, + # small buffers/scalars + bytearray, + memoryview, + range, + # "value" types + Decimal, + Fraction, + UUID, + PurePath, + _dt.date, + _dt.datetime, + _dt.time, + _dt.timedelta, +) + + +def _is_simple_atom(o: Any) -> bool: + return isinstance(o, _SIMPLE_ATOMS) + + +def _safe_copy_internal(obj: Any, memo: dict[int, Any]) -> Any: + oid = id(obj) + if oid in memo: + return memo[oid] + + # 1) Simple "atoms": safe to deepcopy (cheap, predictable). + if _is_simple_atom(obj): + return copy.deepcopy(obj) + + # 2) Containers: rebuild and recurse. + if isinstance(obj, dict): + new_dict = {} + memo[oid] = new_dict + for k, v in obj.items(): + # preserve key identity/value, only copy the value + new_dict[k] = _safe_copy_internal(v, memo) + return new_dict + + if isinstance(obj, list): + new_list: list[Any] = [] + memo[oid] = new_list + new_list.extend(_safe_copy_internal(x, memo) for x in obj) + return new_list + + if isinstance(obj, tuple): + new_tuple = tuple(_safe_copy_internal(x, memo) for x in obj) + memo[oid] = new_tuple + return new_tuple + + if isinstance(obj, set): + new_set: set[Any] = set() + memo[oid] = new_set + for x in obj: + new_set.add(_safe_copy_internal(x, memo)) + return new_set + + if isinstance(obj, frozenset): + new_fset = frozenset(_safe_copy_internal(x, memo) for x in obj) + memo[oid] = new_fset + return new_fset + + # 3) Unknown/complex leaf: return as-is (identity preserved). + memo[oid] = obj + return obj diff --git a/tests/test_items_helpers.py b/tests/test_items_helpers.py index f711f21e1..2fdf0d7fd 100644 --- a/tests/test_items_helpers.py +++ b/tests/test_items_helpers.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + from openai.types.responses.response_computer_tool_call import ( ActionScreenshot, ResponseComputerToolCall, @@ -22,6 +24,7 @@ from openai.types.responses.response_output_text import ResponseOutputText from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary from openai.types.responses.response_reasoning_item_param import ResponseReasoningItemParam +from pydantic import TypeAdapter from agents import ( Agent, @@ -109,6 +112,31 @@ def test_input_to_new_input_list_deep_copies_lists() -> None: assert "content" in original[0] and original[0].get("content") == "abc" +def test_input_to_new_input_list_copies_the_ones_produced_by_pydantic() -> None: + # Given a list of message dictionaries, ensure the returned list is a deep copy. + original = ResponseOutputMessageParam( + id="a75654dc-7492-4d1c-bce0-89e8312fbdd7", + content=[{"type": "text", "text": "Hey, what's up?"}], + role="assistant", + status="completed", + type="message", + ) + original_json = json.dumps(original) + output_item = TypeAdapter(ResponseOutputMessageParam).validate_json(original_json) + new_list = ItemHelpers.input_to_new_input_list([output_item]) + assert len(new_list) == 1 + assert new_list[0]["id"] == original["id"] + size = 0 + for i, item in enumerate(original["content"]): + size += 1 # pydantic_core._pydantic_core.ValidatorIterator does not support len() + assert item["type"] == original["content"][i]["type"] + assert item["text"] == original["content"][i]["text"] + assert size == 1 + assert new_list[0]["role"] == original["role"] + assert new_list[0]["status"] == original["status"] + assert new_list[0]["type"] == original["type"] + + def test_text_message_output_concatenates_text_segments() -> None: # Build a message with both text and refusal segments, only text segments are concatenated. pieces: list[ResponseOutputText | ResponseOutputRefusal] = [] diff --git a/tests/utils/test_safe_copy.py b/tests/utils/test_safe_copy.py new file mode 100644 index 000000000..a03f9ac1a --- /dev/null +++ b/tests/utils/test_safe_copy.py @@ -0,0 +1,191 @@ +# tests/test_safe_copy.py +import datetime as dt +import io +from decimal import Decimal +from fractions import Fraction +from uuid import UUID + +import pytest + +from agents.util._safe_copy import safe_copy + + +class BoomDeepcopy: + """Raises on deepcopy, but shallow copy is fine.""" + + def __init__(self, x=0): + self.x = x + + def __deepcopy__(self, memo): + raise TypeError("no deepcopy") + + def __copy__(self): + # canonical shallow behavior: return self (mutable identity preserved) + return self + + +class NoCopyEither: + """Raises on shallow copy; our safe_copy should return original object.""" + + def __copy__(self): + raise TypeError("no shallow copy") + + def __deepcopy__(self, memo): + raise TypeError("no deepcopy") + + +def test_primitives_are_copied_independently_for_mutable_bytes(): + orig = bytearray(b"abc") + cpy = safe_copy(orig) + assert bytes(cpy) == b"abc" + orig[0] = ord("z") + assert bytes(orig) == b"zbc" + assert bytes(cpy) == b"abc" # unaffected + + +@pytest.mark.parametrize( + "value", + [ + None, + True, + 123, + 3.14, + complex(1, 2), + "hello", + b"bytes", + Decimal("1.23"), + Fraction(3, 7), + UUID(int=1), + dt.date(2020, 1, 2), + dt.datetime(2020, 1, 2, 3, 4, 5), + dt.time(12, 34, 56), + dt.timedelta(days=2), + range(5), + ], +) +def test_simple_atoms_roundtrip(value): + cpy = safe_copy(value) + assert cpy == value + + +def test_deep_copy_for_nested_containers_of_primitives(): + orig = {"a": [1, 2, {"z": (3, 4)}]} + cpy = safe_copy(orig) + + # mutate original deeply + orig["a"][2]["z"] = (99, 100) + + assert cpy == {"a": [1, 2, {"z": (3, 4)}]} # unaffected + + +def test_complex_leaf_is_only_shallow_copied(): + class Leaf: + def __init__(self): + self.val = 1 + + leaf = Leaf() + obj = {"k": leaf, "arr": [1, 2, 3]} + cpy = safe_copy(obj) + + # container structure is new + assert cpy is not obj + assert cpy["arr"] is not obj["arr"] + + # complex leaf is shallow: identity preserved + assert cpy["k"] is leaf + + # mutating the leaf reflects in the copied structure + leaf.val = 42 + assert cpy["k"].val == 42 + + +def test_generator_is_preserved_and_not_consumed(): + gen = (i for i in range(3)) + data = {"g": gen} + cpy = safe_copy(data) + + # generator object is reused (no deepcopy attempt) + assert cpy["g"] is gen + + # ensure it hasn't been consumed by copying + assert next(gen) == 0 + assert next(gen) == 1 + + +def test_file_like_object_is_not_deepcopied(): + f = io.StringIO("hello") + data = {"f": f} + cpy = safe_copy(data) + assert cpy["f"] is f # shallow reuse + + +def test_frozenset_and_set_handling(): + class Marker: + pass + + m = Marker() + s = {1, 2, 3, m} + fs = frozenset({1, 2, 3, m}) + + s2 = safe_copy(s) + fs2 = safe_copy(fs) + + # containers are rebuilt + assert s2 is not s + assert fs2 is not fs + + # primitive members equal, complex leaf identity preserved + assert 1 in s2 and 1 in fs2 + assert any(x is m for x in s2) + assert any(x is m for x in fs2) + + # mutating original set doesn't affect the copy + s.add(99) + assert 99 not in s2 + + +def test_cycles_are_handled_without_recursion_error(): + # a -> (a,) + a = [] + t = (a,) + a.append(t) + + c = safe_copy(a) + # structure cloned: + assert c is not a + assert isinstance(c[0], tuple) + # cycle preserved: the tuple's first element points back to the list + assert c[0][0] is c + + +def test_object_where_deepcopy_would_fail_is_handled_via_shallow_copy(): + b = BoomDeepcopy(7) + c = safe_copy(b) + # shallow copy path returns same instance per __copy__ implementation + assert c is b + assert c.x == 7 + + +def test_object_where_shallow_copy_also_fails_returns_original(): + o = NoCopyEither() + c = safe_copy(o) + # last-resort path: return original object, but do not raise + assert c is o + + +def test_tuple_container_is_rebuilt_and_nested_behavior_respected(): + class Box: + def __init__(self, v): + self.v = v + + box = Box(1) + orig = (1, [2, 3], box) + cpy = safe_copy(orig) + + assert cpy is not orig + assert cpy[0] == 1 + assert cpy[1] is not orig[1] # list rebuilt + assert cpy[2] is box # complex leaf shallow + + orig[1][0] = 999 + assert cpy[1][0] == 2 From 75c872ecd153241157fa633708c118bbed7760e7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Thu, 14 Aug 2025 16:54:32 +0900 Subject: [PATCH 2/5] Fix mypy errors --- src/agents/util/_safe_copy.py | 22 ++++++++++++---------- tests/test_items_helpers.py | 19 +++++++++++++------ tests/utils/test_safe_copy.py | 7 ++++--- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/agents/util/_safe_copy.py b/src/agents/util/_safe_copy.py index d5d5b43db..64f191512 100644 --- a/src/agents/util/_safe_copy.py +++ b/src/agents/util/_safe_copy.py @@ -5,11 +5,13 @@ from decimal import Decimal from fractions import Fraction from pathlib import PurePath -from typing import Any +from typing import Any, TypeVar from uuid import UUID +T = TypeVar("T") -def safe_copy(obj: Any) -> Any: + +def safe_copy(obj: T) -> T: """ Copy 'obj' without triggering deepcopy on complex/fragile objects. @@ -56,10 +58,10 @@ def _is_simple_atom(o: Any) -> bool: return isinstance(o, _SIMPLE_ATOMS) -def _safe_copy_internal(obj: Any, memo: dict[int, Any]) -> Any: +def _safe_copy_internal(obj: T, memo: dict[int, Any]) -> T: oid = id(obj) if oid in memo: - return memo[oid] + return memo[oid] # type: ignore [no-any-return] # 1) Simple "atoms": safe to deepcopy (cheap, predictable). if _is_simple_atom(obj): @@ -67,35 +69,35 @@ def _safe_copy_internal(obj: Any, memo: dict[int, Any]) -> Any: # 2) Containers: rebuild and recurse. if isinstance(obj, dict): - new_dict = {} + new_dict: dict[Any, Any] = {} memo[oid] = new_dict for k, v in obj.items(): # preserve key identity/value, only copy the value new_dict[k] = _safe_copy_internal(v, memo) - return new_dict + return new_dict # type: ignore [return-value] if isinstance(obj, list): new_list: list[Any] = [] memo[oid] = new_list new_list.extend(_safe_copy_internal(x, memo) for x in obj) - return new_list + return new_list # type: ignore [return-value] if isinstance(obj, tuple): new_tuple = tuple(_safe_copy_internal(x, memo) for x in obj) memo[oid] = new_tuple - return new_tuple + return new_tuple # type: ignore [return-value] if isinstance(obj, set): new_set: set[Any] = set() memo[oid] = new_set for x in obj: new_set.add(_safe_copy_internal(x, memo)) - return new_set + return new_set # type: ignore [return-value] if isinstance(obj, frozenset): new_fset = frozenset(_safe_copy_internal(x, memo) for x in obj) memo[oid] = new_fset - return new_fset + return new_fset # type: ignore # 3) Unknown/complex leaf: return as-is (identity preserved). memo[oid] = obj diff --git a/tests/test_items_helpers.py b/tests/test_items_helpers.py index 2fdf0d7fd..a770d6573 100644 --- a/tests/test_items_helpers.py +++ b/tests/test_items_helpers.py @@ -22,6 +22,7 @@ from openai.types.responses.response_output_message_param import ResponseOutputMessageParam from openai.types.responses.response_output_refusal import ResponseOutputRefusal from openai.types.responses.response_output_text import ResponseOutputText +from openai.types.responses.response_output_text_param import ResponseOutputTextParam from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary from openai.types.responses.response_reasoning_item_param import ResponseReasoningItemParam from pydantic import TypeAdapter @@ -116,7 +117,13 @@ def test_input_to_new_input_list_copies_the_ones_produced_by_pydantic() -> None: # Given a list of message dictionaries, ensure the returned list is a deep copy. original = ResponseOutputMessageParam( id="a75654dc-7492-4d1c-bce0-89e8312fbdd7", - content=[{"type": "text", "text": "Hey, what's up?"}], + content=[ + ResponseOutputTextParam( + type="output_text", + text="Hey, what's up?", + annotations=[], + ) + ], role="assistant", status="completed", type="message", @@ -125,15 +132,15 @@ def test_input_to_new_input_list_copies_the_ones_produced_by_pydantic() -> None: output_item = TypeAdapter(ResponseOutputMessageParam).validate_json(original_json) new_list = ItemHelpers.input_to_new_input_list([output_item]) assert len(new_list) == 1 - assert new_list[0]["id"] == original["id"] + assert new_list[0]["id"] == original["id"] # type: ignore size = 0 for i, item in enumerate(original["content"]): size += 1 # pydantic_core._pydantic_core.ValidatorIterator does not support len() - assert item["type"] == original["content"][i]["type"] - assert item["text"] == original["content"][i]["text"] + assert item["type"] == original["content"][i]["type"] # type: ignore + assert item["text"] == original["content"][i]["text"] # type: ignore assert size == 1 - assert new_list[0]["role"] == original["role"] - assert new_list[0]["status"] == original["status"] + assert new_list[0]["role"] == original["role"] # type: ignore + assert new_list[0]["status"] == original["status"] # type: ignore assert new_list[0]["type"] == original["type"] diff --git a/tests/utils/test_safe_copy.py b/tests/utils/test_safe_copy.py index a03f9ac1a..1e47365c7 100644 --- a/tests/utils/test_safe_copy.py +++ b/tests/utils/test_safe_copy.py @@ -3,6 +3,7 @@ import io from decimal import Decimal from fractions import Fraction +from typing import Any from uuid import UUID import pytest @@ -73,7 +74,7 @@ def test_deep_copy_for_nested_containers_of_primitives(): cpy = safe_copy(orig) # mutate original deeply - orig["a"][2]["z"] = (99, 100) + orig["a"][2]["z"] = (99, 100) # type: ignore assert cpy == {"a": [1, 2, {"z": (3, 4)}]} # unaffected @@ -96,7 +97,7 @@ def __init__(self): # mutating the leaf reflects in the copied structure leaf.val = 42 - assert cpy["k"].val == 42 + assert cpy["k"].val == 42 # type: ignore [attr-defined] def test_generator_is_preserved_and_not_consumed(): @@ -146,7 +147,7 @@ class Marker: def test_cycles_are_handled_without_recursion_error(): # a -> (a,) - a = [] + a: list[Any] = [] t = (a,) a.append(t) From 4859a93e5302b22953b8b3d8796d7ec8e3dee243 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 15 Aug 2025 10:49:47 +0900 Subject: [PATCH 3/5] Simplify the code --- src/agents/util/_safe_copy.py | 80 +++-------------------------------- tests/utils/test_safe_copy.py | 54 ----------------------- 2 files changed, 7 insertions(+), 127 deletions(-) diff --git a/src/agents/util/_safe_copy.py b/src/agents/util/_safe_copy.py index 64f191512..9dba92585 100644 --- a/src/agents/util/_safe_copy.py +++ b/src/agents/util/_safe_copy.py @@ -1,104 +1,38 @@ from __future__ import annotations -import copy -import datetime as _dt -from decimal import Decimal -from fractions import Fraction -from pathlib import PurePath from typing import Any, TypeVar -from uuid import UUID T = TypeVar("T") def safe_copy(obj: T) -> T: """ - Copy 'obj' without triggering deepcopy on complex/fragile objects. - - Rules: - - Primitive/simple atoms (ints, strs, datetimes, etc.): deepcopy (cheap and safe). - - Built-in containers (dict, list, tuple, set, frozenset): recurse element-wise. - - Everything else (framework objects, iterators, models, file handles, etc.): - shallow copy if possible; otherwise return as-is. - + Craete a copy of the given object -- it can be either str or list/set/tuple of objects. This avoids failures like: TypeError: cannot pickle '...ValidatorIterator' object because we never call deepcopy() on non-trivial objects. """ - memo: dict[int, Any] = {} - return _safe_copy_internal(obj, memo) - - -_SIMPLE_ATOMS = ( - # basics - type(None), - bool, - int, - float, - complex, - str, - bytes, - # small buffers/scalars - bytearray, - memoryview, - range, - # "value" types - Decimal, - Fraction, - UUID, - PurePath, - _dt.date, - _dt.datetime, - _dt.time, - _dt.timedelta, -) - - -def _is_simple_atom(o: Any) -> bool: - return isinstance(o, _SIMPLE_ATOMS) - - -def _safe_copy_internal(obj: T, memo: dict[int, Any]) -> T: - oid = id(obj) - if oid in memo: - return memo[oid] # type: ignore [no-any-return] - - # 1) Simple "atoms": safe to deepcopy (cheap, predictable). - if _is_simple_atom(obj): - return copy.deepcopy(obj) + return _safe_copy_internal(obj) - # 2) Containers: rebuild and recurse. - if isinstance(obj, dict): - new_dict: dict[Any, Any] = {} - memo[oid] = new_dict - for k, v in obj.items(): - # preserve key identity/value, only copy the value - new_dict[k] = _safe_copy_internal(v, memo) - return new_dict # type: ignore [return-value] +def _safe_copy_internal(obj: T) -> T: if isinstance(obj, list): new_list: list[Any] = [] - memo[oid] = new_list - new_list.extend(_safe_copy_internal(x, memo) for x in obj) + new_list.extend(_safe_copy_internal(x) for x in obj) return new_list # type: ignore [return-value] if isinstance(obj, tuple): - new_tuple = tuple(_safe_copy_internal(x, memo) for x in obj) - memo[oid] = new_tuple + new_tuple = tuple(_safe_copy_internal(x) for x in obj) return new_tuple # type: ignore [return-value] if isinstance(obj, set): new_set: set[Any] = set() - memo[oid] = new_set for x in obj: - new_set.add(_safe_copy_internal(x, memo)) + new_set.add(_safe_copy_internal(x)) return new_set # type: ignore [return-value] if isinstance(obj, frozenset): - new_fset = frozenset(_safe_copy_internal(x, memo) for x in obj) - memo[oid] = new_fset + new_fset = frozenset(_safe_copy_internal(x) for x in obj) return new_fset # type: ignore - # 3) Unknown/complex leaf: return as-is (identity preserved). - memo[oid] = obj return obj diff --git a/tests/utils/test_safe_copy.py b/tests/utils/test_safe_copy.py index 1e47365c7..f45372cd7 100644 --- a/tests/utils/test_safe_copy.py +++ b/tests/utils/test_safe_copy.py @@ -35,15 +35,6 @@ def __deepcopy__(self, memo): raise TypeError("no deepcopy") -def test_primitives_are_copied_independently_for_mutable_bytes(): - orig = bytearray(b"abc") - cpy = safe_copy(orig) - assert bytes(cpy) == b"abc" - orig[0] = ord("z") - assert bytes(orig) == b"zbc" - assert bytes(cpy) == b"abc" # unaffected - - @pytest.mark.parametrize( "value", [ @@ -69,37 +60,6 @@ def test_simple_atoms_roundtrip(value): assert cpy == value -def test_deep_copy_for_nested_containers_of_primitives(): - orig = {"a": [1, 2, {"z": (3, 4)}]} - cpy = safe_copy(orig) - - # mutate original deeply - orig["a"][2]["z"] = (99, 100) # type: ignore - - assert cpy == {"a": [1, 2, {"z": (3, 4)}]} # unaffected - - -def test_complex_leaf_is_only_shallow_copied(): - class Leaf: - def __init__(self): - self.val = 1 - - leaf = Leaf() - obj = {"k": leaf, "arr": [1, 2, 3]} - cpy = safe_copy(obj) - - # container structure is new - assert cpy is not obj - assert cpy["arr"] is not obj["arr"] - - # complex leaf is shallow: identity preserved - assert cpy["k"] is leaf - - # mutating the leaf reflects in the copied structure - leaf.val = 42 - assert cpy["k"].val == 42 # type: ignore [attr-defined] - - def test_generator_is_preserved_and_not_consumed(): gen = (i for i in range(3)) data = {"g": gen} @@ -145,20 +105,6 @@ class Marker: assert 99 not in s2 -def test_cycles_are_handled_without_recursion_error(): - # a -> (a,) - a: list[Any] = [] - t = (a,) - a.append(t) - - c = safe_copy(a) - # structure cloned: - assert c is not a - assert isinstance(c[0], tuple) - # cycle preserved: the tuple's first element points back to the list - assert c[0][0] is c - - def test_object_where_deepcopy_would_fail_is_handled_via_shallow_copy(): b = BoomDeepcopy(7) c = safe_copy(b) From fc97341c03e4027ddc3af1a64db985c5397daba1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 15 Aug 2025 10:53:30 +0900 Subject: [PATCH 4/5] Fix --- src/agents/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agents/run.py b/src/agents/run.py index fab03cfac..82ced8f32 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -647,7 +647,7 @@ async def _maybe_filter_model_input( try: model_input = ModelInputData( - input=copy.deepcopy(effective_input), + input=safe_copy(effective_input), instructions=effective_instructions, ) filter_payload: CallModelData[TContext] = CallModelData( From c0dcb71f543051b78359099014c170c17d3a1267 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Fri, 15 Aug 2025 10:55:18 +0900 Subject: [PATCH 5/5] Fix --- tests/utils/test_safe_copy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/utils/test_safe_copy.py b/tests/utils/test_safe_copy.py index f45372cd7..1de4281c2 100644 --- a/tests/utils/test_safe_copy.py +++ b/tests/utils/test_safe_copy.py @@ -3,7 +3,6 @@ import io from decimal import Decimal from fractions import Fraction -from typing import Any from uuid import UUID import pytest