Skip to content

Conversation

@DanieleMorotti
Copy link
Contributor

@DanieleMorotti DanieleMorotti commented Oct 30, 2025

Hi @seratch,
I tried to implement a fix for the issue #2008 .

This PR refactors the RunItemBase and related classes to improve memory management using weakref.
Previously, strong references to agent, source_agent, and target_agent could persist longer than intended, leading to memory leaks.

Resolves #2008

Checks

  • [ ✅] I've added new tests (if relevant)
  • [ ✅] I've run make lint and make format
  • [ ✅] I've made sure tests pass

@seratch seratch added bug Something isn't working feature:core labels Oct 30, 2025
@seratch
Copy link
Member

seratch commented Oct 30, 2025

Thank you for sending this! At a glance, it looks good to me. I will look into it tomorrow (sorry, it's already in the evening in my timezone)

@seratch seratch changed the title Fix agent memory leak using weakref fix: #2008 Fix agent memory leak using weakref Oct 30, 2025
@seratch seratch self-requested a review October 30, 2025 11:36
@seratch seratch mentioned this pull request Oct 30, 2025
@DanieleMorotti
Copy link
Contributor Author

No worries, have a look when you can!

@seratch
Copy link
Member

seratch commented Oct 31, 2025

@codex can you review this again?

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch seratch added this to the 0.5.x milestone Nov 5, 2025
@seratch
Copy link
Member

seratch commented Nov 14, 2025

@DanieleMorotti If you're still interested in continuing to work on this, please let me know. Since we've received similar feedback from customers, I plan to work on this issue soon.

@DanieleMorotti
Copy link
Contributor Author

Yes, I am. Let me know which changes you’d like to move forward with, and I’ll work on them. Thanks

@seratch
Copy link
Member

seratch commented Nov 14, 2025

If you have time, can you check my above comments?

@DanieleMorotti
Copy link
Contributor Author

Yes, sorry. I thought you were waiting for Rohan's approval

@seratch
Copy link
Member

seratch commented Nov 15, 2025

@codex Can you review the latest changes again? I'd like you to focus on potential impacts on the existing apps and edge case scenarios rather general reviews. Also, if you see other patterns that should be covered by the unit tests, please point those out as well.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 78 to 95
def release_agents(self) -> None:
"""
Release strong references to agents held by this result. After calling this method,
accessing `item.agent` or `last_agent` may return `None` if the agent has been garbage
collected. Callers can use this when they are done inspecting the result and want to
eagerly drop any associated agent graph.
"""
for item in self.new_items:
release = getattr(item, "release_agent", None)
if callable(release):
release()
self._release_last_agent_reference()

def __del__(self) -> None:
try:
# Fall back to releasing agents automatically in case the caller never invoked
# `release_agents()` explicitly. This keeps the no-leak guarantee confirmed by tests.
self.release_agents()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Auto-release discards agent info for retained RunItems

Lines 78‑95 now invoke release_agents() from RunResultBase.__del__, which calls every RunItem’s release_agent() as soon as the RunResult instance is garbage-collected. release_agent() deletes the strong agent attribute and keeps only a weakref, so if the caller created the agent inline (the common Runner.run(Agent(...), input) case) and only keeps references to the MessageOutputItems for later auditing, those items lose their agent metadata the moment the enclosing result drops out of scope—the weakref is the last reference and is collected immediately. Previously the RunItem itself owned a strong reference, so item.agent.name stayed available even when the RunResult object was discarded. This regression silently breaks any code that stores RunItems but frees the RunResult to reduce memory, because their persisted items can no longer tell which agent produced them. Consider making the auto-release opt-in, or at least documenting/providing a way to keep RunItems’ agent data alive when needed (and adding a regression test for retaining items beyond the result lifecycle).

Useful? React with 👍 / 👎.

Comment on lines 103 to 109
def release_agent(self) -> None:
"""Release the strong reference to the agent while keeping a weak reference."""
if "agent" not in self.__dict__:
return
agent = self.__dict__["agent"]
self._agent_ref = weakref.ref(agent) if agent is not None else None
object.__delattr__(self, "agent")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge release_agent deletes dataclass fields causing repr/asdict crashes

In RunItemBase.release_agent() (lines 103‑109) the code removes the dataclass field entirely via object.__delattr__(self, "agent") after storing the weakref. Deleting a dataclass field violates the assumptions of the generated __repr__, __eq__, dataclasses.asdict, etc., which immediately raise AttributeError once the field disappears. Because RunResult.release_agents() now calls this helper automatically (and tests encourage users to call it manually), any instrumentation that logs or serializes a RunItem after releasing agents will start crashing—e.g. repr(item) or dataclasses.asdict(item) now fail even though release was supposed to be a benign cleanup step. Setting the field to None (or keeping a lightweight struct with the metadata) would drop the strong reference without breaking dataclass behavior; please add coverage for repr/asdict after release so this regression is caught.

Useful? React with 👍 / 👎.

@seratch
Copy link
Member

seratch commented Nov 15, 2025

Checked the above review comments and ran a few review sessions with codex locally. Here is the code changes I got so far.

diff --git a/src/agents/items.py b/src/agents/items.py
index ab6f1f7..991a7f8 100644
--- a/src/agents/items.py
+++ b/src/agents/items.py
@@ -73,6 +73,9 @@ TResponseStreamEvent = ResponseStreamEvent
 
 T = TypeVar("T", bound=Union[TResponseOutputItem, TResponseInputItem])
 
+# Distinguish a missing dict entry from an explicit None value.
+_MISSING_ATTR_SENTINEL = object()
+
 
 @dataclass
 class RunItemBase(Generic[T], abc.ABC):
@@ -95,18 +98,38 @@ class RunItemBase(Generic[T], abc.ABC):
         # Store a weak reference so we can release the strong reference later if desired.
         self._agent_ref = weakref.ref(self.agent)
 
-    def __getattr__(self, name: str) -> Any:
+    def __getattribute__(self, name: str) -> Any:
         if name == "agent":
-            return self._agent_ref() if self._agent_ref else None
-        raise AttributeError(name)
+            return self._get_agent_via_weakref("agent", "_agent_ref")
+        return super().__getattribute__(name)
 
     def release_agent(self) -> None:
         """Release the strong reference to the agent while keeping a weak reference."""
         if "agent" not in self.__dict__:
             return
         agent = self.__dict__["agent"]
+        if agent is None:
+            return
         self._agent_ref = weakref.ref(agent) if agent is not None else None
-        object.__delattr__(self, "agent")
+        # Set to None instead of deleting so dataclass repr/asdict keep working.
+        self.__dict__["agent"] = None
+
+    def _get_agent_via_weakref(self, attr_name: str, ref_name: str) -> Any:
+        # Preserve the dataclass field so repr/asdict still read it, but lazily resolve the weakref
+        # when the stored value is None (meaning release_agent already dropped the strong ref).
+        # If the attribute was never overridden we fall back to the default descriptor chain.
+        data = object.__getattribute__(self, "__dict__")
+        value = data.get(attr_name, _MISSING_ATTR_SENTINEL)
+        if value is _MISSING_ATTR_SENTINEL:
+            return object.__getattribute__(self, attr_name)
+        if value is not None:
+            return value
+        ref = object.__getattribute__(self, ref_name)
+        if ref is not None:
+            agent = ref()
+            if agent is not None:
+                return agent
+        return None
 
     def to_input_item(self) -> TResponseInputItem:
         """Converts this item into an input item suitable for passing to the model."""
@@ -172,23 +195,30 @@ class HandoffOutputItem(RunItemBase[TResponseInputItem]):
         self._source_agent_ref = weakref.ref(self.source_agent)
         self._target_agent_ref = weakref.ref(self.target_agent)
 
-    def __getattr__(self, name: str) -> Any:
+    def __getattribute__(self, name: str) -> Any:
         if name == "source_agent":
-            return self._source_agent_ref() if self._source_agent_ref else None
+            # Provide lazy weakref access like the base `agent` field so HandoffOutputItem
+            # callers keep seeing the original agent until GC occurs.
+            return self._get_agent_via_weakref("source_agent", "_source_agent_ref")
         if name == "target_agent":
-            return self._target_agent_ref() if self._target_agent_ref else None
-        return super().__getattr__(name)
+            # Same as above but for the target of the handoff.
+            return self._get_agent_via_weakref("target_agent", "_target_agent_ref")
+        return super().__getattribute__(name)
 
     def release_agent(self) -> None:
         super().release_agent()
         if "source_agent" in self.__dict__:
             source_agent = self.__dict__["source_agent"]
-            self._source_agent_ref = weakref.ref(source_agent) if source_agent is not None else None
-            object.__delattr__(self, "source_agent")
+            if source_agent is not None:
+                self._source_agent_ref = weakref.ref(source_agent)
+            # Preserve dataclass fields for repr/asdict while dropping strong refs.
+            self.__dict__["source_agent"] = None
         if "target_agent" in self.__dict__:
             target_agent = self.__dict__["target_agent"]
-            self._target_agent_ref = weakref.ref(target_agent) if target_agent is not None else None
-            object.__delattr__(self, "target_agent")
+            if target_agent is not None:
+                self._target_agent_ref = weakref.ref(target_agent)
+            # Preserve dataclass fields for repr/asdict while dropping strong refs.
+            self.__dict__["target_agent"] = None
 
 
 ToolCallItemTypes: TypeAlias = Union[
diff --git a/src/agents/result.py b/src/agents/result.py
index 181fffc..438d53a 100644
--- a/src/agents/result.py
+++ b/src/agents/result.py
@@ -75,24 +75,27 @@ class RunResultBase(abc.ABC):
     def last_agent(self) -> Agent[Any]:
         """The last agent that was run."""
 
-    def release_agents(self) -> None:
+    def release_agents(self, *, release_new_items: bool = True) -> None:
         """
         Release strong references to agents held by this result. After calling this method,
         accessing `item.agent` or `last_agent` may return `None` if the agent has been garbage
         collected. Callers can use this when they are done inspecting the result and want to
         eagerly drop any associated agent graph.
         """
-        for item in self.new_items:
-            release = getattr(item, "release_agent", None)
-            if callable(release):
-                release()
+        if release_new_items:
+            for item in self.new_items:
+                release = getattr(item, "release_agent", None)
+                if callable(release):
+                    release()
         self._release_last_agent_reference()
 
     def __del__(self) -> None:
         try:
             # Fall back to releasing agents automatically in case the caller never invoked
-            # `release_agents()` explicitly. This keeps the no-leak guarantee confirmed by tests.
-            self.release_agents()
+            # `release_agents()` explicitly so GC of the RunResult drops the last strong reference.
+            # We pass `release_new_items=False` so RunItems that the user intentionally keeps
+            # continue exposing their originating agent until that agent itself is collected.
+            self.release_agents(release_new_items=False)
         except Exception:
             # Avoid raising from __del__.
             pass
@@ -164,7 +167,8 @@ class RunResult(RunResultBase):
         if agent is None:
             return
         self._last_agent_ref = weakref.ref(agent)
-        object.__delattr__(self, "_last_agent")
+        # Preserve dataclass field so repr/asdict continue to succeed.
+        self.__dict__["_last_agent"] = None
 
     def __str__(self) -> str:
         return pretty_print_result(self)
@@ -244,7 +248,8 @@ class RunResultStreaming(RunResultBase):
         if agent is None:
             return
         self._current_agent_ref = weakref.ref(agent)
-        object.__delattr__(self, "current_agent")
+        # Preserve dataclass field so repr/asdict continue to succeed.
+        self.__dict__["current_agent"] = None
 
     def cancel(self, mode: Literal["immediate", "after_turn"] = "immediate") -> None:
         """Cancel the streaming run.
diff --git a/src/agents/run.py b/src/agents/run.py
index c14f13e..5ea1dba 100644
--- a/src/agents/run.py
+++ b/src/agents/run.py
@@ -648,51 +648,60 @@ class AgentRunner:
                     tool_input_guardrail_results.extend(turn_result.tool_input_guardrail_results)
                     tool_output_guardrail_results.extend(turn_result.tool_output_guardrail_results)
 
-                    if isinstance(turn_result.next_step, NextStepFinalOutput):
-                        output_guardrail_results = await self._run_output_guardrails(
-                            current_agent.output_guardrails + (run_config.output_guardrails or []),
-                            current_agent,
-                            turn_result.next_step.output,
-                            context_wrapper,
-                        )
-                        result = RunResult(
-                            input=original_input,
-                            new_items=generated_items,
-                            raw_responses=model_responses,
-                            final_output=turn_result.next_step.output,
-                            _last_agent=current_agent,
-                            input_guardrail_results=input_guardrail_results,
-                            output_guardrail_results=output_guardrail_results,
-                            tool_input_guardrail_results=tool_input_guardrail_results,
-                            tool_output_guardrail_results=tool_output_guardrail_results,
-                            context_wrapper=context_wrapper,
-                        )
-                        if not any(
-                            guardrail_result.output.tripwire_triggered
-                            for guardrail_result in input_guardrail_results
-                        ):
-                            await self._save_result_to_session(
-                                session, [], turn_result.new_step_items
+                    try:
+                        if isinstance(turn_result.next_step, NextStepFinalOutput):
+                            output_guardrail_results = await self._run_output_guardrails(
+                                current_agent.output_guardrails
+                                + (run_config.output_guardrails or []),
+                                current_agent,
+                                turn_result.next_step.output,
+                                context_wrapper,
+                            )
+                            result = RunResult(
+                                input=original_input,
+                                new_items=generated_items,
+                                raw_responses=model_responses,
+                                final_output=turn_result.next_step.output,
+                                _last_agent=current_agent,
+                                input_guardrail_results=input_guardrail_results,
+                                output_guardrail_results=output_guardrail_results,
+                                tool_input_guardrail_results=tool_input_guardrail_results,
+                                tool_output_guardrail_results=tool_output_guardrail_results,
+                                context_wrapper=context_wrapper,
                             )
+                            if not any(
+                                guardrail_result.output.tripwire_triggered
+                                for guardrail_result in input_guardrail_results
+                            ):
+                                await self._save_result_to_session(
+                                    session, [], turn_result.new_step_items
+                                )
 
-                        return result
-                    elif isinstance(turn_result.next_step, NextStepHandoff):
-                        current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
-                        current_span.finish(reset_current=True)
-                        current_span = None
-                        should_run_agent_start_hooks = True
-                    elif isinstance(turn_result.next_step, NextStepRunAgain):
-                        if not any(
-                            guardrail_result.output.tripwire_triggered
-                            for guardrail_result in input_guardrail_results
-                        ):
-                            await self._save_result_to_session(
-                                session, [], turn_result.new_step_items
+                            return result
+                        elif isinstance(turn_result.next_step, NextStepHandoff):
+                            current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
+                            current_span.finish(reset_current=True)
+                            current_span = None
+                            should_run_agent_start_hooks = True
+                        elif isinstance(turn_result.next_step, NextStepRunAgain):
+                            if not any(
+                                guardrail_result.output.tripwire_triggered
+                                for guardrail_result in input_guardrail_results
+                            ):
+                                await self._save_result_to_session(
+                                    session, [], turn_result.new_step_items
+                                )
+                        else:
+                            raise AgentsException(
+                                f"Unknown next step type: {type(turn_result.next_step)}"
                             )
-                    else:
-                        raise AgentsException(
-                            f"Unknown next step type: {type(turn_result.next_step)}"
-                        )
+                    finally:
+                        # RunImpl.execute_tools_and_side_effects returns a SingleStepResult that
+                        # stores direct references to the `pre_step_items` and `new_step_items`
+                        # lists it manages internally. Clear them here so the next turn does not
+                        # hold on to items from previous turns and to avoid leaking agent refs.
+                        turn_result.pre_step_items.clear()
+                        turn_result.new_step_items.clear()
             except AgentsException as exc:
                 exc.run_data = RunErrorDetails(
                     input=original_input,
diff --git a/tests/test_items_helpers.py b/tests/test_items_helpers.py
index 408ba13..ad8da22 100644
--- a/tests/test_items_helpers.py
+++ b/tests/test_items_helpers.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import gc
 import json
+import weakref
 
 from openai.types.responses.response_computer_tool_call import (
     ActionScreenshot,
@@ -30,6 +31,7 @@ from pydantic import TypeAdapter
 
 from agents import (
     Agent,
+    HandoffOutputItem,
     ItemHelpers,
     MessageOutputItem,
     ModelResponse,
@@ -152,14 +154,59 @@ def test_text_message_outputs_across_list_of_runitems() -> None:
 def test_message_output_item_retains_agent_until_release() -> None:
     # Construct the run item with an inline agent to ensure the run item keeps a strong reference.
     message = make_message([ResponseOutputText(annotations=[], text="hello", type="output_text")])
-    item = MessageOutputItem(agent=Agent(name="inline"), raw_item=message)
-    assert item.agent is not None
+    agent = Agent(name="inline")
+    item = MessageOutputItem(agent=agent, raw_item=message)
+    assert item.agent is agent
     assert item.agent.name == "inline"
 
-    # After explicitly releasing, the weak reference should drop once GC runs.
+    # Releasing the agent should keep the weak reference alive while strong refs remain.
     item.release_agent()
+    assert item.agent is agent
+
+    agent_ref = weakref.ref(agent)
+    del agent
     gc.collect()
+
+    # Once the original agent is collected, the weak reference should drop.
+    assert agent_ref() is None
+    assert item.agent is None
+
+
+def test_handoff_output_item_retains_agents_until_gc() -> None:
+    raw_item: TResponseInputItem = {
+        "call_id": "call1",
+        "output": "handoff",
+        "type": "function_call_output",
+    }
+    owner_agent = Agent(name="owner")
+    source_agent = Agent(name="source")
+    target_agent = Agent(name="target")
+    item = HandoffOutputItem(
+        agent=owner_agent,
+        raw_item=raw_item,
+        source_agent=source_agent,
+        target_agent=target_agent,
+    )
+
+    item.release_agent()
+    assert item.agent is owner_agent
+    assert item.source_agent is source_agent
+    assert item.target_agent is target_agent
+
+    owner_ref = weakref.ref(owner_agent)
+    source_ref = weakref.ref(source_agent)
+    target_ref = weakref.ref(target_agent)
+    del owner_agent
+    del source_agent
+    del target_agent
+    gc.collect()
+
+    assert owner_ref() is None
+    assert source_ref() is None
+    assert target_ref() is None
     assert item.agent is None
+    assert item.source_agent is None
+    assert item.target_agent is None
 
 
 def test_tool_call_output_item_constructs_function_call_output_dict():
diff --git a/tests/test_result_cast.py b/tests/test_result_cast.py
index 87f4fc2..e919171 100644
--- a/tests/test_result_cast.py
+++ b/tests/test_result_cast.py
@@ -1,3 +1,4 @@
+import dataclasses
 import gc
 import weakref
 from typing import Any
@@ -6,7 +7,7 @@ import pytest
 from openai.types.responses import ResponseOutputMessage, ResponseOutputText
 from pydantic import BaseModel
 
-from agents import Agent, MessageOutputItem, RunContextWrapper, RunResult
+from agents import Agent, MessageOutputItem, RunContextWrapper, RunResult, RunResultStreaming
 from agents.exceptions import AgentsException
 
 
@@ -29,6 +30,16 @@ class Foo(BaseModel):
     bar: int
 
 
+def _create_message(text: str) -> ResponseOutputMessage:
+    return ResponseOutputMessage(
+        id="msg",
+        content=[ResponseOutputText(annotations=[], text=text, type="output_text")],
+        role="assistant",
+        status="completed",
+        type="message",
+    )
+
+
 def test_result_cast_typechecks():
     """Correct casts should work fine."""
     result = create_run_result(1)
@@ -66,13 +77,7 @@ def test_bad_cast_with_param_raises():
 
 
 def test_run_result_release_agents_breaks_strong_refs() -> None:
-    message = ResponseOutputMessage(
-        id="msg",
-        content=[ResponseOutputText(annotations=[], text="hello", type="output_text")],
-        role="assistant",
-        status="completed",
-        type="message",
-    )
+    message = _create_message("hello")
     agent = Agent(name="leak-test-agent")
     item = MessageOutputItem(agent=agent, raw_item=message)
     result = RunResult(
@@ -99,3 +104,173 @@ def test_run_result_release_agents_breaks_strong_refs() -> None:
     assert item.agent is None
     with pytest.raises(AgentsException):
         _ = result.last_agent
+
+
+def test_run_item_retains_agent_when_result_is_garbage_collected() -> None:
+    def build_item() -> tuple[MessageOutputItem, weakref.ReferenceType[RunResult]]:
+        message = _create_message("persist")
+        agent = Agent(name="persisted-agent")
+        item = MessageOutputItem(agent=agent, raw_item=message)
+        result = RunResult(
+            input="test",
+            new_items=[item],
+            raw_responses=[],
+            final_output=None,
+            input_guardrail_results=[],
+            output_guardrail_results=[],
+            tool_input_guardrail_results=[],
+            tool_output_guardrail_results=[],
+            _last_agent=agent,
+            context_wrapper=RunContextWrapper(context=None),
+        )
+        return item, weakref.ref(result)
+
+    item, result_ref = build_item()
+    gc.collect()
+
+    assert result_ref() is None
+    assert item.agent is not None
+    assert item.agent.name == "persisted-agent"
+
+
+def test_run_item_repr_and_asdict_after_release() -> None:
+    message = _create_message("repr")
+    agent = Agent(name="repr-agent")
+    item = MessageOutputItem(agent=agent, raw_item=message)
+
+    item.release_agent()
+    assert item.agent is agent
+
+    text = repr(item)
+    assert "MessageOutputItem" in text
+
+    serialized = dataclasses.asdict(item)
+    assert isinstance(serialized["agent"], dict)
+    assert serialized["agent"]["name"] == "repr-agent"
+
+    agent_ref = weakref.ref(agent)
+    del agent
+    gc.collect()
+
+    assert agent_ref() is None
+    assert item.agent is None
+
+    serialized_after_gc = dataclasses.asdict(item)
+    assert serialized_after_gc["agent"] is None
+
+
+def test_run_result_repr_and_asdict_after_release_agents() -> None:
+    agent = Agent(name="repr-result-agent")
+    result = RunResult(
+        input="test",
+        new_items=[],
+        raw_responses=[],
+        final_output=None,
+        input_guardrail_results=[],
+        output_guardrail_results=[],
+        tool_input_guardrail_results=[],
+        tool_output_guardrail_results=[],
+        _last_agent=agent,
+        context_wrapper=RunContextWrapper(context=None),
+    )
+
+    result.release_agents()
+
+    text = repr(result)
+    assert "RunResult" in text
+
+    serialized = dataclasses.asdict(result)
+    assert serialized["_last_agent"] is None
+
+
+def test_run_result_release_agents_without_releasing_new_items() -> None:
+    message = _create_message("keep")
+    item_agent = Agent(name="item-agent")
+    last_agent = Agent(name="last-agent")
+    item = MessageOutputItem(agent=item_agent, raw_item=message)
+    result = RunResult(
+        input="test",
+        new_items=[item],
+        raw_responses=[],
+        final_output=None,
+        input_guardrail_results=[],
+        output_guardrail_results=[],
+        tool_input_guardrail_results=[],
+        tool_output_guardrail_results=[],
+        _last_agent=last_agent,
+        context_wrapper=RunContextWrapper(context=None),
+    )
+
+    result.release_agents(release_new_items=False)
+
+    assert item.agent is item_agent
+
+    last_agent_ref = weakref.ref(last_agent)
+    del last_agent
+    gc.collect()
+
+    assert last_agent_ref() is None
+    with pytest.raises(AgentsException):
+        _ = result.last_agent
+
+
+def test_run_result_release_agents_is_idempotent() -> None:
+    message = _create_message("idempotent")
+    agent = Agent(name="idempotent-agent")
+    item = MessageOutputItem(agent=agent, raw_item=message)
+    result = RunResult(
+        input="test",
+        new_items=[item],
+        raw_responses=[],
+        final_output=None,
+        input_guardrail_results=[],
+        output_guardrail_results=[],
+        tool_input_guardrail_results=[],
+        tool_output_guardrail_results=[],
+        _last_agent=agent,
+        context_wrapper=RunContextWrapper(context=None),
+    )
+
+    result.release_agents()
+    result.release_agents()
+
+    assert item.agent is agent
+
+    agent_ref = weakref.ref(agent)
+    del agent
+    gc.collect()
+
+    assert agent_ref() is None
+    assert item.agent is None
+    with pytest.raises(AgentsException):
+        _ = result.last_agent
+
+
+def test_run_result_streaming_release_agents_releases_current_agent() -> None:
+    agent = Agent(name="streaming-agent")
+    streaming_result = RunResultStreaming(
+        input="stream",
+        new_items=[],
+        raw_responses=[],
+        final_output=None,
+        input_guardrail_results=[],
+        output_guardrail_results=[],
+        tool_input_guardrail_results=[],
+        tool_output_guardrail_results=[],
+        context_wrapper=RunContextWrapper(context=None),
+        current_agent=agent,
+        current_turn=0,
+        max_turns=1,
+        _current_agent_output_schema=None,
+        trace=None,
+    )
+
+    streaming_result.release_agents(release_new_items=False)
+
+    agent_ref = weakref.ref(agent)
+    del agent
+    gc.collect()
+
+    assert agent_ref() is None
+    with pytest.raises(AgentsException):
+        _ = streaming_result.last_agent

@DanieleMorotti
Copy link
Contributor Author

I have integrated the corrections you got so far

@seratch
Copy link
Member

seratch commented Nov 15, 2025

@codex Can you review the latest changes with fresh eyes? I'd like you to focus on potential impacts on the existing apps and edge case scenarios rather general feedback.

@chatgpt-codex-connector
Copy link

Codex Review: Didn't find any major issues. Can't wait for the next one!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch
Copy link
Member

seratch commented Nov 15, 2025

@DanieleMorotti Thanks for quickly updating this PR! I will continue the final validation before merging this PR next week.

@DanieleMorotti
Copy link
Contributor Author

You’re welcome. Happy to provide any additional updates if required

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

Labels

bug Something isn't working feature:core

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory leak of Agent

2 participants