diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index d7779c43d7..9d57e6871d 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -102,6 +102,122 @@ 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 + + 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. + + 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=$! + + 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 + stop_process "$NEG_PID" + rm -rf "$TOOL_DIR" neg_test.log + exit 1 + fi + stop_process "$NEG_PID" + rm -f neg_test.log + + echo "=== Positive test: import WITH OH_EXTRA_PYTHON_PATH ===" + OH_EXTRA_PYTHON_PATH="$EXTRA_TOOL_DIR" \ + "$BIN" --import-modules ci_test_tool --port 8004 \ + > pos_test.log 2>&1 & + POS_PID=$! + + 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 + stop_process "$POS_PID" + rm -rf "$TOOL_DIR" pos_test.log + exit 1 + fi + + 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 + stop_process "$POS_PID" + rm -rf "$TOOL_DIR" pos_test.log + exit 1 + fi + + stop_process "$POS_PID" + + echo "=== Positive test: import WITH --extra-python-path CLI flag ===" + "$BIN" --extra-python-path "$EXTRA_TOOL_DIR" \ + --import-modules ci_test_tool --port 8005 \ + > cli_test.log 2>&1 & + CLI_PID=$! + + 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 + stop_process "$CLI_PID" + rm -rf "$TOOL_DIR" cli_test.log pos_test.log + exit 1 + fi + + stop_process "$CLI_PID" + + # 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: 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 0dea41072b..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,15 +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(), - target="source", # NOTE: "binary" target does not work with custom tools + # 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!") @@ -244,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") diff --git a/openhands-agent-server/openhands/agent_server/__main__.py b/openhands-agent-server/openhands/agent_server/__main__.py index 0fa4602d6a..269120e883 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,46 @@ 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 director%s for custom tool imports", + added, + "y" if added == 1 else "ies", + ) + + def preload_modules(modules_arg: str | None) -> None: """Import user-specified modules so their top-level side effects run. @@ -171,6 +212,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 +232,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"