ag2 0.9.5__py3-none-any.whl → 0.9.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ag2 might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ag2
3
- Version: 0.9.5
3
+ Version: 0.9.6
4
4
  Summary: A programming framework for agentic AI
5
5
  Project-URL: Homepage, https://ag2.ai/
6
6
  Project-URL: Documentation, https://docs.ag2.ai
@@ -8,13 +8,13 @@ autogen/function_utils.py,sha256=YnzwNFA49Jbbe4riAY1sinYcKphg5lrHFCXx0POdYbw,481
8
8
  autogen/graph_utils.py,sha256=2dfGUHZCCF629vh0vMK9WMXIX-Zi-if9NbdC0KFcuw4,7904
9
9
  autogen/import_utils.py,sha256=byPlexRvu1bkGzEAtwwtJ2SQIm1IzModK9B5ONGkqJw,17634
10
10
  autogen/json_utils.py,sha256=_61k__3SS1-ffE2K5Mm9ZGHQJBRUbOL9kHGQGTAWRZk,1378
11
- autogen/llm_config.py,sha256=eFWSN9yR3w2R7d2CdDQ5ZpU2tUy8xBzEwGuq5K6QZLs,14172
11
+ autogen/llm_config.py,sha256=lQPV3zXbLKL_7rJHi1fJAJn1NpNK7wZtqGUxHeapIZQ,14437
12
12
  autogen/math_utils.py,sha256=Ew9-I5chny9eAOnphGxKf6XCeW6Pepz5S-5touWrbYU,9546
13
13
  autogen/retrieve_utils.py,sha256=R3Yp5d8dH4o9ayLZrGn4rCjIaY4glOHIiyQjwClmdi8,20087
14
14
  autogen/runtime_logging.py,sha256=yCmZODvwqYR91m8lX3Q4SoPcY-DK48NF4m56CP6Om3c,4692
15
15
  autogen/token_count_utils.py,sha256=n4wTFVNHwrfjZkrErFr8kNig2K-YCGgMLWsjDRS9D6g,10797
16
16
  autogen/types.py,sha256=qu-7eywhakW2AxQ5lYisLLeIg45UoOW-b3ErIuyRTuw,1000
17
- autogen/version.py,sha256=THVipdL6_RedIuOtoczdX7_iJxpvPiU7gkSD-mvqhIo,193
17
+ autogen/version.py,sha256=Gx2IBExx_OQsL98JzXZ9G56t2UljGjVfvrmaV4UzVp4,193
18
18
  autogen/_website/__init__.py,sha256=c8B9TpO07x9neD0zsJWj6AaEdlcP-WvxrvVOGWLtamk,143
19
19
  autogen/_website/generate_api_references.py,sha256=yKqyeSP_NE27wwLYWsZbTYRceEoxzNxPXqn6vsIzEvk,14789
20
20
  autogen/_website/generate_mkdocs.py,sha256=TkmLnUDv1Ms5cGClXPmenA8nxmwg4kR0E-FHCVjw_og,43246
@@ -212,7 +212,7 @@ autogen/cache/in_memory_cache.py,sha256=qeECs4UnN5FzuCSgiF-Ma11eLGSjJBgGlqLraWhD
212
212
  autogen/cache/redis_cache.py,sha256=XqUVObZRbD_kDL5vDD6H6xu_U-xcL8qKmsUq2Ix8xVo,4248
213
213
  autogen/coding/__init__.py,sha256=fN8UCm3RzJZ4OFMwlp3a0jic0ZzOege6TNVAk5t6J0U,825
214
214
  autogen/coding/base.py,sha256=hj70EwB-b0MVzHXACw-Oj3CiHo-GUC5kGZGniDTMOnw,3801
215
- autogen/coding/docker_commandline_code_executor.py,sha256=njZM__TUdQyIHdIx2tH4DBtptFzjIrQwyxpt2CJgMGo,9900
215
+ autogen/coding/docker_commandline_code_executor.py,sha256=8sr6bBpk1JtWEb1YQNgmj5z_n2mL_-cAP3EzSrn6SLU,10872
216
216
  autogen/coding/factory.py,sha256=bC3kkIBNExAPt8KZvCQ1PsLpyJWLq1jpw1JcvIbKR04,1978
217
217
  autogen/coding/func_with_reqs.py,sha256=dPcjRs4gOjoVO3m5cQYWtFm7FKO94gBYPwr3O7gVj7g,6372
218
218
  autogen/coding/local_commandline_code_executor.py,sha256=XKkT141IYZf_HngNJjMcJ7uZXe3k056wMdXZC2hfAts,16671
@@ -226,6 +226,12 @@ autogen/coding/jupyter/import_utils.py,sha256=7yzEAYmNmJsODsqz3XCNOORKfvp0t6xnaf
226
226
  autogen/coding/jupyter/jupyter_client.py,sha256=ROXAWOKG_EJ_oFNuyqUd_3uOBPUTRoTh6_-8bSpXBdU,8674
227
227
  autogen/coding/jupyter/jupyter_code_executor.py,sha256=Z2vZvou6QzpMBg0IgOzVRoCADswd15mvfkktIjGhUMY,6374
228
228
  autogen/coding/jupyter/local_jupyter_server.py,sha256=7b8yi5qK8ms2e5-PRCrzmXKGp1iC5KgpMU8xiqQ9u8o,6589
229
+ autogen/environments/__init__.py,sha256=SNh4NFcBgySNW--H1YUZPEf7F7QLpBQA3Qc8EvyM-gk,488
230
+ autogen/environments/docker_python_environment.py,sha256=oKdtr2IN2UDoYi8V_Qjvut5VGfwUNzNoj8J1aMRP9GU,15312
231
+ autogen/environments/python_environment.py,sha256=CXY0h3SWJPYSZtcR_nqY5VmVvGItIJwAnBqSx7YvECg,4265
232
+ autogen/environments/system_python_environment.py,sha256=jk1U4CSDPsF1igqdv-GQOKsH_NTxitRzN35-UWng2e8,3081
233
+ autogen/environments/venv_python_environment.py,sha256=i7dgd8lFuxfZwkysnFXuOn_B26CFYCzNHHBfCtExLkk,10076
234
+ autogen/environments/working_directory.py,sha256=MevUpN1lzmJLEKM_o9JHZod9cy9pQgI5AsE0ic6fvrE,2505
229
235
  autogen/events/__init__.py,sha256=XwCA6Rsq9AyjgeecGuiwHcAvDQMmKXgGomw5iLDIU5Q,358
230
236
  autogen/events/agent_events.py,sha256=LSCOMwA-nlBSboE5jOh-Da2Pm7hghiwjfzYTz_bS30k,31112
231
237
  autogen/events/base_event.py,sha256=5K1wzDBAio9wLxahErSvE0-UbFfMuSTxBp6EI8SbPGU,3475
@@ -281,7 +287,7 @@ autogen/mcp/helpers.py,sha256=J5_J6n3jMJUEJH5K8k9BeUb6ymQgRUI0hC1gbY7rlwM,1533
281
287
  autogen/mcp/mcp_client.py,sha256=7c_lHgBJEs77TFYjLcTlVrEu_0z4EafPPY3PgteY87c,7400
282
288
  autogen/mcp/mcp_proxy/__init__.py,sha256=3HTU-TqHLk4XSXeBV1UFd9XkQ1B0yOuXXyGseXvDVec,518
283
289
  autogen/mcp/mcp_proxy/fastapi_code_generator_helpers.py,sha256=dx-w2tGVMnh8pzY2NuXlMD7oIF7_5Gvc5oSbHIySEpc,2110
