From 8b9e4d85da0f0110295d2ffa6db5bfc382b9df6a Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 13 May 2026 14:51:48 +0000 Subject: [PATCH 1/6] feat: add --extra-python-path / OH_EXTRA_PYTHON_PATH for custom tool imports in binary builds Custom tools fail to import in PyInstaller binary builds because the frozen importer cannot find external .py files outside the bundled archive. This adds a mechanism to extend sys.path at startup so importlib.import_module() can locate third-party tool modules. Changes: - Add extend_python_path() function that reads directories from the --extra-python-path CLI arg and/or OH_EXTRA_PYTHON_PATH env var, validates they exist, deduplicates, and prepends to sys.path - Wire up the call in main() before preload_modules() so paths are available before any tool registration - Add 11 tests covering noop, CLI, env var, merge, dedup, nonexistent dir warning, and end-to-end integration with preload_modules Tested with PyInstaller binary: external .py tool files (including ones that import from openhands.sdk and call register_tool()) load successfully. Fixes #1531 Co-authored-by: openhands --- .../openhands/agent_server/__main__.py | 53 +++++++ tests/agent_server/test_preload_modules.py | 138 +++++++++++++++++- 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/__main__.py b/openhands-agent-server/openhands/agent_server/__main__.py index 0fa4602d6a..d37b203b78 100644 --- a/openhands-agent-server/openhands/agent_server/__main__.py +++ b/openhands-agent-server/openhands/agent_server/__main__.py @@ -18,6 +18,7 @@ _INTERNAL_SERVER_URL_ENV = "OH_INTERNAL_SERVER_URL" +_EXTRA_PYTHON_PATH_ENV = "OH_EXTRA_PYTHON_PATH" def _get_internal_server_url(host: str, port: int) -> str: @@ -43,6 +44,44 @@ def _get_internal_server_url(host: str, port: int) -> str: return f"http://{resolved_host}:{port}" +def extend_python_path(extra_paths: str | None) -> None: + """Add directories to ``sys.path`` so ``importlib.import_module`` can find + external custom-tool modules — even when running from a PyInstaller binary. + + Paths are read from *extra_paths* (``--extra-python-path`` CLI arg) **and** + the ``OH_EXTRA_PYTHON_PATH`` environment variable. Both use the + platform path separator (``':'`` on POSIX, ``';'`` on Windows). + + Non-existent directories are skipped with a warning; duplicates and paths + already on ``sys.path`` are silently ignored. + """ + raw_parts: list[str] = [] + for source in (extra_paths, os.environ.get(_EXTRA_PYTHON_PATH_ENV)): + if source: + raw_parts.extend(source.split(os.pathsep)) + + added = 0 + for part in raw_parts: + part = part.strip() + if not part: + continue + resolved = os.path.abspath(part) + if not os.path.isdir(resolved): + logger.warning( + "Ignoring non-existent --extra-python-path entry: %s", resolved + ) + continue + if resolved not in sys.path: + sys.path.insert(0, resolved) + logger.info("Added to sys.path: %s", resolved) + added += 1 + + if added: + logger.info( + "Extended sys.path with %d directory for custom tool imports", added + ) + + def preload_modules(modules_arg: str | None) -> None: """Import user-specified modules so their top-level side effects run. @@ -171,6 +210,16 @@ def main() -> None: "(e.g. 'myapp.tools,myapp.plugins')" ), ) + parser.add_argument( + "--extra-python-path", + type=str, + default=None, + help=( + "Additional directories to add to sys.path for custom tool imports " + f"('{os.pathsep}'-separated). Also reads from the " + f"{_EXTRA_PYTHON_PATH_ENV} environment variable." + ), + ) args = parser.parse_args() @@ -181,6 +230,10 @@ def main() -> None: else: sys.exit(1) + # Extend sys.path before importing user modules so external .py files + # are reachable — critical for PyInstaller binary builds. + extend_python_path(args.extra_python_path) + # Import user modules after early-exit checks preload_modules(args.import_modules) diff --git a/tests/agent_server/test_preload_modules.py b/tests/agent_server/test_preload_modules.py index 54762191b3..f10ff20e1e 100644 --- a/tests/agent_server/test_preload_modules.py +++ b/tests/agent_server/test_preload_modules.py @@ -1,5 +1,6 @@ -"""Tests for the --import-modules preloading helper.""" +"""Tests for the --import-modules preloading and --extra-python-path helpers.""" +import importlib import logging import os import sys @@ -8,7 +9,12 @@ import pytest -from openhands.agent_server.__main__ import _get_internal_server_url, preload_modules +from openhands.agent_server.__main__ import ( + _EXTRA_PYTHON_PATH_ENV, + _get_internal_server_url, + extend_python_path, + preload_modules, +) class TestPreloadModules: @@ -112,6 +118,134 @@ def test_import_error_is_logged_before_raising(self, caplog): ) +class TestExtendPythonPath: + """Tests for extend_python_path() — the enabler for custom tool imports + in both source and binary (PyInstaller) agent-server builds.""" + + def test_none_and_no_env_is_noop(self, monkeypatch): + monkeypatch.delenv(_EXTRA_PYTHON_PATH_ENV, raising=False) + original = sys.path.copy() + extend_python_path(None) + assert sys.path == original + + def test_empty_string_and_no_env_is_noop(self, monkeypatch): + monkeypatch.delenv(_EXTRA_PYTHON_PATH_ENV, raising=False) + original = sys.path.copy() + extend_python_path("") + assert sys.path == original + + def test_adds_directory_from_cli_arg(self, tmp_path, monkeypatch): + monkeypatch.delenv(_EXTRA_PYTHON_PATH_ENV, raising=False) + d = tmp_path / "custom_tools" + d.mkdir() + extend_python_path(str(d)) + assert str(d) in sys.path + sys.path.remove(str(d)) + + def test_adds_directory_from_env_var(self, tmp_path, monkeypatch): + d = tmp_path / "env_tools" + d.mkdir() + monkeypatch.setenv(_EXTRA_PYTHON_PATH_ENV, str(d)) + extend_python_path(None) + assert str(d) in sys.path + sys.path.remove(str(d)) + + def test_merges_cli_and_env(self, tmp_path, monkeypatch): + d1 = tmp_path / "cli_tools" + d2 = tmp_path / "env_tools" + d1.mkdir() + d2.mkdir() + monkeypatch.setenv(_EXTRA_PYTHON_PATH_ENV, str(d2)) + extend_python_path(str(d1)) + assert str(d1) in sys.path + assert str(d2) in sys.path + sys.path.remove(str(d1)) + sys.path.remove(str(d2)) + + def test_skips_nonexistent_dir_with_warning(self, tmp_path, monkeypatch, caplog): + monkeypatch.delenv(_EXTRA_PYTHON_PATH_ENV, raising=False) + bogus = str(tmp_path / "does_not_exist") + with caplog.at_level(logging.WARNING): + extend_python_path(bogus) + assert bogus not in sys.path + assert any("non-existent" in r.message for r in caplog.records) + + def test_deduplicates(self, tmp_path, monkeypatch): + monkeypatch.delenv(_EXTRA_PYTHON_PATH_ENV, raising=False) + d = tmp_path / "dup_tools" + d.mkdir() + extend_python_path(f"{d}{os.pathsep}{d}") + count = sys.path.count(str(d)) + assert count == 1 + sys.path.remove(str(d)) + + def test_skips_already_on_sys_path(self, tmp_path, monkeypatch): + monkeypatch.delenv(_EXTRA_PYTHON_PATH_ENV, raising=False) + d = tmp_path / "already_there" + d.mkdir() + abs_d = str(d.resolve()) + sys.path.insert(0, abs_d) + before_count = sys.path.count(abs_d) + extend_python_path(abs_d) + assert sys.path.count(abs_d) == before_count + sys.path.remove(abs_d) + + def test_multiple_dirs_via_pathsep(self, tmp_path, monkeypatch): + monkeypatch.delenv(_EXTRA_PYTHON_PATH_ENV, raising=False) + d1 = tmp_path / "tools_a" + d2 = tmp_path / "tools_b" + d1.mkdir() + d2.mkdir() + extend_python_path(f"{d1}{os.pathsep}{d2}") + assert str(d1) in sys.path + assert str(d2) in sys.path + sys.path.remove(str(d1)) + sys.path.remove(str(d2)) + + def test_enables_import_of_external_module(self, tmp_path, monkeypatch): + """End-to-end: extend_python_path + importlib.import_module works + for a .py file placed in the extra directory.""" + monkeypatch.delenv(_EXTRA_PYTHON_PATH_ENV, raising=False) + d = tmp_path / "ext_tools" + d.mkdir() + mod_name = "ext_test_tool_abc123" + (d / f"{mod_name}.py").write_text("REGISTERED = True\n") + + with pytest.raises(ModuleNotFoundError): + importlib.import_module(mod_name) + + extend_python_path(str(d)) + try: + mod = importlib.import_module(mod_name) + assert mod.REGISTERED is True + finally: + sys.path.remove(str(d)) + sys.modules.pop(mod_name, None) + + def test_enables_preload_modules_integration(self, tmp_path, monkeypatch): + """Confirm the intended workflow: extend_python_path() then + preload_modules() successfully imports an external tool module.""" + monkeypatch.delenv(_EXTRA_PYTHON_PATH_ENV, raising=False) + d = tmp_path / "integration_tools" + d.mkdir() + mod_name = "integration_test_tool_xyz789" + (d / f"{mod_name}.py").write_text( + textwrap.dedent("""\ + TOOL_REGISTRY = [] + TOOL_REGISTRY.append("IntegrationTestTool") + """) + ) + + extend_python_path(str(d)) + try: + preload_modules(mod_name) + imported = sys.modules[mod_name] + assert imported.TOOL_REGISTRY == ["IntegrationTestTool"] + finally: + sys.path.remove(str(d)) + sys.modules.pop(mod_name, None) + + @pytest.mark.parametrize("host", ["0.0.0.0", "::", "[::]"]) def test_get_internal_server_url_rewrites_wildcard_host(host): assert _get_internal_server_url(host, 4321) == "http://127.0.0.1:4321" From 8e7434933bb9927ba654462dbb31c9fb160168b4 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 13 May 2026 15:10:36 +0000 Subject: [PATCH 2/6] ci: add --extra-python-path smoke test to binary build workflow After building the agent-server binary, run three smoke tests: 1. Negative test: verify import fails WITHOUT --extra-python-path (confirms the binary can't find external .py files by default) 2. Positive test (env var): verify import succeeds when OH_EXTRA_PYTHON_PATH points to a directory containing an external tool module 3. Positive test (CLI flag): verify import succeeds when --extra-python-path is passed on the command line Each test creates a temporary ci_test_tool.py file, launches the binary in the background, waits for startup, and checks log output for the expected import success/failure messages. Co-authored-by: openhands --- .github/workflows/server.yml | 101 +++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index d7779c43d7..62575c2f7e 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -102,6 +102,107 @@ jobs: echo "✓ Binary test completed successfully" + - name: Test --extra-python-path custom tool import + shell: bash + run: | + set -euo pipefail + + if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + BIN=./dist/openhands-agent-server.exe + else + BIN=./dist/openhands-agent-server + fi + + # Create a temporary directory with an external tool module + TOOL_DIR=$(mktemp -d) + cat > "$TOOL_DIR/ci_test_tool.py" << 'TOOL_EOF' + """CI smoke-test tool: NOT bundled in the binary. + + Importing this module proves that --extra-python-path / + OH_EXTRA_PYTHON_PATH correctly extends sys.path at runtime + so external .py files are reachable from a frozen build. + """ + CI_TOOL_LOADED = True + TOOL_EOF + + echo "=== Negative test: import WITHOUT extra path (should fail) ===" + "$BIN" --import-modules ci_test_tool --port 8003 \ + > neg_test.log 2>&1 & + NEG_PID=$! + sleep 5 + + if grep -q "No module named 'ci_test_tool'" neg_test.log; then + echo "✓ Negative test passed: import correctly failed without --extra-python-path" + else + echo "ERROR: Expected ModuleNotFoundError but got:" + cat neg_test.log + kill "$NEG_PID" 2>/dev/null || true + wait "$NEG_PID" 2>/dev/null || true + rm -rf "$TOOL_DIR" neg_test.log + exit 1 + fi + kill "$NEG_PID" 2>/dev/null || true + wait "$NEG_PID" 2>/dev/null || true + rm -f neg_test.log + + echo "=== Positive test: import WITH OH_EXTRA_PYTHON_PATH ===" + OH_EXTRA_PYTHON_PATH="$TOOL_DIR" \ + "$BIN" --import-modules ci_test_tool --port 8004 \ + > pos_test.log 2>&1 & + POS_PID=$! + sleep 5 + + if grep -q "Imported module: ci_test_tool" pos_test.log; then + echo "✓ Positive test passed: external module imported via OH_EXTRA_PYTHON_PATH" + else + echo "ERROR: Module was not imported. Server log:" + cat pos_test.log + kill "$POS_PID" 2>/dev/null || true + wait "$POS_PID" 2>/dev/null || true + rm -rf "$TOOL_DIR" pos_test.log + exit 1 + fi + + if grep -q "Added to sys.path: $TOOL_DIR" pos_test.log; then + echo "✓ sys.path was extended with the tool directory" + else + echo "ERROR: sys.path was not extended. Server log:" + cat pos_test.log + kill "$POS_PID" 2>/dev/null || true + wait "$POS_PID" 2>/dev/null || true + rm -rf "$TOOL_DIR" pos_test.log + exit 1 + fi + + kill "$POS_PID" 2>/dev/null || true + wait "$POS_PID" 2>/dev/null || true + + echo "=== Positive test: import WITH --extra-python-path CLI flag ===" + "$BIN" --extra-python-path "$TOOL_DIR" \ + --import-modules ci_test_tool --port 8005 \ + > cli_test.log 2>&1 & + CLI_PID=$! + sleep 5 + + if grep -q "Imported module: ci_test_tool" cli_test.log; then + echo "✓ CLI flag test passed: external module imported via --extra-python-path" + else + echo "ERROR: Module was not imported via CLI flag. Server log:" + cat cli_test.log + kill "$CLI_PID" 2>/dev/null || true + wait "$CLI_PID" 2>/dev/null || true + rm -rf "$TOOL_DIR" cli_test.log pos_test.log + exit 1 + fi + + kill "$CLI_PID" 2>/dev/null || true + wait "$CLI_PID" 2>/dev/null || true + + # Cleanup + rm -rf "$TOOL_DIR" pos_test.log neg_test.log cli_test.log + + echo "✓ All --extra-python-path tests passed" + - name: Upload binary artifact uses: actions/upload-artifact@v7 with: From af7a89a030e10a1518db0970bcc9bed52a8b4213 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Thu, 14 May 2026 02:11:38 +0700 Subject: [PATCH 3/6] Update openhands-agent-server/openhands/agent_server/__main__.py Co-authored-by: OpenHands Bot --- openhands-agent-server/openhands/agent_server/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openhands-agent-server/openhands/agent_server/__main__.py b/openhands-agent-server/openhands/agent_server/__main__.py index d37b203b78..269120e883 100644 --- a/openhands-agent-server/openhands/agent_server/__main__.py +++ b/openhands-agent-server/openhands/agent_server/__main__.py @@ -78,7 +78,9 @@ def extend_python_path(extra_paths: str | None) -> None: if added: logger.info( - "Extended sys.path with %d directory for custom tool imports", added + "Extended sys.path with %d director%s for custom tool imports", + added, + "y" if added == 1 else "ies", ) From 639f9522e4c0f5d6cf64aa173389c3a8bc353285 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 13 May 2026 19:22:50 +0000 Subject: [PATCH 4/6] Fix binary extra path smoke test on Windows Co-authored-by: openhands --- .github/workflows/server.yml | 61 ++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index 62575c2f7e..9d57e6871d 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -113,8 +113,33 @@ jobs: BIN=./dist/openhands-agent-server fi + wait_for_log() { + local log_file=$1 + local pattern=$2 + local timeout_seconds=${3:-45} + + for _ in $(seq 1 "$timeout_seconds"); do + if grep -q "$pattern" "$log_file"; then + return 0 + fi + sleep 1 + done + return 1 + } + + stop_process() { + local pid=$1 + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + } + # Create a temporary directory with an external tool module TOOL_DIR=$(mktemp -d) + EXTRA_TOOL_DIR=$TOOL_DIR + if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + EXTRA_TOOL_DIR=$(cygpath -w "$TOOL_DIR") + fi + cat > "$TOOL_DIR/ci_test_tool.py" << 'TOOL_EOF' """CI smoke-test tool: NOT bundled in the binary. @@ -129,74 +154,64 @@ jobs: "$BIN" --import-modules ci_test_tool --port 8003 \ > neg_test.log 2>&1 & NEG_PID=$! - sleep 5 - if grep -q "No module named 'ci_test_tool'" neg_test.log; then + if wait_for_log neg_test.log "No module named 'ci_test_tool'"; then echo "✓ Negative test passed: import correctly failed without --extra-python-path" else echo "ERROR: Expected ModuleNotFoundError but got:" cat neg_test.log - kill "$NEG_PID" 2>/dev/null || true - wait "$NEG_PID" 2>/dev/null || true + stop_process "$NEG_PID" rm -rf "$TOOL_DIR" neg_test.log exit 1 fi - kill "$NEG_PID" 2>/dev/null || true - wait "$NEG_PID" 2>/dev/null || true + stop_process "$NEG_PID" rm -f neg_test.log echo "=== Positive test: import WITH OH_EXTRA_PYTHON_PATH ===" - OH_EXTRA_PYTHON_PATH="$TOOL_DIR" \ + OH_EXTRA_PYTHON_PATH="$EXTRA_TOOL_DIR" \ "$BIN" --import-modules ci_test_tool --port 8004 \ > pos_test.log 2>&1 & POS_PID=$! - sleep 5 - if grep -q "Imported module: ci_test_tool" pos_test.log; then + if wait_for_log pos_test.log "Imported module: ci_test_tool"; then echo "✓ Positive test passed: external module imported via OH_EXTRA_PYTHON_PATH" else echo "ERROR: Module was not imported. Server log:" cat pos_test.log - kill "$POS_PID" 2>/dev/null || true - wait "$POS_PID" 2>/dev/null || true + stop_process "$POS_PID" rm -rf "$TOOL_DIR" pos_test.log exit 1 fi - if grep -q "Added to sys.path: $TOOL_DIR" pos_test.log; then + if grep -q "Added to sys.path:" pos_test.log; then echo "✓ sys.path was extended with the tool directory" else echo "ERROR: sys.path was not extended. Server log:" cat pos_test.log - kill "$POS_PID" 2>/dev/null || true - wait "$POS_PID" 2>/dev/null || true + stop_process "$POS_PID" rm -rf "$TOOL_DIR" pos_test.log exit 1 fi - kill "$POS_PID" 2>/dev/null || true - wait "$POS_PID" 2>/dev/null || true + stop_process "$POS_PID" echo "=== Positive test: import WITH --extra-python-path CLI flag ===" - "$BIN" --extra-python-path "$TOOL_DIR" \ + "$BIN" --extra-python-path "$EXTRA_TOOL_DIR" \ --import-modules ci_test_tool --port 8005 \ > cli_test.log 2>&1 & CLI_PID=$! - sleep 5 - if grep -q "Imported module: ci_test_tool" cli_test.log; then + if wait_for_log cli_test.log "Imported module: ci_test_tool"; then echo "✓ CLI flag test passed: external module imported via --extra-python-path" else echo "ERROR: Module was not imported via CLI flag. Server log:" cat cli_test.log - kill "$CLI_PID" 2>/dev/null || true - wait "$CLI_PID" 2>/dev/null || true + stop_process "$CLI_PID" rm -rf "$TOOL_DIR" cli_test.log pos_test.log exit 1 fi - kill "$CLI_PID" 2>/dev/null || true - wait "$CLI_PID" 2>/dev/null || true + stop_process "$CLI_PID" # Cleanup rm -rf "$TOOL_DIR" pos_test.log neg_test.log cli_test.log From 49ff3503b0112faa7a46787c312caa2dbfdc54c2 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 13 May 2026 19:26:16 +0000 Subject: [PATCH 5/6] Clarify custom tool example binary path support Co-authored-by: openhands --- .../06_custom_tool/custom_tool_example.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py b/examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py index 0dea41072b..6b974d4dbc 100644 --- a/examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py +++ b/examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py @@ -109,7 +109,10 @@ def detect_platform(): base_image=CUSTOM_BASE_IMAGE_TAG, host_port=8011, platform=detect_platform(), - target="source", # NOTE: "binary" target does not work with custom tools + # This example uses source mode because the custom base image exposes tools + # via PYTHONPATH. Binary images can load external tools with + # OH_EXTRA_PYTHON_PATH or --extra-python-path. + target="source", ) as workspace: logger.info("✅ Custom agent server started!") From 6357d5ce7045f932f0e2cebade202f9746e1c0f6 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 13 May 2026 19:41:03 +0000 Subject: [PATCH 6/6] Update custom tool example to use binary extra path Co-authored-by: openhands --- .../06_custom_tool/Dockerfile | 12 +++--- .../06_custom_tool/README.md | 42 ++++++++++++------- .../06_custom_tool/build_custom_image.sh | 17 +++++--- .../06_custom_tool/custom_tool_example.py | 35 +++++++++------- 4 files changed, 62 insertions(+), 44 deletions(-) diff --git a/examples/02_remote_agent_server/06_custom_tool/Dockerfile b/examples/02_remote_agent_server/06_custom_tool/Dockerfile index a1fab88730..40427f226a 100644 --- a/examples/02_remote_agent_server/06_custom_tool/Dockerfile +++ b/examples/02_remote_agent_server/06_custom_tool/Dockerfile @@ -1,17 +1,17 @@ # Dockerfile for custom base image with custom tools # # This Dockerfile creates a base image that includes custom tools. -# When used with DockerDevWorkspace(base_image=...), the agent server -# will be built on top of this image automatically. +# When used with DockerDevWorkspace(base_image=..., target="binary"), +# the binary agent server will be built on top of this image automatically. # # Usage: -# cd examples/02_remote_agent_server/05_custom_tool +# cd examples/02_remote_agent_server/06_custom_tool # docker build -t custom-base-image:latest . FROM nikolaik/python-nodejs:python3.13-nodejs22-slim -# Copy custom tools into the Python path +# Copy custom tools into a directory outside the frozen binary. COPY custom_tools /app/custom_tools -# Add /app to PYTHONPATH so custom_tools can be imported -ENV PYTHONPATH="/app:${PYTHONPATH}" +# Tell the binary agent server where to find external Python modules. +ENV OH_EXTRA_PYTHON_PATH="/app" diff --git a/examples/02_remote_agent_server/06_custom_tool/README.md b/examples/02_remote_agent_server/06_custom_tool/README.md index 1b5172c06f..74e5d580c7 100644 --- a/examples/02_remote_agent_server/06_custom_tool/README.md +++ b/examples/02_remote_agent_server/06_custom_tool/README.md @@ -1,14 +1,19 @@ # Custom Tools with Remote Agent Server -This example demonstrates how to use custom tools with a remote agent server by building a custom base image that includes your tool implementations. +This example demonstrates how to use custom tools with a remote agent server by +building a custom base image that includes your tool implementations and exposes +them to the binary agent server through `OH_EXTRA_PYTHON_PATH`. ## Overview -When using a remote agent server, custom tools must be available in the server's Python environment. This example shows the complete workflow for: +When using a remote agent server, custom tools must be available in the server's +Python environment. This example shows the complete workflow for: 1. **Defining custom tools** that log structured data to a JSON file -2. **Building a custom base image** that includes your tools -3. **Using `DockerDevWorkspace`** to build the agent server on top of the custom base image +2. **Building a custom base image** that includes your tools and sets + `OH_EXTRA_PYTHON_PATH` +3. **Using `DockerDevWorkspace`** to build the binary agent server on top of the + custom base image 4. **Using dynamic tool registration** to make tools available at runtime 5. **Verifying the results** by reading the logged data back from the workspace @@ -16,7 +21,8 @@ When using a remote agent server, custom tools must be available in the server's This pattern is useful for: -- **Structured data collection**: Define tools like `log_data`, `record_metric`, or `track_event` to collect structured data during agent runs +- **Structured data collection**: Define tools like `log_data`, `record_metric`, + or `track_event` to collect structured data during agent runs - **Custom integrations**: Tools that interact with external systems (APIs, databases, etc.) - **Domain-specific operations**: Business logic tools specific to your application - **Downstream processing**: Collected data can be used to generate reports, trigger workflows, etc. @@ -26,10 +32,10 @@ This pattern is useful for: ``` ┌─────────────────┐ ┌──────────────────────────┐ │ SDK Client │ │ Remote Agent Server │ -│ │ │ (Built on custom base) │ +│ │ │ (Binary custom image) │ │ - Define tools │◄────────┤ │ │ - Send tasks │ API │ - Custom tools in │ -│ - Get results │ │ Python path │ +│ - Get results │ │ OH_EXTRA_PYTHON_PATH │ │ │ │ - Dynamic registration │ └─────────────────┘ │ - Tool execution │ │ - JSON file output │ @@ -81,14 +87,16 @@ The Dockerfile is very simple: ```dockerfile FROM nikolaik/python-nodejs:python3.13-nodejs22-slim -# Copy custom tools into the Python path +# Copy custom tools into a directory outside the frozen binary COPY custom_tools /app/custom_tools -# Add /app to PYTHONPATH so custom_tools can be imported -ENV PYTHONPATH="/app:${PYTHONPATH}" +# Tell the binary agent server where to find external Python modules +ENV OH_EXTRA_PYTHON_PATH="/app" ``` -This creates a base image with your custom tools. The agent server is built on top of this image automatically by `DockerDevWorkspace`. +This creates a base image with your custom tools and tells the binary agent +server where to import them from. The agent server is built on top of this image +automatically by `DockerDevWorkspace`. ### 3. Dynamic Tool Registration @@ -102,7 +110,7 @@ When creating a conversation, the SDK: The script: - Builds the custom base image (if not already built) -- Uses `DockerDevWorkspace` with `base_image` to build the agent server on top +- Uses `DockerDevWorkspace` with `base_image` and `target="binary"` to build the agent server on top - Creates an agent with the custom tool specified - Sends a task that uses the custom tool - Agent executes on the remote server with access to the custom tool @@ -120,7 +128,7 @@ The script: 1. **Navigate to this directory**: ```bash - cd examples/02_remote_agent_server/05_custom_tool + cd examples/02_remote_agent_server/06_custom_tool ``` 2. **Run the example**: @@ -130,7 +138,7 @@ The script: The script will: - Build the custom base image (first run only) -- Build the agent server on top of the base image (first run may take a few minutes) +- Build the binary agent server on top of the base image (first run may take a few minutes) - Start the agent server with custom tools - Execute the task using the custom tool - Read and display the logged data from the JSON file @@ -214,7 +222,8 @@ register_tool("MyTool", MyTool) ### 2. Update the Dockerfile -No changes needed! The Dockerfile already copies all of `custom_tools/`. +No changes needed! The Dockerfile already copies all of `custom_tools/` and sets +`OH_EXTRA_PYTHON_PATH=/app` so the binary agent server can import the package. ### 3. Use Your Tool @@ -223,10 +232,11 @@ In your SDK script: ```python from openhands.workspace import DockerDevWorkspace -# Use DockerDevWorkspace with your custom base image +# Use DockerDevWorkspace with your custom base image and binary target with DockerDevWorkspace( base_image="custom-base-image:latest", host_port=8010, + target="binary", ) as workspace: # Create agent with your custom tool tools = get_default_tools(enable_browser=False) diff --git a/examples/02_remote_agent_server/06_custom_tool/build_custom_image.sh b/examples/02_remote_agent_server/06_custom_tool/build_custom_image.sh index ded31e801f..3863acec6b 100755 --- a/examples/02_remote_agent_server/06_custom_tool/build_custom_image.sh +++ b/examples/02_remote_agent_server/06_custom_tool/build_custom_image.sh @@ -1,9 +1,10 @@ #!/bin/bash # Build script for custom base image with custom tools # -# This script builds a custom base image that includes your custom tools. -# When used with DockerDevWorkspace(base_image=...), the agent server -# will be built on top of this image automatically. +# This script builds a custom base image that includes your custom tools and +# sets OH_EXTRA_PYTHON_PATH so the binary agent server can import them. +# When used with DockerDevWorkspace(base_image=..., target="binary"), the +# agent server will be built on top of this image automatically. # # Usage: # ./build_custom_image.sh [TAG] @@ -19,7 +20,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Default tag TAG="${1:-custom-base-image:latest}" -echo "🐳 Building custom base image with custom tools..." +echo "🐳 Building custom base image with custom tools and OH_EXTRA_PYTHON_PATH..." echo "🏷️ Tag: $TAG" echo "📂 Build context: $SCRIPT_DIR" echo "" @@ -36,8 +37,12 @@ echo "🏷️ Image tag: $TAG" echo "" echo "To use this image:" echo " 1. Use in SDK with DockerDevWorkspace:" -echo " with DockerDevWorkspace(base_image='$TAG', host_port=8010) as workspace:" -echo " # DockerDevWorkspace will build the agent server on top of this base image" +echo " with DockerDevWorkspace(" +echo " base_image='$TAG'," +echo " host_port=8010," +echo " target='binary'," +echo " ) as workspace:" +echo " # The image sets OH_EXTRA_PYTHON_PATH for custom tool imports" echo " # your code" echo "" echo " 2. Push to registry (optional):" diff --git a/examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py b/examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py index 6b974d4dbc..2d85523a5e 100644 --- a/examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py +++ b/examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py @@ -1,11 +1,12 @@ """Example: Using custom tools with remote agent server. This example demonstrates how to use custom tools with a remote agent server -by building a custom base image that includes the tool implementation. +by building a custom base image that includes the tool implementation and +exposes it to the binary agent server through ``OH_EXTRA_PYTHON_PATH``. Prerequisites: 1. Build the custom base image first: - cd examples/02_remote_agent_server/05_custom_tool + cd examples/02_remote_agent_server/06_custom_tool ./build_custom_image.sh 2. Set LLM_API_KEY environment variable @@ -13,12 +14,14 @@ The workflow is: 1. Define a custom tool (LogDataTool for logging structured data to JSON) 2. Create a simple Dockerfile that copies the tool into the base image -3. Build the custom base image -4. Use DockerDevWorkspace with base_image pointing to the custom image -5. DockerDevWorkspace builds the agent server on top of the custom base image -6. The server dynamically registers tools when the client creates a conversation -7. The agent can use the custom tool during execution -8. Verify the logged data by reading the JSON file from the workspace +3. Set OH_EXTRA_PYTHON_PATH so the binary server can import the custom tool +4. Build the custom base image +5. Use DockerDevWorkspace with base_image pointing to the custom image +6. DockerDevWorkspace builds the binary agent server on top of the custom + base image +7. The server dynamically registers tools when the client creates a conversation +8. The agent can use the custom tool during execution +9. Verify the logged data by reading the JSON file from the workspace This pattern is useful for: - Collecting structured data during agent runs (logs, metrics, events) @@ -101,18 +104,18 @@ def detect_platform(): logger.info(f"✅ Custom base image found: {CUSTOM_BASE_IMAGE_TAG}") # 3) Create a DockerDevWorkspace with the custom base image -# DockerDevWorkspace will build the agent server on top of this base image -logger.info("🚀 Building and starting agent server with custom tools...") +# DockerDevWorkspace will build the binary agent server on top of this +# base image +logger.info("🚀 Building and starting binary agent server with custom tools...") logger.info("📦 This may take a few minutes on first run...") with DockerDevWorkspace( base_image=CUSTOM_BASE_IMAGE_TAG, host_port=8011, platform=detect_platform(), - # This example uses source mode because the custom base image exposes tools - # via PYTHONPATH. Binary images can load external tools with - # OH_EXTRA_PYTHON_PATH or --extra-python-path. - target="source", + # The custom base image sets OH_EXTRA_PYTHON_PATH=/app so the binary + # agent server can import custom_tools.log_data from outside the bundle. + target="binary", ) as workspace: logger.info("✅ Custom agent server started!") @@ -247,8 +250,8 @@ def event_callback(event) -> None: logger.info("\n✅ Example completed successfully!") logger.info("\nThis example demonstrated how to:") logger.info("1. Create a custom tool that logs structured data to JSON") -logger.info("2. Build a simple base image with the custom tool") -logger.info("3. Use DockerDevWorkspace with base_image to build agent server on top") +logger.info("2. Build a base image with the custom tool and OH_EXTRA_PYTHON_PATH") +logger.info("3. Use DockerDevWorkspace to build the binary agent server") logger.info("4. Enable dynamic tool registration on the server") logger.info("5. Use the custom tool during agent execution") logger.info("6. Read the logged data back from the workspace")