Skip to content

Commit 56680f4

Browse files
authored
fix: Enable creating/disposing Computer per agent run (#2191)
1 parent 3523667 commit 56680f4

File tree

9 files changed

+496
-72
lines changed

9 files changed

+496
-72
lines changed

examples/tools/computer_use.py

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
1+
# How to run this example:
2+
# uv run python -m playwright install chromium
3+
# uv run -m examples.tools.computer_use
4+
15
import asyncio
26
import base64
3-
from typing import Literal, Union
7+
import sys
8+
from typing import Any, Literal, Union
49

510
from playwright.async_api import Browser, Page, Playwright, async_playwright
611

712
from agents import (
813
Agent,
914
AsyncComputer,
1015
Button,
16+
ComputerProvider,
1117
ComputerTool,
1218
Environment,
1319
ModelSettings,
20+
RunContextWrapper,
1421
Runner,
1522
trace,
1623
)
@@ -21,21 +28,6 @@
2128
# logging.getLogger("openai.agents").addHandler(logging.StreamHandler())
2229

2330

24-
async def main():
25-
async with LocalPlaywrightComputer() as computer:
26-
with trace("Computer use example"):
27-
agent = Agent(
28-
name="Browser user",
29-
instructions="You are a helpful agent.",
30-
tools=[ComputerTool(computer)],
31-
# Use the computer using model, and set truncation to auto because its required
32-
model="computer-use-preview",
33-
model_settings=ModelSettings(truncation="auto"),
34-
)
35-
result = await Runner.run(agent, "Search for SF sports news and summarize.")
36-
print(result.final_output)
37-
38-
3931
CUA_KEY_TO_PLAYWRIGHT_KEY = {
4032
"/": "Divide",
4133
"\\": "Backslash",
@@ -93,6 +85,16 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
9385
await self._browser.close()
9486
if self._playwright:
9587
await self._playwright.stop()
88+
return None
89+
90+
async def open(self) -> "LocalPlaywrightComputer":
91+
"""Open resources without using a context manager."""
92+
await self.__aenter__()
93+
return self
94+
95+
async def close(self) -> None:
96+
"""Close resources without using a context manager."""
97+
await self.__aexit__(None, None, None)
9698

9799
@property
98100
def playwright(self) -> Playwright:
@@ -164,5 +166,53 @@ async def drag(self, path: list[tuple[int, int]]) -> None:
164166
await self.page.mouse.up()
165167

166168

169+
async def run_agent(
170+
computer_config: ComputerProvider[LocalPlaywrightComputer] | AsyncComputer,
171+
) -> None:
172+
with trace("Computer use example"):
173+
agent = Agent(
174+
name="Browser user",
175+
instructions="You are a helpful agent. Find the current weather in Tokyo.",
176+
tools=[ComputerTool(computer=computer_config)],
177+
# Use the computer using model, and set truncation to auto because it is required.
178+
model="computer-use-preview",
179+
model_settings=ModelSettings(truncation="auto"),
180+
)
181+
result = await Runner.run(agent, "What is the weather in Tokyo right now?")
182+
print(result.final_output)
183+
184+
185+
async def singleton_computer() -> None:
186+
# Use a shared computer when you do not expect to run multiple agents concurrently.
187+
async with LocalPlaywrightComputer() as computer:
188+
await run_agent(computer)
189+
190+
191+
async def computer_per_request() -> None:
192+
# Initialize a new computer per request to avoid sharing state between runs.
193+
async def create_computer(*, run_context: RunContextWrapper[Any]) -> LocalPlaywrightComputer:
194+
print(f"Creating computer for run context: {run_context}")
195+
return await LocalPlaywrightComputer().open()
196+
197+
async def dispose_computer(
198+
*,
199+
run_context: RunContextWrapper[Any],
200+
computer: LocalPlaywrightComputer,
201+
) -> None:
202+
print(f"Disposing computer for run context: {run_context}")
203+
await computer.close()
204+
205+
await run_agent(
206+
ComputerProvider[LocalPlaywrightComputer](
207+
create=create_computer,
208+
dispose=dispose_computer,
209+
)
210+
)
211+
212+
167213
if __name__ == "__main__":
168-
asyncio.run(main())
214+
mode = (sys.argv[1] if len(sys.argv) > 1 else "").lower()
215+
if mode == "singleton":
216+
asyncio.run(singleton_computer())
217+
else:
218+
asyncio.run(computer_per_request())

src/agents/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
from .tool import (
8888
ApplyPatchTool,
8989
CodeInterpreterTool,
90+
ComputerProvider,
9091
ComputerTool,
9192
FileSearchTool,
9293
FunctionTool,
@@ -116,7 +117,9 @@
116117
ToolOutputTextDict,
117118
WebSearchTool,
118119
default_tool_error_function,
120+
dispose_resolved_computers,
119121
function_tool,
122+
resolve_computer,
120123
)
121124
from .tool_guardrails import (
122125
ToolGuardrailFunctionOutput,
@@ -301,6 +304,7 @@ def enable_verbose_stdout_logging():
301304
"FunctionTool",
302305
"FunctionToolResult",
303306
"ComputerTool",
307+
"ComputerProvider",
304308
"FileSearchTool",
305309
"CodeInterpreterTool",
306310
"ImageGenerationTool",
@@ -332,6 +336,8 @@ def enable_verbose_stdout_logging():
332336
"ToolOutputFileContent",
333337
"ToolOutputFileContentDict",
334338
"function_tool",
339+
"resolve_computer",
340+
"dispose_resolved_computers",
335341
"Usage",
336342
"add_trace_processor",
337343
"agent_span",

src/agents/_run_impl.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
ShellResult,
9696
ShellTool,
9797
Tool,
98+
resolve_computer,
9899
)
99100
from .tool_context import ToolContext
100101
from .tool_guardrails import (
@@ -159,7 +160,7 @@ class ToolRunFunction:
159160
@dataclass
160161
class ToolRunComputerAction:
161162
tool_call: ResponseComputerToolCall
162-
computer_tool: ComputerTool
163+
computer_tool: ComputerTool[Any]
163164

164165

165166
@dataclass
@@ -461,6 +462,22 @@ def maybe_reset_tool_choice(
461462

462463
return model_settings
463464

465+
@classmethod
466+
async def initialize_computer_tools(
467+
cls,
468+
*,
469+
tools: list[Tool],
470+
context_wrapper: RunContextWrapper[TContext],
471+
) -> None:
472+
"""Resolve computer tools ahead of model invocation so each run gets its own instance."""
473+
computer_tools = [tool for tool in tools if isinstance(tool, ComputerTool)]
474+
if not computer_tools:
475+
return
476+
477+
await asyncio.gather(
478+
*(resolve_computer(tool=tool, run_context=context_wrapper) for tool in computer_tools)
479+
)
480+
464481
@classmethod
465482
def process_model_response(
466483
cls,
@@ -1529,10 +1546,11 @@ async def execute(
15291546
config: RunConfig,
15301547
acknowledged_safety_checks: list[ComputerCallOutputAcknowledgedSafetyCheck] | None = None,
15311548
) -> RunItem:
1549+
computer = await resolve_computer(tool=action.computer_tool, run_context=context_wrapper)
15321550
output_func = (
1533-
cls._get_screenshot_async(action.computer_tool.computer, action.tool_call)
1534-
if isinstance(action.computer_tool.computer, AsyncComputer)
1535-
else cls._get_screenshot_sync(action.computer_tool.computer, action.tool_call)
1551+
cls._get_screenshot_async(computer, action.tool_call)
1552+
if isinstance(computer, AsyncComputer)
1553+
else cls._get_screenshot_sync(computer, action.tool_call)
15361554
)
15371555

15381556
_, _, output = await asyncio.gather(

src/agents/models/openai_responses.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from .. import _debug
2323
from ..agent_output import AgentOutputSchemaBase
24+
from ..computer import AsyncComputer, Computer
2425
from ..exceptions import UserError
2526
from ..handoffs import Handoff
2627
from ..items import ItemHelpers, ModelResponse, TResponseInputItem
@@ -491,11 +492,18 @@ def _convert_tool(cls, tool: Tool) -> tuple[ToolParam, ResponseIncludable | None
491492

492493
includes = "file_search_call.results" if tool.include_search_results else None
493494
elif isinstance(tool, ComputerTool):
495+
computer = tool.computer
496+
if not isinstance(computer, (Computer, AsyncComputer)):
497+
raise UserError(
498+
"Computer tool is not initialized for serialization. Call "
499+
"resolve_computer({ tool, run_context }) with a run context first "
500+
"when building payloads manually."
501+
)
494502
converted_tool = {
495503
"type": "computer_use_preview",
496-
"environment": tool.computer.environment,
497-
"display_width": tool.computer.dimensions[0],
498-
"display_height": tool.computer.dimensions[1],
504+
"environment": computer.environment,
505+
"display_width": computer.dimensions[0],
506+
"display_height": computer.dimensions[1],
499507
}
500508
includes = None
501509
elif isinstance(tool, HostedMCPTool):

src/agents/run.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
RunItemStreamEvent,
7272
StreamEvent,
7373
)
74-
from .tool import Tool
74+
from .tool import Tool, dispose_resolved_computers
7575
from .tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult
7676
from .tracing import Span, SpanError, agent_span, get_current_trace, trace
7777
from .tracing.span_data import AgentSpanData
@@ -600,6 +600,9 @@ async def run(
600600
try:
601601
while True:
602602
all_tools = await AgentRunner._get_all_tools(current_agent, context_wrapper)
603+
await RunImpl.initialize_computer_tools(
604+
tools=all_tools, context_wrapper=context_wrapper
605+
)
603606

604607
# Start an agent span if we don't have one. This span is ended if the current
605608
# agent changes, or if the agent loop ends.
@@ -782,6 +785,10 @@ async def run(
782785
)
783786
raise
784787
finally:
788+
try:
789+
await dispose_resolved_computers(run_context=context_wrapper)
790+
except Exception as error:
791+
logger.warning("Failed to dispose computers after run: %s", error)
785792
if current_span:
786793
current_span.finish(reset_current=True)
787794

@@ -1113,6 +1120,9 @@ async def _start_streaming(
11131120
break
11141121

11151122
all_tools = await cls._get_all_tools(current_agent, context_wrapper)
1123+
await RunImpl.initialize_computer_tools(
1124+
tools=all_tools, context_wrapper=context_wrapper
1125+
)
11161126

11171127
# Start an agent span if we don't have one. This span is ended if the current
11181128
# agent changes, or if the agent loop ends.
@@ -1323,6 +1333,10 @@ async def _start_streaming(
13231333
logger.debug(
13241334
f"Error in streamed_result finalize for agent {current_agent.name} - {e}"
13251335
)
1336+
try:
1337+
await dispose_resolved_computers(run_context=context_wrapper)
1338+
except Exception as error:
1339+
logger.warning("Failed to dispose computers after streamed run: %s", error)
13261340
if current_span:
13271341
current_span.finish(reset_current=True)
13281342
if streamed_result.trace:

src/agents/run_context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
TContext = TypeVar("TContext", default=Any)
99

1010

11-
@dataclass
11+
@dataclass(eq=False)
1212
class RunContextWrapper(Generic[TContext]):
1313
"""This wraps the context object that you passed to `Runner.run()`. It also contains
1414
information about the usage of the agent run so far.

0 commit comments

Comments
 (0)