284
- autogen/mcp/mcp_proxy/mcp_proxy.py,sha256=NfjNsUnqpPQ_FrKJPiPUL7aDE9SZ_wNB0ZHAsiJHq9I,22131
290
+ autogen/mcp/mcp_proxy/mcp_proxy.py,sha256=1eWwZatPgPxqkhcbEFCW-ErmkrWUF9F_cLkFWVtLkug,22171
285
291
  autogen/mcp/mcp_proxy/operation_grouping.py,sha256=1n4o5qhkQd2hVr7OaOMsRhInDveDGkCozLudsBVusC4,6349
286
292
  autogen/mcp/mcp_proxy/operation_renaming.py,sha256=G5J4VdxUAwSvOo-DsqIUbCfV9TnH3riaHLI6IpctDF8,3956
287
293
  autogen/mcp/mcp_proxy/patch_fastapi_code_generator.py,sha256=vBr8P890nsFvK4iuFicrdNskooHlBQeTQ6jbb5kcPAk,3490
@@ -296,7 +302,7 @@ autogen/oai/__init__.py,sha256=BIwnV6wtHmKgIM4IUdymfPfpdNqos5P7BfRv-7_QL9A,1680
296
302
  autogen/oai/anthropic.py,sha256=AK_Bbcc5I-PqAvoiwB0LaO1ZLugh_wQNAOIFcj7eTtk,29174
297
303
  autogen/oai/bedrock.py,sha256=8AYWZVsDkJS2HmQ0ggoUqlKV_a4H_ON8rks4VW2Jrxk,25719
298
304
  autogen/oai/cerebras.py,sha256=8hiSBq88l2yTXUJPV7AvGXRCtwvW0Y9hIYUnYK2S2os,12462
299
- autogen/oai/client.py,sha256=nXPRAiUrO5MoGGK2CjKG8TNKfuJxRCDdGVHKQGKk0H4,66957
305
+ autogen/oai/client.py,sha256=ZJfax-XdV92xfmIvn-qmJhSwjP-MusU4oDc9_88o-1k,68206
300
306
  autogen/oai/client_utils.py,sha256=lVbHyff7OnpdM-tXskC23xLdFccj2AalTdWA4DxzxS4,7543
301
307
  autogen/oai/cohere.py,sha256=pRcQWjbzKbZ1RfC1vk9WGjgndwjHbIaOVoKEYdV2L6c,19421
302
308
  autogen/oai/gemini.py,sha256=bc_RQwtoGi7DnwQwPie7ZdvsqpD1AjYwzjVwnIKP39U,43168
@@ -323,9 +329,11 @@ autogen/tools/toolkit.py,sha256=1tOmTGJ96RhkJrrtAViKUyEcwacA6ztoIbYbnf8NgTU,2558
323
329
  autogen/tools/contrib/__init__.py,sha256=DWEjPK6xCR2ihAXXdquQZmiuqRLA3Pqb8QV8W1RtS3k,202
324
330
  autogen/tools/contrib/time/__init__.py,sha256=dplie5aBJZ8VoKy6EKcQMLTtSgcCkNDYzpdsC2I0YWk,195
325
331
  autogen/tools/contrib/time/time.py,sha256=tPi49vOUwfvujbYA-zS00CWcLW-y18CPyQ1gnJG6iRg,1271
326
- autogen/tools/experimental/__init__.py,sha256=jGyt9GVoJO6VsZdmBLzwTxXZGBbWEwdLrUjn-rWQ6os,1588
332
+ autogen/tools/experimental/__init__.py,sha256=OPJlrK_tJCPbeBT-qsIWwJBdLuaCMskpshduA8Haj7E,1671
327
333
  autogen/tools/experimental/browser_use/__init__.py,sha256=kfxCajXcVMDH6CZq-lWh2p8PKxOwT9yjC_Za0jr4zUg,290
328
334
  autogen/tools/experimental/browser_use/browser_use.py,sha256=KfU4MI_BWaHepv0bDMf9HTDUaHTJThuBJI8R_BPpjmg,5561
335
+ autogen/tools/experimental/code_execution/__init__.py,sha256=KFUku2awEgsnkSa48t2iMMPC-QCxpqAG7lb8fB4Fr-c,242
336
+ autogen/tools/experimental/code_execution/python_code_execution.py,sha256=Fj1Wxsn6z0HuXWpqa-ahedkCtyPYBShUnbiyvSw_mus,3705
329
337
  autogen/tools/experimental/crawl4ai/__init__.py,sha256=UjFJLSZ9P5xT6WCV0RDPtwt4MHuwPdK90TU7ByXhLWs,207
330
338
  autogen/tools/experimental/crawl4ai/crawl4ai.py,sha256=MsOLtbPHRpRrCnRJPQVVVNwmsBcgsWLSHNXDOF-47ws,6088
331
339
  autogen/tools/experimental/deep_research/__init__.py,sha256=9SFcDEj2OHxNSlXP11lf1uHENlfUeO47ROcOSD9GCDs,220
@@ -406,8 +414,8 @@ autogen/agentchat/contrib/captainagent/tools/math/modular_inverse_sum.py,sha256=
406
414
  autogen/agentchat/contrib/captainagent/tools/math/simplify_mixed_numbers.py,sha256=iqgpFJdyBHPPNCqkehSIbeuV8Rabr2eDMilT23Wx7PI,1687
407
415
  autogen/agentchat/contrib/captainagent/tools/math/sum_of_digit_factorials.py,sha256=-6T5r6Er4mONPldRxv3F9tLoE7Og3qmeSeTC7Du_tTg,596
408
416
  autogen/agentchat/contrib/captainagent/tools/math/sum_of_primes_below.py,sha256=Xig7K3A3DRnbv-UXfyo5bybGZUQYAQsltthfTYW5eV8,509
409
- ag2-0.9.5.dist-info/METADATA,sha256=K4Gz-eYvrw_xTPz7UV0WPlKiR5LluSMxvV0PGJZUWpA,35268
410
- ag2-0.9.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
411
- ag2-0.9.5.dist-info/licenses/LICENSE,sha256=GEFQVNayAR-S_rQD5l8hPdgvgyktVdy4Bx5-v90IfRI,11384
412
- ag2-0.9.5.dist-info/licenses/NOTICE.md,sha256=07iCPQGbth4pQrgkSgZinJGT5nXddkZ6_MGYcBd2oiY,1134
413
- ag2-0.9.5.dist-info/RECORD,,
417
+ ag2-0.9.6.dist-info/METADATA,sha256=Syw0Wb_vvJEdWJ52b0UjycmcDQj0PJtCt8ZRUa-lLeM,35268
418
+ ag2-0.9.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
419
+ ag2-0.9.6.dist-info/licenses/LICENSE,sha256=GEFQVNayAR-S_rQD5l8hPdgvgyktVdy4Bx5-v90IfRI,11384
420
+ ag2-0.9.6.dist-info/licenses/NOTICE.md,sha256=07iCPQGbth4pQrgkSgZinJGT5nXddkZ6_MGYcBd2oiY,1134
421
+ ag2-0.9.6.dist-info/RECORD,,
@@ -71,6 +71,8 @@ class DockerCommandLineCodeExecutor(CodeExecutor):
71
71
  auto_remove: bool = True,
72
72
  stop_container: bool = True,
73
73
  execution_policies: Optional[dict[str, bool]] = None,
74
+ *,
75
+ container_create_kwargs: Optional[dict[str, Any]] = None,
74
76
  ):
