Skip to content

Commit bf3895c

Browse files
Second round of corrections
1 parent 02822d3 commit bf3895c

File tree

5 files changed

+340
-74
lines changed

5 files changed

+340
-74
lines changed

src/agents/items.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@
7373

7474
T = TypeVar("T", bound=Union[TResponseOutputItem, TResponseInputItem])
7575

76+
# Distinguish a missing dict entry from an explicit None value.
77+
_MISSING_ATTR_SENTINEL = object()
78+
7679

7780
@dataclass
7881
class RunItemBase(Generic[T], abc.ABC):
@@ -95,18 +98,38 @@ def __post_init__(self) -> None:
9598
# Store a weak reference so we can release the strong reference later if desired.
9699
self._agent_ref = weakref.ref(self.agent)
97100

98-
def __getattr__(self, name: str) -> Any:
101+
def __getattribute__(self, name: str) -> Any:
99102
if name == "agent":
100-
return self._agent_ref() if self._agent_ref else None
101-
raise AttributeError(name)
103+
return self._get_agent_via_weakref("agent", "_agent_ref")
104+
return super().__getattribute__(name)
102105

103106
def release_agent(self) -> None:
104107
"""Release the strong reference to the agent while keeping a weak reference."""
105108
if "agent" not in self.__dict__:
106109
return
107110
agent = self.__dict__["agent"]
111+
if agent is None:
112+
return
108113
self._agent_ref = weakref.ref(agent) if agent is not None else None
109-
object.__delattr__(self, "agent")
114+
# Set to None instead of deleting so dataclass repr/asdict keep working.
115+
self.__dict__["agent"] = None
116+
117+
def _get_agent_via_weakref(self, attr_name: str, ref_name: str) -> Any:
118+
# Preserve the dataclass field so repr/asdict still read it, but lazily resolve the weakref
119+
# when the stored value is None (meaning release_agent already dropped the strong ref).
120+
# If the attribute was never overridden we fall back to the default descriptor chain.
121+
data = object.__getattribute__(self, "__dict__")
122+
value = data.get(attr_name, _MISSING_ATTR_SENTINEL)
123+
if value is _MISSING_ATTR_SENTINEL:
124+
return object.__getattribute__(self, attr_name)
125+
if value is not None:
126+
return value
127+
ref = object.__getattribute__(self, ref_name)
128+
if ref is not None:
129+
agent = ref()
130+
if agent is not None:
131+
return agent
132+
return None
110133

111134
def to_input_item(self) -> TResponseInputItem:
112135
"""Converts this item into an input item suitable for passing to the model."""
@@ -172,23 +195,30 @@ def __post_init__(self) -> None:
172195
self._source_agent_ref = weakref.ref(self.source_agent)
173196
self._target_agent_ref = weakref.ref(self.target_agent)
174197

175-
def __getattr__(self, name: str) -> Any:
198+
def __getattribute__(self, name: str) -> Any:
176199
if name == "source_agent":
177-
return self._source_agent_ref() if self._source_agent_ref else None
200+
# Provide lazy weakref access like the base `agent` field so HandoffOutputItem
201+
# callers keep seeing the original agent until GC occurs.
202+
return self._get_agent_via_weakref("source_agent", "_source_agent_ref")
178203
if name == "target_agent":
179-
return self._target_agent_ref() if self._target_agent_ref else None
180-
return super().__getattr__(name)
204+
# Same as above but for the target of the handoff.
205+
return self._get_agent_via_weakref("target_agent", "_target_agent_ref")
206+
return super().__getattribute__(name)
181207

182208
def release_agent(self) -> None:
183209
super().release_agent()
184210
if "source_agent" in self.__dict__:
185211
source_agent = self.__dict__["source_agent"]
186-
self._source_agent_ref = weakref.ref(source_agent) if source_agent is not None else None
187-
object.__delattr__(self, "source_agent")
212+
if source_agent is not None:
213+
self._source_agent_ref = weakref.ref(source_agent)
214+
# Preserve dataclass fields for repr/asdict while dropping strong refs.
215+
self.__dict__["source_agent"] = None
188216
if "target_agent" in self.__dict__:
189217
target_agent = self.__dict__["target_agent"]
190-
self._target_agent_ref = weakref.ref(target_agent) if target_agent is not None else None
191-
object.__delattr__(self, "target_agent")
218+
if target_agent is not None:
219+
self._target_agent_ref = weakref.ref(target_agent)
220+
# Preserve dataclass fields for repr/asdict while dropping strong refs.
221+
self.__dict__["target_agent"] = None
192222

193223

