Skip to content

feat: add --extra-python-path / OH_EXTRA_PYTHON_PATH for custom tool imports in binary builds#3240

Open
xingyaoww wants to merge 8 commits into
mainfrom
feat/extra-python-path-for-custom-tools
Open

feat: add --extra-python-path / OH_EXTRA_PYTHON_PATH for custom tool imports in binary builds#3240
xingyaoww wants to merge 8 commits into
mainfrom
feat/extra-python-path-for-custom-tools

Conversation

@xingyaoww
Copy link
Copy Markdown
Collaborator

@xingyaoww xingyaoww commented May 13, 2026

Problem

Custom tools fail to import in PyInstaller binary builds (issue #1531). The frozen importer cannot find external .py files outside the bundled archive, so importlib.import_module() raises ModuleNotFoundError for any tool_module_qualnames pointing to third-party tool code.

Solution

Add extend_python_path() to prepend extra directories to sys.path at agent-server startup, before preload_modules() runs. Directories are specified via:

  • CLI flag: --extra-python-path /path/to/tools:/another/path
  • Environment variable: OH_EXTRA_PYTHON_PATH=/path/to/tools:/another/path

Both sources are merged. Non-existent directories are skipped with a warning; duplicates and paths already on sys.path are silently ignored.

Changes

openhands-agent-server/openhands/agent_server/__main__.py

  • Add _EXTRA_PYTHON_PATH_ENV constant
  • Add extend_python_path(extra_paths) function with validation, dedup, and logging
  • Add --extra-python-path argparse argument
  • Call extend_python_path() before preload_modules() in main()

tests/agent_server/test_preload_modules.py

  • Add TestExtendPythonPath class with 11 test methods covering:
    • No-op for None/empty inputs
    • CLI arg, env var, and merged paths
    • Non-existent dir warning
    • Deduplication and already-on-sys.path handling
    • Multiple dirs via path separator
    • End-to-end import of external .py module
    • Integration test: extend_python_path() then preload_modules()

.github/workflows/server.yml

  • Add "Test --extra-python-path custom tool import" step to the build-binary-and-test job
  • Runs after the binary is built and basic tests pass
  • Three smoke tests against the actual PyInstaller binary:
    1. Negative test: import fails WITHOUT --extra-python-path (confirms the problem exists)
    2. Positive test (env var): import succeeds when OH_EXTRA_PYTHON_PATH points to external tool dir
    3. Positive test (CLI flag): import succeeds when --extra-python-path is passed on the command line

Testing

All 27 unit tests pass (16 existing + 11 new).

Binary build verification (PyInstaller agent-server.spec):

  • Without OH_EXTRA_PYTHON_PATH: ModuleNotFoundError: No module named 'my_external_tool' (confirms the problem)
  • With OH_EXTRA_PYTHON_PATH=/tmp/test_external_tools: Imported module: my_external_tool (confirms the fix)
  • Realistic tool importing from openhands.sdk and calling register_tool(): loads successfully from the frozen binary

Fixes #1531


This PR was created by an AI agent (OpenHands) on behalf of the user.


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:6357d5c-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-6357d5c-python \
  ghcr.io/openhands/agent-server:6357d5c-python

All tags pushed for this build

ghcr.io/openhands/agent-server:6357d5c-golang-amd64
ghcr.io/openhands/agent-server:6357d5ce7045f932f0e2cebade202f9746e1c0f6-golang-amd64
ghcr.io/openhands/agent-server:feat-extra-python-path-for-custom-tools-golang-amd64
ghcr.io/openhands/agent-server:6357d5c-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:6357d5c-golang-arm64
ghcr.io/openhands/agent-server:6357d5ce7045f932f0e2cebade202f9746e1c0f6-golang-arm64
ghcr.io/openhands/agent-server:feat-extra-python-path-for-custom-tools-golang-arm64
ghcr.io/openhands/agent-server:6357d5c-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:6357d5c-java-amd64
ghcr.io/openhands/agent-server:6357d5ce7045f932f0e2cebade202f9746e1c0f6-java-amd64
ghcr.io/openhands/agent-server:feat-extra-python-path-for-custom-tools-java-amd64
ghcr.io/openhands/agent-server:6357d5c-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:6357d5c-java-arm64
ghcr.io/openhands/agent-server:6357d5ce7045f932f0e2cebade202f9746e1c0f6-java-arm64
ghcr.io/openhands/agent-server:feat-extra-python-path-for-custom-tools-java-arm64
ghcr.io/openhands/agent-server:6357d5c-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:6357d5c-python-amd64
ghcr.io/openhands/agent-server:6357d5ce7045f932f0e2cebade202f9746e1c0f6-python-amd64
ghcr.io/openhands/agent-server:feat-extra-python-path-for-custom-tools-python-amd64
ghcr.io/openhands/agent-server:6357d5c-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:6357d5c-python-arm64
ghcr.io/openhands/agent-server:6357d5ce7045f932f0e2cebade202f9746e1c0f6-python-arm64
ghcr.io/openhands/agent-server:feat-extra-python-path-for-custom-tools-python-arm64
ghcr.io/openhands/agent-server:6357d5c-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:6357d5c-golang
ghcr.io/openhands/agent-server:6357d5ce7045f932f0e2cebade202f9746e1c0f6-golang
ghcr.io/openhands/agent-server:feat-extra-python-path-for-custom-tools-golang
ghcr.io/openhands/agent-server:6357d5c-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:6357d5c-java
ghcr.io/openhands/agent-server:6357d5ce7045f932f0e2cebade202f9746e1c0f6-java
ghcr.io/openhands/agent-server:feat-extra-python-path-for-custom-tools-java
ghcr.io/openhands/agent-server:6357d5c-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:6357d5c-python
ghcr.io/openhands/agent-server:6357d5ce7045f932f0e2cebade202f9746e1c0f6-python
ghcr.io/openhands/agent-server:feat-extra-python-path-for-custom-tools-python
ghcr.io/openhands/agent-server:6357d5c-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 6357d5c-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 6357d5c-python-amd64) are also available if needed