75
77
  """(Experimental) A code executor class that executes code through
76
78
  a command line environment in a Docker container.
@@ -98,6 +100,11 @@ class DockerCommandLineCodeExecutor(CodeExecutor):
98
100
  whether code in that language should be executed. True means code in that language
99
101
  will be executed, False means it will only be saved to a file. This overrides the
100
102
  default execution policies. Defaults to None.
103
+ container_create_kwargs: Optional dict forwarded verbatim to
104
+ "docker.client.containers.create". Use it to set advanced Docker
105
+ options (environment variables, GPU device_requests, port mappings, etc.).
106
+ Values here override the class defaults when keys collide. Defaults to None.
107
+
101
108
 
102
109
  Raises:
103
110
  ValueError: On argument error, or if the container fails to start.
@@ -128,16 +135,29 @@ class DockerCommandLineCodeExecutor(CodeExecutor):
128
135
  if container_name is None:
129
136
  container_name = f"autogen-code-exec-{uuid.uuid4()}"
130
137
 
138
+ # build kwargs for docker.create
139
+ base_kwargs: dict[str, Any] = {
140
+ "image": image,
141
+ "name": container_name,
142
+ "entrypoint": "/bin/sh",
143
+ "tty": True,
144
+ "auto_remove": auto_remove,
145
+ "volumes": {str(bind_dir.resolve()): {"bind": "/workspace", "mode": "rw"}},
146
+ "working_dir": "/workspace",
147
+ }
148
+
149
+ if container_create_kwargs:
150
+ for k in ("entrypoint", "volumes", "working_dir", "tty"):
151
+ if k in container_create_kwargs:
152
+ logging.warning(
153
+ "DockerCommandLineCodeExecutor: overriding default %s=%s",
154
+ k,
155
+ container_create_kwargs[k],
156
+ )
157
+ base_kwargs.update(container_create_kwargs)
158
+
131
159
  # Start a container from the image, read to exec commands later
132
- self._container = client.containers.create(
133
- image,
134
- name=container_name,
135
- entrypoint="/bin/sh",
136
- tty=True,
137
- auto_remove=auto_remove,
138
- volumes={str(bind_dir.resolve()): {"bind": "/workspace", "mode": "rw"}},
139
- working_dir="/workspace",
140
- )
160
+ self._container = client.containers.create(**base_kwargs)
141
161
  self._container.start()
142
162
 
143
163
  _wait_for_ready(self._container)
@@ -0,0 +1,10 @@
1
+ # Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from .docker_python_environment import DockerPythonEnvironment
6
+ from .system_python_environment import SystemPythonEnvironment
7
+ from .venv_python_environment import VenvPythonEnvironment
8
+ from .working_directory import WorkingDirectory
9
+
10
+ __all__ = ["DockerPythonEnvironment", "SystemPythonEnvironment", "VenvPythonEnvironment", "WorkingDirectory"]
@@ -0,0 +1,375 @@
1
+ # Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import uuid
11
+ from typing import Any, Optional, Tuple
12
+
13
+ from asyncer import asyncify
14
+
15
+ from .python_environment import PythonEnvironment
16
+
17
+ __all__ = ["DockerPythonEnvironment"]
18
+
19
+
20
+ class DockerPythonEnvironment(PythonEnvironment):
21
+ """A Python environment using Docker containers for isolated execution."""
22
+
23
+ def __init__(
24
+ self,
25
+ image: str = "python:3.11-slim",
26
+ container_name_prefix: str = "ag2_docker_env_",
27
+ volumes: Optional[dict[str, str]] = None,
28
+ environment: Optional[dict[str, str]] = None,
29
+ network: Optional[str] = None,
30
+ pip_packages: Optional[list[str]] = None,
31
+ requirements_file: Optional[str] = None,
32
+ dockerfile: Optional[str] = None,
33
+ build_args: Optional[dict[str, str]] = None,
34
+ cleanup_container: bool = True,
35
+ keep_container_running: bool = False,
36
+ container_startup_timeout: int = 30,
37
+ ):
38
+ """
39
+ Initialize a Docker Python environment.
40
+
41
+ Args:
42
+ image: Docker image to use (ignored if dockerfile is provided)
43
+ container_name_prefix: Prefix for container names
44
+ volumes: Dictionary mapping host paths to container paths for mounting
45
+ environment: Dictionary of environment variables to set in the container
46
+ network: Docker network to attach the container to
47
+ pip_packages: List of pip packages to install in the container
48
+ requirements_file: Path to requirements.txt file to install in the container
49
+ dockerfile: Optional path to a Dockerfile to build and use instead of pulling an image
50
+ build_args: Optional build arguments for the Dockerfile
51
+ cleanup_container: Whether to remove the container after use
52
+ keep_container_running: Whether to keep the container running after execution
53
+ container_startup_timeout: Timeout in seconds for container startup
54
+ """
55
+ self.image = image
56
+ self.container_name_prefix = container_name_prefix
57
+ self.volumes = volumes or {}
58
+ self.environment = environment or {}
59
+ self.network = network
60
+ self.pip_packages = pip_packages or []
61
+ self.requirements_file = requirements_file
62
+ self.dockerfile = dockerfile
63
+ self.build_args = build_args or {}
64
+ self.cleanup_container = cleanup_container
65
+ self.keep_container_running = keep_container_running
66
+ self.container_startup_timeout = container_startup_timeout
67
+
68
+ # Internal state
69
+ self._container_id = None
70
+ self._container_name = None
71
+ self._custom_image_name = None
72
+ self._temp_dir = None
73
+
74
+ super().__init__()
75
+
76
+ def _setup_environment(self) -> None:
77
+ """Set up the Docker environment."""
78
+ # Verify Docker is installed and accessible
79
+ try:
80
+ result = subprocess.run(["docker", "--version"], capture_output=True, text=True, check=True)
81
+ logging.info(f"Docker version: {result.stdout.strip()}")
82
+ except (subprocess.SubprocessError, FileNotFoundError) as e:
83
+ raise RuntimeError(
84
+ "Docker not found or not accessible. Please ensure Docker is installed and running."
85
+ ) from e
86
+
87
+ # Create a temporary directory for file operations
88
+ self._temp_dir = tempfile.mkdtemp(prefix="ag2_docker_")
89
+
90
+ # Generate a unique container name
91
+ self._container_name = f"{self.container_name_prefix}{uuid.uuid4().hex[:8]}"
92
+
93
+ # Build custom image if Dockerfile is provided
94
+ if self.dockerfile:
95
+ self._build_custom_image()
96
+ else:
97
+ # Pull the specified image
98
+ try:
99
+ subprocess.run(
100
+ ["docker", "pull", self.image],
101
+ check=True,
102
+ stdout=subprocess.PIPE,
103
+ stderr=subprocess.PIPE,
104
+ text=True,
105
+ )
106
+ logging.info(f"Pulled Docker image: {self.image}")
107
+ except subprocess.CalledProcessError as e:
108
+ raise RuntimeError(f"Failed to pull Docker image: {e.stderr}") from e
109
+
110
+ # Start the container
111
+ self._start_container()
112
+
113
+ def _build_custom_image(self) -> None:
114
+ """Build a custom Docker image from the provided Dockerfile."""
115
+ if not os.path.exists(self.dockerfile):
116
+ raise RuntimeError(f"Dockerfile not found at: {self.dockerfile}")
117
+
118
+ # Create a unique image name
119
+ self._custom_image_name = f"ag2-custom-python-{uuid.uuid4().hex[:8]}"
120
+
121
+ # Build command
122
+ build_cmd = ["docker", "build", "-t", self._custom_image_name]
123
+
124
+ # Add build args
125
+ for arg_name, arg_value in self.build_args.items():
126
+ build_cmd.extend(["--build-arg", f"{arg_name}={arg_value}"])
127
+
128
+ # Add Dockerfile path
129
+ build_cmd.extend(["-f", self.dockerfile, os.path.dirname(self.dockerfile)])
130
+
131
+ try:
132
+ logging.info(f"Building custom Docker image: {self._custom_image_name}")
133
+ _ = subprocess.run(
134
+ build_cmd,
135
+ check=True,
136
+ stdout=subprocess.PIPE,
137
+ stderr=subprocess.PIPE,
138
+ text=True,
139
+ )
140
+ logging.info(f"Built custom Docker image: {self._custom_image_name}")
141
+ except subprocess.CalledProcessError as e:
142
+ raise RuntimeError(f"Failed to build Docker image: {e.stderr}") from e
143
+
144
+ # Use the custom image
145
+ self.image = self._custom_image_name
146
+
147
+ def _start_container(self) -> None:
148
+ """Start the Docker container."""
149
+ # Basic container run command
150
+ run_cmd = ["docker", "run", "--name", self._container_name]
151
+
152
+ # Add detached mode flag to run container in background
153
+ run_cmd.append("-d")
154
+
155
+ # Add network if specified
156
+ if self.network:
157
+ run_cmd.extend(["--network", self.network])
158
+
159
+ # Add environment variables
160
+ for env_name, env_value in self.environment.items():
161
+ run_cmd.extend(["-e", f"{env_name}={env_value}"])
162
+
163
+ # Add volume mounts including temp directory
164
+ work_dir_mount = f"{self._temp_dir}:/workspace"
165
+ run_cmd.extend(["-v", work_dir_mount])
166
+
167
+ for host_path, container_path in self.volumes.items():
168
+ run_cmd.extend(["-v", f"{host_path}:{container_path}"])
169
+
170
+ # Set workspace as working directory
171
+ run_cmd.extend(["-w", "/workspace"])
172
+
173
+ # Add tty to keep container running
174
+ run_cmd.append("-t")
175
+
176
+ # Add image name
177
+ run_cmd.append(self.image)
178
+
179
+ # Initial command to keep container running
180
+ run_cmd.extend(["tail", "-f", "/dev/null"])
181
+
182
+ try:
183
+ # Start the container
184
+ logging.info(f"Starting Docker container: {self._container_name}")
185
+ result = subprocess.run(
186
+ run_cmd,
187
+ check=True,
188
+ stdout=subprocess.PIPE,
189
+ stderr=subprocess.PIPE,
190
+ text=True,
191
+ )
192
+
193
+ # Get container ID
194
+ self._container_id = result.stdout.strip()
195
+ logging.info(f"Started Docker container: {self._container_name} ({self._container_id})")
196
+
197
+ # Install pip packages if specified
198
+ if self.pip_packages or self.requirements_file:
199
+ self._install_packages()
200
+
201
+ except subprocess.CalledProcessError as e:
202
+ raise RuntimeError(f"Failed to start Docker container: {e.stderr}") from e
203
+
204
+ def _install_packages(self) -> None:
205
+ """Install Python packages in the running container."""
206
+ # Install pip packages
207
+ if self.pip_packages:
208
+ packages_str = " ".join(self.pip_packages)
209
+ try:
210
+ logging.info(f"Installing pip packages: {packages_str}")
211
+ _ = subprocess.run(
212
+ ["docker", "exec", self._container_name, "pip", "install", "--no-cache-dir"] + self.pip_packages,
213
+ check=True,
214
+ stdout=subprocess.PIPE,
215
+ stderr=subprocess.PIPE,
216
+ text=True,
217
+ )
218
+ logging.info("Successfully installed pip packages")
219
+ except subprocess.CalledProcessError as e:
220
+ logging.warning(f"Failed to install pip packages: {e.stderr}")
221
+
222
+ # Install from requirements file
223
+ if self.requirements_file:
224
+ if os.path.exists(self.requirements_file):
225
+ # Copy requirements file to temp directory
226
+ req_filename = os.path.basename(self.requirements_file)
227
+ temp_req_path = os.path.join(self._temp_dir, req_filename)
228
+ shutil.copy(self.requirements_file, temp_req_path)
229
+
230
+ try:
231
+ logging.info(f"Installing requirements from: {req_filename}")
232
+ _ = subprocess.run(
233
+ [
234
+ "docker",
235
+ "exec",
236
+ self._container_name,
237
+ "pip",
238
+ "install",
239
+ "--no-cache-dir",
240
+ "-r",
241
+ f"/workspace/{req_filename}",
242
+ ],
243
+ check=True,
244
+ stdout=subprocess.PIPE,
245
+ stderr=subprocess.PIPE,
246
+ text=True,
247
+ )
248
+ logging.info("Successfully installed requirements")
249
+ except subprocess.CalledProcessError as e:
250
+ logging.warning(f"Failed to install requirements: {e.stderr}")
251
+ else:
252
+ logging.warning(f"Requirements file not found: {self.requirements_file}")
253
+
254
+ def _cleanup_environment(self) -> None:
255
+ """Clean up the Docker environment."""
256
+ if self._container_id:
257
+ # Stop the container if it's running and we want to clean it up
258
+ if not self.keep_container_running:
259
+ try:
260
+ logging.info(f"Stopping Docker container: {self._container_name}")
261
+ subprocess.run(
262
+ ["docker", "stop", self._container_name],
263
+ check=True,
264
+ stdout=subprocess.PIPE,
265
+ stderr=subprocess.PIPE,
266
+ text=True,
267
+ )
268
+ except subprocess.CalledProcessError:
269
+ logging.warning(f"Failed to stop Docker container: {self._container_name}")
270
+
271
+ # Remove the container if cleanup is enabled
272
+ if self.cleanup_container and not self.keep_container_running:
273
+ try:
274
+ logging.info(f"Removing Docker container: {self._container_name}")
275
+ subprocess.run(
276
+ ["docker", "rm", "-f", self._container_name],
277
+ check=True,
278
+ stdout=subprocess.PIPE,
279
+ stderr=subprocess.PIPE,
280
+ text=True,
281
+ )
282
+ except subprocess.CalledProcessError:
283
+ logging.warning(f"Failed to remove Docker container: {self._container_name}")
284
+
285
+ # Remove the custom image if it was created
286
+ if self._custom_image_name and self.cleanup_container:
287
+ try:
288
+ logging.info(f"Removing custom Docker image: {self._custom_image_name}")
289
+ subprocess.run(
290
+ ["docker", "rmi", self._custom_image_name],
291
+ check=True,
292
+ stdout=subprocess.PIPE,
293
+ stderr=subprocess.PIPE,
294
+ text=True,
295
+ )
296
+ except subprocess.CalledProcessError:
297
+ logging.warning(f"Failed to remove custom Docker image: {self._custom_image_name}")
298
+
299
+ # Clean up the temporary directory
300
+ if self._temp_dir and os.path.exists(self._temp_dir):
301
+ try:
302
+ shutil.rmtree(self._temp_dir)
303
+ except Exception as e:
304
+ logging.warning(f"Failed to remove temporary directory: {e}")
305
+
306
+ def get_executable(self) -> str:
307
+ """Get the path to the Python executable in the Docker container."""
308
+ # This is a virtual path in the container
309
+ return "python"
310
+
311
+ async def execute_code(self, code: str, script_path: str, timeout: int = 30) -> dict[str, Any]:
312
+ """Execute code in the Docker container."""
313
+ # Ensure the container is running
314
+ if not self._container_id:
315
+ return {"success": False, "error": "Docker container not started"}
316
+
317
+ try:
318
+ # Calculate the relative path within the temp directory
319
+ if os.path.isabs(script_path):
320
+ rel_path = os.path.basename(script_path)
321
+ host_script_path = os.path.join(self._temp_dir, rel_path)
322
+ else:
323
+ rel_path = script_path
324
+ host_script_path = os.path.join(self._temp_dir, rel_path)
325
+
326
+ # Ensure the directory for the script exists
327
+ script_dir = os.path.dirname(host_script_path)
328
+ if script_dir:
329
+ os.makedirs(script_dir, exist_ok=True)
330
+
331
+ # Write the code to the script file on the host
332
+ await asyncify(self._write_to_file)(host_script_path, code)
333
+
334
+ # Path to the script in the container
335
+ container_script_path = f"/workspace/{rel_path}"
336
+
337
+ # Execute the script in the container
338
+ exec_cmd = ["docker", "exec", self._container_name, "python", container_script_path]
339
+
340
+ # Run the command with a timeout
341
+ result = await asyncify(self._run_subprocess_with_timeout)(exec_cmd, timeout)
342
+
343
+ return {
344
+ "success": result[0],
345
+ "stdout": result[1],
346
+ "stderr": result[2],
347
+ "returncode": result[3] if result[0] else 1,
348
+ }
349
+
350
+ except Exception as e:
351
+ return {"success": False, "error": f"Execution error: {str(e)}"}
352
+
353
+ def _run_subprocess_with_timeout(self, cmd: list[str], timeout: int) -> Tuple[bool, str, str, int]:
354
+ """
355
+ Run a subprocess with timeout and return status, stdout, stderr, and return code.
356
+
357
+ Args:
358
+ cmd: Command to run as a list of strings
359
+ timeout: Maximum execution time in seconds
360
+
361
+ Returns:
362
+ Tuple of (success, stdout, stderr, return_code)
363
+ """
364
+ try:
365
+ result = subprocess.run(
366
+ cmd,
367
+ capture_output=True,
368
+ text=True,
369
+ timeout=timeout,
370
+ )
371
+ return (result.returncode == 0, result.stdout, result.stderr, result.returncode)
372
+ except subprocess.TimeoutExpired:
373
+ return (False, "", f"Execution timed out after {timeout} seconds", -1)
374
+ except Exception as e:
375
+ return (False, "", str(e), -1)
@@ -0,0 +1,134 @@
1
+ # Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import subprocess
6
+ from abc import ABC, abstractmethod
7
+ from contextvars import ContextVar
8
+ from typing import Any, Optional
9
+
10
+ __all__ = ["PythonEnvironment"]
11
+
12
+
13
+ class PythonEnvironment(ABC):
14
+ """Python execution environments base class"""
15
+
16
+ # Shared context variable for tracking the current environment
17
+ _current_python_environment: ContextVar["PythonEnvironment"] = ContextVar("_current_python_environment")
18
+
19
+ def __init__(self):
20
+ """
21
+ Initialize the Python environment.
22
+ """
23
+ self._token = None
24
+
25
+ # Set up the environment
26
+ self._setup_environment()
27
+
28
+ def __enter__(self):
29
+ """
30
+ Enter the environment context.
31
+ Sets this environment as the current one.
32
+ """
33
+ # Set this as the current Python environment in the context
34
+ self._token = PythonEnvironment._current_python_environment.set(self)
35
+
36
+ return self
37
+
38
+ def __exit__(self, exc_type, exc_val, exc_tb):
39
+ """
40
+ Exit the environment context.
41
+ Resets the current environment and performs cleanup.
42
+ """
43
+ # Reset the context variable if this was the active environment
44
+ if self._token is not None:
45
+ PythonEnvironment._current_python_environment.reset(self._token)
46
+ self._token = None
47
+
48
+ # Clean up resources
49
+ self._cleanup_environment()
50
+
51
+ @abstractmethod
52
+ def _setup_environment(self) -> None:
53
+ """Set up the Python environment. Called by __enter__."""
54
+ pass
55
+
56
+ @abstractmethod
57
+ def _cleanup_environment(self) -> None:
58
+ """Clean up the Python environment. Called by __exit__."""
59
+ pass
60
+
61
+ @abstractmethod
62
+ def get_executable(self) -> str:
63
+ """
64
+ Get the path to the Python executable in this environment.
65
+
66
+ Returns:
67
+ The full path to the Python executable.
68
+ """
69
+ pass
70
+
71
+ @abstractmethod
72
+ async def execute_code(self, code: str, script_path: str, timeout: int = 30) -> dict[str, Any]:
73
+ """
74
+ Execute the given code in this environment.
75
+
76
+ Args:
77
+ code: The Python code to execute.
78
+ script_path: Path where the code should be saved before execution.
79
+ timeout: Maximum execution time in seconds.
80
+
81
+ Returns:
82
+ dict with execution results including stdout, stderr, and success status.
83
+ """
84
+ pass
85
+
86
+ # Utility method for subclasses to wrap (for async support)
87
+ def _write_to_file(self, script_path: str, content: str) -> None:
88
+ """
89
+ Write content to a file (blocking operation).
90
+
91
+ This is a helper method for use with asyncify in async contexts.
92
+
93
+ Args:
94
+ script_path: Path to the file to write.
95
+ content: Content to write to the file.
96
+ """
97
+ with open(script_path, "w") as f:
98
+ f.write(content)
99
+
100
+ # Utility method for subclasses to wrap (for async support)
101
+ def _run_subprocess(self, cmd: list[str], timeout: int) -> subprocess.CompletedProcess:
102
+ """
103
+ Run a subprocess (blocking operation).
104
+
105
+ This is a helper method for use with asyncify in async contexts.
106
+
107
+ Args:
108
+ cmd: Command to run as a list of strings.
109
+ timeout: Maximum execution time in seconds.
110
+
111
+ Returns:
112
+ CompletedProcess instance with results of the subprocess.
113
+ """
114
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
115
+
116
+ @classmethod
117
+ def get_current_python_environment(
118
+ cls, python_environment: Optional["PythonEnvironment"] = None
119
+ ) -> Optional["PythonEnvironment"]:
120
+ """
121
+ Get the current Python environment or the specified one if provided.
122
+
123
+ Args:
124
+ python_environment: Optional environment to return if specified.
125
+
126
+ Returns:
127
+ The current Python environment or None if none is active.
128
+ """
129
+ if python_environment is not None:
130
+ return python_environment
131
+ try:
132
+ return cls._current_python_environment.get()
133
+ except LookupError:
134
+ return None
@@ -0,0 +1,86 @@
1
+ # Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from typing import Any, Optional
10
+
11
+ from asyncer import asyncify
12
+
13
+ from .python_environment import PythonEnvironment
14
+
15
+ __all__ = ["SystemPythonEnvironment"]
16
+
17
+
18
+ class SystemPythonEnvironment(PythonEnvironment):
19
+ """A Python environment using the system's Python installation."""
20
+
21
+ def __init__(
22
+ self,
23
+ executable: Optional[str] = None,
24
+ ):
25
+ """
26
+ Initialize a system Python environment.
27
+
28
+ Args:
29
+ executable: Optional path to a specific Python executable. If None, uses the current Python executable.
30
+ """
31
+ self._executable = executable or sys.executable
32
+ super().__init__()
33
+
34
+ def _setup_environment(self) -> None:
35
+ """Set up the system Python environment."""
36
+ # Verify the Python executable exists
37
+ if not os.path.exists(self._executable):
38
+ raise RuntimeError(f"Python executable not found at: {self._executable}")
39
+
40
+ logging.info(f"Using system Python at: {self._executable}")
41
+
42
+ def _cleanup_environment(self) -> None:
43
+ """Clean up the system Python environment."""
44
+ # No cleanup needed for system Python
45
+ pass
46
+
47
+ def get_executable(self) -> str:
48
+ """Get the path to the Python executable."""
49
+ return self._executable
50
+
51
+ async def execute_code(self, code: str, script_path: str, timeout: int = 30) -> dict[str, Any]:
52
+ """Execute code using the system Python."""
53
+ try:
54
+ # Get the Python executable
55
+ python_executable = self.get_executable()
56
+
57
+ # Verify the executable exists
58
+ if not os.path.exists(python_executable):
59
+ return {"success": False, "error": f"Python executable not found at {python_executable}"}
60
+
61
+ # Ensure the directory for the script exists
62
+ script_dir = os.path.dirname(script_path)
63
+ if script_dir:
64
+ os.makedirs(script_dir, exist_ok=True)
65
+
66
+ # Write the code to the script file using asyncify (from base class)
67
+ await asyncify(self._write_to_file)(script_path, code)
68
+
69
+ logging.info(f"Wrote code to {script_path}")
70
+
71
+ try:
72
+ # Execute directly with subprocess using asyncify for better reliability
73
+ result = await asyncify(self._run_subprocess)([python_executable, script_path], timeout)
74
+
75
+ # Main execution result
76
+ return {
77
+ "success": result.returncode == 0,
78
+ "stdout": result.stdout,
79
+ "stderr": result.stderr,
80
+ "returncode": result.returncode,
81
+ }
82
+ except subprocess.TimeoutExpired:
83
+ return {"success": False, "error": f"Execution timed out after {timeout} seconds"}
84
+
85
+ except Exception as e:
86
+ return {"success": False, "error": f"Execution error: {str(e)}"}
@@ -0,0 +1,224 @@
1
+ # Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import logging
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from typing import Any, Optional
11
+
12
+ from asyncer import asyncify
13
+
14
+ from .python_environment import PythonEnvironment
15
+
16
+ __all__ = ["VenvPythonEnvironment"]
17
+
18
+
19
+ class VenvPythonEnvironment(PythonEnvironment):
20
+ """A Python environment using a virtual environment (venv)."""
21
+
22
+ def __init__(
23
+ self,
24
+ python_version: Optional[str] = None,
25
+ python_path: Optional[str] = None,
26
+ venv_path: Optional[str] = None,
27
+ ):
28
+ """
29
+ Initialize a virtual environment for Python execution.
30
+
31
+ If you pass in a venv_path the path will be checked for a valid venv. If the venv doesn't exist it will be created using the python_version or python_path provided.
32
+
33
+ If the python_version or python_path is provided and the venv_path is not, a temporary directory will be created for venv and it will be setup with the provided python version.
34
+
35
+ If python_path is provided, it will take precedence over python_version.
36
+
37
+ The python version will not be installed if it doesn't exist and a RuntimeError will be raised.
38
+
39
+ Args:
40
+ python_version: The Python version to use (e.g., "3.11"), otherwise defaults to the current executing Python version. Ignored if venv_path is provided and has a valid environment already.
41
+ python_path: Optional direct path to a Python executable to use (must include the executable). Takes precedence over python_version if both are provided.
42
+ venv_path: Optional path for the virtual environment, will create it if it doesn't exist. If None, creates a temp directory.
43
+ """
44
+ self.python_version = python_version
45
+ self.python_path = python_path
46
+ self.venv_path = venv_path
47
+ self.created_venv = False
48
+ self._executable = None
49
+ super().__init__()
50
+
51
+ def _setup_environment(self) -> None:
52
+ """Set up the virtual environment."""
53
+ # Create a venv directory if not provided
54
+ if self.venv_path is None:
55
+ self.venv_path = tempfile.mkdtemp(prefix="ag2_python_env_")
56
+ self.created_venv = True
57
+
58
+ # Determine the python version, getting it from the venv if it already has one
59
+ base_python = self._get_python_executable_for_version()
60
+ needs_creation = True
61
+ else:
62
+ # If venv_path is provided, check if it's already a valid venv
63
+ if os.name == "nt": # Windows
64
+ venv_python = os.path.join(self.venv_path, "Scripts", "python.exe")
65
+ else: # Unix-like (Mac/Linux)
66
+ venv_python = os.path.join(self.venv_path, "bin", "python")
67
+
68
+ if os.path.exists(venv_python) and os.access(venv_python, os.X_OK):
69
+ # Valid venv already exists, just use it
70
+ self._executable = venv_python
71
+ logging.info(f"Using existing virtual environment at {self.venv_path}")
72
+ needs_creation = False
73
+ else:
74
+ # Path exists but not a valid venv, or doesn't exist
75
+ if not os.path.exists(self.venv_path):
76
+ os.makedirs(self.venv_path, exist_ok=True)
77
+ self.created_venv = True
78
+ base_python = sys.executable
79
+ needs_creation = True
80
+
81
+ # Only create the venv if needed
82
+ if needs_creation:
83
+ logging.info(f"Creating virtual environment at {self.venv_path} using {base_python}")
84
+
85
+ try:
86
+ # Create the virtual environment
87
+ _ = subprocess.run(
88
+ [base_python, "-m", "venv", "--system-site-packages", self.venv_path],
89
+ check=True,
90
+ stdout=subprocess.PIPE,
91
+ stderr=subprocess.PIPE,
92
+ text=True,
93
+ )
94
+
95
+ # Determine the Python executable path
96
+ if os.name == "nt": # Windows
97
+ self._executable = os.path.join(self.venv_path, "Scripts", "python.exe")
98
+ else: # Unix-like (Mac/Linux)
99
+ self._executable = os.path.join(self.venv_path, "bin", "python")
100
+
101
+ # Verify the executable exists
102
+ if not os.path.exists(self._executable):
103
+ raise RuntimeError(
104
+ f"Virtual environment created but Python executable not found at {self._executable}"
105
+ )
106
+
107
+ except subprocess.CalledProcessError as e:
108
+ raise RuntimeError(f"Failed to create virtual environment: {e.stderr}") from e
109
+
110
+ def _cleanup_environment(self) -> None:
111
+ """Clean up the virtual environment."""
112
+ # Note: We intentionally don't clean up the venv here to allow
113
+ # tools to continue using it after the context exits.
114
+ pass
115
+
116
+ def get_executable(self) -> str:
117
+ """Get the path to the Python executable in the virtual environment."""
118
+ if not self._executable or not os.path.exists(self._executable):
119
+ raise RuntimeError("Virtual environment Python executable not found")
120
+ return self._executable
121
+
122
+ async def execute_code(self, code: str, script_path: str, timeout: int = 30) -> dict[str, Any]:
123
+ """Execute code in the virtual environment."""
124
+ try:
125
+ # Get the Python executable
126
+ python_executable = self.get_executable()
127
+
128
+ # Verify the executable exists
129
+ if not os.path.exists(python_executable):
130
+ return {"success": False, "error": f"Python executable not found at {python_executable}"}
131
+
132
+ # Ensure the directory for the script exists
133
+ script_dir = os.path.dirname(script_path)
134
+ if script_dir:
135
+ os.makedirs(script_dir, exist_ok=True)
136
+
137
+ # Write the code to the script file using asyncify (from base class)
138
+ await asyncify(self._write_to_file)(script_path, code)
139
+
140
+ logging.info(f"Wrote code to {script_path}")
141
+
142
+ try:
143
+ # Execute directly with subprocess using asyncify for better reliability
144
+ result = await asyncify(self._run_subprocess)([python_executable, script_path], timeout)
145
+
146
+ # Main execution result
147
+ return {
148
+ "success": result.returncode == 0,
149
+ "stdout": result.stdout,
150
+ "stderr": result.stderr,
151
+ "returncode": result.returncode,
152
+ }
153
+ except subprocess.TimeoutExpired:
154
+ return {"success": False, "error": f"Execution timed out after {timeout} seconds"}
155
+
156
+ except Exception as e:
157
+ return {"success": False, "error": f"Execution error: {str(e)}"}
158
+
159
+ def _get_python_executable_for_version(self) -> str:
160
+ """Get the Python executable for the specified version and verify it can create a venv."""
161
+ # If a specific path is provided, use it directly
162
+ if self.python_path:
163
+ if not os.path.exists(self.python_path) or not os.access(self.python_path, os.X_OK):
164
+ raise RuntimeError(f"Python executable not found at {self.python_path}")
165
+ return self.python_path
166
+
167
+ # If no specific version is requested, use the current Python
168
+ if not self.python_version:
169
+ return sys.executable
170
+
171
+ potential_executables = []
172
+
173
+ # Try to find a specific Python version using pyenv if available
174
+ try:
175
+ pyenv_result = subprocess.run(
176
+ ["pyenv", "which", f"python{self.python_version}"],
177
+ check=True,
178
+ stdout=subprocess.PIPE,
179
+ stderr=subprocess.PIPE,
180
+ text=True,
181
+ )
182
+ potential_executables.append(pyenv_result.stdout.strip())
183
+ except (subprocess.SubprocessError, FileNotFoundError):
184
+ pass
185
+
186
+ # Try common system paths based on platform
187
+ if os.name == "nt": # Windows
188
+ potential_executables.extend([
189
+ f"C:\\Python{self.python_version.replace('.', '')}\\python.exe",
190
+ f"C:\\Program Files\\Python{self.python_version.replace('.', '')}\\python.exe",
191
+ f"C:\\Program Files (x86)\\Python{self.python_version.replace('.', '')}\\python.exe",
192
+ ])
193
+ else: # Unix-like (Mac and Linux)
194
+ # Add more paths that might exist on macOS
195
+ potential_executables.extend([
196
+ f"/usr/bin/python{self.python_version}",
197
+ f"/usr/local/bin/python{self.python_version}",
198
+ f"/opt/homebrew/bin/python{self.python_version}", # Homebrew on Apple Silicon
199
+ f"/opt/python/bin/python{self.python_version}",
200
+ ])
201
+
202
+ # Try each potential path and verify it can create a venv
203
+ for path in potential_executables:
204
+ if os.path.exists(path) and os.access(path, os.X_OK):
205
+ # Verify this Python can create a venv
206
+ try:
207
+ test_result = subprocess.run(
208
+ [path, "-m", "venv", "--help"],
209
+ check=False, # Don't raise exception
210
+ stdout=subprocess.PIPE,
211
+ stderr=subprocess.PIPE,
212
+ text=True,
213
+ timeout=5, # Add timeout for safety
214
+ )
215
+ if test_result.returncode == 0:
216
+ # Successfully found a valid Python executable
217
+ return path
218
+ except (subprocess.SubprocessError, FileNotFoundError):
219
+ continue
220
+
221
+ # If we couldn't find the specified version, raise an exception
222
+ raise RuntimeError(
223
+ f"Python {self.python_version} not found or cannot create virtual environments. Provide a python_path to use a specific Python executable."
224
+ )
@@ -0,0 +1,75 @@
1
+ # Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import contextlib
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ from contextvars import ContextVar
10
+ from typing import Optional
11
+
12
+ __all__ = ["WorkingDirectory"]
13
+
14
+
15
+ class WorkingDirectory:
16
+ """Context manager for changing the current working directory."""
17
+
18
+ _current_working_directory: ContextVar["WorkingDirectory"] = ContextVar("_current_working_directory")
19
+
20
+ def __init__(self, path: str):
21
+ """
22
+ Initialize with a directory path.
23
+
24
+ Args:
25
+ path: The directory path to change to.
26
+ """
27
+ self.path = path
28
+ self.original_path = None
29
+ self.created_tmp = False
30
+ self._token = None
31
+
32
+ def __enter__(self):
33
+ """Change to the specified directory and return self."""
34
+ self.original_path = os.getcwd()
35
+ if self.path:
36
+ os.makedirs(self.path, exist_ok=True)
37
+ os.chdir(self.path)
38
+
39
+ # Set this as the current working directory in the context
40
+ self._token = WorkingDirectory._current_working_directory.set(self)
41
+
42
+ return self
43
+
44
+ def __exit__(self, exc_type, exc_val, exc_tb):
45
+ """Change back to the original directory and clean up if necessary."""
46
+ # Reset the context variable if this was the active working directory
47
+ if self._token is not None:
48
+ WorkingDirectory._current_working_directory.reset(self._token)
49
+ self._token = None
50
+
51
+ if self.original_path:
52
+ os.chdir(self.original_path)
53
+ if self.created_tmp and self.path and os.path.exists(self.path):
54
+ with contextlib.suppress(Exception):
55
+ shutil.rmtree(self.path)
56
+
57
+ @classmethod
58
+ def create_tmp(cls):
59
+ """Create a temporary directory and return a WorkingDirectory instance for it."""
60
+ tmp_dir = tempfile.mkdtemp(prefix="ag2_work_dir_")
61
+ instance = cls(tmp_dir)
62
+ instance.created_tmp = True
63
+ return instance
64
+
65
+ @classmethod
66
+ def get_current_working_directory(
67
+ cls, working_directory: Optional["WorkingDirectory"] = None
68
+ ) -> Optional["WorkingDirectory"]:
69
+ """Get the current working directory or the specified one if provided."""
70
+ if working_directory is not None:
71
+ return working_directory
72
+ try:
73
+ return cls._current_working_directory.get()
74
+ except LookupError:
75
+ return None
autogen/llm_config.py CHANGED
@@ -9,7 +9,7 @@ from abc import ABC, abstractmethod
9
9
  from collections.abc import Iterable