194224
ToolCallItemTypes: TypeAlias = Union[

src/agents/result.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,24 +75,27 @@ class RunResultBase(abc.ABC):
7575
def last_agent(self) -> Agent[Any]:
7676
"""The last agent that was run."""
7777

78-
def release_agents(self) -> None:
78+
def release_agents(self, *, release_new_items: bool = True) -> None:
7979
"""
8080
Release strong references to agents held by this result. After calling this method,
8181
accessing `item.agent` or `last_agent` may return `None` if the agent has been garbage
8282
collected. Callers can use this when they are done inspecting the result and want to
8383
eagerly drop any associated agent graph.
8484
"""
85-
for item in self.new_items:
86-
release = getattr(item, "release_agent", None)
87-
if callable(release):
88-
release()
85+
if release_new_items:
86+
for item in self.new_items:
87+
release = getattr(item, "release_agent", None)
88+
if callable(release):
89+
release()
8990
self._release_last_agent_reference()
9091

9192
def __del__(self) -> None:
9293
try:
9394
# Fall back to releasing agents automatically in case the caller never invoked
94-
# `release_agents()` explicitly. This keeps the no-leak guarantee confirmed by tests.
95-
self.release_agents()
95+
# `release_agents()` explicitly so GC of the RunResult drops the last strong reference.
96+
# We pass `release_new_items=False` so RunItems that the user intentionally keeps
97+
# continue exposing their originating agent until that agent itself is collected.
98+
self.release_agents(release_new_items=False)
9699
except Exception:
97100
# Avoid raising from __del__.
98101
pass
@@ -164,7 +167,8 @@ def _release_last_agent_reference(self) -> None:
164167
if agent is None:
165168
return
166169
self._last_agent_ref = weakref.ref(agent)
167-
object.__delattr__(self, "_last_agent")
170+
# Preserve dataclass field so repr/asdict continue to succeed.
171+
self.__dict__["_last_agent"] = None
168172

169173
def __str__(self) -> str:
170174
return pretty_print_result(self)
@@ -244,7 +248,8 @@ def _release_last_agent_reference(self) -> None:
244248
if agent is None:
245249
return
246250
self._current_agent_ref = weakref.ref(agent)
247-
object.__delattr__(self, "current_agent")
251+
# Preserve dataclass field so repr/asdict continue to succeed.
252+
self.__dict__["current_agent"] = None
248253

249254
def cancel(self, mode: Literal["immediate", "after_turn"] = "immediate") -> None:
250255
"""Cancel the streaming run.

src/agents/run.py

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -648,51 +648,60 @@ async def run(
648648
tool_input_guardrail_results.extend(turn_result.tool_input_guardrail_results)
649649
tool_output_guardrail_results.extend(turn_result.tool_output_guardrail_results)
650650

651-
if isinstance(turn_result.next_step, NextStepFinalOutput):
652-
output_guardrail_results = await self._run_output_guardrails(
653-
current_agent.output_guardrails + (run_config.output_guardrails or []),
654-
current_agent,
655-
turn_result.next_step.output,
656-
context_wrapper,
657-
)
658-
result = RunResult(
659-
input=original_input,
660-
new_items=generated_items,
661-
raw_responses=model_responses,
662-
final_output=turn_result.next_step.output,
663-
_last_agent=current_agent,
664-
input_guardrail_results=input_guardrail_results,
665-
output_guardrail_results=output_guardrail_results,
666-
tool_input_guardrail_results=tool_input_guardrail_results,
667-
tool_output_guardrail_results=tool_output_guardrail_results,
668-
context_wrapper=context_wrapper,
669-
)
670-
if not any(
671-
guardrail_result.output.tripwire_triggered
672-
for guardrail_result in input_guardrail_results
673-
):
674-
await self._save_result_to_session(
675-
session, [], turn_result.new_step_items
651+
try:
652+
if isinstance(turn_result.next_step, NextStepFinalOutput):
653+
output_guardrail_results = await self._run_output_guardrails(
654+
current_agent.output_guardrails
655+
+ (run_config.output_guardrails or []),
656+
current_agent,
657+
turn_result.next_step.output,
658+
context_wrapper,
659+
)
660+
result = RunResult(
661+
input=original_input,
662+
new_items=generated_items,
663+
raw_responses=model_responses,
664+
final_output=turn_result.next_step.output,
665+
_last_agent=current_agent,
666+
input_guardrail_results=input_guardrail_results,
667+
output_guardrail_results=output_guardrail_results,
668+
tool_input_guardrail_results=tool_input_guardrail_results,
669+
tool_output_guardrail_results=tool_output_guardrail_results,
670+
context_wrapper=context_wrapper,
676671
)
672+
if not any(
673+
guardrail_result.output.tripwire_triggered
674+
for guardrail_result in input_guardrail_results
675+
):
676+
await self._save_result_to_session(
677+
session, [], turn_result.new_step_items
678+
)
677679

678-
return result
679-
elif isinstance(turn_result.next_step, NextStepHandoff):
680-
current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
681-
current_span.finish(reset_current=True)
682-
current_span = None
683-
should_run_agent_start_hooks = True
684-
elif isinstance(turn_result.next_step, NextStepRunAgain):
685-
if not any(
686-
guardrail_result.output.tripwire_triggered
687-
for guardrail_result in input_guardrail_results
688-
):
689-
await self._save_result_to_session(
690-
session, [], turn_result.new_step_items
680+
return result
681+
elif isinstance(turn_result.next_step, NextStepHandoff):
682+
current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
683+
current_span.finish(reset_current=True)
684+
current_span = None
685+
should_run_agent_start_hooks = True
686+
elif isinstance(turn_result.next_step, NextStepRunAgain):
687+
if not any(
688+
guardrail_result.output.tripwire_triggered
689+
for guardrail_result in input_guardrail_results
690+
):
691+
await self._save_result_to_session(
692+
session, [], turn_result.new_step_items
693+
)
694+
else:
695+
raise AgentsException(
696+
f"Unknown next step type: {type(turn_result.next_step)}"
691697
)
692-
else:
693-
raise AgentsException(
694-
f"Unknown next step type: {type(turn_result.next_step)}"
695-
)
698+
finally:
699+
# RunImpl.execute_tools_and_side_effects returns a SingleStepResult that
700+
# stores direct references to the `pre_step_items` and `new_step_items`
701+
# lists it manages internally. Clear them here so the next turn does not
702+
# hold on to items from previous turns and to avoid leaking agent refs.
703+
turn_result.pre_step_items.clear()
704+
turn_result.new_step_items.clear()
696705
except AgentsException as exc:
697706
exc.run_data = RunErrorDetails(
698707
input=original_input,

tests/test_items_helpers.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import gc
44
import json
5+
import weakref
56

67
from openai.types.responses.response_computer_tool_call import (
78
ActionScreenshot,
@@ -30,6 +31,7 @@
3031

3132
from agents import (
3233
Agent,
34+
HandoffOutputItem,
3335
ItemHelpers,
3436
MessageOutputItem,
3537
ModelResponse,
@@ -152,14 +154,59 @@ def test_text_message_outputs_across_list_of_runitems() -> None:
152154
def test_message_output_item_retains_agent_until_release() -> None:
153155
# Construct the run item with an inline agent to ensure the run item keeps a strong reference.
154156
message = make_message([ResponseOutputText(annotations=[], text="hello", type="output_text")])
155-
item = MessageOutputItem(agent=Agent(name="inline"), raw_item=message)
156-
assert item.agent is not None
157+
agent = Agent(name="inline")
158+
item = MessageOutputItem(agent=agent, raw_item=message)
159+
assert item.agent is agent
157160
assert item.agent.name == "inline"
158161

159-
# After explicitly releasing, the weak reference should drop once GC runs.
162+
# Releasing the agent should keep the weak reference alive while strong refs remain.
160163
item.release_agent()
164+
assert item.agent is agent
165+
166+
agent_ref = weakref.ref(agent)
167+
del agent
161168
gc.collect()
169+
170+
# Once the original agent is collected, the weak reference should drop.
171+
assert agent_ref() is None
172+
assert item.agent is None
173+
174+
175+
def test_handoff_output_item_retains_agents_until_gc() -> None:
176+
raw_item: TResponseInputItem = {
177+
"call_id": "call1",
178+
"output": "handoff",
179+
"type": "function_call_output",
180+
}
181+
owner_agent = Agent(name="owner")
182+
source_agent = Agent(name="source")
183+
target_agent = Agent(name="target")
184+
item = HandoffOutputItem(
185+
agent=owner_agent,
186+
raw_item=raw_item,
187+
source_agent=source_agent,
188+
target_agent=target_agent,
189+
)
190+
191+
item.release_agent()
192+
assert item.agent is owner_agent
193+
assert item.source_agent is source_agent
194+
assert item.target_agent is target_agent
195+
196+
owner_ref = weakref.ref(owner_agent)
197+
source_ref = weakref.ref(source_agent)
198+
target_ref = weakref.ref(target_agent)
199+
del owner_agent
200+
del source_agent
201+
del target_agent
202+
gc.collect()
203+
204+
assert owner_ref() is None
205+
assert source_ref() is None
206+
assert target_ref() is None
162207
assert item.agent is None
208+
assert item.source_agent is None
209+
assert item.target_agent is None
163210

164211

165212
def test_tool_call_output_item_constructs_function_call_output_dict():

0 commit comments

Comments
 (0)