Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .github/workflows/server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 6 additions & 6 deletions examples/02_remote_agent_server/06_custom_tool/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"
42 changes: 26 additions & 16 deletions examples/02_remote_agent_server/06_custom_tool/README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
# 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

## Use Cases

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.
Expand All @@ -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 │
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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**:
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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 ""
Expand All @@ -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):"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
"""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

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)
Expand Down Expand Up @@ -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!")

Expand Down Expand Up @@ -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")
Loading
Loading