10
10
  from contextvars import ContextVar
11
11
  from pathlib import Path
12
- from typing import TYPE_CHECKING, Annotated, Any, Mapping, Optional, Type, TypeVar, Union
12
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, Mapping, Optional, Type, TypeVar, Union
13
13
 
14
14
  from httpx import Client as httpxClient
15
15
  from pydantic import BaseModel, ConfigDict, Field, HttpUrl, SecretStr, ValidationInfo, field_serializer, field_validator
@@ -268,6 +268,7 @@ class LLMConfig(metaclass=MetaLLMConfig):
268
268
  list[Annotated[Union[llm_config_classes], Field(discriminator="api_type")]],
269
269
  Field(default_factory=list, min_length=1),
270
270
  ]
271
+ routing_method: Optional[Literal["fixed_order", "round_robin"]] = None
271
272
 
272
273
  # Following field is configuration for pydantic to disallow extra fields
273
274
  model_config = ConfigDict(extra="forbid")
@@ -302,13 +303,15 @@ class LLMConfigEntry(BaseModel, ABC):
302
303
  @field_validator("base_url", mode="before")
303
304
  @classmethod
304
305
  def check_base_url(cls, v: Any, info: ValidationInfo) -> Any:
306
+ if v is None: # Handle None case explicitly
307
+ return None
305
308
  if not str(v).startswith("https://") and not str(v).startswith("http://"):