…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@all-hands.dev>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Solid solution for enabling custom tools in PyInstaller builds. Comprehensive test coverage and proper validation. One minor grammar fix suggested inline.

Comment thread openhands-agent-server/openhands/agent_server/__main__.py Outdated
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   __main__.py1221091%67, 158–159, 164, 180, 233, 252, 282–283, 291
TOTAL267631164656% 

Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

All functional verification passed. The PR successfully enables custom tool imports in both source and binary builds through --extra-python-path CLI flag and OH_EXTRA_PYTHON_PATH environment variable.

Does this PR achieve its stated goal?

Yes. The PR solves the problem of custom tools failing to import in PyInstaller binary builds. I verified that:

  1. Without the fix, external modules cannot be imported (baseline confirmed)
  2. With --extra-python-path, directories are added to sys.path and modules import successfully
  3. With OH_EXTRA_PYTHON_PATH, the same functionality works via environment variable
  4. Both sources merge correctly when used together
  5. Integration with preload_modules() works as intended
  6. The agent-server starts successfully and imports custom modules with both mechanisms
Phase Result
Environment Setup make build completed successfully
CI Status ✅ All checks passing (build-binary-and-test on ubuntu/macos/windows, sdk-tests, pre-commit, API checks)
Functional Verification ✅ All 8 test scenarios passed
Functional Verification

Test 1: Baseline — Reproduce the Problem

Step 1 — Verify module cannot be imported without extra path:

Created test module /tmp/test_custom_tools/my_custom_tool.py with constants and functions.

Ran Python import attempt:

import my_custom_tool

Output:

ModuleNotFoundError: No module named 'my_custom_tool'

This confirms the baseline problem: external modules are not on sys.path and cannot be imported.


Test 2: CLI Argument (--extra-python-path)

Step 1 — Apply the fix with CLI arg:

Called extend_python_path("/tmp/test_custom_tools")

Output:

Added to sys.path: /tmp/test_custom_tools
Extended sys.path with 1 directory for custom tool imports

Step 2 — Verify module imports successfully:

Ran:

import my_custom_tool
print(my_custom_tool.TOOL_NAME)

Output:

✅ SUCCESS: Imported MyCustomTool
   TOOL_REGISTERED = True
   Function call result: Custom tool executed: test

The module imported successfully and functions are callable.


Test 3: Environment Variable (OH_EXTRA_PYTHON_PATH)

Step 1 — Apply the fix with env var:

Set OH_EXTRA_PYTHON_PATH=/tmp/test_custom_tools and called extend_python_path(None)

Output:

Added to sys.path: /tmp/test_custom_tools
Extended sys.path with 1 directory for custom tool imports

Step 2 — Verify module imports:

Output:

✅ SUCCESS: Imported MyCustomTool
   TOOL_REGISTERED = True

Environment variable mechanism works correctly.


Test 4: Merging Both Sources

Setup: Created second test module in /tmp/test_custom_tools_2/another_tool.py

Step 1 — Apply both CLI arg and env var:

