Skip to content

Commit e1b6357

Browse files
committed
fix memory leak in code review and add test
1 parent edbec4e commit e1b6357

File tree

3 files changed

+104
-2
lines changed

3 files changed

+104
-2
lines changed

src/agents/items.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,12 @@ class ToolCallItem(RunItemBase[Any]):
252252
tool_origin: ToolOrigin | None = field(default=None, repr=False)
253253
"""Information about the origin/source of the tool call. Only set for FunctionTool calls."""
254254

255+
def release_agent(self) -> None:
256+
"""Release agent references including tool_origin.agent_as_tool."""
257+
super().release_agent()
258+
if self.tool_origin is not None:
259+
self.tool_origin.release_agent()
260+
255261

256262
ToolCallOutputTypes: TypeAlias = Union[
257263
FunctionCallOutput,
@@ -278,6 +284,12 @@ class ToolCallOutputItem(RunItemBase[Any]):
278284
tool_origin: ToolOrigin | None = field(default=None, repr=False)
279285
"""Information about the origin/source of the tool call. Only set for FunctionTool calls."""
280286

287+
def release_agent(self) -> None:
288+
"""Release agent references including tool_origin.agent_as_tool."""
289+
super().release_agent()
290+
if self.tool_origin is not None:
291+
self.tool_origin.release_agent()
292+
281293
def to_input_item(self) -> TResponseInputItem:
282294
"""Converts the tool output into an input item for the next model turn.
283295

src/agents/tool.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,50 @@ class ToolOrigin:
210210
agent_as_tool: Agent[Any] | None = None
211211
"""The agent object. Only set when type is AGENT_AS_TOOL."""
212212

213+
_agent_as_tool_ref: weakref.ReferenceType[Agent[Any]] | None = field(
214+
default=None, init=False, repr=False
215+
)
216+
"""Weak reference to agent_as_tool for memory management."""
217+
218+
def __post_init__(self) -> None:
219+
"""Initialize weak reference for agent_as_tool."""
220+
if self.agent_as_tool is not None:
221+
self._agent_as_tool_ref = weakref.ref(self.agent_as_tool)
222+
223+
def __getattribute__(self, name: str) -> Any:
224+
"""Lazily resolve agent_as_tool via weakref when strong ref is cleared."""
225+
if name == "agent_as_tool":
226+
# Check if strong reference still exists
227+
value = object.__getattribute__(self, "__dict__").get("agent_as_tool")
228+
if value is not None:
229+
return value
230+
# Try to resolve via weakref
231+
ref = object.__getattribute__(self, "_agent_as_tool_ref")
232+
if ref is not None:
233+
agent = ref()
234+
if agent is not None:
235+
return agent
236+
return None
237+
return super().__getattribute__(name)
238+
239+
def release_agent(self) -> None:
240+
"""Release the strong reference to agent_as_tool while keeping a weak reference."""
241+
if "agent_as_tool" not in self.__dict__:
242+
return
243+
agent = self.__dict__.get("agent_as_tool")
244+
if agent is not None:
245+
self._agent_as_tool_ref = weakref.ref(agent)
246+
# Set to None instead of deleting so dataclass repr/asdict keep working.
247+
self.__dict__["agent_as_tool"] = None
248+
213249
def __repr__(self) -> str:
214250
"""Custom repr that only includes relevant fields."""
215251
parts = [f"type={self.type.value!r}"]
216252
if self.mcp_server is not None:
217253
parts.append(f"mcp_server_name={self.mcp_server.name!r}")
218-
if self.agent_as_tool is not None:
219-
parts.append(f"agent_as_tool_name={self.agent_as_tool.name!r}")
254+
agent = self.agent_as_tool
255+
if agent is not None:
256+
parts.append(f"agent_as_tool_name={agent.name!r}")
220257
return f"ToolOrigin({', '.join(parts)})"
221258

222259

tests/test_tool_origin.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
from __future__ import annotations
44

5+
import gc
56
import sys
7+
import weakref
68
from typing import cast
79

810
import pytest
@@ -331,3 +333,54 @@ def func_tool() -> str:
331333
)
332334

333335
assert item.tool_origin is None
336+
337+
338+
def test_tool_origin_release_agent_clears_strong_reference():
339+
"""Test that release_agent() clears strong reference to agent_as_tool."""
340+
# Create a ToolOrigin with an agent_as_tool
341+
nested_agent = Agent(
342+
name="nested_agent",
343+
model=FakeModel(),
344+
instructions="You are a nested agent.",
345+
)
346+
347+
tool_origin = ToolOrigin(
348+
type=ToolOriginType.AGENT_AS_TOOL,
349+
agent_as_tool=nested_agent,
350+
)
351+
352+
# Create a ToolCallItem with this tool_origin
353+
tool_call_item = ToolCallItem(
354+
raw_item=cast(
355+
ToolCallItemTypes,
356+
{
357+
"type": "function_call",
358+
"name": "test_tool",
359+
"call_id": "call_123",
360+
"arguments": "{}",
361+
},
362+
),
363+
agent=nested_agent,
364+
tool_origin=tool_origin,
365+
)
366+
367+
# Verify agent_as_tool is set
368+
assert tool_call_item.tool_origin is not None
369+
assert tool_call_item.tool_origin.agent_as_tool is nested_agent
370+
371+
# Create weak reference to verify GC behavior
372+
nested_agent_ref = weakref.ref(nested_agent)
373+
374+
# Release agent - this should clear strong reference in tool_origin
375+
tool_call_item.release_agent()
376+
377+
# After release, agent_as_tool should still be accessible via weakref
378+
assert tool_call_item.tool_origin.agent_as_tool is nested_agent
379+
380+
# Delete the agent and force GC
381+
del nested_agent
382+
gc.collect()
383+
384+
# After GC, agent_as_tool should be None since strong refs were cleared
385+
assert nested_agent_ref() is None
386+
assert tool_call_item.tool_origin.agent_as_tool is None

0 commit comments

Comments
 (0)