Skip to content

Commit 4503d96

Browse files
jackgerritsekzhu
andauthored
Implement docker based command line code executor (microsoft#1856)
* implement docker based command line code executor * undo import * test skips * format * fix type issue * skip docker tests * fix paths * add docs * Update __init__.py * class name * precommit * undo twoagent change * use relative to directly * Update, fixes, etc. * update doc * Update docstring --------- Co-authored-by: Eric Zhu <[email protected]>
1 parent 76bc505 commit 4503d96

9 files changed

+701
-32
lines changed

autogen/coding/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from .base import CodeBlock, CodeExecutor, CodeExtractor, CodeResult
22
from .factory import CodeExecutorFactory
33
from .markdown_code_extractor import MarkdownCodeExtractor
4+
from .local_commandline_code_executor import LocalCommandLineCodeExecutor, CommandLineCodeResult
5+
from .docker_commandline_code_executor import DockerCommandLineCodeExecutor
46

57
__all__ = (
68
"CodeBlock",
@@ -9,4 +11,7 @@
911
"CodeExecutor",
1012
"CodeExecutorFactory",
1113
"MarkdownCodeExtractor",
14+
"LocalCommandLineCodeExecutor",
15+
"CommandLineCodeResult",
16+
"DockerCommandLineCodeExecutor",
1217
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
from __future__ import annotations
2+
import atexit
3+
from hashlib import md5
4+
import logging
5+
from pathlib import Path
6+
from time import sleep
7+
from types import TracebackType
8+
import uuid
9+
from typing import List, Optional, Type, Union
10+
import docker
11+
from docker.models.containers import Container
12+
from docker.errors import ImageNotFound
13+
14+
from .local_commandline_code_executor import CommandLineCodeResult
15+
16+
from ..code_utils import TIMEOUT_MSG, _cmd
17+
from .base import CodeBlock, CodeExecutor, CodeExtractor
18+
from .markdown_code_extractor import MarkdownCodeExtractor
19+
import sys
20+
21+
if sys.version_info >= (3, 11):
22+
from typing import Self
23+
else:
24+
from typing_extensions import Self
25+
26+
27+
def _wait_for_ready(container: Container, timeout: int = 60, stop_time: int = 0.1) -> None:
28+
elapsed_time = 0
29+
while container.status != "running" and elapsed_time < timeout:
30+
sleep(stop_time)
31+
elapsed_time += stop_time
32+
container.reload()
33+
continue
34+
if container.status != "running":
35+
raise ValueError("Container failed to start")
36+
37+
38+
__all__ = ("DockerCommandLineCodeExecutor",)
39+
40+
41+
class DockerCommandLineCodeExecutor(CodeExecutor):
42+
def __init__(
43+
self,
44+
image: str = "python:3-slim",
45+
container_name: Optional[str] = None,
46+
timeout: int = 60,
47+
work_dir: Union[Path, str] = Path("."),
48+
auto_remove: bool = True,
49+
stop_container: bool = True,
50+
):
51+
"""(Experimental) A code executor class that executes code through
52+
a command line environment in a Docker container.
53+
54+
The executor first saves each code block in a file in the working
55+
directory, and then executes the code file in the container.
56+
The executor executes the code blocks in the order they are received.
57+
Currently, the executor only supports Python and shell scripts.
58+
For Python code, use the language "python" for the code block.
59+
For shell scripts, use the language "bash", "shell", or "sh" for the code
60+
block.
61+
62+
Args:
63+
image (_type_, optional): Docker image to use for code execution.
64+
Defaults to "python:3-slim".
65+
container_name (Optional[str], optional): Name of the Docker container
66+
which is created. If None, will autogenerate a name. Defaults to None.
67+
timeout (int, optional): The timeout for code execution. Defaults to 60.
68+
work_dir (Union[Path, str], optional): The working directory for the code
69+
execution. Defaults to Path(".").
70+
auto_remove (bool, optional): If true, will automatically remove the Docker
71+
container when it is stopped. Defaults to True.
72+
stop_container (bool, optional): If true, will automatically stop the
73+
container when stop is called, when the context manager exits or when
74+
the Python process exits with atext. Defaults to True.
75+
76+
Raises:
77+
ValueError: On argument error, or if the container fails to start.
78+
"""
79+
80+
if timeout < 1:
81+
raise ValueError("Timeout must be greater than or equal to 1.")
82+
83+
if isinstance(work_dir, str):
84+
work_dir = Path(work_dir)
85+
86+
if not work_dir.exists():
87+
raise ValueError(f"Working directory {work_dir} does not exist.")
88+
89+
client = docker.from_env()
90+
91+
# Check if the image exists
92+
try:
93+
client.images.get(image)
94+
except ImageNotFound:
95+
logging.info(f"Pulling image {image}...")
96+
# Let the docker exception escape if this fails.
97+
client.images.pull(image)
98+
99+
if container_name is None:
100+
container_name = f"autogen-code-exec-{uuid.uuid4()}"
101+
102+
# Start a container from the image, read to exec commands later
103+
self._container = client.containers.create(
104+
image,
105+
name=container_name,
106+
entrypoint="/bin/sh",
107+
tty=True,
108+
auto_remove=auto_remove,
109+
volumes={str(work_dir.resolve()): {"bind": "/workspace", "mode": "rw"}},
110+
working_dir="/workspace",
111+
)
112+
self._container.start()
113+
114+
_wait_for_ready(self._container)
115+
116+
def cleanup():
117+
try:
118+
container = client.containers.get(container_name)
119+
container.stop()
120+
except docker.errors.NotFound:
121+
pass
122+
123+
atexit.unregister(cleanup)
124+
125+
if stop_container:
126+
atexit.register(cleanup)
127+
128+
self._cleanup = cleanup
129+
130+
# Check if the container is running
131+
if self._container.status != "running":
132+
raise ValueError(f"Failed to start container from image {image}. Logs: {self._container.logs()}")
133+
134+
self._timeout = timeout
135+
self._work_dir: Path = work_dir
136+
137+
@property
138+
def timeout(self) -> int:
139+
"""(Experimental) The timeout for code execution."""
140+
return self._timeout
141+
142+
@property
143+
def work_dir(self) -> Path:
144+
"""(Experimental) The working directory for the code execution."""
145+
return self._work_dir
146+
147+
@property
148+
def code_extractor(self) -> CodeExtractor:
149+
"""(Experimental) Export a code extractor that can be used by an agent."""
150+
return MarkdownCodeExtractor()
151+
152+
def execute_code_blocks(self, code_blocks: List[CodeBlock]) -> CommandLineCodeResult:
153+
"""(Experimental) Execute the code blocks and return the result.
154+
155+
Args:
156+
code_blocks (List[CodeBlock]): The code blocks to execute.
157+
158+
Returns:
159+
CommandlineCodeResult: The result of the code execution."""
160+
161+
if len(code_blocks) == 0:
162+
raise ValueError("No code blocks to execute.")
163+
164+
outputs = []
165+
files = []
166+
last_exit_code = 0
167+
for code_block in code_blocks:
168+
lang = code_block.language
169+
code = code_block.code
170+
171+
code_hash = md5(code.encode()).hexdigest()
172+
173+
# Check if there is a filename comment
174+
# Get first line
175+
first_line = code.split("\n")[0]
176+
if first_line.startswith("# filename:"):
177+
filename = first_line.split(":")[1].strip()
178+
179+
# Handle relative paths in the filename
180+
path = Path(filename)
181+
if not path.is_absolute():
182+
path = Path("/workspace") / path
183+
path = path.resolve()
184+
try:
185+
path.relative_to(Path("/workspace"))
186+
except ValueError:
187+
return CommandLineCodeResult(exit_code=1, output="Filename is not in the workspace")
188+
else:
189+
# create a file with a automatically generated name
190+
filename = f"tmp_code_{code_hash}.{'py' if lang.startswith('python') else lang}"
191+
192+
code_path = self._work_dir / filename
193+
with code_path.open("w", encoding="utf-8") as fout:
194+
fout.write(code)
195+
196+
command = ["timeout", str(self._timeout), _cmd(lang), filename]
197+
198+
result = self._container.exec_run(command)
199+
exit_code = result.exit_code
200+
output = result.output.decode("utf-8")
201+
if exit_code == 124:
202+
output += "\n"
203+
output += TIMEOUT_MSG
204+
205+
outputs.append(output)
206+
files.append(code_path)
207+
208+
last_exit_code = exit_code
209+
if exit_code != 0:
210+
break
211+
212+
code_file = str(files[0]) if files else None
213+
return CommandLineCodeResult(exit_code=last_exit_code, output="".join(outputs), code_file=code_file)
214+
215+
def restart(self) -> None:
216+
"""(Experimental) Restart the code executor."""
217+
self._container.restart()
218+
if self._container.status != "running":
219+
raise ValueError(f"Failed to restart container. Logs: {self._container.logs()}")
220+
221+
def stop(self) -> None:
222+
"""(Experimental) Stop the code executor."""
223+
self._cleanup()
224+
225+
def __enter__(self) -> Self:
226+
return self
227+
228+
def __exit__(
229+
self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
230+
) -> None:
231+
self.stop()

autogen/coding/jupyter/docker_jupyter_server.py

+4-14
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@
22

33
from pathlib import Path
44
import sys
5-
from time import sleep
65
from types import TracebackType
76
import uuid
8-
from typing import Dict, Optional, Union
7+
from typing import Dict, Optional, Type, Union
98
import docker
109
import secrets
1110
import io
1211
import atexit
1312
import logging
1413

14+
from ..docker_commandline_code_executor import _wait_for_ready
15+
1516
if sys.version_info >= (3, 11):
1617
from typing import Self
1718
else:
@@ -22,17 +23,6 @@
2223
from .base import JupyterConnectable, JupyterConnectionInfo
2324

2425

25-
def _wait_for_ready(container: docker.Container, timeout: int = 60, stop_time: int = 0.1) -> None:
26-
elapsed_time = 0
27-
while container.status != "running" and elapsed_time < timeout:
28-
sleep(stop_time)
29-
elapsed_time += stop_time
30-
container.reload()
31-
continue
32-
if container.status != "running":
33-
raise ValueError("Container failed to start")
34-
35-
3626
class DockerJupyterServer(JupyterConnectable):
3727
DEFAULT_DOCKERFILE = """FROM quay.io/jupyter/docker-stacks-foundation
3828
@@ -162,6 +152,6 @@ def __enter__(self) -> Self:
162152
return self
163153

164154
def __exit__(
165-
self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
155+
self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
166156
) -> None:
167157
self.stop()

autogen/coding/jupyter/jupyter_client.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass
44
from types import TracebackType
5-
from typing import Any, Dict, List, Optional, cast
5+
from typing import Any, Dict, List, Optional, Type, cast
66
import sys
77

88
if sys.version_info >= (3, 11):
@@ -111,7 +111,7 @@ def __enter__(self) -> Self:
111111
return self
112112

113113
def __exit__(
114-
self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
114+
self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
115115
) -> None:
116116
self.stop()
117117

autogen/coding/jupyter/jupyter_code_executor.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import re
66
from types import TracebackType
77
import uuid
8-
from typing import Any, ClassVar, List, Optional, Union
8+
from typing import Any, ClassVar, List, Optional, Type, Union
99
import sys
1010

1111
if sys.version_info >= (3, 11):
@@ -201,6 +201,6 @@ def __enter__(self) -> Self:
201201
return self
202202

203203
def __exit__(
204-
self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
204+
self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
205205
) -> None:
206206
self.stop()

autogen/coding/jupyter/local_jupyter_server.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22
from types import TracebackType
33

4-
from typing import Optional, Union, cast
4+
from typing import Optional, Type, Union, cast
55
import subprocess
66
import signal
77
import sys
@@ -157,6 +157,6 @@ def __enter__(self) -> Self:
157157
return self
158158

159159
def __exit__(
160-
self, exc_type: Optional[type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
160+
self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
161161
) -> None:
162162
self.stop()

0 commit comments

Comments
 (0)