306
309
  v = f"http://{str(v)}"
307
310
  return v
308
311
 
309
- @field_serializer("base_url")
312
+ @field_serializer("base_url", when_used="unless-none") # Ensure serializer also respects None
310
313
  def serialize_base_url(self, v: Any) -> Any:
311
- return str(v)
314
+ return str(v) if v is not None else None
312
315
 
313
316
  @field_serializer("api_key", when_used="unless-none")
314
317
  def serialize_api_key(self, v: SecretStr) -> Any:
@@ -129,12 +129,12 @@ class MCPProxy:
129
129
  return mcp
130
130
 
131
131
  def _process_params(
132
- self, path: str, func: Callable[[Any], Any], **kwargs: Any
132
+ self, process_path: str, func: Callable[[Any], Any], **kwargs: Any
133
133
  ) -> tuple[str, dict[str, Any], dict[str, Any]]:
134
- path = MCPProxy._convert_camel_case_within_braces_to_snake(path)
135
- q_params, path_params, body, security = MCPProxy._get_params(path, func)
134
+ process_path = MCPProxy._convert_camel_case_within_braces_to_snake(process_path)
135
+ q_params, path_params, body, security = MCPProxy._get_params(process_path, func)
136
136
 
137
- expanded_path = path.format(**{p: kwargs[p] for p in path_params})
137
+ expanded_path = process_path.format(**{p: kwargs[p] for p in path_params})
138
138
 
