feat: DockerCommandLineCodeExecutor support for additional volume mounts, exposed host ports (#5383)

Add the following additional configuration options to
DockerCommandLineCodeExectutor:

- **extra_volumes** (Optional[Dict[str, Dict[str, str]]], optional): A
dictionary of extra volumes (beyond the work_dir) to mount to the
container. Defaults to None.
- **extra_hosts** (Optional[Dict[str, str]], optional): A dictionary of
host mappings to add to the container. (See Docker docs on extra_hosts)
Defaults to None.
- **init_command** (Optional[str], optional): A shell command to run
before each shell operation execution. Defaults to None. 

## Why are these changes needed?

See linked issue below.

In summary: Enable the agents to:
- work with a richer set of sys admin tools on top of code execution
- add support for a 'project' directory the agents can interact on
that's accessible by bash tools and custom scripts

## Related issue number

Closes #5363

## Checks

- [x] I've included any doc changes needed for
https://microsoft.github.io/autogen/. See
https://microsoft.github.io/autogen/docs/Contribute#documentation to
build and test documentation locally.
- [x] I've added tests (if relevant) corresponding to the changes
introduced in this PR.
- [x] I've made sure all auto checks have passed.
This commit is contained in:
Andrej Kyselica 2025-02-11 13:17:34 -05:00 committed by GitHub
parent a9db38461f
commit 540c4fb345
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 68 additions and 3 deletions

View File

@ -12,7 +12,7 @@ from collections.abc import Sequence
from hashlib import sha256
from pathlib import Path
from types import TracebackType
from typing import Any, Callable, ClassVar, List, Optional, ParamSpec, Type, Union
from typing import Any, Callable, ClassVar, Dict, List, Optional, ParamSpec, Type, Union
from autogen_core import CancellationToken
from autogen_core.code_executor import (
@ -88,6 +88,13 @@ class DockerCommandLineCodeExecutor(CodeExecutor):
the Python process exits with atext. Defaults to True.
functions (List[Union[FunctionWithRequirements[Any, A], Callable[..., Any]]]): A list of functions that are available to the code executor. Default is an empty list.
functions_module (str, optional): The name of the module that will be created to store the functions. Defaults to "functions".
extra_volumes (Optional[Dict[str, Dict[str, str]]], optional): A dictionary of extra volumes (beyond the work_dir) to mount to the container;
key is host source path and value 'bind' is the container path. See Defaults to None.
Example: extra_volumes = {'/home/user1/': {'bind': '/mnt/vol2', 'mode': 'rw'}, '/var/www': {'bind': '/mnt/vol1', 'mode': 'ro'}}
extra_hosts (Optional[Dict[str, str]], optional): A dictionary of host mappings to add to the container. (See Docker docs on extra_hosts) Defaults to None.
Example: extra_hosts = {"kubernetes.docker.internal": "host-gateway"}
init_command (Optional[str], optional): A shell command to run before each shell operation execution. Defaults to None.
Example: init_command="kubectl config use-context docker-hub"
"""
SUPPORTED_LANGUAGES: ClassVar[List[str]] = [
@ -126,6 +133,9 @@ $functions"""
]
] = [],
functions_module: str = "functions",
extra_volumes: Optional[Dict[str, Dict[str, str]]] = None,
extra_hosts: Optional[Dict[str, str]] = None,
init_command: Optional[str] = None,
):
if timeout < 1:
raise ValueError("Timeout must be greater than or equal to 1.")
@ -157,6 +167,10 @@ $functions"""
self._functions_module = functions_module
self._functions = functions
self._extra_volumes = extra_volumes if extra_volumes is not None else {}
self._extra_hosts = extra_hosts if extra_hosts is not None else {}
self._init_command = init_command
# Setup could take some time so we intentionally wait for the first code block to do it.
if len(functions) > 0:
self._setup_functions_complete = False
@ -354,16 +368,22 @@ $functions"""
# Let the docker exception escape if this fails.
await asyncio.to_thread(client.images.pull, self._image)
# Prepare the command (if needed)
shell_command = "/bin/sh"
command = ["-c", f"{(self._init_command)};exec {shell_command}"] if self._init_command else None
self._container = await asyncio.to_thread(
client.containers.create,
self._image,
name=self.container_name,
entrypoint="/bin/sh",
entrypoint=shell_command,
command=command,
tty=True,
detach=True,
auto_remove=self._auto_remove,
volumes={str(self._bind_dir.resolve()): {"bind": "/workspace", "mode": "rw"}},
volumes={str(self._bind_dir.resolve()): {"bind": "/workspace", "mode": "rw"}, **self._extra_volumes},
working_dir="/workspace",
extra_hosts=self._extra_hosts,
)
await asyncio.to_thread(self._container.start)

View File

@ -164,3 +164,48 @@ async def test_docker_commandline_code_executor_start_stop_context_manager() ->
with tempfile.TemporaryDirectory() as temp_dir:
async with DockerCommandLineCodeExecutor(work_dir=temp_dir) as _exec:
pass
@pytest.mark.asyncio
async def test_docker_commandline_code_executor_extra_args() -> None:
if not docker_tests_enabled():
pytest.skip("Docker tests are disabled")
with tempfile.TemporaryDirectory() as temp_dir:
# Create a file in temp_dir to mount
host_file_path = Path(temp_dir) / "host_file.txt"
host_file_path.write_text("This is a test file.")
container_file_path = "/container/host_file.txt"
extra_volumes = {str(host_file_path): {"bind": container_file_path, "mode": "rw"}}
init_command = "echo 'Initialization command executed' > /workspace/init_command.txt"
extra_hosts = {"example.com": "127.0.0.1"}
async with DockerCommandLineCodeExecutor(
work_dir=temp_dir,
extra_volumes=extra_volumes,
init_command=init_command,
extra_hosts=extra_hosts,
) as executor:
cancellation_token = CancellationToken()
# Verify init_command was executed
init_command_file_path = Path(temp_dir) / "init_command.txt"
assert init_command_file_path.exists()
# Verify extra_hosts
ns_lookup_code_blocks = [
CodeBlock(code="import socket; print(socket.gethostbyname('example.com'))", language="python")
]
ns_lookup_result = await executor.execute_code_blocks(ns_lookup_code_blocks, cancellation_token)
assert ns_lookup_result.exit_code == 0
assert "127.0.0.1" in ns_lookup_result.output
# Verify the file is accessible in the volume mounted in extra_volumes
code_blocks = [
CodeBlock(code=f"with open('{container_file_path}') as f: print(f.read())", language="python")
]
code_result = await executor.execute_code_blocks(code_blocks, cancellation_token)
assert code_result.exit_code == 0
assert "This is a test file." in code_result.output