Set OH_EXTRA_PYTHON_PATH=/tmp/test_custom_tools_2 and called extend_python_path("/tmp/test_custom_tools")

Output:

Added to sys.path: /tmp/test_custom_tools
Added to sys.path: /tmp/test_custom_tools_2
Extended sys.path with 2 directory for custom tool imports

Step 2 — Verify both modules import:

Output:

✅ SUCCESS: Imported MyCustomTool (from CLI arg path)
✅ SUCCESS: Imported AnotherCustomTool (from env var path)

Both sources merge correctly.


Test 5: Integration with preload_modules()

Step 1 — Extend path then preload:

Called extend_python_path("/tmp/test_custom_tools") then preload_modules("my_custom_tool")

Output:

Added to sys.path: /tmp/test_custom_tools
Extended sys.path with 1 directory for custom tool imports
Imported module: my_custom_tool
✅ SUCCESS: Module preloaded via preload_modules()
   TOOL_NAME = MyCustomTool
   TOOL_REGISTERED = True

The intended workflow (extend_python_path → preload_modules) works as designed.


Test 6: Agent-Server CLI with --extra-python-path

Command:

python -m openhands.agent_server --port 18765 --extra-python-path /tmp/test_custom_tools --import-modules my_custom_tool

Output:

Added to sys.path: /tmp/test_custom_tools
Extended sys.path with 1 directory for custom tool imports
Imported module: my_custom_tool
Started server process [4012]

Agent-server starts successfully and loads the custom module from the external path.


Test 7: Agent-Server with OH_EXTRA_PYTHON_PATH

Command:

OH_EXTRA_PYTHON_PATH=/tmp/test_custom_tools python -m openhands.agent_server --port 18766 --import-modules my_custom_tool

Output:

Added to sys.path: /tmp/test_custom_tools
Extended sys.path with 1 directory for custom tool imports
Imported module: my_custom_tool
Started server process [4064]

Environment variable works with the actual agent-server.


Test 8: Non-Existent Directory Warning

Step 1 — Call with non-existent path:

Called extend_python_path("/tmp/this_does_not_exist_xyz123")

Output:

WARNING: Ignoring non-existent --extra-python-path entry: /tmp/this_does_not_exist_xyz123
✅ SUCCESS: Non-existent directory was skipped

The function correctly warns and skips non-existent directories without crashing.


Test 9: CLI Help Text

Command:

python -m openhands.agent_server --help

Output includes:

--extra-python-path EXTRA_PYTHON_PATH
                    Additional directories to add to sys.path for custom
                    tool imports (':'-separated). Also reads from the
                    OH_EXTRA_PYTHON_PATH environment variable.

CLI flag is properly documented in help text.

Issues Found

None.

openhands-agent and others added 4 commits May 13, 2026 15:10
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 <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
Copy link
Copy Markdown
Contributor

@hieptl hieptl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! 🙏

@xingyaoww
Copy link
Copy Markdown
Collaborator Author

@OpenHands https://github.com/OpenHands/software-agent-sdk/actions/runs/25820768480/job/75861100352?pr=3240 Can you help me fix the CI here and help me create a new pull request on the openhands/docs folder repo?

We basically want to showcase to users how you can use the OpenHands agent server while bringing your own custom tools.

@openhands-ai
Copy link
Copy Markdown

openhands-ai Bot commented May 13, 2026

I'm on it! xingyaoww can track my progress at all-hands.dev

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Copy link
Copy Markdown
Collaborator Author

Fixed the linked Windows CI failure and opened the docs PR.

What changed here:

  • Updated the binary smoke test to wait for expected log output instead of sleeping a fixed 5s, which was too short on Windows startup.
  • Converted the temporary tool path with cygpath -w for Windows binary invocations.
  • Clarified the custom-tool example comment so it points binary users to OH_EXTRA_PYTHON_PATH / --extra-python-path instead of saying binary mode does not work.

Validation:

  • uv run pre-commit run --files .github/workflows/server.yml
  • uv run pytest tests/agent_server/test_preload_modules.py
  • uv run pre-commit run --files examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py
  • The previously failing build-binary-and-test (windows-latest) job is now passing on the latest Agent Server run.

Docs PR: OpenHands/docs#505

This comment was created by an AI agent (OpenHands) on behalf of the user.

@openhands-ai
Copy link
Copy Markdown

openhands-ai Bot commented May 13, 2026

Since my last summary, there were no additional code or docs changes.

Final status:

Comment thread examples/02_remote_agent_server/06_custom_tool/custom_tool_example.py Outdated
Co-authored-by: openhands <openhands@all-hands.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom tools do not work with binary agent server builds

4 participants