139
139
  url = self._servers[0]["url"] + expanded_path
140
140
 
autogen/oai/client.py CHANGED
@@ -806,17 +806,29 @@ class OpenAIWrapper:
806
806
  self._clients: list[ModelClient] = []
807
807
  self._config_list: list[dict[str, Any]] = []
808
808
 
809
+ # Determine routing_method from base_config only.
810
+ self.routing_method = base_config.get("routing_method") or "fixed_order"
811
+ self._round_robin_index = 0
812
+
813
+ # Remove routing_method from extra_kwargs after it has been used to set self.routing_method
814
+ # This ensures it's not part of the individual client configurations that are based on extra_kwargs.
815
+ extra_kwargs.pop("routing_method", None)
816
+
809
817
  if config_list:
810
818
  config_list = [config.copy() for config in config_list] # make a copy before modifying
811
- for config in config_list:
812
- self._register_default_client(config, openai_config) # could modify the config
813
- self._config_list.append({
814
- **extra_kwargs,
815
- **{k: v for k, v in config.items() if k not in self.openai_kwargs},
816
- })
819
+ for config_item in config_list:
820
+ self._register_default_client(config_item, openai_config)
821
+ # Construct current_config_extra_kwargs using the cleaned extra_kwargs
822
+ # (which doesn't have routing_method from base_config)
823
+ # and specific non-openai kwargs from config_item.
824
+ config_item_specific_extras = {k: v for k, v in config_item.items() if k not in self.openai_kwargs}
825
+ self._config_list.append({**extra_kwargs, **config_item_specific_extras})
817
826
  else:
827
+ # For a single config passed via base_config (already in extra_kwargs)
818
828
  self._register_default_client(extra_kwargs, openai_config)
829
+ # extra_kwargs has already had routing_method popped.
819
830
  self._config_list = [extra_kwargs]
831
+
820
832
  self.wrapper_id = id(self)
821
833
 
822
834
  def _separate_openai_config(self, config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
@@ -1074,7 +1086,16 @@ class OpenAIWrapper:
1074
1086
  raise RuntimeError(
1075
1087
  f"Model client(s) {non_activated} are not activated. Please register the custom model clients using `register_model_client` or filter them out form the config list."
1076
1088
  )
1077
- for i, client in enumerate(self._clients):
1089
+
1090
+ ordered_clients_indices = list(range(len(self._clients)))
1091
+ if self.routing_method == "round_robin" and len(self._clients) > 0:
1092
+ ordered_clients_indices = (
1093
+ ordered_clients_indices[self._round_robin_index :] + ordered_clients_indices[: self._round_robin_index]
1094
+ )
1095
+ self._round_robin_index = (self._round_robin_index + 1) % len(self._clients)
1096
+
1097
+ for i in ordered_clients_indices:
1098
+ client = self._clients[i]
1078
1099
  # merge the input config with the i-th config in the config list
1079
1100
  full_config = {**config, **self._config_list[i]}
1080
1101
  # separate the config into create_config and extra_kwargs
@@ -3,6 +3,7 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
5
  from .browser_use import BrowserUseTool
6
+ from .code_execution import PythonCodeExecutionTool
6
7
  from .crawl4ai import Crawl4AITool
7
8
  from .deep_research import DeepResearchTool
8
9
  from .duckduckgo import DuckDuckGoSearchTool
@@ -34,6 +35,7 @@ __all__ = [
34
35
  "FirecrawlTool",
35
36
  "GoogleSearchTool",
36
37
  "PerplexitySearchTool",
38
+ "PythonCodeExecutionTool",
37
39
  "ReliableTool",
38
40
  "ReliableToolError",
39
41
  "SearxngSearchTool",
@@ -0,0 +1,7 @@
1
+ # Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from .python_code_execution import PythonCodeExecutionTool
6
+
7
+ __all__ = ["PythonCodeExecutionTool"]
@@ -0,0 +1,88 @@
1
+ # Copyright (c) 2023 - 2025, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import os
6
+ import tempfile
7
+ from typing import Annotated, Any, Optional
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from ....doc_utils import export_module
12
+ from ....environments import WorkingDirectory
13
+ from ....environments.python_environment import PythonEnvironment
14
+ from ... import Tool
15
+
16
+ __all__ = ["PythonCodeExecutionTool"]
17
+
18
+
19
+ @export_module("autogen.tools.experimental")
20
+ class PythonCodeExecutionTool(Tool):
21
+ """Executes Python code in a given environment and returns the result."""
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ timeout: int = 30,
27
+ working_directory: Optional[WorkingDirectory] = None,
28
+ python_environment: Optional[PythonEnvironment] = None,
29
+ ) -> None:
30
+ """
31
+ Initialize the PythonCodeExecutionTool.
32
+
33
+ **CAUTION**: If provided a local environment, this tool will execute code in your local environment, which can be dangerous if the code is untrusted.
34
+
35
+ Args:
36
+ timeout: Maximum execution time allowed in seconds, will raise a TimeoutError exception if exceeded.
37
+ working_directory: Optional WorkingDirectory context manager to use.
38
+ python_environment: Optional PythonEnvironment to use. If None, will auto-detect or create based on other parameters.
39
+ """
40
+ # Store configuration parameters
41
+ self.timeout = timeout
42
+ self.working_directory = WorkingDirectory.get_current_working_directory(working_directory)
43
+ tool_python_environment = PythonEnvironment.get_current_python_environment(python_environment)
44
+
45
+ assert self.working_directory, "No Working directory found"
46
+ assert tool_python_environment, "No Python environment found"
47
+
48
+ self.python_environment = tool_python_environment
49
+
50
+ # Pydantic model to contain the code and list of libraries to execute
51
+ class CodeExecutionRequest(BaseModel):
52
+ code: Annotated[str, Field(description="Python code to execute")]
53
+ libraries: Annotated[list[str], Field(description="List of libraries to install before execution")]
54
+
55
+ # The tool function, this is what goes to the LLM
56
+ async def execute_python_code(
57
+ code_execution_request: Annotated[CodeExecutionRequest, "Python code and the libraries required"],
58
+ ) -> dict[str, Any]:
59
+ """
60
+ Executes Python code in the attached environment and returns the result.
61
+
62
+ Args:
63
+ code_execution_request (CodeExecutionRequest): The Python code and libraries to execute
64
+ """
65
+ code = code_execution_request.code
66
+
67
+ # NOTE: Libraries are not installed (something to consider for future versions)
68
+
69
+ # Prepare a script file path
70
+ script_dir = self._get_script_directory()
71
+ script_path = os.path.join(script_dir, "script.py")
72
+
73
+ # Execute the code
74
+ return await self.python_environment.execute_code(code=code, script_path=script_path, timeout=self.timeout)
75
+
76
+ super().__init__(
77
+ name="python_execute_code",
78
+ description="Executes Python code and returns the result.",
79
+ func_or_tool=execute_python_code,
80
+ )
81
+
82
+ def _get_script_directory(self) -> str:
83
+ """Get the directory to use for scripts."""
84
+ if self.working_directory and hasattr(self.working_directory, "path") and self.working_directory.path:
85
+ path = self.working_directory.path
86
+ os.makedirs(path, exist_ok=True)
87
+ return path
88
+ return tempfile.mkdtemp(prefix="ag2_script_dir_")
autogen/version.py CHANGED
@@ -4,4 +4,4 @@
4
4
 
5
5
  __all__ = ["__version__"]
6
6
 
7
- __version__ = "0.9.5"
7
+ __version__ = "0.9.6"
File without changes