mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9314052e89 | |||
| 08890f189f |
@@ -46,12 +46,34 @@ These files contain image tags that **must** be updated whenever the SDK version
|
||||
### `openhands/version.py`
|
||||
- Reads version from `pyproject.toml` at runtime → `openhands.__version__`
|
||||
|
||||
### `openhands/resolver/issue_resolver.py`
|
||||
- Builds `ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik` dynamically
|
||||
|
||||
### `openhands/runtime/utils/runtime_build.py`
|
||||
- Base repo URL `ghcr.io/openhands/runtime` is a constant; version comes from elsewhere
|
||||
|
||||
### `.github/scripts/update_pr_description.sh`
|
||||
- Uses `${SHORT_SHA}` variable at CI runtime, not hardcoded
|
||||
|
||||
### `enterprise/Dockerfile`
|
||||
- `ARG BASE="ghcr.io/openhands/openhands"` — base image, version supplied at build time
|
||||
|
||||
## V0 Legacy Files (separate update cadence)
|
||||
|
||||
These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`) for local Docker/Kubernetes paths. They are **not** updated as part of a V1 release but may be updated independently.
|
||||
|
||||
### `Development.md`
|
||||
- `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:X.Y-nikolaik`
|
||||
|
||||
### `openhands/runtime/impl/kubernetes/README.md`
|
||||
- `runtime_container_image = "docker.openhands.dev/openhands/runtime:X.Y-nikolaik"`
|
||||
|
||||
### `enterprise/enterprise_local/README.md`
|
||||
- Uses `ghcr.io/openhands/runtime:main-nikolaik` (points to `main`, not versioned)
|
||||
|
||||
### `third_party/runtime/impl/daytona/README.md`
|
||||
- Uses `${OPENHANDS_VERSION}` variable, not hardcoded
|
||||
|
||||
## Image Registries
|
||||
|
||||
| Registry | Usage |
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
name: End-to-End Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
GITHUB_REPO_NAME: ${{ github.repository }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install poetry via pipx
|
||||
uses: abatilo/actions-poetry@v4
|
||||
with:
|
||||
poetry-version: 2.1.3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'poetry'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend/package-lock.json'
|
||||
|
||||
- name: Setup environment for end-to-end tests
|
||||
run: |
|
||||
# Create test results directory
|
||||
mkdir -p test-results
|
||||
|
||||
# Create downloads directory for OpenHands (use a directory in the home folder)
|
||||
mkdir -p $HOME/downloads
|
||||
sudo chown -R $USER:$USER $HOME/downloads
|
||||
sudo chmod -R 755 $HOME/downloads
|
||||
|
||||
- name: Build OpenHands
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
INSTALL_DOCKER: 1
|
||||
RUNTIME: docker
|
||||
FRONTEND_PORT: 12000
|
||||
FRONTEND_HOST: 0.0.0.0
|
||||
BACKEND_HOST: 0.0.0.0
|
||||
BACKEND_PORT: 3000
|
||||
ENABLE_BROWSER: true
|
||||
INSTALL_PLAYWRIGHT: 1
|
||||
run: |
|
||||
# Fix poetry.lock file if needed
|
||||
echo "Fixing poetry.lock file if needed..."
|
||||
poetry lock
|
||||
|
||||
# Build OpenHands using make build
|
||||
echo "Running make build..."
|
||||
make build
|
||||
|
||||
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
|
||||
echo "Installing Chromium Headless Shell for Playwright..."
|
||||
poetry run playwright install chromium-headless-shell
|
||||
|
||||
# Verify Playwright browsers are installed (for e2e tests only)
|
||||
echo "Verifying Playwright browsers installation for e2e tests..."
|
||||
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
|
||||
|
||||
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
|
||||
echo "ERROR: Chromium browser not found or not working for e2e tests"
|
||||
echo "$BROWSER_CHECK"
|
||||
exit 1
|
||||
else
|
||||
echo "Playwright browsers are properly installed for e2e tests."
|
||||
fi
|
||||
|
||||
# Docker runtime will handle workspace directory creation
|
||||
|
||||
# Start the application using make run with custom parameters and reduced logging
|
||||
echo "Starting OpenHands using make run..."
|
||||
# Set environment variables to reduce logging verbosity
|
||||
export PYTHONUNBUFFERED=1
|
||||
export LOG_LEVEL=WARNING
|
||||
export UVICORN_LOG_LEVEL=warning
|
||||
export OPENHANDS_LOG_LEVEL=WARNING
|
||||
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
|
||||
|
||||
# Store the PID of the make run process
|
||||
MAKE_PID=$!
|
||||
echo "OpenHands started with PID: $MAKE_PID"
|
||||
|
||||
# Wait for the application to start
|
||||
echo "Waiting for OpenHands to start..."
|
||||
max_attempts=15
|
||||
attempt=1
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
|
||||
|
||||
# Check if the process is still running
|
||||
if ! ps -p $MAKE_PID > /dev/null; then
|
||||
echo "ERROR: OpenHands process has terminated unexpectedly"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if frontend port is open
|
||||
if nc -z localhost 12000; then
|
||||
# Verify we can get HTML content
|
||||
if curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
|
||||
break
|
||||
else
|
||||
echo "Port 12000 is open but not serving HTML content yet"
|
||||
fi
|
||||
else
|
||||
echo "Frontend port 12000 is not open yet"
|
||||
fi
|
||||
|
||||
# Show log output on each attempt
|
||||
echo "Recent log output:"
|
||||
tail -n 20 /tmp/openhands-e2e-test.log
|
||||
|
||||
# Wait before next attempt
|
||||
echo "Waiting 10 seconds before next check..."
|
||||
sleep 10
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
# Exit if we've reached the maximum number of attempts
|
||||
if [ $attempt -gt $max_attempts ]; then
|
||||
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Final verification that the app is running
|
||||
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "ERROR: OpenHands is not running properly on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Print success message
|
||||
echo "OpenHands is running successfully on port 12000"
|
||||
|
||||
- name: Run end-to-end tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
run: |
|
||||
# Check if the application is running
|
||||
if ! nc -z localhost 12000; then
|
||||
echo "ERROR: OpenHands is not running on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the tests with detailed output
|
||||
cd tests/e2e
|
||||
poetry run python -m pytest \
|
||||
test_settings.py::test_github_token_configuration \
|
||||
test_conversation.py::test_conversation_start \
|
||||
test_browsing_catchphrase.py::test_browsing_catchphrase \
|
||||
test_multi_conversation_resume.py::test_multi_conversation_resume \
|
||||
-v --no-header --capture=no --timeout=900
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/e2e/test-results/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload OpenHands logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openhands-logs
|
||||
path: |
|
||||
/tmp/openhands-e2e-test.log
|
||||
/tmp/openhands-e2e-build.log
|
||||
/tmp/openhands-backend.log
|
||||
/tmp/openhands-frontend.log
|
||||
/tmp/backend-health-check.log
|
||||
/tmp/frontend-check.log
|
||||
/tmp/vite-config.log
|
||||
/tmp/makefile-contents.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
# Stop OpenHands processes
|
||||
echo "Stopping OpenHands processes..."
|
||||
pkill -f "python -m openhands.server" || true
|
||||
pkill -f "npm run dev" || true
|
||||
pkill -f "make run" || true
|
||||
|
||||
# Print process status for debugging
|
||||
echo "Checking if any OpenHands processes are still running:"
|
||||
ps aux | grep -E "openhands|npm run dev" || true
|
||||
@@ -2,14 +2,12 @@
|
||||
name: PR Review by OpenHands
|
||||
|
||||
on:
|
||||
# Use pull_request for same-repo PRs so workflow changes can self-verify in PRs.
|
||||
# TEMPORARY MITIGATION (Clinejection hardening)
|
||||
#
|
||||
# We temporarily avoid `pull_request_target` here. We'll restore it after the PR review
|
||||
# workflow is fully hardened for untrusted execution.
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
# Use pull_request_target for fork PRs.
|
||||
# The bot token used here is intentionally scoped to PR review operations,
|
||||
# so the remaining blast radius is bounded even though PR content is untrusted.
|
||||
pull_request_target:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,33 +16,13 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pr-review:
|
||||
# Run on same-repo PRs via pull_request and on fork PRs via pull_request_target.
|
||||
# Trigger when one of the following conditions is met:
|
||||
# 1. A new non-draft PR is opened by a non-first-time contributor, OR
|
||||
# 2. A draft PR is converted to ready for review by a non-first-time contributor, OR
|
||||
# 3. The 'review-this' label is added, OR
|
||||
# 4. openhands-agent or all-hands-bot is requested as a reviewer
|
||||
# Note: FIRST_TIME_CONTRIBUTOR and NONE PRs require manual trigger via label/reviewer request.
|
||||
# Trigger logic:
|
||||
# 1. Route same-repo PRs through `pull_request` and fork PRs through `pull_request_target`
|
||||
# 2. Auto-trigger on `opened` / `ready_for_review` for non-first-time contributors
|
||||
# 3. Always allow manual triggers via `review-this` or reviewer request
|
||||
# The author association check is duplicated intentionally for both
|
||||
# auto-triggered actions (`opened` and `ready_for_review`).
|
||||
# Note: fork PRs will not have access to repository secrets under `pull_request`.
|
||||
# Skip forks to avoid noisy failures until we restore a hardened `pull_request_target` flow.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
)
|
||||
) &&
|
||||
(
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
|
||||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false) ||
|
||||
github.event.action == 'ready_for_review' ||
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'review-this') ||
|
||||
(
|
||||
github.event.action == 'review_requested' &&
|
||||
|
||||
@@ -60,6 +60,10 @@ jobs:
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -36,6 +36,7 @@ Full details in our [Development Guide](./Development.md).
|
||||
|
||||
- **[Frontend](./frontend/README.md)** - React application
|
||||
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
|
||||
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
|
||||
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
|
||||
|
||||
## What Can You Build?
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ open source community:
|
||||
|
||||
#### [Aider](https://github.com/paul-gauthier/aider)
|
||||
- License: Apache License 2.0
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
|
||||
|
||||
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
|
||||
- License: Apache License 2.0
|
||||
|
||||
@@ -309,6 +309,16 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
|
||||
---
|
||||
|
||||
## Using Existing Docker Images
|
||||
|
||||
To reduce build time, you can use an existing runtime image:
|
||||
|
||||
```bash
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Help
|
||||
|
||||
```bash
|
||||
@@ -329,3 +339,4 @@ make help
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
|
||||
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
@@ -88,6 +88,7 @@ USER openhands
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
|
||||
@@ -23,6 +23,18 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
|
||||
unset WORKSPACE_BASE
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
|
||||
echo "Downloading and installing third_party_runtimes..."
|
||||
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
|
||||
|
||||
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
|
||||
echo "third_party_runtimes installed successfully."
|
||||
else
|
||||
echo "Failed to install third_party_runtimes." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
|
||||
echo "Running OpenHands as root"
|
||||
export RUN_AS_OPENHANDS=false
|
||||
|
||||
@@ -3,9 +3,9 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- id: check-yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
@@ -37,12 +37,12 @@ repos:
|
||||
entry: ruff check --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix, --unsafe-fixes]
|
||||
exclude: ^(enterprise/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(enterprise/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
|
||||
@@ -10,7 +10,7 @@ strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
|
||||
# Exclude third-party runtime directory from type checking
|
||||
exclude = (enterprise/)
|
||||
exclude = (third_party/|enterprise/)
|
||||
|
||||
[mypy-openai.*]
|
||||
follow_imports = skip
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Exclude third-party runtime directory from linting
|
||||
exclude = ["enterprise/"]
|
||||
exclude = ["third_party/", "enterprise/"]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
|
||||
@@ -61,6 +61,13 @@ export LITE_LLM_API_KEY=<your LLM API key>
|
||||
python enterprise_local/convert_to_env.py
|
||||
```
|
||||
|
||||
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
|
||||
|
||||
```
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
|
||||
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
|
||||
```
|
||||
|
||||
By default the application will log in json, you can override.
|
||||
|
||||
```
|
||||
@@ -196,6 +203,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
@@ -229,6 +237,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
|
||||
Generated
+4
-1
@@ -6547,7 +6547,7 @@ python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = ">=1.1"
|
||||
python-json-logger = ">=3.2.1"
|
||||
python-multipart = ">=0.0.26"
|
||||
python-multipart = ">=0.0.22"
|
||||
python-pptx = "*"
|
||||
python-socketio = "5.14"
|
||||
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
@@ -6571,6 +6571,9 @@ uvicorn = "*"
|
||||
whatthepatch = ">=1.0.6"
|
||||
zope-interface = "7.2"
|
||||
|
||||
[package.extras]
|
||||
third-party-runtimes = ["daytona (==0.24.2)", "e2b-code-interpreter (>=2)", "modal (>=0.66.26,<1.2)", "runloop-api-client (==0.50)"]
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
url = ".."
|
||||
|
||||
@@ -2,7 +2,15 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
Header,
|
||||
HTTPException,
|
||||
Request,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
@@ -15,12 +23,15 @@ from integrations.models import Message, SourceType
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL, IS_LOCAL_DEPLOYMENT
|
||||
from pydantic import BaseModel
|
||||
from server.auth.constants import AUTOMATION_EVENT_FORWARDING_ENABLED
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.services.automation_event_service import AutomationEventService
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.server.shared import sio
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -29,6 +40,7 @@ webhook_store = GitlabWebhookStore()
|
||||
|
||||
token_manager = TokenManager()
|
||||
gitlab_manager = GitlabManager(token_manager)
|
||||
automation_event_service = AutomationEventService(token_manager)
|
||||
|
||||
|
||||
# Request/Response models
|
||||
@@ -82,6 +94,7 @@ async def verify_gitlab_signature(
|
||||
@gitlab_integration_router.post('/gitlab/events')
|
||||
async def gitlab_events(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
x_gitlab_token: str = Header(None),
|
||||
x_openhands_webhook_id: str = Header(None),
|
||||
x_openhands_user_id: str = Header(None),
|
||||
@@ -112,6 +125,16 @@ async def gitlab_events(
|
||||
content={'message': 'Duplicate GitLab event ignored.'},
|
||||
)
|
||||
|
||||
# Forward to automation service (fire-and-forget background task)
|
||||
if AUTOMATION_EVENT_FORWARDING_ENABLED:
|
||||
background_tasks.add_task(
|
||||
automation_event_service.forward_event,
|
||||
provider=ProviderType.GITLAB,
|
||||
payload=payload_data,
|
||||
installation_id=x_openhands_webhook_id,
|
||||
)
|
||||
|
||||
# Existing resolver bot processing
|
||||
message = Message(
|
||||
source=SourceType.GITLAB,
|
||||
message={
|
||||
|
||||
@@ -163,7 +163,8 @@ class AutomationEventService:
|
||||
org_id = await self._resolve_git_org(provider, git_org_name)
|
||||
|
||||
# Fallback for personal repos (owner_type indicates individual user)
|
||||
if not org_id and owner_type == 'User':
|
||||
# GitHub uses 'User', GitLab uses 'user'
|
||||
if not org_id and owner_type and owner_type.lower() == 'user':
|
||||
org_id = await self._resolve_personal_org(provider, owner_id)
|
||||
if org_id:
|
||||
logger.info(
|
||||
@@ -206,6 +207,18 @@ class AutomationEventService:
|
||||
owner = repo.get('owner', {})
|
||||
return owner.get('login'), owner.get('type'), owner.get('id')
|
||||
|
||||
if provider == ProviderType.GITLAB:
|
||||
# GitLab uses 'project' instead of 'repository'
|
||||
# path_with_namespace is like "org-name/repo-name" or "user-name/repo-name"
|
||||
project = payload.get('project', {})
|
||||
path_with_namespace = project.get('path_with_namespace', '')
|
||||
git_org = path_with_namespace.split('/')[0] if path_with_namespace else None
|
||||
namespace = project.get('namespace', {})
|
||||
# GitLab uses 'group' for organizations and 'user' for personal projects
|
||||
owner_type = namespace.get('kind')
|
||||
owner_id = namespace.get('id')
|
||||
return git_org, owner_type, owner_id
|
||||
|
||||
logger.warning(f'Unsupported provider ({provider.value})')
|
||||
return None, None, None
|
||||
|
||||
|
||||
@@ -826,3 +826,249 @@ class TestCacheHelpers:
|
||||
service = create_service(mock_token_manager)
|
||||
# Should not raise
|
||||
await service._set_cached_value('test-key', 'test-value', 3600)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GitLab-specific tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitlab_group_payload():
|
||||
"""Create a sample GitLab webhook payload for a group-owned project."""
|
||||
return {
|
||||
'object_kind': 'issue',
|
||||
'event_type': 'issue',
|
||||
'project': {
|
||||
'id': 123456,
|
||||
'path_with_namespace': 'test-org/test-repo',
|
||||
'visibility': 'public',
|
||||
'namespace': {
|
||||
'id': 789,
|
||||
'kind': 'group',
|
||||
'name': 'test-org',
|
||||
},
|
||||
},
|
||||
'user': {
|
||||
'id': 12345,
|
||||
'username': 'testuser',
|
||||
},
|
||||
'object_attributes': {
|
||||
'id': 1,
|
||||
'iid': 1,
|
||||
'title': 'Test Issue',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitlab_user_payload():
|
||||
"""Create a sample GitLab webhook payload for a user-owned project."""
|
||||
return {
|
||||
'object_kind': 'issue',
|
||||
'event_type': 'issue',
|
||||
'project': {
|
||||
'id': 654321,
|
||||
'path_with_namespace': 'testuser/personal-repo',
|
||||
'visibility': 'private',
|
||||
'namespace': {
|
||||
'id': 12345,
|
||||
'kind': 'user',
|
||||
'name': 'testuser',
|
||||
},
|
||||
},
|
||||
'user': {
|
||||
'id': 12345,
|
||||
'username': 'testuser',
|
||||
},
|
||||
'object_attributes': {
|
||||
'id': 2,
|
||||
'iid': 1,
|
||||
'title': 'Personal Issue',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestExtractOwnerInfoGitLab:
|
||||
"""Tests for _extract_owner_info method with GitLab payloads."""
|
||||
|
||||
def test_extract_gitlab_group_owner(self, mock_token_manager, gitlab_group_payload):
|
||||
"""
|
||||
GIVEN: GitLab payload for a group-owned project
|
||||
WHEN: _extract_owner_info is called
|
||||
THEN: Returns correct git_org, owner_type, owner_id
|
||||
"""
|
||||
with patch('server.services.automation_event_service.sio'):
|
||||
service = create_service(mock_token_manager)
|
||||
git_org, owner_type, owner_id = service._extract_owner_info(
|
||||
ProviderType.GITLAB, gitlab_group_payload
|
||||
)
|
||||
|
||||
assert git_org == 'test-org'
|
||||
assert owner_type == 'group'
|
||||
assert owner_id == 789
|
||||
|
||||
def test_extract_gitlab_user_owner(self, mock_token_manager, gitlab_user_payload):
|
||||
"""
|
||||
GIVEN: GitLab payload for a user-owned project
|
||||
WHEN: _extract_owner_info is called
|
||||
THEN: Returns correct git_org, owner_type, owner_id
|
||||
"""
|
||||
with patch('server.services.automation_event_service.sio'):
|
||||
service = create_service(mock_token_manager)
|
||||
git_org, owner_type, owner_id = service._extract_owner_info(
|
||||
ProviderType.GITLAB, gitlab_user_payload
|
||||
)
|
||||
|
||||
assert git_org == 'testuser'
|
||||
assert owner_type == 'user'
|
||||
assert owner_id == 12345
|
||||
|
||||
def test_extract_gitlab_missing_project(self, mock_token_manager):
|
||||
"""
|
||||
GIVEN: GitLab payload without project data
|
||||
WHEN: _extract_owner_info is called
|
||||
THEN: Returns None values
|
||||
"""
|
||||
payload = {'object_kind': 'issue'}
|
||||
|
||||
with patch('server.services.automation_event_service.sio'):
|
||||
service = create_service(mock_token_manager)
|
||||
git_org, owner_type, owner_id = service._extract_owner_info(
|
||||
ProviderType.GITLAB, payload
|
||||
)
|
||||
|
||||
assert git_org is None
|
||||
assert owner_type is None
|
||||
assert owner_id is None
|
||||
|
||||
|
||||
class TestResolveOrgContextGitLab:
|
||||
"""Tests for _resolve_org_context method with GitLab payloads."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_gitlab_group_org(
|
||||
self, mock_token_manager, mock_org_git_claim, gitlab_group_payload
|
||||
):
|
||||
"""
|
||||
GIVEN: GitLab payload for a group project with claimed org
|
||||
WHEN: _resolve_org_context is called
|
||||
THEN: Returns correct OrgContext with org_id
|
||||
"""
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value=None) # Cache miss
|
||||
mock_redis.setex = AsyncMock()
|
||||
|
||||
with patch(
|
||||
'server.services.automation_event_service.resolve_org_for_repo',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_org_git_claim.org_id,
|
||||
), patch('server.services.automation_event_service.sio') as mock_sio:
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
service = create_service(mock_token_manager)
|
||||
result = await service._resolve_org_context(
|
||||
ProviderType.GITLAB, gitlab_group_payload
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.org_id == mock_org_git_claim.org_id
|
||||
assert result.git_org == 'test-org'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_gitlab_user_personal_org_fallback(
|
||||
self, mock_token_manager, mock_org_git_claim, gitlab_user_payload
|
||||
):
|
||||
"""
|
||||
GIVEN: GitLab payload for a user project (not claimed via OrgGitClaim)
|
||||
WHEN: _resolve_org_context is called
|
||||
THEN: Falls back to personal org resolution
|
||||
"""
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value=None) # Cache miss
|
||||
mock_redis.setex = AsyncMock()
|
||||
|
||||
with patch(
|
||||
'server.services.automation_event_service.resolve_org_for_repo',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None, # No OrgGitClaim for user
|
||||
), patch('server.services.automation_event_service.sio') as mock_sio:
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
service = create_service(mock_token_manager)
|
||||
# Mock _resolve_personal_org to return a personal org
|
||||
service._resolve_personal_org = AsyncMock(
|
||||
return_value=mock_org_git_claim.org_id
|
||||
)
|
||||
|
||||
result = await service._resolve_org_context(
|
||||
ProviderType.GITLAB, gitlab_user_payload
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.org_id == mock_org_git_claim.org_id
|
||||
assert result.git_org == 'testuser'
|
||||
# Verify personal org fallback was called with GitLab user ID
|
||||
service._resolve_personal_org.assert_called_once_with(
|
||||
ProviderType.GITLAB, 12345
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_gitlab_no_org_found(
|
||||
self, mock_token_manager, gitlab_group_payload
|
||||
):
|
||||
"""
|
||||
GIVEN: GitLab payload for a project with no org claim and no personal org
|
||||
WHEN: _resolve_org_context is called
|
||||
THEN: Returns None
|
||||
"""
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value=None)
|
||||
mock_redis.setex = AsyncMock()
|
||||
|
||||
with patch(
|
||||
'server.services.automation_event_service.resolve_org_for_repo',
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
), patch('server.services.automation_event_service.sio') as mock_sio:
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
service = create_service(mock_token_manager)
|
||||
result = await service._resolve_org_context(
|
||||
ProviderType.GITLAB, gitlab_group_payload
|
||||
)
|
||||
|
||||
# Group owner doesn't fall back to personal org
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestResolveGitOrgGitLab:
|
||||
"""Tests for _resolve_git_org method with GitLab provider."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_gitlab_org_includes_provider_in_cache_key(
|
||||
self, mock_token_manager, mock_org_git_claim
|
||||
):
|
||||
"""
|
||||
GIVEN: GitLab provider with an org name
|
||||
WHEN: _resolve_git_org is called
|
||||
THEN: Cache key includes the gitlab provider name
|
||||
"""
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.get = AsyncMock(return_value=None)
|
||||
mock_redis.setex = AsyncMock()
|
||||
|
||||
with patch(
|
||||
'server.services.automation_event_service.resolve_org_for_repo',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_org_git_claim.org_id,
|
||||
), patch('server.services.automation_event_service.sio') as mock_sio:
|
||||
mock_sio.manager.redis = mock_redis
|
||||
|
||||
service = create_service(mock_token_manager)
|
||||
await service._resolve_git_org(ProviderType.GITLAB, 'Test-Org')
|
||||
|
||||
# Verify cache key includes provider and normalized org name
|
||||
get_call_args = mock_redis.get.call_args[0][0]
|
||||
assert 'gitlab' in get_call_args
|
||||
assert 'test-org' in get_call_args # Lowercase normalized
|
||||
|
||||
@@ -8,16 +8,23 @@ import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
from server.routes.integration.gitlab import gitlab_events
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_background_tasks():
|
||||
"""Create a mock BackgroundTasks."""
|
||||
return MagicMock(spec=BackgroundTasks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.integration.gitlab.verify_gitlab_signature')
|
||||
@patch('server.routes.integration.gitlab.gitlab_manager')
|
||||
@patch('server.routes.integration.gitlab.sio')
|
||||
async def test_gitlab_events_deduplication_with_object_id(
|
||||
mock_sio, mock_gitlab_manager, mock_verify_signature
|
||||
mock_sio, mock_gitlab_manager, mock_verify_signature, mock_background_tasks
|
||||
):
|
||||
"""Test that duplicate GitLab events are deduplicated using object_attributes.id."""
|
||||
# Setup mocks
|
||||
@@ -47,6 +54,7 @@ async def test_gitlab_events_deduplication_with_object_id(
|
||||
# Call the endpoint
|
||||
response = await gitlab_events(
|
||||
request=mock_request,
|
||||
background_tasks=mock_background_tasks,
|
||||
x_gitlab_token='test_token',
|
||||
x_openhands_webhook_id='test_webhook_id',
|
||||
x_openhands_user_id='test_user_id',
|
||||
@@ -70,6 +78,7 @@ async def test_gitlab_events_deduplication_with_object_id(
|
||||
# Call the endpoint again with the same payload
|
||||
response = await gitlab_events(
|
||||
request=mock_request,
|
||||
background_tasks=mock_background_tasks,
|
||||
x_gitlab_token='test_token',
|
||||
x_openhands_webhook_id='test_webhook_id',
|
||||
x_openhands_user_id='test_user_id',
|
||||
@@ -92,7 +101,7 @@ async def test_gitlab_events_deduplication_with_object_id(
|
||||
@patch('server.routes.integration.gitlab.gitlab_manager')
|
||||
@patch('server.routes.integration.gitlab.sio')
|
||||
async def test_gitlab_events_deduplication_without_object_id(
|
||||
mock_sio, mock_gitlab_manager, mock_verify_signature
|
||||
mock_sio, mock_gitlab_manager, mock_verify_signature, mock_background_tasks
|
||||
):
|
||||
"""Test that GitLab events without object_attributes.id are deduplicated using hash of payload."""
|
||||
# Setup mocks
|
||||
@@ -127,6 +136,7 @@ async def test_gitlab_events_deduplication_without_object_id(
|
||||
# Call the endpoint
|
||||
response = await gitlab_events(
|
||||
request=mock_request,
|
||||
background_tasks=mock_background_tasks,
|
||||
x_gitlab_token='test_token',
|
||||
x_openhands_webhook_id='test_webhook_id',
|
||||
x_openhands_user_id='test_user_id',
|
||||
@@ -150,6 +160,7 @@ async def test_gitlab_events_deduplication_without_object_id(
|
||||
# Call the endpoint again with the same payload
|
||||
response = await gitlab_events(
|
||||
request=mock_request,
|
||||
background_tasks=mock_background_tasks,
|
||||
x_gitlab_token='test_token',
|
||||
x_openhands_webhook_id='test_webhook_id',
|
||||
x_openhands_user_id='test_user_id',
|
||||
@@ -172,7 +183,7 @@ async def test_gitlab_events_deduplication_without_object_id(
|
||||
@patch('server.routes.integration.gitlab.gitlab_manager')
|
||||
@patch('server.routes.integration.gitlab.sio')
|
||||
async def test_gitlab_events_different_payloads_not_deduplicated(
|
||||
mock_sio, mock_gitlab_manager, mock_verify_signature
|
||||
mock_sio, mock_gitlab_manager, mock_verify_signature, mock_background_tasks
|
||||
):
|
||||
"""Test that different GitLab events are not deduplicated."""
|
||||
# Setup mocks
|
||||
@@ -196,6 +207,7 @@ async def test_gitlab_events_different_payloads_not_deduplicated(
|
||||
# Call the endpoint with first payload
|
||||
response1 = await gitlab_events(
|
||||
request=mock_request1,
|
||||
background_tasks=mock_background_tasks,
|
||||
x_gitlab_token='test_token',
|
||||
x_openhands_webhook_id='test_webhook_id',
|
||||
x_openhands_user_id='test_user_id',
|
||||
@@ -223,6 +235,7 @@ async def test_gitlab_events_different_payloads_not_deduplicated(
|
||||
# Call the endpoint with second payload
|
||||
response2 = await gitlab_events(
|
||||
request=mock_request2,
|
||||
background_tasks=mock_background_tasks,
|
||||
x_gitlab_token='test_token',
|
||||
x_openhands_webhook_id='test_webhook_id',
|
||||
x_openhands_user_id='test_user_id',
|
||||
@@ -242,7 +255,7 @@ async def test_gitlab_events_different_payloads_not_deduplicated(
|
||||
@patch('server.routes.integration.gitlab.gitlab_manager')
|
||||
@patch('server.routes.integration.gitlab.sio')
|
||||
async def test_gitlab_events_multiple_identical_payloads_deduplicated(
|
||||
mock_sio, mock_gitlab_manager, mock_verify_signature
|
||||
mock_sio, mock_gitlab_manager, mock_verify_signature, mock_background_tasks
|
||||
):
|
||||
"""Test that multiple identical GitLab events are properly deduplicated."""
|
||||
# Setup mocks
|
||||
@@ -273,6 +286,7 @@ async def test_gitlab_events_multiple_identical_payloads_deduplicated(
|
||||
# Call the endpoint first time
|
||||
response1 = await gitlab_events(
|
||||
request=mock_request,
|
||||
background_tasks=mock_background_tasks,
|
||||
x_gitlab_token='test_token',
|
||||
x_openhands_webhook_id='test_webhook_id',
|
||||
x_openhands_user_id='test_user_id',
|
||||
@@ -298,6 +312,7 @@ async def test_gitlab_events_multiple_identical_payloads_deduplicated(
|
||||
# Call the endpoint second time with the same payload
|
||||
response2 = await gitlab_events(
|
||||
request=mock_request,
|
||||
background_tasks=mock_background_tasks,
|
||||
x_gitlab_token='test_token',
|
||||
x_openhands_webhook_id='test_webhook_id',
|
||||
x_openhands_user_id='test_user_id',
|
||||
@@ -321,6 +336,7 @@ async def test_gitlab_events_multiple_identical_payloads_deduplicated(
|
||||
# Call the endpoint third time with the same payload
|
||||
response3 = await gitlab_events(
|
||||
request=mock_request,
|
||||
background_tasks=mock_background_tasks,
|
||||
x_gitlab_token='test_token',
|
||||
x_openhands_webhook_id='test_webhook_id',
|
||||
x_openhands_user_id='test_user_id',
|
||||
|
||||
@@ -196,16 +196,23 @@ describe("useWebSocket", () => {
|
||||
const onCloseSpy = vi.fn();
|
||||
const options = { onClose: onCloseSpy };
|
||||
|
||||
const closeLink = ws.link("ws://close-test.com/ws");
|
||||
mswServer.use(
|
||||
closeLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.close(1000, "Normal closure");
|
||||
}),
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws", options),
|
||||
);
|
||||
|
||||
renderHook(() => useWebSocket("ws://close-test.com/ws", options));
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Reset spy after connection is established to ignore any spurious
|
||||
// close events fired by the MSW mock during the handshake.
|
||||
onCloseSpy.mockClear();
|
||||
|
||||
// Unmount to trigger close
|
||||
unmount();
|
||||
|
||||
// Wait for onClose handler to be called
|
||||
await waitFor(() => {
|
||||
expect(onCloseSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -35,8 +35,6 @@ const VALID_OSS_CONFIG: WebClientConfig = {
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
gitlab_enabled: false,
|
||||
slack_enabled: false,
|
||||
};
|
||||
|
||||
const VALID_SAAS_CONFIG: WebClientConfig = {
|
||||
@@ -60,8 +58,6 @@ const VALID_SAAS_CONFIG: WebClientConfig = {
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
gitlab_enabled: false,
|
||||
slack_enabled: false,
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -272,10 +268,7 @@ describe("Content", () => {
|
||||
|
||||
it("should render the 'Configure GitHub Repositories' button if SaaS mode and github_app_slug exists", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
|
||||
@@ -290,24 +283,15 @@ describe("Content", () => {
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
// wait until queries are resolved
|
||||
expect(queryClient.isFetching()).toBe(0);
|
||||
button = screen.queryByTestId("configure-github-repositories-button");
|
||||
expect(button).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("gitlab-status-text")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("install-slack-app-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("disconnect-tokens-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
providers_configured: ["gitlab"],
|
||||
github_app_slug: "test-slug",
|
||||
gitlab_enabled: true,
|
||||
slack_enabled: true,
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
@@ -315,8 +299,6 @@ describe("Content", () => {
|
||||
await waitFor(() => {
|
||||
button = screen.getByTestId("configure-github-repositories-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("install-slack-app-button")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("disconnect-tokens-button"),
|
||||
@@ -632,49 +614,10 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should render configured GitLab and Slack sections in SaaS mode without APP_SLUG", async () => {
|
||||
it("should not render GitLab webhook manager in SaaS mode without APP_SLUG", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
providers_configured: ["gitlab"],
|
||||
gitlab_enabled: true,
|
||||
slack_enabled: true,
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("configure-github-repositories-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("install-slack-app-button")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render GitLab or Slack sections when the backend does not enable them", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
@@ -682,25 +625,19 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("gitlab-status-text")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("install-slack-app-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render GitLab webhook manager when the token is not set", async () => {
|
||||
it("should not render GitLab webhook manager when token is not set", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
providers_configured: ["gitlab"],
|
||||
gitlab_enabled: true,
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
@@ -713,7 +650,6 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -43,6 +43,4 @@ export interface WebClientConfig {
|
||||
error_message: string | null;
|
||||
updated_at: string;
|
||||
github_app_slug: string | null;
|
||||
gitlab_enabled?: boolean;
|
||||
slack_enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -62,8 +62,6 @@ export const createMockWebClientConfig = (
|
||||
error_message: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
github_app_slug: null,
|
||||
gitlab_enabled: false,
|
||||
slack_enabled: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -427,8 +425,6 @@ export const SETTINGS_HANDLERS = [
|
||||
error_message: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
github_app_slug: mockSaas ? "openhands" : null,
|
||||
gitlab_enabled: false,
|
||||
slack_enabled: false,
|
||||
};
|
||||
|
||||
return HttpResponse.json(config);
|
||||
|
||||
@@ -181,9 +181,8 @@ function GitSettingsScreen() {
|
||||
!bitbucketDCHostInputHasValue &&
|
||||
!azureDevOpsHostInputHasValue &&
|
||||
!forgejoHostInputHasValue;
|
||||
const shouldRenderGitHubConfigureButton = isSaas && config?.github_app_slug;
|
||||
const shouldRenderGitLabSection = isSaas && Boolean(config?.gitlab_enabled);
|
||||
const shouldRenderSlackSection = isSaas && Boolean(config?.slack_enabled);
|
||||
const shouldRenderExternalConfigureButtons =
|
||||
isSaas && config?.github_app_slug;
|
||||
const shouldRenderProjectManagementIntegrations =
|
||||
config?.feature_flags?.enable_jira ||
|
||||
config?.feature_flags?.enable_jira_dc ||
|
||||
@@ -197,7 +196,7 @@ function GitSettingsScreen() {
|
||||
>
|
||||
{!isLoading && (
|
||||
<div className="flex flex-col">
|
||||
{shouldRenderGitHubConfigureButton && (
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<>
|
||||
<div className="pb-1 flex flex-col">
|
||||
<h3 className="text-xl font-medium text-white">
|
||||
@@ -211,7 +210,7 @@ function GitSettingsScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldRenderGitLabSection && (
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<>
|
||||
<div className="mt-6 flex flex-col gap-4 pb-8">
|
||||
<Typography.H3 className="text-xl">
|
||||
@@ -238,7 +237,7 @@ function GitSettingsScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldRenderSlackSection && (
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<>
|
||||
<div className="pb-1 mt-6 flex flex-col">
|
||||
<h3 className="text-xl font-medium text-white">
|
||||
@@ -347,7 +346,7 @@ function GitSettingsScreen() {
|
||||
{isLoading && <GitSettingInputsSkeleton />}
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end">
|
||||
{!isSaas && (
|
||||
{!shouldRenderExternalConfigureButtons && (
|
||||
<>
|
||||
<BrandButton
|
||||
testId="disconnect-tokens-button"
|
||||
|
||||
@@ -1304,10 +1304,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
plan_path = self._compute_plan_path(project_dir, git_provider)
|
||||
tools = get_planning_tools(plan_path=plan_path)
|
||||
else:
|
||||
tools = get_default_tools(
|
||||
enable_browser=True,
|
||||
enable_sub_agents=user.agent_settings.enable_sub_agents,
|
||||
)
|
||||
tools = get_default_tools(enable_browser=True)
|
||||
|
||||
# --- build AgentSettings and create agent ---------------------------
|
||||
from fastmcp.mcp_config import MCPConfig
|
||||
|
||||
@@ -71,7 +71,7 @@ def get_agent_server_image() -> str:
|
||||
|
||||
# Prefixes for environment variables that should be auto-forwarded to agent-server
|
||||
# These are typically configuration variables that affect the agent's behavior
|
||||
AUTO_FORWARD_PREFIXES = ('LLM_', 'LMNR_')
|
||||
AUTO_FORWARD_PREFIXES = ('LLM_',)
|
||||
|
||||
|
||||
def get_agent_server_env() -> dict[str, str]:
|
||||
@@ -80,10 +80,9 @@ def get_agent_server_env() -> dict[str, str]:
|
||||
This function combines two sources of environment variables:
|
||||
|
||||
1. **Auto-forwarded variables**: Environment variables with certain prefixes
|
||||
(e.g., LLM_*, LMNR_*) are automatically forwarded to the agent-server container.
|
||||
(e.g., LLM_*) are automatically forwarded to the agent-server container.
|
||||
This ensures that LLM configuration like timeouts and retry settings
|
||||
work correctly in the two-container V1 architecture, as well as
|
||||
Laminar monitoring/analytics configuration.
|
||||
work correctly in the two-container V1 architecture.
|
||||
|
||||
2. **Explicit overrides via OH_AGENT_SERVER_ENV**: A JSON string that allows
|
||||
setting arbitrary environment variables in the agent-server container.
|
||||
@@ -91,7 +90,6 @@ def get_agent_server_env() -> dict[str, str]:
|
||||
|
||||
Auto-forwarded prefixes:
|
||||
- LLM_* : LLM configuration (timeout, retries, model settings, etc.)
|
||||
- LMNR_* : Laminar monitoring/analytics configuration
|
||||
|
||||
Usage:
|
||||
# Auto-forwarding (no action needed):
|
||||
@@ -99,11 +97,6 @@ def get_agent_server_env() -> dict[str, str]:
|
||||
export LLM_NUM_RETRIES=10
|
||||
# These will automatically be available in the agent-server
|
||||
|
||||
# Auto-forwarding for Laminar:
|
||||
export LMNR_PROJECT_API_KEY=your-api-key
|
||||
export LMNR_BASE_URL=https://app.lmnr.ai
|
||||
# These will automatically be available in the agent-server
|
||||
|
||||
# Explicit override via JSON:
|
||||
OH_AGENT_SERVER_ENV='{"DEBUG": "true", "CUSTOM_VAR": "value"}'
|
||||
|
||||
|
||||
@@ -58,11 +58,6 @@ def _get_maintenance_start_time() -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _is_gitlab_enabled() -> bool:
|
||||
"""Return whether GitLab OAuth is configured for the web client."""
|
||||
return bool(os.getenv('GITLAB_APP_CLIENT_ID', '').strip())
|
||||
|
||||
|
||||
def _get_providers_configured() -> list[ProviderType]:
|
||||
"""Get configured OAuth providers from environment variables.
|
||||
|
||||
@@ -74,7 +69,7 @@ def _get_providers_configured() -> list[ProviderType]:
|
||||
if os.getenv('GITHUB_APP_CLIENT_ID', '').strip():
|
||||
providers.append(ProviderType.GITHUB)
|
||||
|
||||
if _is_gitlab_enabled():
|
||||
if os.getenv('GITLAB_APP_CLIENT_ID', '').strip():
|
||||
providers.append(ProviderType.GITLAB)
|
||||
|
||||
if os.getenv('BITBUCKET_APP_CLIENT_ID', '').strip():
|
||||
@@ -96,16 +91,6 @@ def _get_github_app_slug() -> str | None:
|
||||
return slug if slug else None
|
||||
|
||||
|
||||
def _get_slack_enabled() -> bool:
|
||||
"""Return whether Slack integration is fully configured for the web client."""
|
||||
return (
|
||||
os.getenv('SLACK_WEBHOOKS_ENABLED', 'false').lower() == 'true'
|
||||
and bool(os.getenv('SLACK_CLIENT_ID', '').strip())
|
||||
and bool(os.getenv('SLACK_CLIENT_SECRET', '').strip())
|
||||
and bool(os.getenv('SLACK_SIGNING_SECRET', '').strip())
|
||||
)
|
||||
|
||||
|
||||
def _get_feature_flags() -> WebClientFeatureFlags:
|
||||
"""Get feature flags from environment variables.
|
||||
|
||||
@@ -148,8 +133,6 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
|
||||
),
|
||||
)
|
||||
github_app_slug: str | None = Field(default_factory=_get_github_app_slug)
|
||||
gitlab_enabled: bool = Field(default_factory=_is_gitlab_enabled)
|
||||
slack_enabled: bool = Field(default_factory=_get_slack_enabled)
|
||||
|
||||
async def get_web_client_config(self) -> WebClientConfig:
|
||||
from openhands.app_server.config import get_global_config
|
||||
@@ -167,7 +150,5 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
|
||||
error_message=self.error_message,
|
||||
updated_at=self.updated_at,
|
||||
github_app_slug=self.github_app_slug,
|
||||
gitlab_enabled=self.gitlab_enabled,
|
||||
slack_enabled=self.slack_enabled,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -42,5 +42,3 @@ class WebClientConfig(DiscriminatedUnionMixin):
|
||||
error_message: str | None
|
||||
updated_at: datetime
|
||||
github_app_slug: str | None
|
||||
gitlab_enabled: bool = False
|
||||
slack_enabled: bool = False
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# OpenHands Architecture
|
||||
|
||||
Architecture diagrams and explanations for the OpenHands system.
|
||||
|
||||
## Documentation Sections
|
||||
|
||||
- [System Architecture Overview](./system-architecture.md) - Multi-tier architecture and component responsibilities
|
||||
- [Conversation Startup & WebSocket Flow](./conversation-startup.md) - Runtime provisioning and real-time communication
|
||||
- [Agent Execution & LLM Flow](./agent-execution.md) - LLM integration and action execution loop
|
||||
- [Observability](./observability.md) - Logging, metrics, and monitoring
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Execution & LLM Flow
|
||||
|
||||
When the agent executes inside the sandbox, it makes LLM calls through LiteLLM:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as User (Browser)
|
||||
participant AS as Agent Server
|
||||
participant Agent as Agent<br/>(CodeAct)
|
||||
participant LLM as LLM Class
|
||||
participant Lite as LiteLLM
|
||||
participant Proxy as LLM Proxy<br/>(llm-proxy.app.all-hands.dev)
|
||||
participant Provider as LLM Provider<br/>(OpenAI, Anthropic, etc.)
|
||||
participant AES as Action Execution Server
|
||||
|
||||
Note over User,AES: Agent Loop - LLM Call Flow
|
||||
|
||||
User->>AS: WebSocket: User message
|
||||
AS->>Agent: Process message
|
||||
Note over Agent: Build prompt from state
|
||||
|
||||
Agent->>LLM: completion(messages, tools)
|
||||
Note over LLM: Apply config (model, temp, etc.)
|
||||
|
||||
alt Using OpenHands Provider
|
||||
LLM->>Lite: litellm_proxy/{model}
|
||||
Lite->>Proxy: POST /chat/completions
|
||||
Note over Proxy: Auth, rate limit, routing
|
||||
Proxy->>Provider: Forward request
|
||||
Provider-->>Proxy: Response
|
||||
Proxy-->>Lite: Response
|
||||
else Using Direct Provider
|
||||
LLM->>Lite: {provider}/{model}
|
||||
Lite->>Provider: Direct API call
|
||||
Provider-->>Lite: Response
|
||||
end
|
||||
|
||||
Lite-->>LLM: ModelResponse
|
||||
Note over LLM: Track metrics (cost, tokens)
|
||||
LLM-->>Agent: Parsed response
|
||||
|
||||
Note over Agent: Parse action from response
|
||||
AS->>User: WebSocket: Action event
|
||||
|
||||
Note over User,AES: Action Execution
|
||||
|
||||
AS->>AES: HTTP: Execute action
|
||||
Note over AES: Run command/edit file
|
||||
AES-->>AS: Observation
|
||||
AS->>User: WebSocket: Observation event
|
||||
|
||||
Note over Agent: Update state
|
||||
Note over Agent: Loop continues...
|
||||
```
|
||||
|
||||
### LLM Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **LLM Class** | Wrapper with retries, metrics, config | `openhands/llm/llm.py` |
|
||||
| **LiteLLM** | Universal LLM API adapter | External library |
|
||||
| **LLM Proxy** | OpenHands managed proxy for billing/routing | `llm-proxy.app.all-hands.dev` |
|
||||
| **LLM Registry** | Manages multiple LLM instances | `openhands/llm/llm_registry.py` |
|
||||
|
||||
### Model Routing
|
||||
|
||||
```
|
||||
User selects model
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Model prefix? │
|
||||
└───────────────────┘
|
||||
│
|
||||
├── openhands/claude-3-5 ──► Rewrite to litellm_proxy/claude-3-5
|
||||
│ Base URL: llm-proxy.app.all-hands.dev
|
||||
│
|
||||
├── anthropic/claude-3-5 ──► Direct to Anthropic API
|
||||
│ (User's API key)
|
||||
│
|
||||
├── openai/gpt-4 ──► Direct to OpenAI API
|
||||
│ (User's API key)
|
||||
│
|
||||
└── azure/gpt-4 ──► Direct to Azure OpenAI
|
||||
(User's API key + endpoint)
|
||||
```
|
||||
|
||||
### LLM Proxy
|
||||
|
||||
When using `openhands/` prefixed models, requests are routed through a managed proxy.
|
||||
See the [OpenHands documentation](https://docs.openhands.dev/) for details on supported models.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Conversation Startup & WebSocket Flow
|
||||
|
||||
When a user starts a conversation, this sequence occurs:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as User (Browser)
|
||||
participant App as App Server
|
||||
participant SS as Sandbox Service
|
||||
participant RAPI as Runtime API
|
||||
participant Pool as Warm Pool
|
||||
participant Sandbox as Sandbox (Container)
|
||||
participant AS as Agent Server
|
||||
participant AES as Action Execution Server
|
||||
|
||||
Note over User,AES: Phase 1: Conversation Creation
|
||||
User->>App: POST /api/conversations
|
||||
Note over App: Authenticate user
|
||||
App->>SS: Create sandbox
|
||||
|
||||
Note over SS,Pool: Phase 2: Runtime Provisioning
|
||||
SS->>RAPI: POST /start (image, env, config)
|
||||
RAPI->>Pool: Check for warm runtime
|
||||
alt Warm runtime available
|
||||
Pool-->>RAPI: Return warm runtime
|
||||
Note over RAPI: Assign to session
|
||||
else No warm runtime
|
||||
RAPI->>Sandbox: Create new container
|
||||
Sandbox->>AS: Start Agent Server
|
||||
Sandbox->>AES: Start Action Execution Server
|
||||
AES-->>AS: Ready
|
||||
end
|
||||
RAPI-->>SS: Runtime URL + session API key
|
||||
SS-->>App: Sandbox info
|
||||
App-->>User: Conversation ID + Sandbox URL
|
||||
|
||||
Note over User,AES: Phase 3: Direct WebSocket Connection
|
||||
User->>AS: WebSocket: /sockets/events/{id}
|
||||
AS-->>User: Connection accepted
|
||||
AS->>User: Replay historical events
|
||||
|
||||
Note over User,AES: Phase 4: User Sends Message
|
||||
User->>AS: WebSocket: SendMessageRequest
|
||||
Note over AS: Agent processes message
|
||||
Note over AS: LLM call → generate action
|
||||
|
||||
Note over User,AES: Phase 5: Action Execution Loop
|
||||
loop Agent Loop
|
||||
AS->>AES: HTTP: Execute action
|
||||
Note over AES: Run in sandbox
|
||||
AES-->>AS: Observation result
|
||||
AS->>User: WebSocket: Event update
|
||||
Note over AS: Update state, next action
|
||||
end
|
||||
|
||||
Note over User,AES: Phase 6: Task Complete
|
||||
AS->>User: WebSocket: AgentStateChanged (FINISHED)
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
1. **Initial Setup via App Server**: The App Server handles authentication and coordinates with the Sandbox Service
|
||||
2. **Runtime API Provisioning**: The Sandbox Service calls the Runtime API, which checks for warm runtimes before creating new containers
|
||||
3. **Warm Pool Optimization**: Pre-warmed runtimes reduce startup latency significantly
|
||||
4. **Direct WebSocket to Sandbox**: Once created, the user's browser connects **directly** to the Agent Server inside the sandbox
|
||||
5. **App Server Not in Hot Path**: After connection, all real-time communication bypasses the App Server entirely
|
||||
6. **Agent Server Orchestrates**: The Agent Server manages the AI loop, calling the Action Execution Server for actual command execution
|
||||
@@ -0,0 +1,85 @@
|
||||
# Observability
|
||||
|
||||
OpenHands provides structured logging and metrics collection for monitoring and debugging.
|
||||
|
||||
> **SDK Documentation**: For detailed guidance on observability and metrics in agent development, see:
|
||||
> - [SDK Observability Guide](https://docs.openhands.dev/sdk/guides/observability)
|
||||
> - [SDK Metrics Guide](https://docs.openhands.dev/sdk/guides/metrics)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Sources["Sources"]
|
||||
Agent["Agent Server"]
|
||||
App["App Server"]
|
||||
Frontend["Frontend"]
|
||||
end
|
||||
|
||||
subgraph Collection["Collection"]
|
||||
JSONLog["JSON Logs<br/>(stdout)"]
|
||||
Metrics["Metrics<br/>(Internal)"]
|
||||
end
|
||||
|
||||
subgraph External["External (Optional)"]
|
||||
LogAgg["Log Aggregator"]
|
||||
Analytics["Analytics Service"]
|
||||
end
|
||||
|
||||
Agent --> JSONLog
|
||||
App --> JSONLog
|
||||
App --> Metrics
|
||||
|
||||
JSONLog --> LogAgg
|
||||
Frontend --> Analytics
|
||||
```
|
||||
|
||||
### Structured Logging
|
||||
|
||||
OpenHands uses Python's standard logging library with structured JSON output support.
|
||||
|
||||
| Component | Format | Destination | Purpose |
|
||||
|-----------|--------|-------------|---------|
|
||||
| **Application Logs** | JSON (when `LOG_JSON=1`) | stdout | Debugging, error tracking |
|
||||
| **Access Logs** | JSON (Uvicorn) | stdout | Request tracing |
|
||||
| **LLM Debug Logs** | Plain text | File (optional) | LLM call debugging |
|
||||
|
||||
### JSON Log Format
|
||||
|
||||
When `LOG_JSON=1` is set, logs are emitted as single-line JSON for ingestion by log aggregators:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Conversation started",
|
||||
"severity": "INFO",
|
||||
"conversation_id": "abc-123",
|
||||
"user_id": "user-456",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Additional context can be added using Python's logger `extra=` parameter (see [Python logging docs](https://docs.python.org/3/library/logging.html)).
|
||||
|
||||
### Metrics
|
||||
|
||||
| Metric | Tracked By | Storage | Purpose |
|
||||
|--------|------------|---------|---------|
|
||||
| **LLM Cost** | `Metrics` class | Conversation stats file | Billing, budget limits |
|
||||
| **Token Usage** | `Metrics` class | Conversation stats file | Usage analytics |
|
||||
| **Response Latency** | `Metrics` class | Conversation stats file | Performance monitoring |
|
||||
|
||||
### Conversation Stats Persistence
|
||||
|
||||
Per-conversation metrics are persisted for analytics:
|
||||
|
||||
```python
|
||||
# Location: openhands/server/services/conversation_stats.py
|
||||
ConversationStats:
|
||||
- service_to_metrics: Dict[str, Metrics]
|
||||
- accumulated_cost: float
|
||||
- token_usage: TokenUsage
|
||||
|
||||
# Stored at: {file_store}/conversation_stats/{conversation_id}.pkl
|
||||
```
|
||||
|
||||
### Integration with External Services
|
||||
|
||||
Structured JSON logging allows integration with any log aggregation service (e.g., ELK Stack, Loki, Splunk). Configure your log collector to ingest from container stdout/stderr.
|
||||
@@ -0,0 +1,88 @@
|
||||
# System Architecture Overview
|
||||
|
||||
OpenHands supports multiple deployment configurations. This document describes the core components and how they interact.
|
||||
|
||||
## Local/Docker Deployment
|
||||
|
||||
The simplest deployment runs everything locally or in Docker containers:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Server["OpenHands Server"]
|
||||
API["REST API<br/>(FastAPI)"]
|
||||
ConvMgr["Conversation<br/>Manager"]
|
||||
Runtime["Runtime<br/>Manager"]
|
||||
end
|
||||
|
||||
subgraph Sandbox["Sandbox (Docker Container)"]
|
||||
AES["Action Execution<br/>Server"]
|
||||
Browser["Browser<br/>Environment"]
|
||||
FS["File System"]
|
||||
end
|
||||
|
||||
User["User"] -->|"HTTP/WebSocket"| API
|
||||
API --> ConvMgr
|
||||
ConvMgr --> Runtime
|
||||
Runtime -->|"Provision"| Sandbox
|
||||
|
||||
Server -->|"Execute actions"| AES
|
||||
AES --> Browser
|
||||
AES --> FS
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Server** | REST API, conversation management, runtime orchestration | `openhands/server/` |
|
||||
| **Runtime** | Abstract interface for sandbox execution | `openhands/runtime/` |
|
||||
| **Action Execution Server** | Execute bash, file ops, browser actions | Inside sandbox |
|
||||
| **EventStream** | Central event bus for all communication | `openhands/events/` |
|
||||
|
||||
## Scalable Deployment
|
||||
|
||||
For production deployments, OpenHands can be configured with a separate Runtime API service:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph AppServer["App Server"]
|
||||
API["REST API"]
|
||||
ConvMgr["Conversation<br/>Manager"]
|
||||
end
|
||||
|
||||
subgraph RuntimeAPI["Runtime API (Optional)"]
|
||||
RuntimeMgr["Runtime<br/>Manager"]
|
||||
WarmPool["Warm Pool"]
|
||||
end
|
||||
|
||||
subgraph Sandbox["Sandbox"]
|
||||
AS["Agent Server"]
|
||||
AES["Action Execution<br/>Server"]
|
||||
end
|
||||
|
||||
User["User"] -->|"HTTP"| API
|
||||
API --> ConvMgr
|
||||
ConvMgr -->|"Provision"| RuntimeMgr
|
||||
RuntimeMgr --> WarmPool
|
||||
RuntimeMgr --> Sandbox
|
||||
|
||||
User -.->|"WebSocket"| AS
|
||||
AS -->|"HTTP"| AES
|
||||
```
|
||||
|
||||
This configuration enables:
|
||||
- **Warm pool**: Pre-provisioned runtimes for faster startup
|
||||
- **Direct WebSocket**: Users connect directly to their sandbox, bypassing the App Server
|
||||
- **Horizontal scaling**: App Server and Runtime API can scale independently
|
||||
|
||||
### Runtime Options
|
||||
|
||||
OpenHands supports multiple runtime implementations:
|
||||
|
||||
| Runtime | Use Case |
|
||||
|---------|----------|
|
||||
| **DockerRuntime** | Local development, single-machine deployments |
|
||||
| **RemoteRuntime** | Connect to externally managed sandboxes |
|
||||
| **ModalRuntime** | Serverless execution via Modal |
|
||||
|
||||
See the [Runtime documentation](https://docs.openhands.dev/usage/architecture/runtime) for details.
|
||||
@@ -14,11 +14,15 @@ from pydantic import SecretStr
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
|
||||
|
||||
|
||||
def get_provider_tokens():
|
||||
@@ -72,6 +76,44 @@ def get_provider_tokens():
|
||||
return secret_store.provider_tokens if secret_store else None
|
||||
|
||||
|
||||
def initialize_repository_for_runtime(
|
||||
runtime: Runtime,
|
||||
immutable_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
selected_repository: str | None = None,
|
||||
) -> str | None:
|
||||
"""Initialize the repository for the runtime by cloning or initializing it,
|
||||
running setup scripts, and setting up git hooks if present.
|
||||
|
||||
Args:
|
||||
runtime: The runtime to initialize the repository for.
|
||||
immutable_provider_tokens: (optional) Provider tokens to use for authentication.
|
||||
selected_repository: (optional) The repository to use.
|
||||
|
||||
Returns:
|
||||
The repository directory path if a repository was cloned, None otherwise.
|
||||
"""
|
||||
# If provider tokens are not provided, attempt to retrieve them from the environment
|
||||
if not immutable_provider_tokens:
|
||||
immutable_provider_tokens = get_provider_tokens()
|
||||
|
||||
logger.debug(f'Selected repository {selected_repository}.')
|
||||
|
||||
# Clone or initialize the repository using the runtime
|
||||
repo_directory = call_async_from_sync(
|
||||
runtime.clone_or_init_repo,
|
||||
GENERAL_TIMEOUT,
|
||||
immutable_provider_tokens,
|
||||
selected_repository,
|
||||
None,
|
||||
)
|
||||
# Run setup script if it exists in the repository
|
||||
runtime.maybe_run_setup_script()
|
||||
# Set up git hooks if pre-commit.sh exists in the repository
|
||||
runtime.maybe_setup_git_hooks()
|
||||
|
||||
return repo_directory
|
||||
|
||||
|
||||
def generate_sid(config: OpenHandsConfig, session_name: str | None = None) -> str:
|
||||
"""Generate a session id based on the session name and the jwt secret.
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# OpenHands Runtime
|
||||
|
||||
## Introduction
|
||||
|
||||
The OpenHands Runtime folder contains the core components responsible for executing actions and managing the runtime environment for the OpenHands project. This README provides an overview of the main components and their interactions.
|
||||
You can learn more about how the runtime works in the [Docker Runtime](https://docs.openhands.dev/usage/architecture/runtime) documentation.
|
||||
|
||||
## Main Components
|
||||
|
||||
### 1. base.py
|
||||
|
||||
The `base.py` file defines the `Runtime` class, which serves as the primary [interface](./base.py) for agent interactions with the external environment. It handles various operations including:
|
||||
|
||||
- Bash sandbox execution
|
||||
- Browser interactions
|
||||
- Filesystem operations
|
||||
- Environment variable management
|
||||
- Plugin management
|
||||
|
||||
Key features of the `Runtime` class:
|
||||
- Initialization with configuration and event stream
|
||||
- Asynchronous initialization (`ainit`) for setting up environment variables
|
||||
- Action execution methods for different types of actions (run, read, write, browse, etc.)
|
||||
- Abstract methods for file operations (to be implemented by subclasses)
|
||||
|
||||
### 2. impl/action_execution/action_execution_client.py
|
||||
The `action_execution_client.py` file contains the `ActionExecutionClient` class, which implements the Runtime interface. It is an abstract implementation, meaning
|
||||
it still needs to be extended by a concrete implementation to be used.
|
||||
|
||||
This client interacts with an action_execution_server (defined below) via HTTP
|
||||
calls to actually perform runtime actions.
|
||||
|
||||
### 3. action_execution_server.py
|
||||
|
||||
The `action_executor_server.py` file contains the `ActionExecutor` class, which is responsible for executing actions received via the `/execute_action` HTTP endpoint. It returns observations in the HTTP response.
|
||||
|
||||
Key features of the `ActionExecutor` class:
|
||||
- Initialization of user environment and bash shell
|
||||
- Plugin management and initialization
|
||||
- Execution of various action types (bash commands, IPython cells, file operations, browsing)
|
||||
- Integration with BrowserEnv for web interactions
|
||||
|
||||
### 4. Other Implementations
|
||||
The `./impl/` directory contains a few different Runtime implementations, all of
|
||||
which extend the `ActionExecutionClient` class. These implementations
|
||||
handle the lifecycle of a Docker container or other environment running the
|
||||
ActionExecutor server.
|
||||
|
||||
There are currently four implementations:
|
||||
* Docker (runs locally in a Docker container)
|
||||
* Remote (runs via a custom HTTP API for creating, pausing, resuming, and stopping runtimes in a remote environment)
|
||||
* Modal (uses the Modal API)
|
||||
* Runloop (uses the Runloop API)
|
||||
|
||||
You may also add your own `Runtime` subclass to the classpath and configure it like this:
|
||||
|
||||
```toml
|
||||
runtime = "app.my.CustomRuntime"
|
||||
```
|
||||
|
||||
## Workflow Description
|
||||
|
||||
1. **Initialization**:
|
||||
- The `Runtime` is initialized with configuration and event stream.
|
||||
- Environment variables are set up using `ainit` method.
|
||||
- Plugins are loaded and initialized.
|
||||
|
||||
2. **Action Handling**:
|
||||
- The `Runtime` receives actions through the event stream.
|
||||
- Actions are validated and routed to appropriate execution methods.
|
||||
|
||||
3. **Action Execution**:
|
||||
- Different types of actions are executed:
|
||||
- Bash commands using `run` method
|
||||
- IPython cells using `run_ipython` method
|
||||
- File operations (read/write) using `read` and `write` methods
|
||||
- Web browsing using `browse` and `browse_interactive` methods
|
||||
|
||||
4. **Observation Generation**:
|
||||
- After action execution, corresponding observations are generated.
|
||||
- Observations are added to the event stream.
|
||||
|
||||
5. **Plugin Integration**:
|
||||
- Plugins like Jupyter and AgentSkills are initialized and integrated into the runtime.
|
||||
|
||||
6. **Sandbox Environment**:
|
||||
- The `ActionExecutor` sets up a sandboxed environment inside a Docker container.
|
||||
- User environment and bash shell are initialized.
|
||||
- Actions received from the OpenHands backend are executed in this sandboxed environment.
|
||||
|
||||
7. **Browser Interactions**:
|
||||
- Web browsing actions are handled using the `BrowserEnv` class.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The runtime uses asynchronous programming (asyncio) for efficient execution.
|
||||
- Environment variables can be added dynamically to both IPython and Bash environments.
|
||||
- File operations and command executions are abstracted, allowing for different implementations in subclasses.
|
||||
- The system uses a plugin architecture for extensibility.
|
||||
- All interactions with the external environment are managed through the Runtime, ensuring a controlled and secure execution environment.
|
||||
|
||||
## Runtime Types
|
||||
|
||||
### Docker Runtime
|
||||
|
||||
The Docker Runtime is designed for local execution using Docker containers:
|
||||
|
||||
- Creates and manages a Docker container for each session
|
||||
- Executes actions within the container
|
||||
- Supports direct file system access and local resource management
|
||||
- Ideal for development, testing, and scenarios requiring full control over the execution environment
|
||||
|
||||
Key features:
|
||||
- Real-time logging and debugging capabilities
|
||||
- Direct access to the local file system
|
||||
- Faster execution due to local resources
|
||||
- Container isolation for security
|
||||
|
||||
This is the default runtime used within OpenHands.
|
||||
|
||||
### Local Runtime
|
||||
|
||||
The Local Runtime is designed for direct execution on the local machine. Currently only supports running as the local user:
|
||||
|
||||
- Runs the action_execution_server directly on the host
|
||||
- No Docker container overhead
|
||||
- Direct access to local system resources
|
||||
- Ideal for development and testing when Docker is not available or desired
|
||||
|
||||
Key features:
|
||||
- Minimal setup required
|
||||
- Direct access to local resources
|
||||
- No container overhead
|
||||
- Fastest execution speed
|
||||
|
||||
**Important: This runtime provides no isolation as it runs directly on the host machine. All actions are executed with the same permissions as the user running OpenHands. For secure execution with proper isolation, use the Docker Runtime instead.**
|
||||
|
||||
### Remote Runtime
|
||||
|
||||
The Remote Runtime is designed for execution in a remote environment:
|
||||
|
||||
- Connects to a remote server running the ActionExecutor
|
||||
- Executes actions by sending requests to the remote client
|
||||
- Supports distributed execution and cloud-based deployments
|
||||
- Ideal for production environments, scalability, and scenarios where local resource constraints are a concern
|
||||
|
||||
Key features:
|
||||
- Scalability and resource flexibility
|
||||
- Reduced local resource usage
|
||||
- Support for cloud-based deployments
|
||||
- Potential for improved security through isolation
|
||||
|
||||
At the time of this writing, this is mostly used in parallel evaluation, such as this example for [SWE-Bench](https://github.com/OpenHands/OpenHands/tree/main/evaluation/benchmarks/swe_bench#run-inference-on-remoteruntime-experimental).
|
||||
|
||||
## Related Components
|
||||
|
||||
- The runtime interacts closely with the event system defined in the `openhands.events` module.
|
||||
- It relies on configuration classes from `openhands.core.config`.
|
||||
- Logging is handled through `openhands.core.logger`.
|
||||
|
||||
This section provides an overview of the OpenHands Runtime folder. For more detailed information on specific components or usage, please refer to the individual files and their docstrings.
|
||||
@@ -0,0 +1,126 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import importlib
|
||||
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
from openhands.runtime.impl.docker.docker_runtime import (
|
||||
DockerRuntime,
|
||||
)
|
||||
from openhands.runtime.impl.kubernetes.kubernetes_runtime import KubernetesRuntime
|
||||
from openhands.runtime.impl.local.local_runtime import LocalRuntime
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
# mypy: disable-error-code="type-abstract"
|
||||
_DEFAULT_RUNTIME_CLASSES: dict[str, type[Runtime]] = {
|
||||
'eventstream': DockerRuntime,
|
||||
'docker': DockerRuntime,
|
||||
'remote': RemoteRuntime,
|
||||
'local': LocalRuntime,
|
||||
'kubernetes': KubernetesRuntime,
|
||||
'cli': CLIRuntime,
|
||||
}
|
||||
|
||||
# Try to import third-party runtimes if available
|
||||
_THIRD_PARTY_RUNTIME_CLASSES: dict[str, type[Runtime]] = {}
|
||||
|
||||
# Dynamically discover and import third-party runtimes
|
||||
|
||||
# Check if third_party package exists and discover runtimes
|
||||
try:
|
||||
import third_party.runtime.impl
|
||||
|
||||
third_party_base = 'third_party.runtime.impl'
|
||||
|
||||
# List of potential third-party runtime modules to try
|
||||
# These are discovered from the third_party directory structure
|
||||
potential_runtimes = []
|
||||
try:
|
||||
import pkgutil
|
||||
|
||||
for importer, modname, ispkg in pkgutil.iter_modules(
|
||||
third_party.runtime.impl.__path__
|
||||
):
|
||||
if ispkg:
|
||||
potential_runtimes.append(modname)
|
||||
except Exception:
|
||||
# If discovery fails, no third-party runtimes will be loaded
|
||||
potential_runtimes = []
|
||||
|
||||
# Try to import each discovered runtime
|
||||
for runtime_name in potential_runtimes:
|
||||
try:
|
||||
module_path = f'{third_party_base}.{runtime_name}.{runtime_name}_runtime'
|
||||
module = importlib.import_module(module_path)
|
||||
|
||||
# Try different class name patterns
|
||||
possible_class_names = [
|
||||
f'{runtime_name.upper()}Runtime', # E2BRuntime
|
||||
f'{runtime_name.capitalize()}Runtime', # E2bRuntime, DaytonaRuntime, etc.
|
||||
]
|
||||
|
||||
runtime_class = None
|
||||
for class_name in possible_class_names:
|
||||
try:
|
||||
runtime_class = getattr(module, class_name)
|
||||
break
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
if runtime_class:
|
||||
_THIRD_PARTY_RUNTIME_CLASSES[runtime_name] = runtime_class
|
||||
|
||||
except ImportError:
|
||||
# ImportError means the library is not installed (expected for optional dependencies)
|
||||
pass
|
||||
except Exception as e:
|
||||
# Other exceptions mean the library is present but broken, which should be logged
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
logger.warning(f'Failed to import third-party runtime {module_path}: {e}')
|
||||
pass
|
||||
|
||||
except ImportError:
|
||||
# third_party package not available
|
||||
pass
|
||||
|
||||
# Combine core and third-party runtimes
|
||||
_ALL_RUNTIME_CLASSES = {**_DEFAULT_RUNTIME_CLASSES, **_THIRD_PARTY_RUNTIME_CLASSES}
|
||||
|
||||
|
||||
def get_runtime_cls(name: str) -> type[Runtime]:
|
||||
"""If name is one of the predefined runtime names (e.g. 'docker'), return its class.
|
||||
Otherwise attempt to resolve name as subclass of Runtime and return it.
|
||||
Raise on invalid selections.
|
||||
"""
|
||||
if name in _ALL_RUNTIME_CLASSES:
|
||||
return _ALL_RUNTIME_CLASSES[name]
|
||||
try:
|
||||
return get_impl(Runtime, name)
|
||||
except Exception as e:
|
||||
known_keys = _ALL_RUNTIME_CLASSES.keys()
|
||||
raise ValueError(
|
||||
f'Runtime {name} not supported, known are: {known_keys}'
|
||||
) from e
|
||||
|
||||
|
||||
# Build __all__ list dynamically based on available runtimes
|
||||
__all__ = [
|
||||
'Runtime',
|
||||
'RemoteRuntime',
|
||||
'DockerRuntime',
|
||||
'KubernetesRuntime',
|
||||
'CLIRuntime',
|
||||
'LocalRuntime',
|
||||
'get_runtime_cls',
|
||||
]
|
||||
|
||||
# Add third-party runtimes to __all__ if they're available
|
||||
for runtime_name, runtime_class in _THIRD_PARTY_RUNTIME_CLASSES.items():
|
||||
__all__.append(runtime_class.__name__)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from openhands.runtime.browser.utils import browse
|
||||
|
||||
__all__ = ['browse']
|
||||
@@ -0,0 +1,41 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import base64
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def image_to_png_base64_url(
|
||||
image: np.ndarray | Image.Image, add_data_prefix: bool = False
|
||||
) -> str:
|
||||
"""Convert a numpy array to a base64 encoded png image url."""
|
||||
if isinstance(image, np.ndarray):
|
||||
image = Image.fromarray(image)
|
||||
if image.mode in ('RGBA', 'LA'):
|
||||
image = image.convert('RGB')
|
||||
buffered = io.BytesIO()
|
||||
image.save(buffered, format='PNG')
|
||||
|
||||
image_base64 = base64.b64encode(buffered.getvalue()).decode()
|
||||
return (
|
||||
f'data:image/png;base64,{image_base64}'
|
||||
if add_data_prefix
|
||||
else f'{image_base64}'
|
||||
)
|
||||
|
||||
|
||||
def png_base64_url_to_image(png_base64_url: str) -> Image.Image:
|
||||
"""Convert a base64 encoded png image url to a PIL Image."""
|
||||
splited = png_base64_url.split(',')
|
||||
if len(splited) == 2:
|
||||
base64_data = splited[1]
|
||||
else:
|
||||
base64_data = png_base64_url
|
||||
return Image.open(io.BytesIO(base64.b64decode(base64_data)))
|
||||
@@ -0,0 +1,253 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import atexit
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import browsergym.core # noqa F401 (we register the openended task as a gym environment)
|
||||
import gymnasium as gym
|
||||
import html2text
|
||||
import tenacity
|
||||
from browsergym.utils.obs import flatten_dom_to_str, overlay_som
|
||||
|
||||
from openhands.core.exceptions import BrowserInitException
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.browser.base64 import image_to_png_base64_url
|
||||
from openhands.utils.shutdown_listener import should_continue, should_exit
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
BROWSER_EVAL_GET_GOAL_ACTION = 'GET_EVAL_GOAL'
|
||||
BROWSER_EVAL_GET_REWARDS_ACTION = 'GET_EVAL_REWARDS'
|
||||
|
||||
|
||||
class BrowserEnv:
|
||||
def __init__(self, browsergym_eval_env: str | None = None):
|
||||
self.html_text_converter = self.get_html_text_converter()
|
||||
self.eval_mode = False
|
||||
self.eval_dir = ''
|
||||
|
||||
# EVAL only: browsergym_eval_env must be provided for evaluation
|
||||
self.browsergym_eval_env = browsergym_eval_env
|
||||
self.eval_mode = bool(browsergym_eval_env)
|
||||
|
||||
# Initialize browser environment process
|
||||
multiprocessing.set_start_method('spawn', force=True)
|
||||
self.browser_side, self.agent_side = multiprocessing.Pipe()
|
||||
|
||||
self.init_browser()
|
||||
atexit.register(self.close)
|
||||
|
||||
def get_html_text_converter(self) -> html2text.HTML2Text:
|
||||
html_text_converter = html2text.HTML2Text()
|
||||
# ignore links and images
|
||||
html_text_converter.ignore_links = False
|
||||
html_text_converter.ignore_images = True
|
||||
# use alt text for images
|
||||
html_text_converter.images_to_alt = True
|
||||
# disable auto text wrapping
|
||||
html_text_converter.body_width = 0
|
||||
return html_text_converter
|
||||
|
||||
@tenacity.retry(
|
||||
wait=tenacity.wait_fixed(1),
|
||||
stop=tenacity.stop_after_attempt(5) | stop_if_should_exit(),
|
||||
retry=tenacity.retry_if_exception_type(BrowserInitException),
|
||||
)
|
||||
def init_browser(self) -> None:
|
||||
logger.debug('Starting browser env...')
|
||||
try:
|
||||
self.process = multiprocessing.Process(target=self.browser_process)
|
||||
self.process.start()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to start browser process: {e}')
|
||||
raise
|
||||
|
||||
if not self.check_alive(timeout=200):
|
||||
self.close()
|
||||
raise BrowserInitException('Failed to start browser environment.')
|
||||
|
||||
def browser_process(self) -> None:
|
||||
def _is_local_runtime() -> bool:
|
||||
runtime_flag = os.getenv('RUNTIME', '').lower()
|
||||
return runtime_flag == 'local'
|
||||
|
||||
# Default Playwright cache for local runs only; do not override in docker
|
||||
if _is_local_runtime() and 'PLAYWRIGHT_BROWSERS_PATH' not in os.environ:
|
||||
os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(
|
||||
Path.home() / '.cache' / 'playwright'
|
||||
)
|
||||
|
||||
if self.eval_mode:
|
||||
assert self.browsergym_eval_env is not None
|
||||
logger.info('Initializing browser env for web browsing evaluation.')
|
||||
if not self.browsergym_eval_env.startswith('browsergym/'):
|
||||
self.browsergym_eval_env = 'browsergym/' + self.browsergym_eval_env
|
||||
if 'visualwebarena' in self.browsergym_eval_env:
|
||||
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
|
||||
import nltk
|
||||
|
||||
nltk.download('punkt_tab')
|
||||
elif 'webarena' in self.browsergym_eval_env:
|
||||
import browsergym.webarena # noqa F401 register webarena tasks as gym environments
|
||||
elif 'miniwob' in self.browsergym_eval_env:
|
||||
import browsergym.miniwob # noqa F401 register miniwob tasks as gym environments
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Unsupported browsergym eval env: {self.browsergym_eval_env}'
|
||||
)
|
||||
env = gym.make(self.browsergym_eval_env, tags_to_mark='all', timeout=100000)
|
||||
else:
|
||||
downloads_path = os.getenv('BROWSERGYM_DOWNLOAD_DIR')
|
||||
if not downloads_path and _is_local_runtime():
|
||||
downloads_path = str(Path.home() / '.cache' / 'browsergym-downloads')
|
||||
if not downloads_path:
|
||||
downloads_path = '/workspace/.downloads/'
|
||||
env = gym.make(
|
||||
'browsergym/openended',
|
||||
task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'},
|
||||
wait_for_user_message=False,
|
||||
headless=True,
|
||||
disable_env_checker=True,
|
||||
tags_to_mark='all',
|
||||
timeout=100000,
|
||||
pw_context_kwargs={'accept_downloads': True},
|
||||
pw_chromium_kwargs={'downloads_path': downloads_path},
|
||||
)
|
||||
obs, info = env.reset()
|
||||
|
||||
logger.info('Successfully called env.reset')
|
||||
# EVAL ONLY: save the goal into file for evaluation
|
||||
self.eval_goal = None
|
||||
self.goal_image_urls = []
|
||||
self.eval_rewards: list[float] = []
|
||||
if self.eval_mode:
|
||||
self.eval_goal = obs['goal']
|
||||
if 'goal_object' in obs:
|
||||
obs['goal_object'] = list(obs['goal_object'])
|
||||
if len(obs['goal_object']) > 0:
|
||||
self.eval_goal = obs['goal_object'][0]['text']
|
||||
for message in obs['goal_object']:
|
||||
if message['type'] == 'image_url':
|
||||
image_src = message['image_url']
|
||||
if isinstance(image_src, dict):
|
||||
image_src = image_src['url']
|
||||
self.goal_image_urls.append(image_src)
|
||||
logger.debug(f'Browsing goal: {self.eval_goal}')
|
||||
logger.info('Browser env started.')
|
||||
|
||||
while should_continue():
|
||||
try:
|
||||
if self.browser_side.poll(timeout=0.01):
|
||||
unique_request_id, action_data = self.browser_side.recv()
|
||||
|
||||
# shutdown the browser environment
|
||||
if unique_request_id == 'SHUTDOWN':
|
||||
logger.debug('SHUTDOWN recv, shutting down browser env...')
|
||||
env.close()
|
||||
return
|
||||
elif unique_request_id == 'IS_ALIVE':
|
||||
self.browser_side.send(('ALIVE', None))
|
||||
continue
|
||||
|
||||
# EVAL ONLY: Get evaluation info
|
||||
if action_data['action'] == BROWSER_EVAL_GET_GOAL_ACTION:
|
||||
self.browser_side.send(
|
||||
(
|
||||
unique_request_id,
|
||||
{
|
||||
'text_content': self.eval_goal,
|
||||
'image_content': self.goal_image_urls,
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
elif action_data['action'] == BROWSER_EVAL_GET_REWARDS_ACTION:
|
||||
self.browser_side.send(
|
||||
(
|
||||
unique_request_id,
|
||||
{'text_content': json.dumps(self.eval_rewards)},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
action = action_data['action']
|
||||
obs, reward, terminated, truncated, info = env.step(action)
|
||||
|
||||
# EVAL ONLY: Save the rewards into file for evaluation
|
||||
if self.eval_mode:
|
||||
self.eval_rewards.append(reward)
|
||||
|
||||
# add text content of the page
|
||||
html_str = flatten_dom_to_str(obs['dom_object'])
|
||||
obs['text_content'] = self.html_text_converter.handle(html_str)
|
||||
# make observation serializable
|
||||
obs['set_of_marks'] = image_to_png_base64_url(
|
||||
overlay_som(
|
||||
obs['screenshot'], obs.get('extra_element_properties', {})
|
||||
),
|
||||
add_data_prefix=True,
|
||||
)
|
||||
obs['screenshot'] = image_to_png_base64_url(
|
||||
obs['screenshot'], add_data_prefix=True
|
||||
)
|
||||
obs['active_page_index'] = obs['active_page_index'].item()
|
||||
obs['elapsed_time'] = obs['elapsed_time'].item()
|
||||
self.browser_side.send((unique_request_id, obs))
|
||||
except KeyboardInterrupt:
|
||||
logger.debug('Browser env process interrupted by user.')
|
||||
try:
|
||||
env.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
def step(self, action_str: str, timeout: float = 120) -> dict:
|
||||
"""Execute an action in the browser environment and return the observation."""
|
||||
unique_request_id = str(uuid.uuid4())
|
||||
self.agent_side.send((unique_request_id, {'action': action_str}))
|
||||
start_time = time.time()
|
||||
while True:
|
||||
if should_exit() or time.time() - start_time > timeout:
|
||||
raise TimeoutError('Browser environment took too long to respond.')
|
||||
if self.agent_side.poll(timeout=0.01):
|
||||
response_id, obs = self.agent_side.recv()
|
||||
if response_id == unique_request_id:
|
||||
return dict(obs)
|
||||
|
||||
def check_alive(self, timeout: float = 60) -> bool:
|
||||
self.agent_side.send(('IS_ALIVE', None))
|
||||
if self.agent_side.poll(timeout=timeout):
|
||||
response_id, _ = self.agent_side.recv()
|
||||
if response_id == 'ALIVE':
|
||||
return True
|
||||
logger.debug(f'Browser env is not alive. Response ID: {response_id}')
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
if not self.process.is_alive():
|
||||
return
|
||||
try:
|
||||
self.agent_side.send(('SHUTDOWN', None))
|
||||
self.process.join(5) # Wait for the process to terminate
|
||||
if self.process.is_alive():
|
||||
logger.error(
|
||||
'Browser process did not terminate, forcefully terminating...'
|
||||
)
|
||||
self.process.terminate()
|
||||
self.process.join(5) # Wait for the process to terminate
|
||||
if self.process.is_alive():
|
||||
self.process.kill()
|
||||
self.process.join(5) # Wait for the process to terminate
|
||||
self.agent_side.close()
|
||||
self.browser_side.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Encountered an error when closing browser env: {e}')
|
||||
@@ -0,0 +1,230 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import base64
|
||||
import datetime
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from browsergym.utils.obs import flatten_axtree_to_str
|
||||
from PIL import Image
|
||||
|
||||
from openhands.core.exceptions import BrowserUnavailableException
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.events.action import BrowseInteractiveAction, BrowseURLAction
|
||||
from openhands.events.observation import BrowserOutputObservation
|
||||
from openhands.runtime.browser.base64 import png_base64_url_to_image
|
||||
from openhands.runtime.browser.browser_env import BrowserEnv
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
|
||||
def get_axtree_str(
|
||||
axtree_object: dict[str, Any],
|
||||
extra_element_properties: dict[str, Any],
|
||||
filter_visible_only: bool = False,
|
||||
) -> str:
|
||||
cur_axtree_txt = flatten_axtree_to_str(
|
||||
axtree_object,
|
||||
extra_properties=extra_element_properties,
|
||||
with_clickable=True,
|
||||
skip_generic=False,
|
||||
filter_visible_only=filter_visible_only,
|
||||
)
|
||||
return str(cur_axtree_txt)
|
||||
|
||||
|
||||
def get_agent_obs_text(obs: BrowserOutputObservation) -> str:
|
||||
"""Get a concise text that will be shown to the agent."""
|
||||
if obs.trigger_by_action == ActionType.BROWSE_INTERACTIVE:
|
||||
text = f'[Current URL: {obs.url}]\n'
|
||||
text += f'[Focused element bid: {obs.focused_element_bid}]\n'
|
||||
|
||||
# Add screenshot path information if available
|
||||
if obs.screenshot_path:
|
||||
text += f'[Screenshot saved to: {obs.screenshot_path}]\n'
|
||||
|
||||
text += '\n'
|
||||
|
||||
if obs.error:
|
||||
text += (
|
||||
'================ BEGIN error message ===============\n'
|
||||
'The following error occurred when executing the last action:\n'
|
||||
f'{obs.last_browser_action_error}\n'
|
||||
'================ END error message ===============\n'
|
||||
)
|
||||
else:
|
||||
text += '[Action executed successfully.]\n'
|
||||
try:
|
||||
# We do not filter visible only here because we want to show the full content
|
||||
# of the web page to the agent for simplicity.
|
||||
# FIXME: handle the case when the web page is too large
|
||||
cur_axtree_txt = get_axtree_str(
|
||||
obs.axtree_object,
|
||||
obs.extra_element_properties,
|
||||
filter_visible_only=obs.filter_visible_only,
|
||||
)
|
||||
if not obs.filter_visible_only:
|
||||
text += (
|
||||
f'Accessibility tree of the COMPLETE webpage:\nNote: [bid] is the unique alpha-numeric identifier at the beginning of lines for each element in the AXTree. Always use bid to refer to elements in your actions.\n'
|
||||
f'============== BEGIN accessibility tree ==============\n'
|
||||
f'{cur_axtree_txt}\n'
|
||||
f'============== END accessibility tree ==============\n'
|
||||
)
|
||||
else:
|
||||
text += (
|
||||
f'Accessibility tree of the VISIBLE portion of the webpage (accessibility tree of complete webpage is too large and you may need to scroll to view remaining portion of the webpage):\nNote: [bid] is the unique alpha-numeric identifier at the beginning of lines for each element in the AXTree. Always use bid to refer to elements in your actions.\n'
|
||||
f'============== BEGIN accessibility tree ==============\n'
|
||||
f'{cur_axtree_txt}\n'
|
||||
f'============== END accessibility tree ==============\n'
|
||||
)
|
||||
except Exception as e:
|
||||
text += f'\n[Error encountered when processing the accessibility tree: {e}]'
|
||||
return text
|
||||
|
||||
elif obs.trigger_by_action == ActionType.BROWSE:
|
||||
text = f'[Current URL: {obs.url}]\n'
|
||||
|
||||
if obs.error:
|
||||
text += (
|
||||
'================ BEGIN error message ===============\n'
|
||||
'The following error occurred when trying to visit the URL:\n'
|
||||
f'{obs.last_browser_action_error}\n'
|
||||
'================ END error message ===============\n'
|
||||
)
|
||||
text += '============== BEGIN webpage content ==============\n'
|
||||
text += obs.content
|
||||
text += '\n============== END webpage content ==============\n'
|
||||
return text
|
||||
else:
|
||||
raise ValueError(f'Invalid trigger_by_action: {obs.trigger_by_action}')
|
||||
|
||||
|
||||
async def browse(
|
||||
action: BrowseURLAction | BrowseInteractiveAction,
|
||||
browser: BrowserEnv | None,
|
||||
workspace_dir: str | None = None,
|
||||
) -> BrowserOutputObservation:
|
||||
if browser is None:
|
||||
raise BrowserUnavailableException()
|
||||
|
||||
if isinstance(action, BrowseURLAction):
|
||||
# legacy BrowseURLAction
|
||||
asked_url = action.url
|
||||
if not asked_url.startswith('http'):
|
||||
asked_url = os.path.abspath(os.curdir) + action.url
|
||||
action_str = f'goto("{asked_url}")'
|
||||
|
||||
elif isinstance(action, BrowseInteractiveAction):
|
||||
# new BrowseInteractiveAction, supports full featured BrowserGym actions
|
||||
# action in BrowserGym: see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/action/functions.py
|
||||
action_str = action.browser_actions
|
||||
else:
|
||||
raise ValueError(f'Invalid action type: {action.action}')
|
||||
|
||||
try:
|
||||
# obs provided by BrowserGym: see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/env.py#L396
|
||||
obs = await call_sync_from_async(browser.step, action_str)
|
||||
|
||||
# Save screenshot if workspace_dir is provided
|
||||
screenshot_path = None
|
||||
if workspace_dir is not None and obs.get('screenshot'):
|
||||
# Create screenshots directory if it doesn't exist
|
||||
screenshots_dir = Path(workspace_dir) / '.browser_screenshots'
|
||||
screenshots_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Generate a filename based on timestamp
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')
|
||||
screenshot_filename = f'screenshot_{timestamp}.png'
|
||||
screenshot_path = str(screenshots_dir / screenshot_filename)
|
||||
|
||||
# Direct image saving from base64 data without using PIL's Image.open
|
||||
# This approach bypasses potential encoding issues that might occur when
|
||||
# converting between different image representations, ensuring the raw PNG
|
||||
# data from the browser is saved directly to disk.
|
||||
|
||||
# Extract the base64 data
|
||||
base64_data = obs.get('screenshot', '')
|
||||
if ',' in base64_data:
|
||||
base64_data = base64_data.split(',')[1]
|
||||
|
||||
try:
|
||||
# Decode base64 directly to binary
|
||||
image_data = base64.b64decode(base64_data)
|
||||
|
||||
# Write binary data directly to file
|
||||
with open(screenshot_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
# Verify the image was saved correctly by opening it
|
||||
# This is just a verification step and can be removed in production
|
||||
Image.open(screenshot_path).verify()
|
||||
except Exception:
|
||||
# If direct saving fails, fall back to the original method
|
||||
image = png_base64_url_to_image(obs.get('screenshot'))
|
||||
image.save(screenshot_path, format='PNG', optimize=True)
|
||||
|
||||
# Create the observation with all data
|
||||
observation = BrowserOutputObservation(
|
||||
content=obs['text_content'], # text content of the page
|
||||
url=obs.get('url', ''), # URL of the page
|
||||
screenshot=obs.get('screenshot', None), # base64-encoded screenshot, png
|
||||
screenshot_path=screenshot_path, # path to saved screenshot file
|
||||
set_of_marks=obs.get(
|
||||
'set_of_marks', None
|
||||
), # base64-encoded Set-of-Marks annotated screenshot, png,
|
||||
goal_image_urls=obs.get('image_content', []),
|
||||
open_pages_urls=obs.get('open_pages_urls', []), # list of open pages
|
||||
active_page_index=obs.get(
|
||||
'active_page_index', -1
|
||||
), # index of the active page
|
||||
axtree_object=obs.get('axtree_object', {}), # accessibility tree object
|
||||
extra_element_properties=obs.get('extra_element_properties', {}),
|
||||
focused_element_bid=obs.get(
|
||||
'focused_element_bid', None
|
||||
), # focused element bid
|
||||
last_browser_action=obs.get(
|
||||
'last_action', ''
|
||||
), # last browser env action performed
|
||||
last_browser_action_error=obs.get('last_action_error', ''),
|
||||
error=True if obs.get('last_action_error', '') else False, # error flag
|
||||
trigger_by_action=action.action,
|
||||
)
|
||||
|
||||
# Process the content first using the axtree_object
|
||||
observation.content = get_agent_obs_text(observation)
|
||||
|
||||
# If return_axtree is False, remove the axtree_object to save space
|
||||
if not action.return_axtree:
|
||||
observation.dom_object = {}
|
||||
observation.axtree_object = {}
|
||||
observation.extra_element_properties = {}
|
||||
|
||||
return observation
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
error_url = asked_url if action.action == ActionType.BROWSE else ''
|
||||
|
||||
# Create error observation
|
||||
observation = BrowserOutputObservation(
|
||||
content=error_message,
|
||||
screenshot='',
|
||||
screenshot_path=None,
|
||||
error=True,
|
||||
last_browser_action_error=error_message,
|
||||
url=error_url,
|
||||
trigger_by_action=action.action,
|
||||
)
|
||||
|
||||
# Process the content using get_agent_obs_text regardless of return_axtree value
|
||||
try:
|
||||
observation.content = get_agent_obs_text(observation)
|
||||
except Exception:
|
||||
# If get_agent_obs_text fails, keep the original error message
|
||||
pass
|
||||
|
||||
return observation
|
||||
@@ -0,0 +1,11 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from openhands.runtime.builder.base import RuntimeBuilder
|
||||
from openhands.runtime.builder.docker import DockerRuntimeBuilder
|
||||
|
||||
__all__ = ['RuntimeBuilder', 'DockerRuntimeBuilder']
|
||||
@@ -0,0 +1,49 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import abc
|
||||
|
||||
|
||||
class RuntimeBuilder(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def build(
|
||||
self,
|
||||
path: str,
|
||||
tags: list[str],
|
||||
platform: str | None = None,
|
||||
extra_build_args: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Build the runtime image.
|
||||
|
||||
Args:
|
||||
path (str): The path to the runtime image's build directory.
|
||||
tags (list[str]): The tags to apply to the runtime image (e.g., ["repo:my-repo", "sha:my-sha"]).
|
||||
platform (str, optional): The target platform for the build. Defaults to None.
|
||||
extra_build_args (list[str], optional): Additional build arguments to pass to the builder. Defaults to None.
|
||||
|
||||
Returns:
|
||||
str: The name:tag of the runtime image after build (e.g., "repo:sha").
|
||||
This can be different from the tags input if the builder chooses to mutate the tags (e.g., adding a
|
||||
registry prefix). This should be used for subsequent use (e.g., `docker run`).
|
||||
|
||||
Raises:
|
||||
AgentRuntimeBuildError: If the build failed.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
|
||||
"""Check if the runtime image exists.
|
||||
|
||||
Args:
|
||||
image_name (str): The name of the runtime image (e.g., "repo:sha").
|
||||
pull_from_repo (bool): Whether to pull from the remote repo if the image not present locally
|
||||
|
||||
Returns:
|
||||
bool: Whether the runtime image exists.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,434 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import datetime
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import docker
|
||||
|
||||
from openhands.core.exceptions import AgentRuntimeBuildError
|
||||
from openhands.core.logger import RollingLogger
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.builder.base import RuntimeBuilder
|
||||
from openhands.utils.term_color import TermColor, colorize
|
||||
from openhands.version import get_version
|
||||
|
||||
|
||||
class DockerRuntimeBuilder(RuntimeBuilder):
|
||||
def __init__(self, docker_client: docker.DockerClient):
|
||||
self.docker_client = docker_client
|
||||
|
||||
version_info = self.docker_client.version()
|
||||
server_version = version_info.get('Version', '').replace('-', '.')
|
||||
components = version_info.get('Components')
|
||||
self.is_podman = (
|
||||
components is not None
|
||||
and len(components) > 0
|
||||
and components[0].get('Name', '').startswith('Podman')
|
||||
)
|
||||
if (
|
||||
tuple(map(int, server_version.split('.')[:2])) < (18, 9)
|
||||
and not self.is_podman
|
||||
):
|
||||
raise AgentRuntimeBuildError(
|
||||
'Docker server version must be >= 18.09 to use BuildKit'
|
||||
)
|
||||
|
||||
if self.is_podman and tuple(map(int, server_version.split('.')[:2])) < (4, 9):
|
||||
raise AgentRuntimeBuildError('Podman server version must be >= 4.9.0')
|
||||
|
||||
self.rolling_logger = RollingLogger(max_lines=10)
|
||||
|
||||
@staticmethod
|
||||
def check_buildx(is_podman: bool = False) -> bool:
|
||||
"""Check if Docker Buildx is available."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['docker' if not is_podman else 'podman', 'buildx', 'version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def build(
|
||||
self,
|
||||
path: str,
|
||||
tags: list[str],
|
||||
platform: str | None = None,
|
||||
extra_build_args: list[str] | None = None,
|
||||
use_local_cache: bool = False,
|
||||
) -> str:
|
||||
"""Builds a Docker image using BuildKit and handles the build logs appropriately.
|
||||
|
||||
Args:
|
||||
path (str): The path to the Docker build context.
|
||||
tags (list[str]): A list of image tags to apply to the built image.
|
||||
platform (str, optional): The target platform for the build. Defaults to None.
|
||||
use_local_cache (bool, optional): Whether to use and update the local build cache. Defaults to True.
|
||||
extra_build_args (list[str], optional): Additional arguments to pass to the Docker build command. Defaults to None.
|
||||
|
||||
Returns:
|
||||
str: The name of the built Docker image.
|
||||
|
||||
Raises:
|
||||
AgentRuntimeBuildError: If the Docker server version is incompatible or if the build process fails.
|
||||
|
||||
Note:
|
||||
This method uses Docker BuildKit for improved build performance and caching capabilities.
|
||||
If `use_local_cache` is True, it will attempt to use and update the build cache in a local directory.
|
||||
The `extra_build_args` parameter allows for passing additional Docker build arguments as needed.
|
||||
"""
|
||||
self.docker_client = docker.from_env()
|
||||
version_info = self.docker_client.version()
|
||||
server_version = version_info.get('Version', '').split('+')[0].replace('-', '.')
|
||||
components = version_info.get('Components')
|
||||
self.is_podman = (
|
||||
components is not None
|
||||
and len(components) > 0
|
||||
and components[0].get('Name', '').startswith('Podman')
|
||||
)
|
||||
if tuple(map(int, server_version.split('.'))) < (18, 9) and not self.is_podman:
|
||||
raise AgentRuntimeBuildError(
|
||||
'Docker server version must be >= 18.09 to use BuildKit'
|
||||
)
|
||||
|
||||
if self.is_podman and tuple(map(int, server_version.split('.'))) < (4, 9):
|
||||
raise AgentRuntimeBuildError('Podman server version must be >= 4.9.0')
|
||||
|
||||
if not DockerRuntimeBuilder.check_buildx(self.is_podman):
|
||||
# when running openhands in a container, there might not be a "docker"
|
||||
# binary available, in which case we need to download docker binary.
|
||||
# since the official openhands app image is built from debian, we use
|
||||
# debian way to install docker binary
|
||||
logger.info(
|
||||
'No docker binary available inside openhands-app container, trying to download online...'
|
||||
)
|
||||
commands = [
|
||||
'apt-get update',
|
||||
'apt-get install -y ca-certificates curl gnupg',
|
||||
'install -m 0755 -d /etc/apt/keyrings',
|
||||
'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc',
|
||||
'chmod a+r /etc/apt/keyrings/docker.asc',
|
||||
'echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||
tee /etc/apt/sources.list.d/docker.list > /dev/null',
|
||||
'apt-get update',
|
||||
'apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin',
|
||||
]
|
||||
for cmd in commands:
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd, shell=True, check=True, stdout=subprocess.DEVNULL
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f'Image build failed:\n{e}')
|
||||
logger.error(f'Command output:\n{e.output}')
|
||||
raise
|
||||
logger.info('Downloaded and installed docker binary')
|
||||
|
||||
target_image_hash_name = tags[0]
|
||||
target_image_repo, target_image_source_tag = target_image_hash_name.split(':')
|
||||
target_image_tag = tags[1].split(':')[1] if len(tags) > 1 else None
|
||||
|
||||
buildx_cmd = [
|
||||
'docker' if not self.is_podman else 'podman',
|
||||
'buildx',
|
||||
'build',
|
||||
'--progress=plain',
|
||||
f'--build-arg=OPENHANDS_RUNTIME_VERSION={get_version()}',
|
||||
f'--build-arg=OPENHANDS_RUNTIME_BUILD_TIME={datetime.datetime.now().isoformat()}',
|
||||
f'--tag={target_image_hash_name}',
|
||||
'--load',
|
||||
]
|
||||
|
||||
# Include the platform argument only if platform is specified
|
||||
if platform:
|
||||
buildx_cmd.append(f'--platform={platform}')
|
||||
|
||||
cache_dir = '/tmp/.buildx-cache'
|
||||
if use_local_cache and self._is_cache_usable(cache_dir):
|
||||
buildx_cmd.extend(
|
||||
[
|
||||
f'--cache-from=type=local,src={cache_dir}',
|
||||
f'--cache-to=type=local,dest={cache_dir},mode=max',
|
||||
]
|
||||
)
|
||||
|
||||
if extra_build_args:
|
||||
buildx_cmd.extend(extra_build_args)
|
||||
|
||||
buildx_cmd.append(path) # must be last!
|
||||
|
||||
self.rolling_logger.start(
|
||||
f'================ {buildx_cmd[0].upper()} BUILD STARTED ================'
|
||||
)
|
||||
|
||||
builder_cmd = ['docker', 'buildx', 'use', 'default']
|
||||
subprocess.Popen(
|
||||
builder_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
buildx_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
universal_newlines=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
output_lines = []
|
||||
if process.stdout:
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
line = line.strip()
|
||||
if line:
|
||||
output_lines.append(line) # Store all output lines
|
||||
self._output_logs(line)
|
||||
|
||||
return_code = process.wait()
|
||||
|
||||
if return_code != 0:
|
||||
# Use the collected output for error reporting
|
||||
output_str = '\n'.join(output_lines)
|
||||
raise subprocess.CalledProcessError(
|
||||
return_code,
|
||||
process.args,
|
||||
output=output_str, # Use the collected output
|
||||
stderr=None,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f'Image build failed with exit code {e.returncode}')
|
||||
if e.output:
|
||||
logger.error(f'Command output:\n{e.output}')
|
||||
elif self.rolling_logger.is_enabled() and self.rolling_logger.all_lines:
|
||||
logger.error(f'Docker build output:\n{self.rolling_logger.all_lines}')
|
||||
raise
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error('Image build timed out')
|
||||
raise
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f'Python executable not found: {e}')
|
||||
raise
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error(
|
||||
f'Permission denied when trying to execute the build command:\n{e}'
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'An unexpected error occurred during the build process: {e}')
|
||||
raise
|
||||
|
||||
logger.info(f'Image [{target_image_hash_name}] build finished.')
|
||||
|
||||
if target_image_tag:
|
||||
image = self.docker_client.images.get(target_image_hash_name)
|
||||
image.tag(target_image_repo, target_image_tag)
|
||||
logger.info(
|
||||
f'Re-tagged image [{target_image_hash_name}] with more generic tag [{target_image_tag}]'
|
||||
)
|
||||
|
||||
# Check if the image is built successfully
|
||||
image = self.docker_client.images.get(target_image_hash_name)
|
||||
if image is None:
|
||||
raise AgentRuntimeBuildError(
|
||||
f'Build failed: Image {target_image_hash_name} not found'
|
||||
)
|
||||
|
||||
tags_str = (
|
||||
f'{target_image_source_tag}, {target_image_tag}'
|
||||
if target_image_tag
|
||||
else target_image_source_tag
|
||||
)
|
||||
logger.info(
|
||||
f'Image {target_image_repo} with tags [{tags_str}] built successfully'
|
||||
)
|
||||
return target_image_hash_name
|
||||
|
||||
def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
|
||||
"""Check if the image exists in the registry (try to pull it first) or in the local store.
|
||||
|
||||
Args:
|
||||
image_name (str): The Docker image to check (<image repo>:<image tag>)
|
||||
pull_from_repo (bool): Whether to pull from the remote repo if the image not present locally
|
||||
Returns:
|
||||
bool: Whether the Docker image exists in the registry or in the local store
|
||||
"""
|
||||
if not image_name:
|
||||
logger.error(f'Invalid image name: `{image_name}`')
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.debug(f'Checking, if image exists locally:\n{image_name}')
|
||||
self.docker_client.images.get(image_name)
|
||||
logger.debug('Image found locally.')
|
||||
return True
|
||||
except docker.errors.ImageNotFound:
|
||||
if not pull_from_repo:
|
||||
logger.debug(
|
||||
f'Image {image_name} {colorize("not found", TermColor.WARNING)} locally'
|
||||
)
|
||||
return False
|
||||
try:
|
||||
logger.debug(
|
||||
'Image not found locally. Trying to pull it, please wait...'
|
||||
)
|
||||
|
||||
layers: dict[str, dict[str, str]] = {}
|
||||
previous_layer_count = 0
|
||||
|
||||
if ':' in image_name:
|
||||
image_repo, image_tag = image_name.split(':', 1)
|
||||
else:
|
||||
image_repo = image_name
|
||||
image_tag = None
|
||||
|
||||
for line in self.docker_client.api.pull(
|
||||
image_repo, tag=image_tag, stream=True, decode=True
|
||||
):
|
||||
self._output_build_progress(line, layers, previous_layer_count)
|
||||
previous_layer_count = len(layers)
|
||||
logger.debug('Image pulled')
|
||||
return True
|
||||
except docker.errors.ImageNotFound:
|
||||
logger.debug('Could not find image locally or in registry.')
|
||||
return False
|
||||
except Exception as e:
|
||||
msg = f'Image {colorize("could not be pulled", TermColor.ERROR)}: '
|
||||
ex_msg = str(e)
|
||||
if 'Not Found' in ex_msg:
|
||||
msg += 'image not found in registry.'
|
||||
else:
|
||||
msg += f'{ex_msg}'
|
||||
logger.debug(msg)
|
||||
return False
|
||||
|
||||
def _output_logs(self, new_line: str) -> None:
|
||||
if not self.rolling_logger.is_enabled():
|
||||
logger.debug(new_line)
|
||||
else:
|
||||
self.rolling_logger.add_line(new_line)
|
||||
|
||||
def _output_build_progress(
|
||||
self, current_line: dict, layers: dict, previous_layer_count: int
|
||||
) -> None:
|
||||
if 'id' in current_line and 'progressDetail' in current_line:
|
||||
layer_id = current_line['id']
|
||||
if layer_id not in layers:
|
||||
layers[layer_id] = {'status': '', 'progress': '', 'last_logged': 0}
|
||||
|
||||
if 'status' in current_line:
|
||||
layers[layer_id]['status'] = current_line['status']
|
||||
|
||||
if 'progress' in current_line:
|
||||
layers[layer_id]['progress'] = current_line['progress']
|
||||
|
||||
if 'progressDetail' in current_line:
|
||||
progress_detail = current_line['progressDetail']
|
||||
if 'total' in progress_detail and 'current' in progress_detail:
|
||||
total = progress_detail['total']
|
||||
current = progress_detail['current']
|
||||
percentage = min(
|
||||
(current / total) * 100, 100
|
||||
) # Ensure it doesn't exceed 100%
|
||||
else:
|
||||
percentage = (
|
||||
100 if layers[layer_id]['status'] == 'Download complete' else 0
|
||||
)
|
||||
|
||||
if self.rolling_logger.is_enabled():
|
||||
self.rolling_logger.move_back(previous_layer_count)
|
||||
for lid, layer_data in sorted(layers.items()):
|
||||
self.rolling_logger.replace_current_line()
|
||||
status = layer_data['status']
|
||||
progress = layer_data['progress']
|
||||
if status == 'Download complete':
|
||||
self.rolling_logger.write_immediately(
|
||||
f'Layer {lid}: Download complete'
|
||||
)
|
||||
elif status == 'Already exists':
|
||||
self.rolling_logger.write_immediately(
|
||||
f'Layer {lid}: Already exists'
|
||||
)
|
||||
else:
|
||||
self.rolling_logger.write_immediately(
|
||||
f'Layer {lid}: {progress} {status}'
|
||||
)
|
||||
elif percentage != 0 and (
|
||||
percentage - layers[layer_id]['last_logged'] >= 10 or percentage == 100
|
||||
):
|
||||
logger.debug(
|
||||
f'Layer {layer_id}: {layers[layer_id]["progress"]} {layers[layer_id]["status"]}'
|
||||
)
|
||||
|
||||
layers[layer_id]['last_logged'] = percentage
|
||||
elif 'status' in current_line:
|
||||
logger.debug(current_line['status'])
|
||||
|
||||
def _prune_old_cache_files(self, cache_dir: str, max_age_days: int = 7) -> None:
|
||||
"""Prune cache files older than the specified number of days.
|
||||
|
||||
Args:
|
||||
cache_dir (str): The path to the cache directory.
|
||||
max_age_days (int): The maximum age of cache files in days.
|
||||
"""
|
||||
try:
|
||||
current_time = time.time()
|
||||
max_age_seconds = max_age_days * 24 * 60 * 60
|
||||
|
||||
for root, _, files in os.walk(cache_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
file_age = current_time - os.path.getmtime(file_path)
|
||||
if file_age > max_age_seconds:
|
||||
os.remove(file_path)
|
||||
logger.debug(f'Removed old cache file: {file_path}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Error processing cache file {file_path}: {e}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Error during build cache pruning: {e}')
|
||||
|
||||
def _is_cache_usable(self, cache_dir: str) -> bool:
|
||||
"""Check if the cache directory is usable (exists and is writable).
|
||||
|
||||
Args:
|
||||
cache_dir (str): The path to the cache directory.
|
||||
|
||||
Returns:
|
||||
bool: True if the cache directory is usable, False otherwise.
|
||||
"""
|
||||
if not os.path.exists(cache_dir):
|
||||
try:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
logger.debug(f'Created cache directory: {cache_dir}')
|
||||
except OSError as e:
|
||||
logger.debug(f'Failed to create cache directory {cache_dir}: {e}')
|
||||
return False
|
||||
|
||||
if not os.access(cache_dir, os.W_OK):
|
||||
logger.warning(
|
||||
f'Cache directory {cache_dir} is not writable. Caches will not be used for Docker builds.'
|
||||
)
|
||||
return False
|
||||
|
||||
self._prune_old_cache_files(cache_dir)
|
||||
|
||||
logger.debug(f'Cache directory {cache_dir} is usable')
|
||||
return True
|
||||
@@ -0,0 +1,156 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import base64
|
||||
import io
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.core.exceptions import AgentRuntimeBuildError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.builder import RuntimeBuilder
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.utils.http_session import HttpSession
|
||||
from openhands.utils.shutdown_listener import (
|
||||
should_continue,
|
||||
sleep_if_should_continue,
|
||||
)
|
||||
|
||||
|
||||
class RemoteRuntimeBuilder(RuntimeBuilder):
|
||||
"""This class interacts with the remote Runtime API for building and managing container images."""
|
||||
|
||||
def __init__(self, api_url: str, api_key: str, session: HttpSession | None = None):
|
||||
self.api_url = api_url
|
||||
self.api_key = api_key
|
||||
self.session = session or HttpSession()
|
||||
self.session.headers.update({'X-API-Key': self.api_key})
|
||||
|
||||
def build(
|
||||
self,
|
||||
path: str,
|
||||
tags: list[str],
|
||||
platform: str | None = None,
|
||||
extra_build_args: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Builds a Docker image using the Runtime API's /build endpoint."""
|
||||
# Create a tar archive of the build context
|
||||
tar_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar:
|
||||
tar.add(path, arcname='.')
|
||||
tar_buffer.seek(0)
|
||||
|
||||
# Encode the tar file as base64
|
||||
base64_encoded_tar = base64.b64encode(tar_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
# Prepare the multipart form data
|
||||
files = [
|
||||
('context', ('context.tar.gz', base64_encoded_tar)),
|
||||
('target_image', (None, tags[0])),
|
||||
]
|
||||
|
||||
# Add additional tags if present
|
||||
for tag in tags[1:]:
|
||||
files.append(('tags', (None, tag)))
|
||||
|
||||
# Send the POST request to /build (Begins the build process)
|
||||
try:
|
||||
response = send_request(
|
||||
self.session,
|
||||
'POST',
|
||||
f'{self.api_url}/build',
|
||||
files=files,
|
||||
timeout=30,
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response and e.response.status_code == 429:
|
||||
logger.warning('Build was rate limited. Retrying in 30 seconds.')
|
||||
time.sleep(30)
|
||||
return self.build(path, tags, platform)
|
||||
else:
|
||||
raise e
|
||||
|
||||
build_data = response.json()
|
||||
build_id = build_data['build_id']
|
||||
logger.info(f'Build initiated with ID: {build_id}')
|
||||
|
||||
# Poll /build_status until the build is complete
|
||||
start_time = time.time()
|
||||
timeout = 30 * 60 # 20 minutes in seconds
|
||||
while should_continue():
|
||||
if time.time() - start_time > timeout:
|
||||
logger.error('Build timed out after 30 minutes')
|
||||
raise AgentRuntimeBuildError('Build timed out after 30 minutes')
|
||||
|
||||
status_response = send_request(
|
||||
self.session,
|
||||
'GET',
|
||||
f'{self.api_url}/build_status',
|
||||
params={'build_id': build_id},
|
||||
)
|
||||
|
||||
if status_response.status_code != 200:
|
||||
logger.error(f'Failed to get build status: {status_response.text}')
|
||||
raise AgentRuntimeBuildError(
|
||||
f'Failed to get build status: {status_response.text}'
|
||||
)
|
||||
|
||||
status_data = status_response.json()
|
||||
status = status_data['status']
|
||||
logger.info(f'Build status: {status}')
|
||||
|
||||
if status == 'SUCCESS':
|
||||
logger.debug(f'Successfully built {status_data["image"]}')
|
||||
return str(status_data['image'])
|
||||
elif status in [
|
||||
'FAILURE',
|
||||
'INTERNAL_ERROR',
|
||||
'TIMEOUT',
|
||||
'CANCELLED',
|
||||
'EXPIRED',
|
||||
]:
|
||||
error_message = status_data.get(
|
||||
'error', f'Build failed with status: {status}. Build ID: {build_id}'
|
||||
)
|
||||
logger.error(error_message)
|
||||
raise AgentRuntimeBuildError(error_message)
|
||||
|
||||
# Wait before polling again
|
||||
sleep_if_should_continue(30)
|
||||
|
||||
raise AgentRuntimeBuildError('Build interrupted')
|
||||
|
||||
def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
|
||||
"""Checks if an image exists in the remote registry using the /image_exists endpoint."""
|
||||
params = {'image': image_name}
|
||||
response = send_request(
|
||||
self.session,
|
||||
'GET',
|
||||
f'{self.api_url}/image_exists',
|
||||
params=params,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f'Failed to check image existence: {response.text}')
|
||||
raise AgentRuntimeBuildError(
|
||||
f'Failed to check image existence: {response.text}'
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
|
||||
if result['exists']:
|
||||
logger.debug(
|
||||
f'Image {image_name} exists. '
|
||||
f'Uploaded at: {result["image"]["upload_time"]}, '
|
||||
f'Size: {result["image"]["image_size_bytes"] / 1024 / 1024:.2f} MB'
|
||||
)
|
||||
else:
|
||||
logger.debug(f'Image {image_name} does not exist.')
|
||||
|
||||
return bool(result['exists'])
|
||||
@@ -0,0 +1,119 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""A tiny, isolated server that provides only the /view endpoint from the action execution server.
|
||||
This server has no authentication and only listens to localhost traffic.
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from uvicorn import Config, Server
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.utils.file_viewer import generate_file_viewer_html
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create the FastAPI application."""
|
||||
app = FastAPI(
|
||||
title='File Viewer Server', openapi_url=None, docs_url=None, redoc_url=None
|
||||
)
|
||||
|
||||
@app.get('/')
|
||||
async def root() -> dict[str, str]:
|
||||
"""Root endpoint to check if the server is running."""
|
||||
return {'status': 'File viewer server is running'}
|
||||
|
||||
@app.get('/view')
|
||||
async def view_file(path: str, request: Request) -> HTMLResponse:
|
||||
"""View a file using an embedded viewer.
|
||||
|
||||
Args:
|
||||
path (str): The absolute path of the file to view.
|
||||
request (Request): The FastAPI request object.
|
||||
|
||||
Returns:
|
||||
HTMLResponse: An HTML page with an appropriate viewer for the file.
|
||||
"""
|
||||
# Security check: Only allow requests from localhost
|
||||
client_host = request.client.host if request.client else None
|
||||
if client_host not in ['127.0.0.1', 'localhost', '::1']:
|
||||
return HTMLResponse(
|
||||
content='<h1>Access Denied</h1><p>This endpoint is only accessible from localhost</p>',
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
if not os.path.isabs(path):
|
||||
return HTMLResponse(
|
||||
content=f'<h1>Error: Path must be absolute</h1><p>{path}</p>',
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not os.path.exists(path):
|
||||
return HTMLResponse(
|
||||
content=f'<h1>Error: File not found</h1><p>{path}</p>', status_code=404
|
||||
)
|
||||
|
||||
if os.path.isdir(path):
|
||||
return HTMLResponse(
|
||||
content=f'<h1>Error: Path is a directory</h1><p>{path}</p>',
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
html_content = generate_file_viewer_html(path)
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
except Exception as e:
|
||||
return HTMLResponse(
|
||||
content=f'<h1>Error viewing file</h1><p>{path}</p><p>{str(e)}</p>',
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def start_file_viewer_server(port: int) -> tuple[str, threading.Thread]:
|
||||
"""Start the file viewer server on the specified port or find an available one.
|
||||
|
||||
Args:
|
||||
port (int, optional): The port to bind to. If None, an available port will be found.
|
||||
|
||||
Returns:
|
||||
Tuple[str, threading.Thread]: The server URL and the thread object.
|
||||
"""
|
||||
# Save the server URL to a file
|
||||
server_url = f'http://localhost:{port}'
|
||||
port_path = '/tmp/oh-server-url'
|
||||
os.makedirs(os.path.dirname(port_path), exist_ok=True)
|
||||
with open(port_path, 'w') as f:
|
||||
f.write(server_url)
|
||||
|
||||
logger.info(f'File viewer server URL saved to /tmp/oh-server-url: {server_url}')
|
||||
logger.info(f'Starting file viewer server on port {port}')
|
||||
|
||||
app = create_app()
|
||||
config = Config(app=app, host='127.0.0.1', port=port, log_level='error')
|
||||
server = Server(config=config)
|
||||
|
||||
# Run the server in a new thread
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return server_url, thread
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
url, thread = start_file_viewer_server(port=8000)
|
||||
# Keep the main thread running
|
||||
try:
|
||||
thread.join()
|
||||
except KeyboardInterrupt:
|
||||
logger.info('Server stopped')
|
||||
@@ -0,0 +1,24 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""Runtime implementations for OpenHands."""
|
||||
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.impl.cli import CLIRuntime
|
||||
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
|
||||
from openhands.runtime.impl.local.local_runtime import LocalRuntime
|
||||
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
|
||||
|
||||
__all__ = [
|
||||
'ActionExecutionClient',
|
||||
'CLIRuntime',
|
||||
'DockerRuntime',
|
||||
'LocalRuntime',
|
||||
'RemoteRuntime',
|
||||
]
|
||||
@@ -0,0 +1,489 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||
|
||||
from openhands.app_server.status.system_stats import update_last_execution_time
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.config.mcp_config import (
|
||||
MCPConfig,
|
||||
RemoteMCPServer,
|
||||
StdioMCPServer,
|
||||
)
|
||||
from openhands.core.exceptions import (
|
||||
AgentRuntimeTimeoutError,
|
||||
)
|
||||
from openhands.events import EventStream
|
||||
from openhands.events.action import (
|
||||
ActionConfirmationStatus,
|
||||
AgentThinkAction,
|
||||
BrowseInteractiveAction,
|
||||
BrowseURLAction,
|
||||
CmdRunAction,
|
||||
FileEditAction,
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
IPythonRunCellAction,
|
||||
)
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.files import FileEditSource
|
||||
from openhands.events.action.mcp import MCPAction
|
||||
from openhands.events.observation import (
|
||||
AgentThinkObservation,
|
||||
ErrorObservation,
|
||||
NullObservation,
|
||||
Observation,
|
||||
UserRejectObservation,
|
||||
)
|
||||
from openhands.events.serialization import event_to_dict, observation_from_dict
|
||||
from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.utils.http_session import HttpSession
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
|
||||
def _is_retryable_error(exception):
|
||||
return isinstance(
|
||||
exception, (httpx.RemoteProtocolError, httpcore.RemoteProtocolError)
|
||||
)
|
||||
|
||||
|
||||
class ActionExecutionClient(Runtime):
|
||||
"""Base class for runtimes that interact with the action execution server.
|
||||
|
||||
This class contains shared logic between DockerRuntime and RemoteRuntime
|
||||
for interacting with the HTTP server defined in action_execution_server.py.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
llm_registry: LLMRegistry,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Any | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
self.session = HttpSession()
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
self._runtime_closed: bool = False
|
||||
self._vscode_token: str | None = None # initial dummy value
|
||||
self._last_updated_mcp_stdio_servers: dict[str, StdioMCPServer] = {}
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
llm_registry,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self) -> str:
|
||||
raise NotImplementedError('Action execution server URL is not implemented')
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception(_is_retryable_error),
|
||||
stop=stop_after_attempt(5) | stop_if_should_exit(),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=15),
|
||||
)
|
||||
def _send_action_server_request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Send a request to the action execution server.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
url: URL to send the request to
|
||||
**kwargs: Additional arguments to pass to requests.request()
|
||||
|
||||
Returns:
|
||||
Response from the server
|
||||
|
||||
Raises:
|
||||
AgentRuntimeError: If the request fails
|
||||
"""
|
||||
return send_request(self.session, method, url, **kwargs)
|
||||
|
||||
def check_if_alive(self) -> None:
|
||||
request_url = f'{self.action_execution_server_url}/alive'
|
||||
self.log('debug', f'Sending request to: {request_url}')
|
||||
response = self._send_action_server_request(
|
||||
'GET',
|
||||
request_url,
|
||||
timeout=5,
|
||||
)
|
||||
self.log('debug', f'Response status code: {response.status_code}')
|
||||
self.log('debug', f'Response text: {response.text}')
|
||||
assert response.is_closed
|
||||
|
||||
def list_files(self, path: str | None = None) -> list[str]:
|
||||
"""List files in the sandbox.
|
||||
|
||||
If path is None, list files in the sandbox's initial working directory (e.g., /workspace).
|
||||
"""
|
||||
try:
|
||||
data = {}
|
||||
if path is not None:
|
||||
data['path'] = path
|
||||
|
||||
response = self._send_action_server_request(
|
||||
'POST',
|
||||
f'{self.action_execution_server_url}/list_files',
|
||||
json=data,
|
||||
timeout=10,
|
||||
)
|
||||
assert response.is_closed
|
||||
response_json = response.json()
|
||||
assert isinstance(response_json, list)
|
||||
return response_json
|
||||
except httpx.TimeoutException:
|
||||
raise TimeoutError('List files operation timed out')
|
||||
|
||||
def copy_from(self, path: str) -> Path:
|
||||
"""Zip all files in the sandbox and return as a stream of bytes."""
|
||||
try:
|
||||
params = {'path': path}
|
||||
with self.session.stream(
|
||||
'GET',
|
||||
f'{self.action_execution_server_url}/download_files',
|
||||
params=params,
|
||||
timeout=30,
|
||||
) as response:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix='.zip', delete=False
|
||||
) as temp_file:
|
||||
for chunk in response.iter_bytes():
|
||||
temp_file.write(chunk)
|
||||
temp_file.flush()
|
||||
return Path(temp_file.name)
|
||||
except httpx.TimeoutException:
|
||||
raise TimeoutError('Copy operation timed out')
|
||||
|
||||
def copy_to(
|
||||
self, host_src: str, sandbox_dest: str, recursive: bool = False
|
||||
) -> None:
|
||||
if not os.path.exists(host_src):
|
||||
raise FileNotFoundError(f'Source file {host_src} does not exist')
|
||||
|
||||
temp_zip_path: str | None = None # Define temp_zip_path outside the try block
|
||||
|
||||
try:
|
||||
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
|
||||
file_to_upload = None
|
||||
upload_data = {}
|
||||
|
||||
if recursive:
|
||||
# Create and write the zip file inside the try block
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix='.zip', delete=False
|
||||
) as temp_zip:
|
||||
temp_zip_path = temp_zip.name
|
||||
|
||||
try:
|
||||
with ZipFile(temp_zip_path, 'w') as zipf:
|
||||
for root, _, files in os.walk(host_src):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(
|
||||
file_path, os.path.dirname(host_src)
|
||||
)
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'Opening temporary zip file for upload: {temp_zip_path}',
|
||||
)
|
||||
file_to_upload = open(temp_zip_path, 'rb')
|
||||
upload_data = {'file': file_to_upload}
|
||||
except Exception as e:
|
||||
# Ensure temp file is cleaned up if zipping fails
|
||||
if temp_zip_path and os.path.exists(temp_zip_path):
|
||||
os.unlink(temp_zip_path)
|
||||
raise e # Re-raise the exception after cleanup attempt
|
||||
else:
|
||||
file_to_upload = open(host_src, 'rb')
|
||||
upload_data = {'file': file_to_upload}
|
||||
|
||||
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
|
||||
|
||||
response = self._send_action_server_request(
|
||||
'POST',
|
||||
f'{self.action_execution_server_url}/upload_file',
|
||||
files=upload_data,
|
||||
params=params,
|
||||
timeout=300,
|
||||
)
|
||||
self.log(
|
||||
'debug',
|
||||
f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}. Response: {response.text}',
|
||||
)
|
||||
finally:
|
||||
if file_to_upload:
|
||||
file_to_upload.close()
|
||||
|
||||
# Cleanup the temporary zip file if it was created
|
||||
if temp_zip_path and os.path.exists(temp_zip_path):
|
||||
try:
|
||||
os.unlink(temp_zip_path)
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Failed to delete temporary zip file {temp_zip_path}: {e}',
|
||||
)
|
||||
|
||||
def get_vscode_token(self) -> str:
|
||||
if self.vscode_enabled and self.runtime_initialized:
|
||||
if self._vscode_token is not None: # cached value
|
||||
return self._vscode_token
|
||||
response = self._send_action_server_request(
|
||||
'GET',
|
||||
f'{self.action_execution_server_url}/vscode/connection_token',
|
||||
timeout=10,
|
||||
)
|
||||
response_json = response.json()
|
||||
assert isinstance(response_json, dict)
|
||||
if response_json['token'] is None:
|
||||
return ''
|
||||
self._vscode_token = response_json['token']
|
||||
return response_json['token']
|
||||
else:
|
||||
return ''
|
||||
|
||||
def send_action_for_execution(self, action: Action) -> Observation:
|
||||
if (
|
||||
isinstance(action, FileEditAction)
|
||||
and action.impl_source == FileEditSource.LLM_BASED_EDIT
|
||||
):
|
||||
return self.llm_based_edit(action)
|
||||
|
||||
# set timeout to default if not set
|
||||
if action.timeout is None:
|
||||
if isinstance(action, CmdRunAction) and action.blocking:
|
||||
raise RuntimeError('Blocking command with no timeout set')
|
||||
# We don't block the command if this is a default timeout action
|
||||
action.set_hard_timeout(self.config.sandbox.timeout, blocking=False)
|
||||
|
||||
with self.action_semaphore:
|
||||
if not action.runnable:
|
||||
if isinstance(action, AgentThinkAction):
|
||||
return AgentThinkObservation('Your thought has been logged.')
|
||||
return NullObservation('')
|
||||
if (
|
||||
hasattr(action, 'confirmation_state')
|
||||
and action.confirmation_state
|
||||
== ActionConfirmationStatus.AWAITING_CONFIRMATION
|
||||
):
|
||||
return NullObservation('')
|
||||
action_type = action.action # type: ignore[attr-defined]
|
||||
if action_type not in ACTION_TYPE_TO_CLASS:
|
||||
raise ValueError(f'Action {action_type} does not exist.')
|
||||
if not hasattr(self, action_type):
|
||||
return ErrorObservation(
|
||||
f'Action {action_type} is not supported in the current runtime.',
|
||||
error_id='AGENT_ERROR$BAD_ACTION',
|
||||
)
|
||||
if (
|
||||
getattr(action, 'confirmation_state', None)
|
||||
== ActionConfirmationStatus.REJECTED
|
||||
):
|
||||
return UserRejectObservation(
|
||||
'Action has been rejected by the user! Waiting for further user input.'
|
||||
)
|
||||
|
||||
assert action.timeout is not None
|
||||
|
||||
try:
|
||||
execution_action_body: dict[str, Any] = {
|
||||
'action': event_to_dict(action),
|
||||
}
|
||||
response = self._send_action_server_request(
|
||||
'POST',
|
||||
f'{self.action_execution_server_url}/execute_action',
|
||||
json=execution_action_body,
|
||||
# wait a few more seconds to get the timeout error from client side
|
||||
timeout=action.timeout + 5,
|
||||
)
|
||||
assert response.is_closed
|
||||
output = response.json()
|
||||
if getattr(action, 'hidden', False):
|
||||
output.get('extras')['hidden'] = True
|
||||
obs = observation_from_dict(output)
|
||||
obs._cause = action.id # type: ignore[attr-defined]
|
||||
except httpx.TimeoutException:
|
||||
raise AgentRuntimeTimeoutError(
|
||||
f'Runtime failed to return execute_action before the requested timeout of {action.timeout}s'
|
||||
)
|
||||
finally:
|
||||
update_last_execution_time()
|
||||
return obs
|
||||
|
||||
def run(self, action: CmdRunAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def run_ipython(self, action: IPythonRunCellAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def read(self, action: FileReadAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def write(self, action: FileWriteAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def edit(self, action: FileEditAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def browse(self, action: BrowseURLAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def get_mcp_config(
|
||||
self, extra_stdio_servers: dict[str, StdioMCPServer] | None = None
|
||||
) -> MCPConfig:
|
||||
import sys
|
||||
|
||||
if sys.platform == 'win32':
|
||||
self.log('debug', 'MCP is disabled on Windows, returning empty config')
|
||||
return MCPConfig(mcpServers={})
|
||||
|
||||
updated_mcp_config = self.config.mcp.model_copy()
|
||||
|
||||
# Collect current stdio servers from config + extras
|
||||
current_stdio: dict[str, StdioMCPServer] = {
|
||||
name: server
|
||||
for name, server in updated_mcp_config.mcpServers.items()
|
||||
if isinstance(server, StdioMCPServer)
|
||||
}
|
||||
if extra_stdio_servers:
|
||||
current_stdio.update(extra_stdio_servers)
|
||||
|
||||
# Find servers not yet sent to the action execution server
|
||||
new_servers = {
|
||||
name: server
|
||||
for name, server in current_stdio.items()
|
||||
if name not in self._last_updated_mcp_stdio_servers
|
||||
}
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'adding {len(new_servers)} new stdio servers to MCP config: {list(new_servers.keys())}',
|
||||
)
|
||||
|
||||
if new_servers:
|
||||
# Merge current + previously-sent for the update payload
|
||||
combined = {**self._last_updated_mcp_stdio_servers, **current_stdio}
|
||||
|
||||
stdio_tools = [
|
||||
{'name': name, **server.model_dump(mode='json')}
|
||||
for name, server in sorted(combined.items())
|
||||
]
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'Updating MCP server with {len(new_servers)} new stdio servers (total: {len(combined)})',
|
||||
)
|
||||
response = self._send_action_server_request(
|
||||
'POST',
|
||||
f'{self.action_execution_server_url}/update_mcp_server',
|
||||
json=stdio_tools,
|
||||
timeout=60,
|
||||
)
|
||||
result = response.json()
|
||||
if response.status_code != 200:
|
||||
self.log('warning', f'Failed to update MCP server: {response.text}')
|
||||
else:
|
||||
if result.get('router_error_log'):
|
||||
self.log(
|
||||
'warning',
|
||||
f'Some MCP servers failed to be added: {result["router_error_log"]}',
|
||||
)
|
||||
self._last_updated_mcp_stdio_servers = dict(combined)
|
||||
self.log(
|
||||
'debug',
|
||||
f'Successfully updated MCP stdio servers, now tracking {len(combined)} servers',
|
||||
)
|
||||
self.log(
|
||||
'info',
|
||||
f'Updated MCP config: {list(updated_mcp_config.mcpServers.keys())}',
|
||||
)
|
||||
else:
|
||||
self.log('debug', 'No new stdio servers to update')
|
||||
|
||||
# Expose the runtime's MCP SSE proxy when stdio servers exist
|
||||
if self._last_updated_mcp_stdio_servers:
|
||||
updated_mcp_config.mcpServers['_runtime_proxy'] = RemoteMCPServer(
|
||||
url=self.action_execution_server_url.rstrip('/') + '/mcp/sse',
|
||||
transport='sse',
|
||||
auth=self.session_api_key,
|
||||
)
|
||||
|
||||
return updated_mcp_config
|
||||
|
||||
async def call_tool_mcp(self, action: MCPAction) -> Observation:
|
||||
import sys
|
||||
|
||||
from openhands.events.observation import ErrorObservation
|
||||
|
||||
# Check if we're on Windows - MCP is disabled on Windows
|
||||
if sys.platform == 'win32':
|
||||
self.log('info', 'MCP functionality is disabled on Windows')
|
||||
return ErrorObservation('MCP functionality is not available on Windows')
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from openhands.mcp.utils import call_tool_mcp as call_tool_mcp_handler
|
||||
from openhands.mcp.utils import create_mcp_clients
|
||||
|
||||
# Get the updated MCP config
|
||||
updated_mcp_config = self.get_mcp_config()
|
||||
self.log(
|
||||
'debug',
|
||||
f'Creating MCP clients with servers: {list(updated_mcp_config.mcpServers.keys())}',
|
||||
)
|
||||
|
||||
# Create clients for this specific operation
|
||||
mcp_clients = await create_mcp_clients(updated_mcp_config, self.sid)
|
||||
|
||||
# Call the tool and return the result
|
||||
# No need for try/finally since disconnect() is now just resetting state
|
||||
result = await call_tool_mcp_handler(mcp_clients, action)
|
||||
return result
|
||||
|
||||
def close(self) -> None:
|
||||
# Make sure we don't close the session multiple times
|
||||
# Can happen in evaluation
|
||||
if self._runtime_closed:
|
||||
return
|
||||
self._runtime_closed = True
|
||||
self.session.close()
|
||||
@@ -0,0 +1,12 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""CLI Runtime implementation for OpenHands."""
|
||||
|
||||
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
|
||||
|
||||
__all__ = ['CLIRuntime']
|
||||
@@ -0,0 +1,959 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""This runtime runs commands locally using subprocess and performs file operations using Python's standard library.
|
||||
It does not implement browser functionality.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from binaryornot.check import is_binary
|
||||
from openhands_aci.editor.editor import OHEditor
|
||||
from openhands_aci.editor.exceptions import ToolError
|
||||
from openhands_aci.editor.results import ToolResult
|
||||
from openhands_aci.utils.diff import get_diff
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig, StdioMCPServer
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.events.action import (
|
||||
BrowseInteractiveAction,
|
||||
BrowseURLAction,
|
||||
CmdRunAction,
|
||||
FileEditAction,
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
IPythonRunCellAction,
|
||||
)
|
||||
from openhands.events.action.mcp import MCPAction
|
||||
from openhands.events.event import FileEditSource, FileReadSource
|
||||
from openhands.events.observation import (
|
||||
CmdOutputObservation,
|
||||
ErrorObservation,
|
||||
FileEditObservation,
|
||||
FileReadObservation,
|
||||
FileWriteObservation,
|
||||
Observation,
|
||||
)
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
|
||||
|
||||
# Import Windows PowerShell support if on Windows
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
from openhands.runtime.utils.windows_exceptions import DotNetMissingError
|
||||
from openhands.runtime.utils.windows_bash import WindowsPowershellSession # isort: skip
|
||||
except (ImportError, DotNetMissingError) as err:
|
||||
# Print a user-friendly error message without stack trace
|
||||
friendly_message = """
|
||||
ERROR: PowerShell and .NET SDK are required but not properly configured
|
||||
|
||||
The .NET SDK and PowerShell are required for OpenHands CLI on Windows.
|
||||
PowerShell integration cannot function without .NET Core.
|
||||
|
||||
Please install the .NET SDK by following the instructions at:
|
||||
https://docs.all-hands.dev/usage/windows-without-wsl
|
||||
|
||||
After installing .NET SDK, restart your terminal and try again.
|
||||
"""
|
||||
print(friendly_message, file=sys.stderr)
|
||||
logger.error(
|
||||
f'Windows runtime initialization failed: {type(err).__name__}: {str(err)}'
|
||||
)
|
||||
if (
|
||||
isinstance(err, DotNetMissingError)
|
||||
and hasattr(err, 'details')
|
||||
and err.details
|
||||
):
|
||||
logger.debug(f'Details: {err.details}')
|
||||
|
||||
# Exit the program with an error code
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class CLIRuntime(Runtime):
|
||||
"""A runtime implementation that runs commands locally using subprocess and performs
|
||||
file operations using Python's standard library. It does not implement browser functionality.
|
||||
|
||||
Args:
|
||||
config (OpenHandsConfig): The application configuration.
|
||||
event_stream (EventStream): The event stream to subscribe to.
|
||||
sid (str, optional): The session ID. Defaults to 'default'.
|
||||
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
status_callback (Callable | None, optional): Callback for status updates. Defaults to None.
|
||||
attach_to_existing (bool, optional): Whether to attach to an existing session. Defaults to False.
|
||||
headless_mode (bool, optional): Whether to run in headless mode. Defaults to False.
|
||||
user_id (str | None, optional): User ID for authentication. Defaults to None.
|
||||
git_provider_tokens (PROVIDER_TOKEN_TYPE | None, optional): Git provider tokens. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
llm_registry: LLMRegistry,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable[[str, RuntimeStatus, str], None] | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = False,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
llm_registry,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
|
||||
# Set up workspace
|
||||
if self.config.workspace_base is not None:
|
||||
logger.warning(
|
||||
f'Workspace base path is set to {self.config.workspace_base}. '
|
||||
'It will be used as the path for the agent to run in. '
|
||||
'Be careful, the agent can EDIT files in this directory!'
|
||||
)
|
||||
self._workspace_path = self.config.workspace_base
|
||||
else:
|
||||
# Create a temporary directory for the workspace
|
||||
self._workspace_path = tempfile.mkdtemp(
|
||||
prefix=f'openhands_workspace_{sid}_'
|
||||
)
|
||||
logger.info(f'Created temporary workspace at {self._workspace_path}')
|
||||
|
||||
# Runtime tests rely on this being set correctly.
|
||||
self.config.workspace_mount_path_in_sandbox = self._workspace_path
|
||||
|
||||
# Initialize runtime state
|
||||
self._runtime_initialized = False
|
||||
self.file_editor = OHEditor(workspace_root=self._workspace_path)
|
||||
self._shell_stream_callback: Callable[[str], None] | None = None
|
||||
|
||||
# Initialize PowerShell session on Windows
|
||||
self._is_windows = sys.platform == 'win32'
|
||||
self._powershell_session: WindowsPowershellSession | None = None
|
||||
|
||||
logger.warning(
|
||||
'Initializing CLIRuntime. WARNING: NO SANDBOX IS USED. '
|
||||
'This runtime executes commands directly on the local system. '
|
||||
'Use with caution in untrusted environments.'
|
||||
)
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Initialize the runtime connection."""
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
# Ensure workspace directory exists
|
||||
os.makedirs(self._workspace_path, exist_ok=True)
|
||||
|
||||
# Change to the workspace directory
|
||||
os.chdir(self._workspace_path)
|
||||
|
||||
# Initialize PowerShell session if on Windows
|
||||
if self._is_windows:
|
||||
self._powershell_session = WindowsPowershellSession(
|
||||
work_dir=self._workspace_path,
|
||||
username=None, # Use current user
|
||||
no_change_timeout_seconds=30,
|
||||
max_memory_mb=None,
|
||||
)
|
||||
|
||||
if not self.attach_to_existing:
|
||||
await asyncio.to_thread(self.setup_initial_env)
|
||||
|
||||
self._runtime_initialized = True
|
||||
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
|
||||
logger.info(f'CLIRuntime initialized with workspace at {self._workspace_path}')
|
||||
|
||||
def add_env_vars(self, env_vars: dict[str, Any]) -> None:
|
||||
"""Adds environment variables to the current runtime environment.
|
||||
For CLIRuntime, this means updating os.environ for the current process,
|
||||
so that subsequent commands inherit these variables.
|
||||
This overrides the BaseRuntime behavior which tries to run shell commands
|
||||
before it's initialized and modify .bashrc, which is not ideal for local CLI.
|
||||
"""
|
||||
if not env_vars:
|
||||
return
|
||||
|
||||
# We log only keys to avoid leaking sensitive values like tokens into logs.
|
||||
logger.info(
|
||||
f'[CLIRuntime] Setting environment variables for this session: {list(env_vars.keys())}'
|
||||
)
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if isinstance(value, SecretStr):
|
||||
os.environ[key] = value.get_secret_value()
|
||||
logger.warning(f'[CLIRuntime] Set os.environ["{key}"] (from SecretStr)')
|
||||
else:
|
||||
os.environ[key] = value
|
||||
logger.debug(f'[CLIRuntime] Set os.environ["{key}"]')
|
||||
|
||||
# We don't use self.run() here because this method is called
|
||||
# during initialization before self._runtime_initialized is True.
|
||||
|
||||
def _safe_terminate_process(self, process_obj, signal_to_send=signal.SIGTERM):
|
||||
"""Safely attempts to terminate/kill a process group or a single process.
|
||||
|
||||
Args:
|
||||
process_obj: the subprocess.Popen object started with start_new_session=True
|
||||
signal_to_send: the signal to send to the process group or process.
|
||||
"""
|
||||
pid = getattr(process_obj, 'pid', None)
|
||||
if pid is None:
|
||||
return
|
||||
|
||||
group_desc = (
|
||||
'kill process group'
|
||||
if signal_to_send == signal.SIGKILL
|
||||
else 'terminate process group'
|
||||
)
|
||||
process_desc = (
|
||||
'kill process' if signal_to_send == signal.SIGKILL else 'terminate process'
|
||||
)
|
||||
|
||||
try:
|
||||
# Try to terminate/kill the entire process group
|
||||
logger.debug(f'[_safe_terminate_process] Original PID to act on: {pid}')
|
||||
pgid_to_kill = os.getpgid(
|
||||
pid
|
||||
) # This might raise ProcessLookupError if pid is already gone
|
||||
logger.debug(
|
||||
f'[_safe_terminate_process] Attempting to {group_desc} for PID {pid} (PGID: {pgid_to_kill}) with {signal_to_send}.'
|
||||
)
|
||||
os.killpg(pgid_to_kill, signal_to_send)
|
||||
logger.debug(
|
||||
f'[_safe_terminate_process] Successfully sent signal {signal_to_send} to PGID {pgid_to_kill} (original PID: {pid}).'
|
||||
)
|
||||
except ProcessLookupError as e_pgid:
|
||||
logger.warning(
|
||||
f'[_safe_terminate_process] ProcessLookupError getting PGID for PID {pid} (it might have already exited): {e_pgid}. Falling back to direct kill/terminate.'
|
||||
)
|
||||
try:
|
||||
if signal_to_send == signal.SIGKILL:
|
||||
process_obj.kill()
|
||||
else:
|
||||
process_obj.terminate()
|
||||
logger.debug(
|
||||
f'[_safe_terminate_process] Fallback: Terminated {process_desc} (PID: {pid}).'
|
||||
)
|
||||
except Exception as e_fallback:
|
||||
logger.error(
|
||||
f'[_safe_terminate_process] Fallback: Error during {process_desc} (PID: {pid}): {e_fallback}'
|
||||
)
|
||||
except (AttributeError, OSError) as e_os:
|
||||
logger.error(
|
||||
f'[_safe_terminate_process] OSError/AttributeError during {group_desc} for PID {pid}: {e_os}. Falling back.'
|
||||
)
|
||||
# Fallback: try to terminate/kill the main process directly.
|
||||
try:
|
||||
if signal_to_send == signal.SIGKILL:
|
||||
process_obj.kill()
|
||||
else:
|
||||
process_obj.terminate()
|
||||
logger.debug(
|
||||
f'[_safe_terminate_process] Fallback: Terminated {process_desc} (PID: {pid}).'
|
||||
)
|
||||
except Exception as e_fallback:
|
||||
logger.error(
|
||||
f'[_safe_terminate_process] Fallback: Error during {process_desc} (PID: {pid}): {e_fallback}'
|
||||
)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error: {e}')
|
||||
|
||||
def _execute_powershell_command(
|
||||
self, command: str, timeout: float
|
||||
) -> CmdOutputObservation | ErrorObservation:
|
||||
"""Execute a command using PowerShell session on Windows.
|
||||
|
||||
Args:
|
||||
command: The command to execute
|
||||
timeout: Timeout in seconds for the command
|
||||
Returns:
|
||||
CmdOutputObservation containing the complete output and exit code
|
||||
"""
|
||||
if self._powershell_session is None:
|
||||
return ErrorObservation(
|
||||
content='PowerShell session is not available.',
|
||||
error_id='POWERSHELL_SESSION_ERROR',
|
||||
)
|
||||
|
||||
try:
|
||||
# Create a CmdRunAction for the PowerShell session
|
||||
from openhands.events.action import CmdRunAction
|
||||
|
||||
ps_action = CmdRunAction(command=command)
|
||||
ps_action.set_hard_timeout(timeout)
|
||||
|
||||
# Execute the command using the PowerShell session
|
||||
return self._powershell_session.execute(ps_action)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error executing PowerShell command "{command}": {e}')
|
||||
return ErrorObservation(
|
||||
content=f'Error executing PowerShell command "{command}": {str(e)}',
|
||||
error_id='POWERSHELL_EXECUTION_ERROR',
|
||||
)
|
||||
|
||||
def _execute_shell_command(
|
||||
self, command: str, timeout: float
|
||||
) -> CmdOutputObservation:
|
||||
"""Execute a shell command and stream its output to a callback function.
|
||||
|
||||
Args:
|
||||
command: The shell command to execute
|
||||
timeout: Timeout in seconds for the command
|
||||
Returns:
|
||||
CmdOutputObservation containing the complete output and exit code
|
||||
"""
|
||||
output_lines = []
|
||||
timed_out = False
|
||||
start_time = time.monotonic()
|
||||
|
||||
# Use shell=True to run complex bash commands
|
||||
process = subprocess.Popen(
|
||||
['bash', '-c', command],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1, # Explicitly line-buffered for text mode
|
||||
universal_newlines=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
logger.debug(
|
||||
f'[_execute_shell_command] PID of bash -c: {process.pid} for command: "{command}"'
|
||||
)
|
||||
|
||||
exit_code = None
|
||||
|
||||
try:
|
||||
if process.stdout:
|
||||
while process.poll() is None:
|
||||
if (
|
||||
timeout is not None
|
||||
and (time.monotonic() - start_time) > timeout
|
||||
):
|
||||
logger.debug(
|
||||
f'Command "{command}" timed out after {timeout:.1f} seconds. Terminating.'
|
||||
)
|
||||
# Attempt to terminate the process group (SIGTERM)
|
||||
self._safe_terminate_process(
|
||||
process, signal_to_send=signal.SIGTERM
|
||||
)
|
||||
timed_out = True
|
||||
break
|
||||
|
||||
ready_to_read, _, _ = select.select([process.stdout], [], [], 0.1)
|
||||
|
||||
if ready_to_read:
|
||||
line = process.stdout.readline()
|
||||
if line:
|
||||
output_lines.append(line)
|
||||
if self._shell_stream_callback:
|
||||
self._shell_stream_callback(line)
|
||||
|
||||
# Attempt to read any remaining data from stdout
|
||||
if process.stdout and not process.stdout.closed:
|
||||
try:
|
||||
while line:
|
||||
line = process.stdout.readline()
|
||||
if line:
|
||||
output_lines.append(line)
|
||||
if self._shell_stream_callback:
|
||||
self._shell_stream_callback(line)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error reading directly from stdout after loop for "{command}": {e}'
|
||||
)
|
||||
|
||||
exit_code = process.returncode
|
||||
|
||||
# If timeout occurred, ensure exit_code reflects this for the observation.
|
||||
if timed_out:
|
||||
exit_code = -1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Outer exception in _execute_shell_command for "{command}": {e}'
|
||||
)
|
||||
if process and process.poll() is None:
|
||||
self._safe_terminate_process(process, signal_to_send=signal.SIGKILL)
|
||||
return CmdOutputObservation(
|
||||
command=command,
|
||||
content=''.join(output_lines) + f'\nError during execution: {e}',
|
||||
exit_code=-1,
|
||||
)
|
||||
|
||||
complete_output = ''.join(output_lines)
|
||||
logger.debug(
|
||||
f'[_execute_shell_command] Complete output for "{command}" (len: {len(complete_output)}): {complete_output!r}'
|
||||
)
|
||||
obs_metadata = {'working_dir': self._workspace_path}
|
||||
if timed_out:
|
||||
obs_metadata['suffix'] = (
|
||||
f'[The command timed out after {timeout:.1f} seconds.]'
|
||||
)
|
||||
# exit_code = -1 # This is already set if timed_out is True
|
||||
|
||||
return CmdOutputObservation(
|
||||
command=command,
|
||||
content=complete_output,
|
||||
exit_code=exit_code,
|
||||
metadata=obs_metadata,
|
||||
)
|
||||
|
||||
def run(self, action: CmdRunAction) -> Observation:
|
||||
"""Run a command using subprocess."""
|
||||
if not self._runtime_initialized:
|
||||
return ErrorObservation(
|
||||
f'Runtime not initialized for command: {action.command}'
|
||||
)
|
||||
|
||||
if action.is_input:
|
||||
logger.warning(
|
||||
f"CLIRuntime received an action with `is_input=True` (command: '{action.command}'). "
|
||||
'CLIRuntime currently does not support sending input or signals to active processes. '
|
||||
'This action will be ignored and an error observation will be returned.'
|
||||
)
|
||||
return ErrorObservation(
|
||||
content=f"CLIRuntime does not support interactive input from the agent (e.g., 'C-c'). The command '{action.command}' was not sent to any process.",
|
||||
error_id='AGENT_ERROR$BAD_ACTION',
|
||||
)
|
||||
|
||||
try:
|
||||
effective_timeout = (
|
||||
action.timeout
|
||||
if action.timeout is not None
|
||||
else self.config.sandbox.timeout
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Running command in CLIRuntime: "{action.command}" with effective timeout: {effective_timeout}s'
|
||||
)
|
||||
|
||||
# Use PowerShell on Windows if available, otherwise use subprocess
|
||||
if self._is_windows and self._powershell_session is not None:
|
||||
return self._execute_powershell_command(
|
||||
action.command, timeout=effective_timeout
|
||||
)
|
||||
else:
|
||||
return self._execute_shell_command(
|
||||
action.command, timeout=effective_timeout
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error in CLIRuntime.run for command "{action.command}": {str(e)}'
|
||||
)
|
||||
return ErrorObservation(
|
||||
f'Error running command "{action.command}": {str(e)}'
|
||||
)
|
||||
|
||||
def run_ipython(self, action: IPythonRunCellAction) -> Observation:
|
||||
"""Run a Python code cell.
|
||||
This functionality is not implemented in CLIRuntime.
|
||||
Users should also disable the Jupyter plugin in AgentConfig.
|
||||
"""
|
||||
# This functionality is not implemented in CLIRuntime.
|
||||
# If you need to run IPython/Jupyter cells, please consider using a different runtime
|
||||
# or ensure the Jupyter plugin is disabled in your AgentConfig to avoid
|
||||
# attempting to use this disabled feature.
|
||||
logger.warning(
|
||||
"run_ipython is called on CLIRuntime, but it's not implemented. "
|
||||
'Please disable the Jupyter plugin in AgentConfig.'
|
||||
)
|
||||
return ErrorObservation(
|
||||
'Executing IPython cells is not implemented in CLIRuntime. '
|
||||
)
|
||||
|
||||
def _sanitize_filename(self, filename: str) -> str:
|
||||
# if path is absolute, ensure it starts with _workspace_path
|
||||
if filename == '/workspace':
|
||||
actual_filename = self._workspace_path
|
||||
elif filename.startswith('/workspace/'):
|
||||
# Map /workspace/ to the actual workspace path
|
||||
# Note: /workspace is widely used, so we map it to allow using it with CLIRuntime
|
||||
actual_filename = os.path.join(
|
||||
self._workspace_path, filename[len('/workspace/') :]
|
||||
)
|
||||
elif filename.startswith('/'):
|
||||
if not filename.startswith(self._workspace_path):
|
||||
raise PermissionError(
|
||||
f'Invalid path: {filename}. You can only work with files in {self._workspace_path}.'
|
||||
)
|
||||
actual_filename = filename
|
||||
else:
|
||||
actual_filename = os.path.join(self._workspace_path, filename.lstrip('/'))
|
||||
|
||||
# Resolve the path to handle any '..' or '.' components
|
||||
resolved_path = os.path.realpath(actual_filename)
|
||||
|
||||
# Check if the resolved path is still within the workspace
|
||||
if not resolved_path.startswith(self._workspace_path):
|
||||
raise PermissionError(
|
||||
f'Invalid path traversal: {filename}. Path resolves outside the workspace. Resolved: {resolved_path}, Workspace: {self._workspace_path}'
|
||||
)
|
||||
|
||||
return resolved_path
|
||||
|
||||
def read(self, action: FileReadAction) -> Observation:
|
||||
"""Read a file using Python's standard library or OHEditor."""
|
||||
if not self._runtime_initialized:
|
||||
return ErrorObservation('Runtime not initialized')
|
||||
|
||||
file_path = self._sanitize_filename(action.path)
|
||||
|
||||
# Use OHEditor for OH_ACI implementation source
|
||||
if action.impl_source == FileReadSource.OH_ACI:
|
||||
result_str, _ = self._execute_file_editor(
|
||||
command='view',
|
||||
path=file_path,
|
||||
view_range=action.view_range,
|
||||
)
|
||||
|
||||
return FileReadObservation(
|
||||
content=result_str,
|
||||
path=action.path,
|
||||
impl_source=FileReadSource.OH_ACI,
|
||||
)
|
||||
|
||||
try:
|
||||
# Check if the file exists
|
||||
if not os.path.exists(file_path):
|
||||
return ErrorObservation(f'File not found: {action.path}')
|
||||
|
||||
# Check if it's a directory
|
||||
if os.path.isdir(file_path):
|
||||
return ErrorObservation(f'Cannot read directory: {action.path}')
|
||||
|
||||
# Cannot read binary files
|
||||
if is_binary(file_path):
|
||||
return ErrorObservation('ERROR_BINARY_FILE')
|
||||
|
||||
# Read the file
|
||||
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
return FileReadObservation(content=content, path=action.path)
|
||||
except Exception as e:
|
||||
logger.error(f'Error reading file: {str(e)}')
|
||||
return ErrorObservation(f'Error reading file {action.path}: {str(e)}')
|
||||
|
||||
def write(self, action: FileWriteAction) -> Observation:
|
||||
"""Write to a file using Python's standard library."""
|
||||
if not self._runtime_initialized:
|
||||
return ErrorObservation('Runtime not initialized')
|
||||
|
||||
file_path = self._sanitize_filename(action.path)
|
||||
|
||||
try:
|
||||
# Create parent directories if they don't exist
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
# Write to the file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(action.content)
|
||||
|
||||
return FileWriteObservation(content='', path=action.path)
|
||||
except Exception as e:
|
||||
logger.error(f'Error writing to file: {str(e)}')
|
||||
return ErrorObservation(f'Error writing to file {action.path}: {str(e)}')
|
||||
|
||||
def browse(self, action: BrowseURLAction) -> Observation:
|
||||
"""Not implemented for CLI runtime."""
|
||||
return ErrorObservation(
|
||||
'Browser functionality is not implemented in CLIRuntime'
|
||||
)
|
||||
|
||||
def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
|
||||
"""Not implemented for CLI runtime."""
|
||||
return ErrorObservation(
|
||||
'Browser functionality is not implemented in CLIRuntime'
|
||||
)
|
||||
|
||||
def _execute_file_editor(
|
||||
self,
|
||||
command: str,
|
||||
path: str,
|
||||
file_text: str | None = None,
|
||||
view_range: list[int] | None = None,
|
||||
old_str: str | None = None,
|
||||
new_str: str | None = None,
|
||||
insert_line: int | None = None,
|
||||
enable_linting: bool = False,
|
||||
) -> tuple[str, tuple[str | None, str | None]]:
|
||||
"""Execute file editor command and handle exceptions.
|
||||
|
||||
Args:
|
||||
command: Editor command to execute
|
||||
path: File path
|
||||
file_text: Optional file text content
|
||||
view_range: Optional view range tuple (start, end)
|
||||
old_str: Optional string to replace
|
||||
new_str: Optional replacement string
|
||||
insert_line: Optional line number for insertion
|
||||
enable_linting: Whether to enable linting
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing the output string and a tuple of old and new file content
|
||||
"""
|
||||
result: ToolResult | None = None
|
||||
try:
|
||||
result = self.file_editor(
|
||||
command=command,
|
||||
path=path,
|
||||
file_text=file_text,
|
||||
view_range=view_range,
|
||||
old_str=old_str,
|
||||
new_str=new_str,
|
||||
insert_line=insert_line,
|
||||
enable_linting=enable_linting,
|
||||
)
|
||||
except ToolError as e:
|
||||
result = ToolResult(error=e.message)
|
||||
|
||||
if result.error:
|
||||
return f'ERROR:\n{result.error}', (None, None)
|
||||
|
||||
if not result.output:
|
||||
logger.warning(f'No output from file_editor for {path}')
|
||||
return '', (None, None)
|
||||
|
||||
return result.output, (result.old_content, result.new_content)
|
||||
|
||||
def edit(self, action: FileEditAction) -> Observation:
|
||||
"""Edit a file using the OHEditor."""
|
||||
if not self._runtime_initialized:
|
||||
return ErrorObservation('Runtime not initialized')
|
||||
|
||||
# Ensure the path is within the workspace
|
||||
file_path = self._sanitize_filename(action.path)
|
||||
|
||||
# Check if it's a binary file
|
||||
if os.path.exists(file_path) and is_binary(file_path):
|
||||
return ErrorObservation('ERROR_BINARY_FILE')
|
||||
|
||||
assert action.impl_source == FileEditSource.OH_ACI
|
||||
|
||||
result_str, (old_content, new_content) = self._execute_file_editor(
|
||||
command=action.command,
|
||||
path=file_path,
|
||||
file_text=action.file_text,
|
||||
old_str=action.old_str,
|
||||
new_str=action.new_str,
|
||||
insert_line=action.insert_line,
|
||||
enable_linting=False,
|
||||
)
|
||||
|
||||
return FileEditObservation(
|
||||
content=result_str,
|
||||
path=action.path,
|
||||
old_content=action.old_str,
|
||||
new_content=action.new_str,
|
||||
impl_source=FileEditSource.OH_ACI,
|
||||
diff=get_diff(
|
||||
old_contents=old_content or '',
|
||||
new_contents=new_content or '',
|
||||
filepath=action.path,
|
||||
),
|
||||
)
|
||||
|
||||
async def call_tool_mcp(self, action: MCPAction) -> Observation:
|
||||
"""Execute an MCP tool action in CLI runtime.
|
||||
|
||||
Args:
|
||||
action: The MCP action to execute
|
||||
|
||||
Returns:
|
||||
Observation: The result of the MCP tool execution
|
||||
"""
|
||||
# Check if we're on Windows - MCP is disabled on Windows
|
||||
if sys.platform == 'win32':
|
||||
self.log('info', 'MCP functionality is disabled on Windows')
|
||||
return ErrorObservation('MCP functionality is not available on Windows')
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from openhands.mcp.utils import call_tool_mcp as call_tool_mcp_handler
|
||||
from openhands.mcp.utils import create_mcp_clients
|
||||
|
||||
try:
|
||||
# Get the MCP config for this runtime
|
||||
mcp_config = self.get_mcp_config()
|
||||
|
||||
if not mcp_config.mcpServers:
|
||||
self.log('warning', 'No MCP servers configured')
|
||||
return ErrorObservation('No MCP servers configured')
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'Creating MCP clients for action {action.name} with '
|
||||
f'{len(mcp_config.mcpServers)} servers',
|
||||
)
|
||||
|
||||
# Create clients for this specific operation
|
||||
mcp_clients = await create_mcp_clients(mcp_config, self.sid)
|
||||
|
||||
if not mcp_clients:
|
||||
self.log('warning', 'No MCP clients could be created')
|
||||
return ErrorObservation(
|
||||
'No MCP clients could be created - check server configurations'
|
||||
)
|
||||
|
||||
# Call the tool and return the result
|
||||
self.log(
|
||||
'debug',
|
||||
f'Executing MCP tool: {action.name} with arguments: {action.arguments}',
|
||||
)
|
||||
result = await call_tool_mcp_handler(mcp_clients, action)
|
||||
self.log('debug', f'MCP tool {action.name} executed successfully')
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Error executing MCP tool {action.name}: {str(e)}'
|
||||
self.log('error', error_msg)
|
||||
return ErrorObservation(error_msg)
|
||||
|
||||
@property
|
||||
def workspace_root(self) -> Path:
|
||||
"""Return the workspace root path."""
|
||||
return Path(os.path.abspath(self._workspace_path))
|
||||
|
||||
def copy_to(self, host_src: str, sandbox_dest: str, recursive: bool = False):
|
||||
"""Copy a file or directory from the host to the sandbox."""
|
||||
if not self._runtime_initialized:
|
||||
raise RuntimeError('Runtime not initialized')
|
||||
if not os.path.exists(host_src): # Source must exist on host
|
||||
raise FileNotFoundError(f"Source path '{host_src}' does not exist.")
|
||||
|
||||
dest = self._sanitize_filename(sandbox_dest)
|
||||
|
||||
try:
|
||||
# Case 1: Source is a directory and recursive copy.
|
||||
if os.path.isdir(host_src) and recursive:
|
||||
# Target is dest / basename(host_src)
|
||||
final_target_dir = os.path.join(dest, os.path.basename(host_src))
|
||||
|
||||
# If source and final target are the same, skip.
|
||||
if os.path.realpath(host_src) == os.path.realpath(final_target_dir):
|
||||
logger.debug(
|
||||
'Skipping recursive copy: source and target are identical.'
|
||||
)
|
||||
pass
|
||||
else:
|
||||
# Ensure parent of final_target_dir exists.
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
shutil.copytree(host_src, final_target_dir, dirs_exist_ok=True)
|
||||
# Why: Copies dir host_src into dest. Merges if target exists.
|
||||
|
||||
# Case 2: Source is a file.
|
||||
elif os.path.isfile(host_src):
|
||||
final_target_file_path: str
|
||||
# Scenario A: sandbox_dest is clearly a directory.
|
||||
if os.path.isdir(dest) or (sandbox_dest.endswith(('/', os.sep))):
|
||||
target_dir = dest
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
final_target_file_path = os.path.join(
|
||||
target_dir, os.path.basename(host_src)
|
||||
)
|
||||
# Why: Copies file into specified directory.
|
||||
|
||||
# Scenario B: sandbox_dest is likely a new directory (e.g., 'new_dir').
|
||||
elif not os.path.exists(dest) and '.' not in os.path.basename(dest):
|
||||
target_dir = dest
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
final_target_file_path = os.path.join(
|
||||
target_dir, os.path.basename(host_src)
|
||||
)
|
||||
# Why: Creates 'new_dir' and copies file into it.
|
||||
|
||||
# Scenario C: sandbox_dest is a full file path.
|
||||
else:
|
||||
final_target_file_path = dest
|
||||
os.makedirs(os.path.dirname(final_target_file_path), exist_ok=True)
|
||||
# Why: Copies file to a specific path, possibly renaming.
|
||||
|
||||
shutil.copy2(host_src, final_target_file_path)
|
||||
|
||||
else: # Source is not a valid file or directory.
|
||||
raise FileNotFoundError(
|
||||
f"Source path '{host_src}' is not a valid file or directory."
|
||||
)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f'File not found during copy: {str(e)}')
|
||||
raise
|
||||
except shutil.SameFileError as e:
|
||||
# We can be lenient here, just ignore this error.
|
||||
logger.debug(
|
||||
f'Skipping copy as source and destination are the same: {str(e)}'
|
||||
)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f'Unexpected error copying file: {str(e)}')
|
||||
raise RuntimeError(f'Unexpected error copying file: {str(e)}')
|
||||
|
||||
def list_files(self, path: str | None = None) -> list[str]:
|
||||
"""List files in the sandbox."""
|
||||
if not self._runtime_initialized:
|
||||
raise RuntimeError('Runtime not initialized')
|
||||
|
||||
if path is None:
|
||||
dir_path = self._workspace_path
|
||||
else:
|
||||
dir_path = self._sanitize_filename(path)
|
||||
|
||||
try:
|
||||
if not os.path.exists(dir_path):
|
||||
return []
|
||||
|
||||
if not os.path.isdir(dir_path):
|
||||
return [dir_path]
|
||||
|
||||
# List files in the directory
|
||||
return [os.path.join(dir_path, f) for f in os.listdir(dir_path)]
|
||||
except Exception as e:
|
||||
logger.error(f'Error listing files: {str(e)}')
|
||||
return []
|
||||
|
||||
def copy_from(self, path: str) -> Path:
|
||||
"""Zip all files in the sandbox and return a path in the local filesystem."""
|
||||
if not self._runtime_initialized:
|
||||
raise RuntimeError('Runtime not initialized')
|
||||
|
||||
source_path = self._sanitize_filename(path)
|
||||
|
||||
if not os.path.exists(source_path):
|
||||
raise FileNotFoundError(f'Path not found: {path}')
|
||||
|
||||
# Create a temporary zip file
|
||||
temp_zip = tempfile.NamedTemporaryFile(suffix='.zip', delete=False)
|
||||
temp_zip.close()
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(temp_zip.name, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
if os.path.isdir(source_path):
|
||||
# Add all files in the directory
|
||||
for root, _, files in os.walk(source_path):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, source_path)
|
||||
zipf.write(file_path, arcname)
|
||||
else:
|
||||
# Add a single file
|
||||
zipf.write(source_path, os.path.basename(source_path))
|
||||
|
||||
return Path(temp_zip.name)
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating zip file: {str(e)}')
|
||||
raise RuntimeError(f'Error creating zip file: {str(e)}')
|
||||
|
||||
def close(self) -> None:
|
||||
# Clean up PowerShell session if it exists
|
||||
if self._powershell_session is not None:
|
||||
try:
|
||||
self._powershell_session.close()
|
||||
logger.debug('PowerShell session closed successfully.')
|
||||
except Exception as e:
|
||||
logger.warning(f'Error closing PowerShell session: {e}')
|
||||
finally:
|
||||
self._powershell_session = None
|
||||
|
||||
self._runtime_initialized = False
|
||||
super().close()
|
||||
|
||||
@classmethod
|
||||
async def delete(cls, conversation_id: str) -> None:
|
||||
"""Delete any resources associated with a conversation."""
|
||||
# Look for temporary directories that might be associated with this conversation
|
||||
temp_dir = tempfile.gettempdir()
|
||||
prefix = f'openhands_workspace_{conversation_id}_'
|
||||
|
||||
for item in os.listdir(temp_dir):
|
||||
if item.startswith(prefix):
|
||||
try:
|
||||
path = os.path.join(temp_dir, item)
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
logger.info(f'Deleted workspace directory: {path}')
|
||||
except Exception as e:
|
||||
logger.error(f'Error deleting workspace directory: {str(e)}')
|
||||
|
||||
@property
|
||||
def additional_agent_instructions(self) -> str:
|
||||
return '\n\n'.join(
|
||||
[
|
||||
f'Your working directory is {self._workspace_path}. You can only read and write files in this directory.',
|
||||
"You are working directly on the user's machine. In most cases, the working environment is already set up.",
|
||||
]
|
||||
)
|
||||
|
||||
def get_mcp_config(
|
||||
self, extra_stdio_servers: dict[str, StdioMCPServer] | None = None
|
||||
) -> MCPConfig:
|
||||
"""Get MCP configuration for CLI runtime."""
|
||||
if sys.platform == 'win32':
|
||||
self.log('debug', 'MCP is disabled on Windows, returning empty config')
|
||||
return MCPConfig(mcpServers={})
|
||||
|
||||
mcp_config = self.config.mcp
|
||||
|
||||
if extra_stdio_servers:
|
||||
merged = dict(mcp_config.mcpServers)
|
||||
for name, server in extra_stdio_servers.items():
|
||||
if name not in merged:
|
||||
merged[name] = server
|
||||
self.log('info', f'Added extra stdio server: {name}')
|
||||
mcp_config = MCPConfig(mcpServers=merged)
|
||||
self.config.mcp = mcp_config
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'CLI MCP config: {len(mcp_config.mcpServers)} servers',
|
||||
)
|
||||
|
||||
return mcp_config
|
||||
|
||||
def subscribe_to_shell_stream(
|
||||
self, callback: Callable[[str], None] | None = None
|
||||
) -> bool:
|
||||
"""Subscribe to shell command output stream.
|
||||
|
||||
Args:
|
||||
callback: A function that will be called with each line of output from shell commands.
|
||||
If None, any existing subscription will be removed.
|
||||
"""
|
||||
self._shell_stream_callback = callback
|
||||
return True
|
||||
@@ -0,0 +1,26 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import docker
|
||||
|
||||
|
||||
def stop_all_containers(prefix: str) -> None:
|
||||
docker_client = docker.from_env()
|
||||
try:
|
||||
containers = docker_client.containers.list(all=True)
|
||||
for container in containers:
|
||||
try:
|
||||
if container.name and container.name.startswith(prefix):
|
||||
container.stop()
|
||||
except docker.errors.APIError:
|
||||
pass
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
except docker.errors.NotFound: # yes, this can happen!
|
||||
pass
|
||||
finally:
|
||||
docker_client.close()
|
||||
@@ -0,0 +1,778 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import os
|
||||
import platform
|
||||
import typing
|
||||
from functools import lru_cache
|
||||
from typing import Callable
|
||||
from uuid import UUID
|
||||
|
||||
import docker
|
||||
import httpx
|
||||
import tenacity
|
||||
from docker.models.containers import Container
|
||||
from docker.types import DriverConfig, Mount
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.exceptions import (
|
||||
AgentRuntimeDisconnectedError,
|
||||
AgentRuntimeNotFoundError,
|
||||
)
|
||||
from openhands.core.logger import DEBUG, DEBUG_RUNTIME
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime.builder import DockerRuntimeBuilder
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.impl.docker.containers import stop_all_containers
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.runtime.utils.command import (
|
||||
DEFAULT_MAIN_MODULE,
|
||||
get_action_execution_server_startup_command,
|
||||
)
|
||||
from openhands.runtime.utils.log_streamer import LogStreamer
|
||||
from openhands.runtime.utils.port_lock import PortLock, find_available_port_with_lock
|
||||
from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.shutdown_listener import add_shutdown_listener
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
||||
|
||||
EXECUTION_SERVER_PORT_RANGE = (30000, 39999)
|
||||
VSCODE_PORT_RANGE = (40000, 49999)
|
||||
APP_PORT_RANGE_1 = (50000, 54999)
|
||||
APP_PORT_RANGE_2 = (55000, 59999)
|
||||
|
||||
if os.name == 'nt' or platform.release().endswith('microsoft-standard-WSL2'):
|
||||
EXECUTION_SERVER_PORT_RANGE = (30000, 34999)
|
||||
VSCODE_PORT_RANGE = (35000, 39999)
|
||||
APP_PORT_RANGE_1 = (40000, 44999)
|
||||
APP_PORT_RANGE_2 = (45000, 49151)
|
||||
|
||||
|
||||
def _is_retryablewait_until_alive_error(exception: BaseException) -> bool:
|
||||
if isinstance(exception, tenacity.RetryError):
|
||||
cause = exception.last_attempt.exception()
|
||||
return cause is not None and _is_retryablewait_until_alive_error(cause)
|
||||
|
||||
return isinstance(
|
||||
exception,
|
||||
(
|
||||
ConnectionError,
|
||||
httpx.ConnectTimeout,
|
||||
httpx.NetworkError,
|
||||
httpx.RemoteProtocolError,
|
||||
httpx.HTTPStatusError,
|
||||
httpx.ReadTimeout,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DockerRuntime(ActionExecutionClient):
|
||||
"""This runtime will subscribe the event stream.
|
||||
|
||||
When receive an event, it will send the event to runtime-client which run inside the docker environment.
|
||||
|
||||
Args:
|
||||
config (OpenHandsConfig): The application configuration.
|
||||
event_stream (EventStream): The event stream to subscribe to.
|
||||
sid (str, optional): The session ID. Defaults to 'default'.
|
||||
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
|
||||
_shutdown_listener_id: UUID | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
llm_registry: LLMRegistry,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
main_module: str = DEFAULT_MAIN_MODULE,
|
||||
):
|
||||
if not DockerRuntime._shutdown_listener_id:
|
||||
DockerRuntime._shutdown_listener_id = add_shutdown_listener(
|
||||
lambda: stop_all_containers(CONTAINER_NAME_PREFIX)
|
||||
)
|
||||
|
||||
self.config = config
|
||||
self.status_callback = status_callback
|
||||
|
||||
self._host_port = -1
|
||||
self._container_port = -1
|
||||
self._vscode_port = -1
|
||||
self._app_ports: list[int] = []
|
||||
|
||||
# Port locks to prevent race conditions
|
||||
self._host_port_lock: PortLock | None = None
|
||||
self._vscode_port_lock: PortLock | None = None
|
||||
self._app_port_locks: list[PortLock] = []
|
||||
|
||||
if os.environ.get('DOCKER_HOST_ADDR'):
|
||||
logger.info(
|
||||
f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url'
|
||||
)
|
||||
self.config.sandbox.local_runtime_url = (
|
||||
f'http://{os.environ["DOCKER_HOST_ADDR"]}'
|
||||
)
|
||||
|
||||
self.docker_client: docker.DockerClient = self._init_docker_client()
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
|
||||
self.base_container_image = self.config.sandbox.base_container_image
|
||||
self.runtime_container_image = self.config.sandbox.runtime_container_image
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
self.container: Container | None = None
|
||||
self.main_module = main_module
|
||||
|
||||
self.runtime_builder = DockerRuntimeBuilder(self.docker_client)
|
||||
|
||||
# Buffer for container logs
|
||||
self.log_streamer: LogStreamer | None = None
|
||||
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
llm_registry,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
|
||||
# Log runtime_extra_deps after base class initialization so self.sid is available
|
||||
if self.config.sandbox.runtime_extra_deps:
|
||||
self.log(
|
||||
'debug',
|
||||
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
|
||||
)
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self) -> str:
|
||||
return self.api_url
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
try:
|
||||
await call_sync_from_async(self._attach_to_container)
|
||||
except docker.errors.NotFound as e:
|
||||
if self.attach_to_existing:
|
||||
self.log(
|
||||
'warning',
|
||||
f'Container {self.container_name} not found.',
|
||||
)
|
||||
raise AgentRuntimeDisconnectedError from e
|
||||
self.maybe_build_runtime_container_image()
|
||||
self.log(
|
||||
'info', f'Starting runtime with image: {self.runtime_container_image}'
|
||||
)
|
||||
await call_sync_from_async(self.init_container)
|
||||
self.log(
|
||||
'info',
|
||||
f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
|
||||
)
|
||||
|
||||
if DEBUG_RUNTIME and self.container:
|
||||
self.log_streamer = LogStreamer(self.container, self.log)
|
||||
else:
|
||||
self.log_streamer = None
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', f'Waiting for client to become ready at {self.api_url}...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
await call_sync_from_async(self.wait_until_alive)
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', 'Runtime is ready.')
|
||||
|
||||
if not self.attach_to_existing:
|
||||
await call_sync_from_async(self.setup_initial_env)
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}',
|
||||
)
|
||||
if not self.attach_to_existing:
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
self._runtime_initialized = True
|
||||
|
||||
for network_name in self.config.sandbox.additional_networks:
|
||||
try:
|
||||
network = self.docker_client.networks.get(network_name)
|
||||
if self.container is not None:
|
||||
network.connect(self.container)
|
||||
else:
|
||||
self.log(
|
||||
'warning',
|
||||
f'Container not available to connect to network {network_name}',
|
||||
)
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Error: Failed to connect instance {self.container_name} to network {network_name}',
|
||||
)
|
||||
self.log('error', str(e))
|
||||
|
||||
def maybe_build_runtime_container_image(self):
|
||||
if self.runtime_container_image is None:
|
||||
if self.base_container_image is None:
|
||||
raise ValueError(
|
||||
'Neither runtime container image nor base container image is set'
|
||||
)
|
||||
self.set_runtime_status(RuntimeStatus.BUILDING_RUNTIME)
|
||||
self.runtime_container_image = build_runtime_image(
|
||||
self.base_container_image,
|
||||
self.runtime_builder,
|
||||
platform=self.config.sandbox.platform,
|
||||
extra_deps=self.config.sandbox.runtime_extra_deps,
|
||||
force_rebuild=self.config.sandbox.force_rebuild_runtime,
|
||||
extra_build_args=self.config.sandbox.runtime_extra_build_args,
|
||||
enable_browser=self.config.enable_browser,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=1)
|
||||
def _init_docker_client() -> docker.DockerClient:
|
||||
try:
|
||||
return docker.from_env()
|
||||
except Exception as ex:
|
||||
logger.error(
|
||||
'Launch docker client failed. Please make sure you have installed docker and started docker desktop/daemon.',
|
||||
)
|
||||
raise ex
|
||||
|
||||
def _process_volumes(self) -> dict[str, dict[str, str]]:
|
||||
"""Process volume mounts based on configuration.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping host paths to container bind mounts with their modes.
|
||||
"""
|
||||
# Initialize volumes dictionary
|
||||
volumes: dict[str, dict[str, str]] = {}
|
||||
|
||||
# Process volumes (comma-delimited)
|
||||
if self.config.sandbox.volumes is not None:
|
||||
# Handle multiple mounts with comma delimiter
|
||||
mounts = self.config.sandbox.volumes.split(',')
|
||||
|
||||
for mount in mounts:
|
||||
parts = mount.split(':')
|
||||
if len(parts) >= 2:
|
||||
# Support both bind mounts (absolute paths) and Docker named volumes.
|
||||
# Named volume syntax:
|
||||
# volume:<name> (explicit)
|
||||
# <name> (implicit when not starting with '/')
|
||||
raw_host_part = parts[0]
|
||||
|
||||
if raw_host_part.startswith('volume:'):
|
||||
host_path = raw_host_part.split('volume:', 1)[1]
|
||||
elif not os.path.isabs(raw_host_part):
|
||||
host_path = raw_host_part # treat as named volume
|
||||
else:
|
||||
host_path = os.path.abspath(raw_host_part)
|
||||
container_path = parts[1]
|
||||
# Default mode is 'rw' if not specified
|
||||
mount_mode = parts[2] if len(parts) > 2 else 'rw'
|
||||
# Skip overlay mounts here; they will be handled separately via Mount objects
|
||||
if 'overlay' in mount_mode:
|
||||
continue
|
||||
|
||||
volumes[host_path] = {
|
||||
'bind': container_path,
|
||||
'mode': mount_mode,
|
||||
}
|
||||
logger.debug(
|
||||
f'Mount dir (sandbox.volumes): {host_path} to {container_path} with mode: {mount_mode}'
|
||||
)
|
||||
|
||||
# Legacy mounting with workspace_* parameters
|
||||
elif (
|
||||
self.config.workspace_mount_path is not None
|
||||
and self.config.workspace_mount_path_in_sandbox is not None
|
||||
):
|
||||
mount_mode = 'rw' # Default mode
|
||||
|
||||
# e.g. result would be: {"/home/user/openhands/workspace": {'bind': "/workspace", 'mode': 'rw'}}
|
||||
# Add os.path.abspath() here so that relative paths can be used when workspace_mount_path is configured in config.toml
|
||||
volumes[os.path.abspath(self.config.workspace_mount_path)] = {
|
||||
'bind': self.config.workspace_mount_path_in_sandbox,
|
||||
'mode': mount_mode,
|
||||
}
|
||||
logger.debug(
|
||||
f'Mount dir (legacy): {self.config.workspace_mount_path} with mode: {mount_mode}'
|
||||
)
|
||||
|
||||
return volumes
|
||||
|
||||
def _process_overlay_mounts(self) -> list[Mount]:
|
||||
"""Process overlay mounts specified in sandbox.volumes with mode containing 'overlay'.
|
||||
|
||||
Returns:
|
||||
List of docker.types.Mount objects configured with overlay driver providing
|
||||
read-only lowerdir with per-container copy-on-write upper/work layers.
|
||||
"""
|
||||
overlay_mounts: list[Mount] = []
|
||||
|
||||
# No volumes configured
|
||||
if self.config.sandbox.volumes is None:
|
||||
return overlay_mounts
|
||||
|
||||
# Base directory for overlay upper/work layers from env var
|
||||
overlay_base = os.environ.get('SANDBOX_VOLUME_OVERLAYS')
|
||||
if not overlay_base:
|
||||
# If no base path provided, skip overlay processing
|
||||
return overlay_mounts
|
||||
|
||||
os.makedirs(overlay_base, exist_ok=True)
|
||||
|
||||
mount_specs = self.config.sandbox.volumes.split(',')
|
||||
|
||||
for idx, mount_spec in enumerate(mount_specs):
|
||||
parts = mount_spec.split(':')
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
host_path = os.path.abspath(parts[0])
|
||||
container_path = parts[1]
|
||||
mount_mode = parts[2] if len(parts) > 2 else 'rw'
|
||||
|
||||
# Only consider overlay mounts for host-bind paths (absolute)
|
||||
if (not os.path.isabs(parts[0])) or ('overlay' not in mount_mode):
|
||||
continue
|
||||
|
||||
# Prepare upper and work directories unique to this container and mount
|
||||
overlay_dir = os.path.join(overlay_base, self.container_name, f'{idx}')
|
||||
upper_dir = os.path.join(overlay_dir, 'upper')
|
||||
work_dir = os.path.join(overlay_dir, 'work')
|
||||
os.makedirs(upper_dir, exist_ok=True)
|
||||
os.makedirs(work_dir, exist_ok=True)
|
||||
|
||||
driver_cfg = DriverConfig(
|
||||
name='local',
|
||||
options={
|
||||
'type': 'overlay',
|
||||
'device': 'overlay',
|
||||
'o': f'lowerdir={host_path},upperdir={upper_dir},workdir={work_dir}',
|
||||
},
|
||||
)
|
||||
|
||||
mount = Mount(
|
||||
target=container_path,
|
||||
source='', # Anonymous volume
|
||||
type='volume',
|
||||
labels={
|
||||
'app': 'openhands',
|
||||
'role': 'worker',
|
||||
'container': self.container_name,
|
||||
},
|
||||
driver_config=driver_cfg,
|
||||
)
|
||||
|
||||
overlay_mounts.append(mount)
|
||||
|
||||
return overlay_mounts
|
||||
|
||||
def init_container(self) -> None:
|
||||
self.log('debug', 'Preparing to start container...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
# Allocate host port with locking to prevent race conditions
|
||||
self._host_port, self._host_port_lock = self._find_available_port_with_lock(
|
||||
EXECUTION_SERVER_PORT_RANGE
|
||||
)
|
||||
self._container_port = self._host_port
|
||||
|
||||
# Use the configured vscode_port if provided, otherwise find an available port
|
||||
if self.config.sandbox.vscode_port:
|
||||
self._vscode_port = self.config.sandbox.vscode_port
|
||||
self._vscode_port_lock = None # No lock needed for configured port
|
||||
else:
|
||||
self._vscode_port, self._vscode_port_lock = (
|
||||
self._find_available_port_with_lock(VSCODE_PORT_RANGE)
|
||||
)
|
||||
|
||||
# Allocate app ports with locking
|
||||
app_port_1, app_lock_1 = self._find_available_port_with_lock(APP_PORT_RANGE_1)
|
||||
app_port_2, app_lock_2 = self._find_available_port_with_lock(APP_PORT_RANGE_2)
|
||||
|
||||
self._app_ports = [app_port_1, app_port_2]
|
||||
self._app_port_locks = [
|
||||
lock for lock in [app_lock_1, app_lock_2] if lock is not None
|
||||
]
|
||||
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
|
||||
use_host_network = self.config.sandbox.use_host_network
|
||||
network_mode: typing.Literal['host'] | None = (
|
||||
'host' if use_host_network else None
|
||||
)
|
||||
|
||||
# Initialize port mappings
|
||||
port_mapping: dict[str, list[dict[str, str]]] | None = None
|
||||
if not use_host_network:
|
||||
port_mapping = {
|
||||
f'{self._container_port}/tcp': [
|
||||
{
|
||||
'HostPort': str(self._host_port),
|
||||
'HostIp': self.config.sandbox.runtime_binding_address,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if self.vscode_enabled:
|
||||
port_mapping[f'{self._vscode_port}/tcp'] = [
|
||||
{
|
||||
'HostPort': str(self._vscode_port),
|
||||
'HostIp': self.config.sandbox.runtime_binding_address,
|
||||
}
|
||||
]
|
||||
|
||||
for port in self._app_ports:
|
||||
port_mapping[f'{port}/tcp'] = [
|
||||
{
|
||||
'HostPort': str(port),
|
||||
'HostIp': self.config.sandbox.runtime_binding_address,
|
||||
}
|
||||
]
|
||||
else:
|
||||
self.log(
|
||||
'warn',
|
||||
'Using host network mode. If you are using MacOS, please make sure you have the latest version of Docker Desktop and enabled host network feature: https://docs.docker.com/network/drivers/host/#docker-desktop',
|
||||
)
|
||||
|
||||
# Combine environment variables
|
||||
environment = dict(**self.initial_env_vars)
|
||||
environment.update(
|
||||
{
|
||||
'port': str(self._container_port),
|
||||
'PYTHONUNBUFFERED': '1',
|
||||
# Passing in the ports means nested runtimes do not come up with their own ports!
|
||||
'VSCODE_PORT': str(self._vscode_port),
|
||||
'APP_PORT_1': str(self._app_ports[0]),
|
||||
'APP_PORT_2': str(self._app_ports[1]),
|
||||
'OPENHANDS_SESSION_ID': str(self.sid),
|
||||
'PIP_BREAK_SYSTEM_PACKAGES': '1',
|
||||
}
|
||||
)
|
||||
if self.config.debug or DEBUG:
|
||||
environment['DEBUG'] = 'true'
|
||||
# Pass DOCKER_HOST_ADDR to spawned containers if it exists
|
||||
if os.environ.get('DOCKER_HOST_ADDR'):
|
||||
environment['DOCKER_HOST_ADDR'] = os.environ['DOCKER_HOST_ADDR']
|
||||
# also update with runtime_startup_env_vars
|
||||
environment.update(self.config.sandbox.runtime_startup_env_vars)
|
||||
|
||||
self.log('debug', f'Workspace Base: {self.config.workspace_base}')
|
||||
|
||||
# Process volumes for mounting
|
||||
volumes = self._process_volumes()
|
||||
|
||||
# If no volumes were configured, set to None
|
||||
if not volumes:
|
||||
logger.debug(
|
||||
'Mount dir is not set, will not mount the workspace directory to the container'
|
||||
)
|
||||
volumes = {} # Empty dict instead of None to satisfy mypy
|
||||
self.log(
|
||||
'debug',
|
||||
f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}',
|
||||
)
|
||||
|
||||
command = self.get_action_execution_server_startup_command()
|
||||
self.log('info', f'Starting server with command: {command}')
|
||||
|
||||
if self.config.sandbox.enable_gpu:
|
||||
gpu_ids = self.config.sandbox.cuda_visible_devices
|
||||
if gpu_ids is None:
|
||||
device_requests = [
|
||||
docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)
|
||||
]
|
||||
else:
|
||||
device_requests = [
|
||||
docker.types.DeviceRequest(
|
||||
capabilities=[['gpu']],
|
||||
device_ids=[str(i) for i in gpu_ids.split(',')],
|
||||
)
|
||||
]
|
||||
else:
|
||||
device_requests = None
|
||||
try:
|
||||
if self.runtime_container_image is None:
|
||||
raise ValueError('Runtime container image is not set')
|
||||
# Process overlay mounts (read-only lower with per-container COW)
|
||||
overlay_mounts = self._process_overlay_mounts()
|
||||
|
||||
self.container = self.docker_client.containers.run(
|
||||
self.runtime_container_image,
|
||||
# Use Docker's tini init process to ensure proper signal handling and reaping of
|
||||
# zombie child processes.
|
||||
init=True,
|
||||
command=command,
|
||||
# Override the default 'bash' entrypoint because the command is a binary.
|
||||
entrypoint=[],
|
||||
network_mode=network_mode,
|
||||
ports=port_mapping,
|
||||
working_dir='/openhands/code/', # do not change this!
|
||||
name=self.container_name,
|
||||
detach=True,
|
||||
environment=environment,
|
||||
volumes=volumes, # type: ignore
|
||||
mounts=overlay_mounts, # type: ignore
|
||||
device_requests=device_requests,
|
||||
**(self.config.sandbox.docker_runtime_kwargs or {}),
|
||||
)
|
||||
self.log('debug', f'Container started. Server url: {self.api_url}')
|
||||
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Error: Instance {self.container_name} FAILED to start container!\n',
|
||||
)
|
||||
self.close()
|
||||
raise e
|
||||
|
||||
def _attach_to_container(self) -> None:
|
||||
self.container = self.docker_client.containers.get(self.container_name)
|
||||
if self.container.status == 'exited':
|
||||
self.container.start()
|
||||
|
||||
config = self.container.attrs['Config']
|
||||
for env_var in config['Env']:
|
||||
if env_var.startswith('port='):
|
||||
self._host_port = int(env_var.split('port=')[1])
|
||||
self._container_port = self._host_port
|
||||
elif env_var.startswith('VSCODE_PORT='):
|
||||
self._vscode_port = int(env_var.split('VSCODE_PORT=')[1])
|
||||
|
||||
self._app_ports = []
|
||||
exposed_ports = config.get('ExposedPorts')
|
||||
if exposed_ports:
|
||||
for exposed_port in exposed_ports.keys():
|
||||
exposed_port = int(exposed_port.split('/tcp')[0])
|
||||
if (
|
||||
exposed_port != self._host_port
|
||||
and exposed_port != self._vscode_port
|
||||
):
|
||||
self._app_ports.append(exposed_port)
|
||||
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
||||
self.log(
|
||||
'debug',
|
||||
f'attached to container: {self.container_name} {self._container_port} {self.api_url}',
|
||||
)
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
retry=tenacity.retry_if_exception(_is_retryablewait_until_alive_error),
|
||||
reraise=True,
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
def wait_until_alive(self) -> None:
|
||||
try:
|
||||
container = self.docker_client.containers.get(self.container_name)
|
||||
if container.status == 'exited':
|
||||
raise AgentRuntimeDisconnectedError(
|
||||
f'Container {self.container_name} has exited.'
|
||||
)
|
||||
except docker.errors.NotFound:
|
||||
raise AgentRuntimeNotFoundError(
|
||||
f'Container {self.container_name} not found.'
|
||||
)
|
||||
|
||||
self.check_if_alive()
|
||||
|
||||
def close(self, rm_all_containers: bool | None = None) -> None:
|
||||
"""Closes the DockerRuntime and associated objects
|
||||
|
||||
Parameters:
|
||||
- rm_all_containers (bool): Whether to remove all containers with the 'openhands-sandbox-' prefix
|
||||
"""
|
||||
super().close()
|
||||
if self.log_streamer:
|
||||
self.log_streamer.close()
|
||||
|
||||
if rm_all_containers is None:
|
||||
rm_all_containers = self.config.sandbox.rm_all_containers
|
||||
|
||||
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
|
||||
return
|
||||
close_prefix = (
|
||||
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
|
||||
)
|
||||
stop_all_containers(close_prefix)
|
||||
self._release_port_locks()
|
||||
|
||||
def _release_port_locks(self) -> None:
|
||||
"""Release all acquired port locks."""
|
||||
if self._host_port_lock:
|
||||
self._host_port_lock.release()
|
||||
self._host_port_lock = None
|
||||
logger.debug(f'Released host port lock for port {self._host_port}')
|
||||
|
||||
if self._vscode_port_lock:
|
||||
self._vscode_port_lock.release()
|
||||
self._vscode_port_lock = None
|
||||
logger.debug(f'Released VSCode port lock for port {self._vscode_port}')
|
||||
|
||||
for i, lock in enumerate(self._app_port_locks):
|
||||
if lock:
|
||||
lock.release()
|
||||
logger.debug(
|
||||
f'Released app port lock for port {self._app_ports[i] if i < len(self._app_ports) else "unknown"}'
|
||||
)
|
||||
|
||||
self._app_port_locks.clear()
|
||||
|
||||
def _is_port_in_use_docker(self, port: int) -> bool:
|
||||
containers = self.docker_client.containers.list()
|
||||
for container in containers:
|
||||
container_ports = container.ports
|
||||
if str(port) in str(container_ports):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _find_available_port_with_lock(
|
||||
self, port_range: tuple[int, int], max_attempts: int = 5
|
||||
) -> tuple[int, PortLock | None]:
|
||||
"""Find an available port with race condition protection.
|
||||
|
||||
This method uses file-based locking to prevent multiple workers
|
||||
from allocating the same port simultaneously.
|
||||
|
||||
Args:
|
||||
port_range: Tuple of (min_port, max_port)
|
||||
max_attempts: Maximum number of attempts to find a port
|
||||
|
||||
Returns:
|
||||
Tuple of (port_number, port_lock) where port_lock may be None if locking failed
|
||||
"""
|
||||
# Try to find and lock an available port
|
||||
result = find_available_port_with_lock(
|
||||
min_port=port_range[0],
|
||||
max_port=port_range[1],
|
||||
max_attempts=max_attempts,
|
||||
bind_address='0.0.0.0',
|
||||
lock_timeout=1.0,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
# Fallback to original method if port locking fails
|
||||
logger.warning(
|
||||
f'Port locking failed for range {port_range}, falling back to original method'
|
||||
)
|
||||
port = port_range[1]
|
||||
for _ in range(max_attempts):
|
||||
port = find_available_tcp_port(port_range[0], port_range[1])
|
||||
if not self._is_port_in_use_docker(port):
|
||||
return port, None
|
||||
return port, None
|
||||
|
||||
port, port_lock = result
|
||||
|
||||
# Additional check with Docker to ensure port is not in use
|
||||
if self._is_port_in_use_docker(port):
|
||||
port_lock.release()
|
||||
# Try again with a different port
|
||||
logger.debug(f'Port {port} is in use by Docker, trying again')
|
||||
return self._find_available_port_with_lock(port_range, max_attempts - 1)
|
||||
|
||||
return port, port_lock
|
||||
|
||||
def _find_available_port(
|
||||
self, port_range: tuple[int, int], max_attempts: int = 5
|
||||
) -> int:
|
||||
"""Find an available port (legacy method for backward compatibility)."""
|
||||
port, _ = self._find_available_port_with_lock(port_range, max_attempts)
|
||||
return port
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
token = super().get_vscode_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
vscode_url = f'http://localhost:{self._vscode_port}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
return vscode_url
|
||||
|
||||
@property
|
||||
def web_hosts(self) -> dict[str, int]:
|
||||
hosts: dict[str, int] = {}
|
||||
|
||||
host_addr = os.environ.get('DOCKER_HOST_ADDR', 'localhost')
|
||||
for port in self._app_ports:
|
||||
hosts[f'http://{host_addr}:{port}'] = port
|
||||
|
||||
return hosts
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause the runtime by stopping the container.
|
||||
This is different from container.stop() as it ensures environment variables are properly preserved.
|
||||
"""
|
||||
if not self.container:
|
||||
raise RuntimeError('Container not initialized')
|
||||
|
||||
# First, ensure all environment variables are properly persisted in .bashrc
|
||||
# This is already handled by add_env_vars in base.py
|
||||
|
||||
# Stop the container
|
||||
self.container.stop()
|
||||
self.log('debug', f'Container {self.container_name} paused')
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the runtime by starting the container.
|
||||
This is different from container.start() as it ensures environment variables are properly restored.
|
||||
"""
|
||||
if not self.container:
|
||||
raise RuntimeError('Container not initialized')
|
||||
|
||||
# Start the container
|
||||
self.container.start()
|
||||
self.log('debug', f'Container {self.container_name} resumed')
|
||||
|
||||
# Wait for the container to be ready
|
||||
self.wait_until_alive()
|
||||
|
||||
@classmethod
|
||||
async def delete(cls, conversation_id: str) -> None:
|
||||
docker_client = cls._init_docker_client()
|
||||
try:
|
||||
container_name = CONTAINER_NAME_PREFIX + conversation_id
|
||||
container = docker_client.containers.get(container_name)
|
||||
container.remove(force=True)
|
||||
except docker.errors.APIError:
|
||||
pass
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
finally:
|
||||
docker_client.close()
|
||||
|
||||
def get_action_execution_server_startup_command(self) -> list[str]:
|
||||
return get_action_execution_server_startup_command(
|
||||
server_port=self._container_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
main_module=self.main_module,
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
# OpenHands Kubernetes Runtime
|
||||
|
||||
This directory contains the Kubernetes runtime implementation for OpenHands, which allows the software to run on Kubernetes clusters for scalable and isolated execution environments.
|
||||
|
||||
## Local Development with KIND
|
||||
|
||||
For local development and testing, OpenHands provides a convenient setup using KIND (Kubernetes IN Docker) that creates a local Kubernetes cluster.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before setting up the local Kubernetes environment, ensure you have the following tools installed:
|
||||
|
||||
1. **KIND (Kubernetes IN Docker)** - [Installation Guide](https://kind.sigs.k8s.io/docs/user/quick-start/)
|
||||
|
||||
2. **kubectl** - [Installation Guide](https://kubernetes.io/docs/tasks/tools/#kubectl)
|
||||
|
||||
3. **mirrord** - [Installation Guide](https://metalbear.co/mirrord/docs/overview/quick-start/#installation)
|
||||
|
||||
MirrorD is used for network mirroring allowing the locally running process to interact with the kubernetes cluster as if it were running inside of kubernetes.
|
||||
|
||||
4. **Docker or Podman** - Required for KIND to work
|
||||
- Docker: Follow the official Docker installation guide for your platform
|
||||
- Podman: [Installation Guide](https://podman.io/docs/installation)
|
||||
|
||||
### Configuration
|
||||
|
||||
To use the Kubernetes runtime, you need to configure OpenHands properly. The configuration is done through a TOML configuration file.
|
||||
|
||||
#### Required Configuration
|
||||
|
||||
Two configuration options are required to use the Kubernetes runtime:
|
||||
|
||||
1. **Runtime Type**: Set the runtime to use Kubernetes
|
||||
|
||||
```toml
|
||||
[core]
|
||||
runtime = "kubernetes"
|
||||
```
|
||||
|
||||
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
|
||||
```toml
|
||||
[sandbox]
|
||||
runtime_container_image = "docker.openhands.dev/openhands/runtime:1.2-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
OpenHands provides extensive configuration options for Kubernetes deployments under the `[kubernetes]` section. These options allow you to customize:
|
||||
|
||||
- Kubernetes namespace
|
||||
- Persistent volume configuration
|
||||
- Ingress and networking settings
|
||||
- Runtime Pod Security settings
|
||||
- Resource limits and requests
|
||||
|
||||
For a complete list of available Kubernetes configuration options, refer to the `[kubernetes]` section in the `config.template.toml` file in the repository root.
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### Quick Start
|
||||
|
||||
To set up and run OpenHands with the Kubernetes runtime locally:
|
||||
|
||||
First build the application with
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Then
|
||||
|
||||
```bash
|
||||
make kind # target is stateless and will check for an existing kind cluster or make a new one if not present.
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
1. **Check Dependencies**: Verify that `kind`, `kubectl`, and `mirrord` are installed
|
||||
2. **Create KIND Cluster**: Create a local Kubernetes cluster named "local-hands" using the configuration in `kind/cluster.yaml`
|
||||
3. **Deploy Infrastructure**: Apply Kubernetes manifests including:
|
||||
- Ubuntu development pod for runtime execution
|
||||
- Nginx ingress controller for HTTP routing
|
||||
- RBAC configurations for proper permissions
|
||||
4. **Setup Mirrord**: Install mirrord resources for development workflow
|
||||
5. **Run Application**: Execute `make run` inside the mirrord environment
|
||||
|
||||
### Cluster Configuration
|
||||
|
||||
The KIND cluster is configured with:
|
||||
|
||||
- **Cluster Name**: `local-hands`
|
||||
- **Node Configuration**: Single control-plane node
|
||||
- **Port Mapping**: Host port 80 maps to container port 80 for nginx ingress
|
||||
- **Base Image**: Ubuntu 22.04 for the development environment
|
||||
|
||||
### Infrastructure Components
|
||||
|
||||
The local setup includes several Kubernetes resources:
|
||||
|
||||
#### Development Environment
|
||||
|
||||
- **Deployment**: `ubuntu-dev` - Ubuntu 22.04 container for code execution
|
||||
- **Service**: Exposes the development environment within the cluster
|
||||
|
||||
#### Ingress Controller (Nginx)
|
||||
|
||||
- **Namespace**: `ingress-nginx` - Dedicated namespace for ingress resources
|
||||
- **Deployment**: `ingress-nginx-controller` - Handles HTTP routing and load balancing
|
||||
- **Service**: LoadBalancer service for external access
|
||||
- **ConfigMap**: Custom configuration for nginx controller
|
||||
- **RBAC**: Roles and bindings for proper cluster permissions
|
||||
|
||||
#### Development Workflow
|
||||
|
||||
- **Mirrord Integration**: Allows running local development server while connecting to cluster resources
|
||||
- **Port Forwarding**: Direct access to cluster services from localhost
|
||||
|
||||
### Usage
|
||||
|
||||
Once the environment is set up with `make kind`, the system will:
|
||||
|
||||
1. Wait for all deployments to be ready
|
||||
2. Automatically start the OpenHands application using mirrord
|
||||
3. Provide access to the application at http://127.0.0.1:3000/
|
||||
|
||||
The mirrord integration allows you to develop locally while your application has access to the Kubernetes cluster resources, providing a seamless development experience that mirrors production behavior.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. **Check cluster status**: `kubectl get nodes`
|
||||
2. **Verify deployments**: `kubectl get deployments --all-namespaces`
|
||||
3. **Check ingress**: `kubectl get ingress --all-namespaces`
|
||||
4. **View logs**: `kubectl logs -l app=ubuntu-dev`
|
||||
|
||||
To clean up the environment:
|
||||
|
||||
```bash
|
||||
kind delete cluster --name local-hands
|
||||
```
|
||||
@@ -0,0 +1,759 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from functools import lru_cache
|
||||
from typing import Callable
|
||||
from uuid import UUID
|
||||
|
||||
import tenacity
|
||||
import yaml
|
||||
from kubernetes import client, config
|
||||
from kubernetes.client.models import (
|
||||
V1Container,
|
||||
V1ContainerPort,
|
||||
V1EnvVar,
|
||||
V1HTTPIngressPath,
|
||||
V1HTTPIngressRuleValue,
|
||||
V1Ingress,
|
||||
V1IngressBackend,
|
||||
V1IngressRule,
|
||||
V1IngressServiceBackend,
|
||||
V1IngressSpec,
|
||||
V1IngressTLS,
|
||||
V1ObjectMeta,
|
||||
V1PersistentVolumeClaim,
|
||||
V1PersistentVolumeClaimSpec,
|
||||
V1PersistentVolumeClaimVolumeSource,
|
||||
V1Pod,
|
||||
V1PodSpec,
|
||||
V1ResourceRequirements,
|
||||
V1SecurityContext,
|
||||
V1Service,
|
||||
V1ServiceBackendPort,
|
||||
V1ServicePort,
|
||||
V1ServiceSpec,
|
||||
V1Toleration,
|
||||
V1Volume,
|
||||
V1VolumeMount,
|
||||
)
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.exceptions import (
|
||||
AgentRuntimeDisconnectedError,
|
||||
AgentRuntimeNotFoundError,
|
||||
)
|
||||
from openhands.core.logger import DEBUG
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.shutdown_listener import add_shutdown_listener
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
POD_NAME_PREFIX = 'openhands-runtime-'
|
||||
POD_LABEL = 'openhands-runtime'
|
||||
|
||||
|
||||
class KubernetesRuntime(ActionExecutionClient):
|
||||
"""A Kubernetes runtime for OpenHands that works with Kind.
|
||||
|
||||
This runtime creates pods in a Kubernetes cluster to run the agent code.
|
||||
It uses the Kubernetes Python client to create and manage the pods.
|
||||
|
||||
Args:
|
||||
config (OpenHandsConfig): The application configuration.
|
||||
event_stream (EventStream): The event stream to subscribe to.
|
||||
sid (str, optional): The session ID. Defaults to 'default'.
|
||||
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
status_callback (Callable | None, optional): Callback for status updates. Defaults to None.
|
||||
attach_to_existing (bool, optional): Whether to attach to an existing pod. Defaults to False.
|
||||
headless_mode (bool, optional): Whether to run in headless mode. Defaults to True.
|
||||
"""
|
||||
|
||||
_shutdown_listener_id: UUID | None = None
|
||||
_namespace: str = ''
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
llm_registry: LLMRegistry,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
if not KubernetesRuntime._shutdown_listener_id:
|
||||
KubernetesRuntime._shutdown_listener_id = add_shutdown_listener(
|
||||
lambda: KubernetesRuntime._cleanup_k8s_resources(
|
||||
namespace=self._k8s_namespace,
|
||||
remove_pvc=True,
|
||||
conversation_id=self.sid,
|
||||
) # this is when you ctrl+c.
|
||||
)
|
||||
self.config = config
|
||||
self._runtime_initialized: bool = False
|
||||
self.status_callback = status_callback
|
||||
|
||||
# Load and validate Kubernetes configuration
|
||||
if self.config.kubernetes is None:
|
||||
raise ValueError(
|
||||
'Kubernetes configuration is required when using KubernetesRuntime. '
|
||||
'Please add a [kubernetes] section to your configuration.'
|
||||
)
|
||||
|
||||
self._k8s_config = self.config.kubernetes
|
||||
self._k8s_namespace = self._k8s_config.namespace
|
||||
KubernetesRuntime._namespace = self._k8s_namespace
|
||||
|
||||
# Initialize ports with default values in the required range
|
||||
self._container_port = 8080 # Default internal container port
|
||||
self._vscode_port = 8081 # Default VSCode port.
|
||||
self._app_ports: list[int] = [
|
||||
30082,
|
||||
30083,
|
||||
] # Default app ports in valid range # The agent prefers these when exposing an application.
|
||||
|
||||
self.k8s_client, self.k8s_networking_client = self._init_kubernetes_client()
|
||||
|
||||
self.pod_image = self.config.sandbox.runtime_container_image
|
||||
if not self.pod_image:
|
||||
# If runtime_container_image isn't set, use the base_container_image as a fallback
|
||||
self.pod_image = self.config.sandbox.base_container_image
|
||||
|
||||
self.pod_name = POD_NAME_PREFIX + sid
|
||||
|
||||
# Initialize the API URL with the initial port value
|
||||
self.k8s_local_url = f'http://{self._get_svc_name(self.pod_name)}.{self._k8s_namespace}.svc.cluster.local'
|
||||
self.api_url = f'{self.k8s_local_url}:{self._container_port}'
|
||||
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
llm_registry,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_svc_name(pod_name: str) -> str:
|
||||
"""Get the service name for the pod."""
|
||||
return f'{pod_name}-svc'
|
||||
|
||||
@staticmethod
|
||||
def _get_vscode_svc_name(pod_name: str) -> str:
|
||||
"""Get the VSCode service name for the pod."""
|
||||
return f'{pod_name}-svc-code'
|
||||
|
||||
@staticmethod
|
||||
def _get_vscode_ingress_name(pod_name: str) -> str:
|
||||
"""Get the VSCode ingress name for the pod."""
|
||||
return f'{pod_name}-ingress-code'
|
||||
|
||||
@staticmethod
|
||||
def _get_vscode_tls_secret_name(pod_name: str) -> str:
|
||||
"""Get the TLS secret name for the VSCode ingress."""
|
||||
return f'{pod_name}-tls-secret'
|
||||
|
||||
@staticmethod
|
||||
def _get_pvc_name(pod_name: str) -> str:
|
||||
"""Get the PVC name for the pod."""
|
||||
return f'{pod_name}-pvc'
|
||||
|
||||
@staticmethod
|
||||
def _get_pod_name(sid: str) -> str:
|
||||
"""Get the pod name for the session."""
|
||||
return POD_NAME_PREFIX + sid
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self):
|
||||
return self.api_url
|
||||
|
||||
@property
|
||||
def node_selector(self) -> dict[str, str] | None:
|
||||
if (
|
||||
not self._k8s_config.node_selector_key
|
||||
or not self._k8s_config.node_selector_val
|
||||
):
|
||||
return None
|
||||
return {self._k8s_config.node_selector_key: self._k8s_config.node_selector_val}
|
||||
|
||||
@property
|
||||
def tolerations(self) -> list[V1Toleration] | None:
|
||||
if not self._k8s_config.tolerations_yaml:
|
||||
return None
|
||||
tolerations_yaml_str = self._k8s_config.tolerations_yaml
|
||||
tolerations = []
|
||||
try:
|
||||
tolerations_data = yaml.safe_load(tolerations_yaml_str)
|
||||
if isinstance(tolerations_data, list):
|
||||
for toleration in tolerations_data:
|
||||
tolerations.append(V1Toleration(**toleration))
|
||||
else:
|
||||
logger.error(
|
||||
f'Invalid tolerations format. Should be type list: {tolerations_yaml_str}. Expected a list.'
|
||||
)
|
||||
return None
|
||||
except yaml.YAMLError as e:
|
||||
logger.error(
|
||||
f'Error parsing tolerations YAML: {tolerations_yaml_str}. Error: {e}'
|
||||
)
|
||||
return None
|
||||
return tolerations
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to the runtime by creating or attaching to a pod."""
|
||||
self.log('info', f'Connecting to runtime with conversation ID: {self.sid}')
|
||||
self.log('info', f'self._attach_to_existing: {self.attach_to_existing}')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
self.log('info', f'Using API URL {self.api_url}')
|
||||
|
||||
try:
|
||||
await call_sync_from_async(self._attach_to_pod)
|
||||
except client.rest.ApiException as e:
|
||||
# we are not set to attach to existing, ignore error and init k8s resources.
|
||||
if self.attach_to_existing:
|
||||
self.log(
|
||||
'error',
|
||||
f'Pod {self.pod_name} not found or cannot connect to it.',
|
||||
)
|
||||
raise AgentRuntimeDisconnectedError from e
|
||||
|
||||
self.log('info', f'Starting runtime with image: {self.pod_image}')
|
||||
try:
|
||||
await call_sync_from_async(self._init_k8s_resources)
|
||||
self.log(
|
||||
'info',
|
||||
f'Pod started: {self.pod_name}. VSCode URL: {self.vscode_url}',
|
||||
)
|
||||
except Exception as init_error:
|
||||
self.log('error', f'Failed to initialize k8s resources: {init_error}')
|
||||
raise AgentRuntimeNotFoundError(
|
||||
f'Failed to initialize kubernetes resources: {init_error}'
|
||||
) from init_error
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', 'Waiting for pod to become ready ...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
try:
|
||||
await call_sync_from_async(self._wait_until_ready)
|
||||
except Exception as alive_error:
|
||||
self.log('error', f'Failed to connect to runtime: {alive_error}')
|
||||
self.set_runtime_status(
|
||||
RuntimeStatus.ERROR_RUNTIME_DISCONNECTED,
|
||||
f'Failed to connect to runtime: {alive_error}',
|
||||
)
|
||||
raise AgentRuntimeDisconnectedError(
|
||||
f'Failed to connect to runtime: {alive_error}'
|
||||
) from alive_error
|
||||
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', 'Runtime is ready.')
|
||||
|
||||
if not self.attach_to_existing:
|
||||
await call_sync_from_async(self.setup_initial_env)
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
f'Pod initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}',
|
||||
)
|
||||
if not self.attach_to_existing:
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
self._runtime_initialized = True
|
||||
|
||||
def _attach_to_pod(self):
|
||||
"""Attach to an existing pod."""
|
||||
try:
|
||||
pod = self.k8s_client.read_namespaced_pod(
|
||||
name=self.pod_name, namespace=self._k8s_namespace
|
||||
)
|
||||
|
||||
if pod.status.phase != 'Running':
|
||||
try:
|
||||
self._wait_until_ready()
|
||||
except TimeoutError:
|
||||
raise AgentRuntimeDisconnectedError(
|
||||
f'Pod {self.pod_name} exists but failed to become ready.'
|
||||
)
|
||||
|
||||
self.log('info', f'Successfully attached to pod {self.pod_name}')
|
||||
return True
|
||||
|
||||
except client.rest.ApiException as e:
|
||||
self.log('error', f'Failed to attach to pod: {e}')
|
||||
raise
|
||||
|
||||
@tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(300) | stop_if_should_exit(),
|
||||
retry=tenacity.retry_if_exception_type(TimeoutError),
|
||||
reraise=True,
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
def _wait_until_ready(self):
|
||||
"""Wait until the runtime server is alive by checking the pod status in Kubernetes."""
|
||||
self.log('info', f'Checking if pod {self.pod_name} is ready in Kubernetes')
|
||||
pod = self.k8s_client.read_namespaced_pod(
|
||||
name=self.pod_name, namespace=self._k8s_namespace
|
||||
)
|
||||
if pod.status.phase == 'Running' and pod.status.conditions:
|
||||
for condition in pod.status.conditions:
|
||||
if condition.type == 'Ready' and condition.status == 'True':
|
||||
self.log('info', f'Pod {self.pod_name} is ready!')
|
||||
return True # Exit the function if the pod is ready
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
f'Pod {self.pod_name} is not ready yet. Current phase: {pod.status.phase}',
|
||||
)
|
||||
raise TimeoutError(f'Pod {self.pod_name} is not in Running state yet.')
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=1)
|
||||
def _init_kubernetes_client() -> tuple[client.CoreV1Api, client.NetworkingV1Api]:
|
||||
"""Initialize the Kubernetes client."""
|
||||
try:
|
||||
config.load_incluster_config() # Even local usage with mirrord technically uses an incluster config.
|
||||
return client.CoreV1Api(), client.NetworkingV1Api()
|
||||
except Exception as ex:
|
||||
logger.error(
|
||||
'Failed to initialize Kubernetes client. Make sure you have kubectl configured correctly or are running in a Kubernetes cluster.',
|
||||
)
|
||||
raise ex
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_k8s_resources(
|
||||
namespace: str, remove_pvc: bool = False, conversation_id: str = ''
|
||||
):
|
||||
"""Clean up Kubernetes resources with our prefix in the namespace.
|
||||
|
||||
:param remove_pvc: If True, also remove persistent volume claims (defaults to False).
|
||||
"""
|
||||
try:
|
||||
k8s_api, k8s_networking_api = KubernetesRuntime._init_kubernetes_client()
|
||||
|
||||
pod_name = KubernetesRuntime._get_pod_name(conversation_id)
|
||||
service_name = KubernetesRuntime._get_svc_name(pod_name)
|
||||
vscode_service_name = KubernetesRuntime._get_vscode_svc_name(pod_name)
|
||||
ingress_name = KubernetesRuntime._get_vscode_ingress_name(pod_name)
|
||||
pvc_name = KubernetesRuntime._get_pvc_name(pod_name)
|
||||
|
||||
try:
|
||||
if remove_pvc:
|
||||
# Delete PVC if requested
|
||||
k8s_api.delete_namespaced_persistent_volume_claim(
|
||||
name=pvc_name,
|
||||
namespace=namespace,
|
||||
body=client.V1DeleteOptions(),
|
||||
)
|
||||
logger.info(f'Deleted PVC {pvc_name}')
|
||||
|
||||
k8s_api.delete_namespaced_pod(
|
||||
name=pod_name,
|
||||
namespace=namespace,
|
||||
body=client.V1DeleteOptions(),
|
||||
)
|
||||
logger.info(f'Deleted pod {pod_name}')
|
||||
|
||||
k8s_api.delete_namespaced_service(
|
||||
name=service_name,
|
||||
namespace=namespace,
|
||||
)
|
||||
logger.info(f'Deleted service {service_name}')
|
||||
# Delete the vs code service
|
||||
k8s_api.delete_namespaced_service(
|
||||
name=vscode_service_name, namespace=namespace
|
||||
)
|
||||
logger.info(f'Deleted service {vscode_service_name}')
|
||||
|
||||
k8s_networking_api.delete_namespaced_ingress(
|
||||
name=ingress_name, namespace=namespace
|
||||
)
|
||||
logger.info(f'Deleted ingress {ingress_name}')
|
||||
except client.rest.ApiException:
|
||||
# Service might not exist, ignore
|
||||
pass
|
||||
logger.info('Cleaned up Kubernetes resources')
|
||||
except Exception as e:
|
||||
logger.error(f'Error cleaning up k8s resources: {e}')
|
||||
|
||||
def _get_pvc_manifest(self):
|
||||
"""Create a PVC manifest for the runtime pod."""
|
||||
# Create PVC
|
||||
pvc = V1PersistentVolumeClaim(
|
||||
api_version='v1',
|
||||
kind='PersistentVolumeClaim',
|
||||
metadata=V1ObjectMeta(
|
||||
name=self._get_pvc_name(self.pod_name), namespace=self._k8s_namespace
|
||||
),
|
||||
spec=V1PersistentVolumeClaimSpec(
|
||||
access_modes=['ReadWriteOnce'],
|
||||
resources=client.V1ResourceRequirements(
|
||||
requests={'storage': self._k8s_config.pvc_storage_size}
|
||||
),
|
||||
storage_class_name=self._k8s_config.pvc_storage_class,
|
||||
),
|
||||
)
|
||||
|
||||
return pvc
|
||||
|
||||
def _get_vscode_service_manifest(self):
|
||||
"""Create a service manifest for the VSCode server."""
|
||||
vscode_service_spec = V1ServiceSpec(
|
||||
selector={'app': POD_LABEL, 'session': self.sid},
|
||||
type='ClusterIP',
|
||||
ports=[
|
||||
V1ServicePort(
|
||||
port=self._vscode_port,
|
||||
target_port='vscode',
|
||||
name='code',
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
vscode_service = V1Service(
|
||||
metadata=V1ObjectMeta(name=self._get_vscode_svc_name(self.pod_name)),
|
||||
spec=vscode_service_spec,
|
||||
)
|
||||
return vscode_service
|
||||
|
||||
def _get_runtime_service_manifest(self):
|
||||
"""Create a service manifest for the runtime pod execution-server."""
|
||||
service_spec = V1ServiceSpec(
|
||||
selector={'app': POD_LABEL, 'session': self.sid},
|
||||
type='ClusterIP',
|
||||
ports=[
|
||||
V1ServicePort(
|
||||
port=self._container_port,
|
||||
target_port='http',
|
||||
name='execution-server',
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
service = V1Service(
|
||||
metadata=V1ObjectMeta(name=self._get_svc_name(self.pod_name)),
|
||||
spec=service_spec,
|
||||
)
|
||||
return service
|
||||
|
||||
def _get_runtime_pod_manifest(self):
|
||||
"""Create a pod manifest for the runtime sandbox."""
|
||||
# Prepare environment variables
|
||||
environment = [
|
||||
V1EnvVar(name='port', value=str(self._container_port)),
|
||||
V1EnvVar(name='PYTHONUNBUFFERED', value='1'),
|
||||
V1EnvVar(name='VSCODE_PORT', value=str(self._vscode_port)),
|
||||
]
|
||||
|
||||
if self.config.debug or DEBUG:
|
||||
environment.append(V1EnvVar(name='DEBUG', value='true'))
|
||||
|
||||
# Add runtime startup env vars
|
||||
for key, value in self.config.sandbox.runtime_startup_env_vars.items():
|
||||
environment.append(V1EnvVar(name=key, value=value))
|
||||
|
||||
# Prepare volume mounts if workspace is configured
|
||||
volume_mounts = [
|
||||
V1VolumeMount(
|
||||
name='workspace-volume',
|
||||
mount_path=self.config.workspace_mount_path_in_sandbox,
|
||||
),
|
||||
]
|
||||
volumes = [
|
||||
V1Volume(
|
||||
name='workspace-volume',
|
||||
persistent_volume_claim=V1PersistentVolumeClaimVolumeSource(
|
||||
claim_name=self._get_pvc_name(self.pod_name)
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
# Prepare container ports
|
||||
container_ports = [
|
||||
V1ContainerPort(container_port=self._container_port, name='http'),
|
||||
]
|
||||
|
||||
if self.vscode_enabled:
|
||||
container_ports.append(
|
||||
V1ContainerPort(container_port=self._vscode_port, name='vscode')
|
||||
)
|
||||
|
||||
for port in self._app_ports:
|
||||
container_ports.append(V1ContainerPort(container_port=port))
|
||||
|
||||
# Define the readiness probe
|
||||
health_check = client.V1Probe(
|
||||
http_get=client.V1HTTPGetAction(
|
||||
path='/alive',
|
||||
port=self._container_port, # Or the port your application listens on
|
||||
),
|
||||
initial_delay_seconds=5, # Adjust as needed
|
||||
period_seconds=10, # Adjust as needed
|
||||
timeout_seconds=5, # Adjust as needed
|
||||
success_threshold=1,
|
||||
failure_threshold=3,
|
||||
)
|
||||
# Prepare command
|
||||
# Entry point command for generated sandbox runtime pod.
|
||||
command = get_action_execution_server_startup_command(
|
||||
server_port=self._container_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
override_user_id=0, # if we use the default of app_config.run_as_openhands then we cant edit files in vscode due to file perms.
|
||||
override_username='root',
|
||||
)
|
||||
|
||||
# Prepare resource requirements based on config
|
||||
resources = V1ResourceRequirements(
|
||||
limits={'memory': self._k8s_config.resource_memory_limit},
|
||||
requests={
|
||||
'cpu': self._k8s_config.resource_cpu_request,
|
||||
'memory': self._k8s_config.resource_memory_request,
|
||||
},
|
||||
)
|
||||
|
||||
# Set security context for the container
|
||||
security_context = V1SecurityContext(privileged=self._k8s_config.privileged)
|
||||
|
||||
# Create the container definition
|
||||
container = V1Container(
|
||||
name='runtime',
|
||||
image=self.pod_image,
|
||||
command=command,
|
||||
env=environment,
|
||||
ports=container_ports,
|
||||
volume_mounts=volume_mounts,
|
||||
working_dir='/openhands/code/',
|
||||
resources=resources,
|
||||
readiness_probe=health_check,
|
||||
security_context=security_context,
|
||||
)
|
||||
|
||||
# Create the pod definition
|
||||
image_pull_secrets = None
|
||||
if self._k8s_config.image_pull_secret:
|
||||
image_pull_secrets = [
|
||||
client.V1LocalObjectReference(name=self._k8s_config.image_pull_secret)
|
||||
]
|
||||
pod = V1Pod(
|
||||
metadata=V1ObjectMeta(
|
||||
name=self.pod_name, labels={'app': POD_LABEL, 'session': self.sid}
|
||||
),
|
||||
spec=V1PodSpec(
|
||||
containers=[container],
|
||||
volumes=volumes,
|
||||
restart_policy='Never',
|
||||
image_pull_secrets=image_pull_secrets,
|
||||
node_selector=self.node_selector,
|
||||
tolerations=self.tolerations,
|
||||
),
|
||||
)
|
||||
|
||||
return pod
|
||||
|
||||
def _get_vscode_ingress_manifest(self):
|
||||
"""Create an ingress manifest for the VSCode server."""
|
||||
tls = []
|
||||
if self._k8s_config.ingress_tls_secret:
|
||||
runtime_tls = V1IngressTLS(
|
||||
hosts=[self.ingress_domain],
|
||||
secret_name=self._k8s_config.ingress_tls_secret,
|
||||
)
|
||||
tls = [runtime_tls]
|
||||
|
||||
rules = [
|
||||
V1IngressRule(
|
||||
host=self.ingress_domain,
|
||||
http=V1HTTPIngressRuleValue(
|
||||
paths=[
|
||||
V1HTTPIngressPath(
|
||||
path='/',
|
||||
path_type='Prefix',
|
||||
backend=V1IngressBackend(
|
||||
service=V1IngressServiceBackend(
|
||||
port=V1ServiceBackendPort(
|
||||
number=self._vscode_port,
|
||||
),
|
||||
name=self._get_vscode_svc_name(self.pod_name),
|
||||
)
|
||||
),
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
ingress_spec = V1IngressSpec(rules=rules, tls=tls)
|
||||
|
||||
ingress = V1Ingress(
|
||||
api_version='networking.k8s.io/v1',
|
||||
metadata=V1ObjectMeta(
|
||||
name=self._get_vscode_ingress_name(self.pod_name),
|
||||
annotations={
|
||||
'external-dns.alpha.kubernetes.io/hostname': self.ingress_domain
|
||||
},
|
||||
),
|
||||
spec=ingress_spec,
|
||||
)
|
||||
|
||||
return ingress
|
||||
|
||||
def _pvc_exists(self):
|
||||
"""Check if the PVC already exists."""
|
||||
try:
|
||||
pvc = self.k8s_client.read_namespaced_persistent_volume_claim(
|
||||
name=self._get_pvc_name(self.pod_name), namespace=self._k8s_namespace
|
||||
)
|
||||
return pvc is not None
|
||||
except client.rest.ApiException as e:
|
||||
if e.status == 404:
|
||||
return False
|
||||
self.log('error', f'Error checking PVC existence: {e}')
|
||||
|
||||
def _init_k8s_resources(self):
|
||||
"""Initialize the Kubernetes resources."""
|
||||
self.log('info', 'Preparing to start pod...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
self.log('info', f'Runtime will be accessible at {self.api_url}')
|
||||
|
||||
pod = self._get_runtime_pod_manifest()
|
||||
service = self._get_runtime_service_manifest()
|
||||
vscode_service = self._get_vscode_service_manifest()
|
||||
pvc_manifest = self._get_pvc_manifest()
|
||||
ingress = self._get_vscode_ingress_manifest()
|
||||
|
||||
# Create the pod in Kubernetes
|
||||
try:
|
||||
if not self._pvc_exists():
|
||||
# Create PVC if it doesn't exist
|
||||
self.k8s_client.create_namespaced_persistent_volume_claim(
|
||||
namespace=self._k8s_namespace, body=pvc_manifest
|
||||
)
|
||||
self.log('info', f'Created PVC {self._get_pvc_name(self.pod_name)}')
|
||||
self.k8s_client.create_namespaced_pod(
|
||||
namespace=self._k8s_namespace, body=pod
|
||||
)
|
||||
self.log('info', f'Created pod {self.pod_name}.')
|
||||
# Create a service to expose the pod for external access
|
||||
self.k8s_client.create_namespaced_service(
|
||||
namespace=self._k8s_namespace, body=service
|
||||
)
|
||||
self.log('info', f'Created service {self._get_svc_name(self.pod_name)}')
|
||||
|
||||
# Create second service service for the vscode server.
|
||||
self.k8s_client.create_namespaced_service(
|
||||
namespace=self._k8s_namespace, body=vscode_service
|
||||
)
|
||||
self.log(
|
||||
'info', f'Created service {self._get_vscode_svc_name(self.pod_name)}'
|
||||
)
|
||||
|
||||
# create the vscode ingress.
|
||||
self.k8s_networking_client.create_namespaced_ingress(
|
||||
namespace=self._k8s_namespace, body=ingress
|
||||
)
|
||||
self.log(
|
||||
'info',
|
||||
f'Created ingress {self._get_vscode_ingress_name(self.pod_name)}',
|
||||
)
|
||||
|
||||
# Wait for the pod to be running
|
||||
self._wait_until_ready()
|
||||
|
||||
except client.rest.ApiException as e:
|
||||
self.log('error', f'Failed to create pod and services: {e}')
|
||||
raise
|
||||
except RuntimeError as e:
|
||||
self.log('error', f'Port forwarding failed: {e}')
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""Close the runtime and clean up resources."""
|
||||
# this is called when a single conversation question is answered or a tab is closed.
|
||||
self.log(
|
||||
'info',
|
||||
f'Closing runtime and cleaning up resources for conersation ID: {self.sid}',
|
||||
)
|
||||
# Call parent class close method first
|
||||
super().close()
|
||||
|
||||
# Return early if we should keep the runtime alive or if we're attaching to existing
|
||||
if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
|
||||
self.log(
|
||||
'info', 'Keeping runtime alive due to configuration or attach mode'
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
self._cleanup_k8s_resources(
|
||||
namespace=self._k8s_namespace,
|
||||
remove_pvc=False,
|
||||
conversation_id=self.sid,
|
||||
)
|
||||
except Exception as e:
|
||||
self.log('error', f'Error closing runtime: {e}')
|
||||
|
||||
@property
|
||||
def ingress_domain(self) -> str:
|
||||
"""Get the ingress domain for the runtime."""
|
||||
return f'{self.sid}.{self._k8s_config.ingress_domain}'
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
"""Get the URL for VSCode server if enabled."""
|
||||
if not self.vscode_enabled:
|
||||
return None
|
||||
token = super().get_vscode_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
protocol = 'https' if self._k8s_config.ingress_tls_secret else 'http'
|
||||
vscode_url = f'{protocol}://{self.ingress_domain}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
self.log('info', f'VSCode URL: {vscode_url}')
|
||||
return vscode_url
|
||||
|
||||
@property
|
||||
def web_hosts(self) -> dict[str, int]:
|
||||
"""Get web hosts dict mapping for browser access."""
|
||||
hosts = {}
|
||||
for idx, port in enumerate(self._app_ports):
|
||||
hosts[f'{self.k8s_local_url}:{port}'] = port
|
||||
return hosts
|
||||
|
||||
@classmethod
|
||||
async def delete(cls, conversation_id: str):
|
||||
"""Delete resources associated with a conversation."""
|
||||
# This is triggered when you actually do the delete in the UI on the conversation.
|
||||
try:
|
||||
cls._cleanup_k8s_resources(
|
||||
namespace=cls._namespace,
|
||||
remove_pvc=True,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error deleting resources for conversation {conversation_id}: {e}'
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""Local runtime implementation."""
|
||||
|
||||
from openhands.runtime.impl.local.local_runtime import LocalRuntime
|
||||
|
||||
__all__ = ['LocalRuntime']
|
||||
@@ -0,0 +1,843 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""This runtime runs the action_execution_server directly on the local machine without Docker."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import tenacity
|
||||
|
||||
import openhands
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.exceptions import AgentRuntimeDisconnectedError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
Observation,
|
||||
)
|
||||
from openhands.events.serialization import event_to_dict, observation_from_dict
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.impl.docker.docker_runtime import (
|
||||
APP_PORT_RANGE_1,
|
||||
APP_PORT_RANGE_2,
|
||||
EXECUTION_SERVER_PORT_RANGE,
|
||||
VSCODE_PORT_RANGE,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.plugins.vscode import VSCodeRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true'
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionExecutionServerInfo:
|
||||
"""Information about a running server process."""
|
||||
|
||||
process: subprocess.Popen
|
||||
execution_server_port: int
|
||||
vscode_port: int
|
||||
app_ports: list[int]
|
||||
log_thread: threading.Thread
|
||||
log_thread_exit_event: threading.Event
|
||||
temp_workspace: str | None
|
||||
workspace_mount_path: str
|
||||
|
||||
|
||||
# Global dictionary to track running server processes by session ID
|
||||
_RUNNING_SERVERS: dict[str, ActionExecutionServerInfo] = {}
|
||||
|
||||
# Global list to track warm servers waiting for use
|
||||
_WARM_SERVERS: list[ActionExecutionServerInfo] = []
|
||||
|
||||
|
||||
def get_user_info() -> tuple[int, str | None]:
|
||||
"""Get user ID and username in a cross-platform way."""
|
||||
username = os.getenv('USER')
|
||||
if sys.platform == 'win32':
|
||||
# On Windows, we don't use user IDs the same way
|
||||
# Return a default value that won't cause issues
|
||||
return 1000, username
|
||||
else:
|
||||
# On Unix systems, use os.getuid()
|
||||
return os.getuid(), username
|
||||
|
||||
|
||||
def check_dependencies(code_repo_path: str, check_browser: bool) -> None:
|
||||
ERROR_MESSAGE = 'Please follow the instructions in https://github.com/OpenHands/OpenHands/blob/main/Development.md to install OpenHands.'
|
||||
if not os.path.exists(code_repo_path):
|
||||
raise ValueError(
|
||||
f'Code repo path {code_repo_path} does not exist. ' + ERROR_MESSAGE
|
||||
)
|
||||
# Check jupyter is installed
|
||||
logger.debug('Checking dependencies: Jupyter')
|
||||
output = subprocess.check_output(
|
||||
[sys.executable, '-m', 'jupyter', '--version'],
|
||||
text=True,
|
||||
cwd=code_repo_path,
|
||||
)
|
||||
logger.debug(f'Jupyter output: {output}')
|
||||
if 'jupyter' not in output.lower():
|
||||
raise ValueError('Jupyter is not properly installed. ' + ERROR_MESSAGE)
|
||||
|
||||
# Check libtmux is installed (skip on Windows)
|
||||
if sys.platform != 'win32':
|
||||
logger.debug('Checking dependencies: libtmux')
|
||||
import libtmux
|
||||
|
||||
server = libtmux.Server()
|
||||
try:
|
||||
session = server.new_session(session_name='test-session')
|
||||
except Exception:
|
||||
raise ValueError('tmux is not properly installed or available on the path.')
|
||||
pane = session.active_pane
|
||||
|
||||
if pane:
|
||||
pane.send_keys('echo "test"')
|
||||
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)
|
||||
else:
|
||||
pane_output = ''
|
||||
session.kill()
|
||||
if 'test' not in pane_output:
|
||||
raise ValueError('libtmux is not properly installed. ' + ERROR_MESSAGE)
|
||||
|
||||
if check_browser:
|
||||
logger.debug('Checking dependencies: browser')
|
||||
from openhands.runtime.browser.browser_env import BrowserEnv
|
||||
|
||||
browser = BrowserEnv()
|
||||
browser.close()
|
||||
|
||||
|
||||
class LocalRuntime(ActionExecutionClient):
|
||||
"""This runtime will run the action_execution_server directly on the local machine.
|
||||
When receiving an event, it will send the event to the server via HTTP.
|
||||
|
||||
Args:
|
||||
config (OpenHandsConfig): The application configuration.
|
||||
event_stream (EventStream): The event stream to subscribe to.
|
||||
sid (str, optional): The session ID. Defaults to 'default'.
|
||||
plugins (list[PluginRequirement] | None, optional): list of plugin requirements. Defaults to None.
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
llm_registry: LLMRegistry,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable[[str, RuntimeStatus, str], None] | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
) -> None:
|
||||
self.is_windows = sys.platform == 'win32'
|
||||
if self.is_windows:
|
||||
logger.warning(
|
||||
'Running on Windows - some features that require tmux will be limited. '
|
||||
'For full functionality, please consider using WSL or Docker runtime.'
|
||||
)
|
||||
|
||||
self.config = config
|
||||
self._user_id, self._username = get_user_info()
|
||||
|
||||
logger.warning(
|
||||
'Initializing LocalRuntime. WARNING: NO SANDBOX IS USED. '
|
||||
'This is an experimental feature, please report issues to https://github.com/OpenHands/OpenHands/issues. '
|
||||
'`run_as_openhands` will be ignored since the current user will be used to launch the server. '
|
||||
'We highly recommend using a sandbox (eg. DockerRuntime) unless you '
|
||||
'are running in a controlled environment.\n'
|
||||
f'User ID: {self._user_id}. '
|
||||
f'Username: {self._username}.'
|
||||
)
|
||||
|
||||
# Initialize these values to be set in connect()
|
||||
self._temp_workspace: str | None = None
|
||||
self._execution_server_port = -1
|
||||
self._vscode_port = -1
|
||||
self._app_ports: list[int] = []
|
||||
|
||||
self.api_url = (
|
||||
f'{self.config.sandbox.local_runtime_url}:{self._execution_server_port}'
|
||||
)
|
||||
self.status_callback = status_callback
|
||||
self.server_process: subprocess.Popen[str] | None = None
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
self._log_thread_exit_event = threading.Event() # Add exit event
|
||||
|
||||
# Update env vars
|
||||
if self.config.sandbox.runtime_startup_env_vars:
|
||||
os.environ.update(self.config.sandbox.runtime_startup_env_vars)
|
||||
|
||||
# Initialize the action_execution_server
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
llm_registry,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
|
||||
# If there is an API key in the environment we use this in requests to the runtime
|
||||
session_api_key = os.getenv('SESSION_API_KEY')
|
||||
self._session_api_key: str | None = None
|
||||
if session_api_key:
|
||||
self.session.headers['X-Session-API-Key'] = session_api_key
|
||||
self._session_api_key = session_api_key
|
||||
|
||||
@property
|
||||
def session_api_key(self) -> str | None:
|
||||
return self._session_api_key
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self) -> str:
|
||||
return self.api_url
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Start the action_execution_server on the local machine or connect to an existing one."""
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
# Get environment variables for warm server configuration
|
||||
desired_num_warm_servers = int(os.getenv('DESIRED_NUM_WARM_SERVERS', '0'))
|
||||
|
||||
# Check if there's already a server running for this session ID
|
||||
if self.sid in _RUNNING_SERVERS:
|
||||
self.log('info', f'Connecting to existing server for session {self.sid}')
|
||||
server_info = _RUNNING_SERVERS[self.sid]
|
||||
self.server_process = server_info.process
|
||||
self._execution_server_port = server_info.execution_server_port
|
||||
self._log_thread = server_info.log_thread
|
||||
self._log_thread_exit_event = server_info.log_thread_exit_event
|
||||
self._vscode_port = server_info.vscode_port
|
||||
self._app_ports = server_info.app_ports
|
||||
self._temp_workspace = server_info.temp_workspace
|
||||
self.config.workspace_mount_path_in_sandbox = (
|
||||
server_info.workspace_mount_path
|
||||
)
|
||||
self.api_url = (
|
||||
f'{self.config.sandbox.local_runtime_url}:{self._execution_server_port}'
|
||||
)
|
||||
elif self.attach_to_existing:
|
||||
# If we're supposed to attach to an existing server but none exists, raise an error
|
||||
self.log('error', f'No existing server found for session {self.sid}')
|
||||
raise AgentRuntimeDisconnectedError(
|
||||
f'No existing server found for session {self.sid}'
|
||||
)
|
||||
else:
|
||||
# Set up workspace directory
|
||||
# For local runtime, prefer a stable host path over /workspace defaults.
|
||||
if (
|
||||
self.config.workspace_base is None
|
||||
and self.config.runtime
|
||||
and self.config.runtime.lower() == 'local'
|
||||
):
|
||||
env_base = os.getenv('LOCAL_WORKSPACE_BASE')
|
||||
if env_base:
|
||||
self.config.workspace_base = os.path.abspath(env_base)
|
||||
else:
|
||||
self.config.workspace_base = os.path.abspath(
|
||||
os.path.join(os.getcwd(), 'workspace', 'local')
|
||||
)
|
||||
|
||||
if self.config.workspace_base is not None:
|
||||
os.makedirs(self.config.workspace_base, exist_ok=True)
|
||||
logger.warning(
|
||||
f'Workspace base path is set to {self.config.workspace_base}. '
|
||||
'It will be used as the path for the agent to run in. '
|
||||
'Be careful, the agent can EDIT files in this directory!'
|
||||
)
|
||||
self.config.workspace_mount_path_in_sandbox = self.config.workspace_base
|
||||
self._temp_workspace = None
|
||||
else:
|
||||
# A temporary directory is created for the agent to run in
|
||||
logger.warning(
|
||||
'Workspace base path is NOT set. Agent will run in a temporary directory.'
|
||||
)
|
||||
self._temp_workspace = tempfile.mkdtemp(
|
||||
prefix=f'openhands_workspace_{self.sid}',
|
||||
)
|
||||
self.config.workspace_mount_path_in_sandbox = self._temp_workspace
|
||||
|
||||
logger.info(
|
||||
f'Using workspace directory: {self.config.workspace_mount_path_in_sandbox}'
|
||||
)
|
||||
|
||||
# Check if we have a warm server available
|
||||
warm_server_available = False
|
||||
if _WARM_SERVERS and not self.attach_to_existing:
|
||||
try:
|
||||
# Pop a warm server from the list
|
||||
self.log('info', 'Using a warm server')
|
||||
server_info = _WARM_SERVERS.pop(0)
|
||||
|
||||
# Use the warm server
|
||||
self.server_process = server_info.process
|
||||
self._execution_server_port = server_info.execution_server_port
|
||||
self._log_thread = server_info.log_thread
|
||||
self._log_thread_exit_event = server_info.log_thread_exit_event
|
||||
self._vscode_port = server_info.vscode_port
|
||||
self._app_ports = server_info.app_ports
|
||||
|
||||
# We need to clean up the warm server's temp workspace and create a new one
|
||||
if server_info.temp_workspace:
|
||||
shutil.rmtree(server_info.temp_workspace)
|
||||
|
||||
# Create a new temp workspace for this session
|
||||
if (
|
||||
self._temp_workspace is None
|
||||
and self.config.workspace_base is None
|
||||
):
|
||||
self._temp_workspace = tempfile.mkdtemp(
|
||||
prefix=f'openhands_workspace_{self.sid}',
|
||||
)
|
||||
self.config.workspace_mount_path_in_sandbox = (
|
||||
self._temp_workspace
|
||||
)
|
||||
|
||||
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._execution_server_port}'
|
||||
|
||||
# Store the server process in the global dictionary with the new workspace
|
||||
_RUNNING_SERVERS[self.sid] = ActionExecutionServerInfo(
|
||||
process=self.server_process,
|
||||
execution_server_port=self._execution_server_port,
|
||||
vscode_port=self._vscode_port,
|
||||
app_ports=self._app_ports,
|
||||
log_thread=self._log_thread,
|
||||
log_thread_exit_event=self._log_thread_exit_event,
|
||||
temp_workspace=self._temp_workspace,
|
||||
workspace_mount_path=self.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
|
||||
warm_server_available = True
|
||||
except IndexError:
|
||||
# No warm servers available
|
||||
self.log('info', 'No warm servers available, starting a new server')
|
||||
warm_server_available = False
|
||||
except Exception as e:
|
||||
# Error using warm server
|
||||
self.log('error', f'Error using warm server: {e}')
|
||||
warm_server_available = False
|
||||
|
||||
# If no warm server is available, start a new one
|
||||
if not warm_server_available:
|
||||
# Create a new server
|
||||
server_info, api_url = _create_server(
|
||||
config=self.config,
|
||||
plugins=self.plugins,
|
||||
workspace_prefix=self.sid,
|
||||
)
|
||||
|
||||
# Set instance variables
|
||||
self.server_process = server_info.process
|
||||
self._execution_server_port = server_info.execution_server_port
|
||||
self._vscode_port = server_info.vscode_port
|
||||
self._app_ports = server_info.app_ports
|
||||
self._log_thread = server_info.log_thread
|
||||
self._log_thread_exit_event = server_info.log_thread_exit_event
|
||||
|
||||
# We need to use the existing temp workspace, not the one created by _create_server
|
||||
if (
|
||||
server_info.temp_workspace
|
||||
and server_info.temp_workspace != self._temp_workspace
|
||||
):
|
||||
shutil.rmtree(server_info.temp_workspace)
|
||||
|
||||
self.api_url = api_url
|
||||
|
||||
# Store the server process in the global dictionary with the correct workspace
|
||||
_RUNNING_SERVERS[self.sid] = ActionExecutionServerInfo(
|
||||
process=self.server_process,
|
||||
execution_server_port=self._execution_server_port,
|
||||
vscode_port=self._vscode_port,
|
||||
app_ports=self._app_ports,
|
||||
log_thread=self._log_thread,
|
||||
log_thread_exit_event=self._log_thread_exit_event,
|
||||
temp_workspace=self._temp_workspace,
|
||||
workspace_mount_path=self.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
|
||||
self.log('info', f'Waiting for server to become ready at {self.api_url}...')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
|
||||
await call_sync_from_async(self._wait_until_alive)
|
||||
|
||||
if not self.attach_to_existing:
|
||||
await call_sync_from_async(self.setup_initial_env)
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'Server initialized with plugins: {[plugin.name for plugin in self.plugins]}',
|
||||
)
|
||||
if not self.attach_to_existing:
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
self._runtime_initialized = True
|
||||
|
||||
# Check if we need to create more warm servers after connecting
|
||||
if (
|
||||
desired_num_warm_servers > 0
|
||||
and len(_WARM_SERVERS) < desired_num_warm_servers
|
||||
):
|
||||
num_to_create = desired_num_warm_servers - len(_WARM_SERVERS)
|
||||
self.log(
|
||||
'info',
|
||||
f'Creating {num_to_create} additional warm servers to reach desired count',
|
||||
)
|
||||
for _ in range(num_to_create):
|
||||
_create_warm_server_in_background(self.config, self.plugins)
|
||||
|
||||
@classmethod
|
||||
def setup(cls, config: OpenHandsConfig, headless_mode: bool = False):
|
||||
should_check_dependencies = os.getenv('SKIP_DEPENDENCY_CHECK', '') != '1'
|
||||
if should_check_dependencies:
|
||||
code_repo_path = os.path.dirname(os.path.dirname(openhands.__file__))
|
||||
check_browser = config.enable_browser and sys.platform != 'win32'
|
||||
check_dependencies(code_repo_path, check_browser)
|
||||
|
||||
initial_num_warm_servers = int(os.getenv('INITIAL_NUM_WARM_SERVERS', '0'))
|
||||
# Initialize warm servers if needed
|
||||
if initial_num_warm_servers > 0 and len(_WARM_SERVERS) == 0:
|
||||
plugins: list[PluginRequirement] = []
|
||||
|
||||
# Copy the logic from Runtime where we add a VSCodePlugin on init if missing
|
||||
if not headless_mode and not DISABLE_VSCODE_PLUGIN:
|
||||
plugins.append(VSCodeRequirement())
|
||||
|
||||
for _ in range(initial_num_warm_servers):
|
||||
_create_warm_server(config, plugins)
|
||||
|
||||
@tenacity.retry(
|
||||
wait=tenacity.wait_fixed(2),
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
before_sleep=lambda retry_state: logger.debug(
|
||||
f'Waiting for server to be ready... (attempt {retry_state.attempt_number})'
|
||||
),
|
||||
)
|
||||
def _wait_until_alive(self) -> bool:
|
||||
"""Wait until the server is ready to accept requests."""
|
||||
if self.server_process and self.server_process.poll() is not None:
|
||||
raise RuntimeError('Server process died')
|
||||
|
||||
try:
|
||||
response = self.session.get(f'{self.api_url}/alive')
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.log('debug', f'Server not ready yet: {e}')
|
||||
raise
|
||||
|
||||
async def execute_action(self, action: Action) -> Observation:
|
||||
"""Execute an action by sending it to the server."""
|
||||
if not self.runtime_initialized:
|
||||
raise AgentRuntimeDisconnectedError('Runtime not initialized')
|
||||
|
||||
# Check if our server process is still valid
|
||||
if self.server_process is None:
|
||||
# Check if there's a server in the global dictionary
|
||||
if self.sid in _RUNNING_SERVERS:
|
||||
self.server_process = _RUNNING_SERVERS[self.sid].process
|
||||
else:
|
||||
raise AgentRuntimeDisconnectedError('Server process not found')
|
||||
|
||||
# Check if the server process is still running
|
||||
if self.server_process.poll() is not None:
|
||||
# If the process died, remove it from the global dictionary
|
||||
if self.sid in _RUNNING_SERVERS:
|
||||
del _RUNNING_SERVERS[self.sid]
|
||||
raise AgentRuntimeDisconnectedError('Server process died')
|
||||
|
||||
with self.action_semaphore:
|
||||
try:
|
||||
response = await call_sync_from_async(
|
||||
lambda: self.session.post(
|
||||
f'{self.api_url}/execute_action',
|
||||
json={'action': event_to_dict(action)},
|
||||
)
|
||||
)
|
||||
|
||||
# After executing the action, check if we need to create more warm servers
|
||||
desired_num_warm_servers = int(
|
||||
os.getenv('DESIRED_NUM_WARM_SERVERS', '0')
|
||||
)
|
||||
if (
|
||||
desired_num_warm_servers > 0
|
||||
and len(_WARM_SERVERS) < desired_num_warm_servers
|
||||
):
|
||||
self.log(
|
||||
'info',
|
||||
f'Creating a new warm server to maintain desired count of {desired_num_warm_servers}',
|
||||
)
|
||||
_create_warm_server_in_background(self.config, self.plugins)
|
||||
|
||||
return observation_from_dict(response.json())
|
||||
except httpx.NetworkError:
|
||||
raise AgentRuntimeDisconnectedError('Server connection lost')
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the server process if not in attach_to_existing mode."""
|
||||
# If we're in attach_to_existing mode, don't close the server
|
||||
if self.attach_to_existing:
|
||||
self.log(
|
||||
'info',
|
||||
f'Not closing server for session {self.sid} (attach_to_existing=True)',
|
||||
)
|
||||
# Just clean up our reference to the process, but leave it running
|
||||
self.server_process = None
|
||||
# Don't clean up temp workspace when attach_to_existing=True
|
||||
super().close()
|
||||
return
|
||||
|
||||
# Signal the log thread to exit
|
||||
self._log_thread_exit_event.set()
|
||||
|
||||
# Remove from global dictionary
|
||||
if self.sid in _RUNNING_SERVERS:
|
||||
del _RUNNING_SERVERS[self.sid]
|
||||
|
||||
if self.server_process:
|
||||
self.server_process.terminate()
|
||||
try:
|
||||
self.server_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.server_process.kill()
|
||||
self.server_process = None
|
||||
self._log_thread.join(timeout=5) # Add timeout to join
|
||||
|
||||
# Clean up temp workspace if it exists and we created it
|
||||
if self._temp_workspace and not self.attach_to_existing:
|
||||
shutil.rmtree(self._temp_workspace)
|
||||
self._temp_workspace = None
|
||||
|
||||
super().close()
|
||||
|
||||
@classmethod
|
||||
async def delete(cls, conversation_id: str) -> None:
|
||||
"""Delete the runtime for a conversation."""
|
||||
if conversation_id in _RUNNING_SERVERS:
|
||||
logger.info(f'Deleting LocalRuntime for conversation {conversation_id}')
|
||||
server_info = _RUNNING_SERVERS[conversation_id]
|
||||
|
||||
# Signal the log thread to exit
|
||||
server_info.log_thread_exit_event.set()
|
||||
|
||||
# Terminate the server process
|
||||
if server_info.process:
|
||||
server_info.process.terminate()
|
||||
try:
|
||||
server_info.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server_info.process.kill()
|
||||
|
||||
# Wait for the log thread to finish
|
||||
server_info.log_thread.join(timeout=5)
|
||||
|
||||
# Remove from global dictionary
|
||||
del _RUNNING_SERVERS[conversation_id]
|
||||
logger.info(f'LocalRuntime for conversation {conversation_id} deleted')
|
||||
|
||||
# Also clean up any warm servers if this is the last conversation being deleted
|
||||
if not _RUNNING_SERVERS:
|
||||
logger.info('No active conversations, cleaning up warm servers')
|
||||
for server_info in _WARM_SERVERS[:]:
|
||||
# Signal the log thread to exit
|
||||
server_info.log_thread_exit_event.set()
|
||||
|
||||
# Terminate the server process
|
||||
if server_info.process:
|
||||
server_info.process.terminate()
|
||||
try:
|
||||
server_info.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server_info.process.kill()
|
||||
|
||||
# Wait for the log thread to finish
|
||||
server_info.log_thread.join(timeout=5)
|
||||
|
||||
# Clean up temp workspace
|
||||
if server_info.temp_workspace:
|
||||
shutil.rmtree(server_info.temp_workspace)
|
||||
|
||||
# Remove from warm servers list
|
||||
_WARM_SERVERS.remove(server_info)
|
||||
|
||||
logger.info('All warm servers cleaned up')
|
||||
|
||||
@property
|
||||
def runtime_url(self) -> str:
|
||||
runtime_url = os.getenv('RUNTIME_URL')
|
||||
if runtime_url:
|
||||
return runtime_url
|
||||
|
||||
# TODO: This could be removed if we had a straightforward variable containing the RUNTIME_URL in the K8 env.
|
||||
runtime_url_pattern = os.getenv('RUNTIME_URL_PATTERN')
|
||||
runtime_id = os.getenv('RUNTIME_ID')
|
||||
if runtime_url_pattern and runtime_id:
|
||||
runtime_url = runtime_url_pattern.format(runtime_id=runtime_id)
|
||||
return runtime_url
|
||||
|
||||
# Fallback to localhost
|
||||
return self.config.sandbox.local_runtime_url
|
||||
|
||||
def _create_url(self, prefix: str, port: int) -> str:
|
||||
runtime_url = self.runtime_url
|
||||
logger.debug(f'runtime_url is {runtime_url}')
|
||||
if 'localhost' in runtime_url:
|
||||
url = f'{self.runtime_url}:{self._vscode_port}'
|
||||
else:
|
||||
runtime_id = os.getenv('RUNTIME_ID')
|
||||
parsed = urlparse(self.runtime_url)
|
||||
scheme, netloc, path = parsed.scheme, parsed.netloc, parsed.path or '/'
|
||||
path_mode = path.startswith(f'/{runtime_id}') if runtime_id else False
|
||||
if path_mode:
|
||||
url = f'{scheme}://{netloc}/{runtime_id}/{prefix}'
|
||||
else:
|
||||
url = f'{scheme}://{prefix}-{netloc}'
|
||||
logger.debug(f'_create_url url is {url}')
|
||||
return url
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
token = super().get_vscode_token()
|
||||
if not token:
|
||||
return None
|
||||
vscode_url = self._create_url('vscode', self._vscode_port)
|
||||
return f'{vscode_url}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
|
||||
@property
|
||||
def web_hosts(self) -> dict[str, int]:
|
||||
hosts: dict[str, int] = {}
|
||||
for index, port in enumerate(self._app_ports):
|
||||
url = self._create_url(f'work-{index + 1}', port)
|
||||
hosts[url] = port
|
||||
return hosts
|
||||
|
||||
|
||||
def _python_bin_path():
|
||||
# Derive environment paths using sys.executable
|
||||
interpreter_path = sys.executable
|
||||
python_bin_path = os.path.dirname(interpreter_path)
|
||||
return python_bin_path
|
||||
|
||||
|
||||
def _create_server(
|
||||
config: OpenHandsConfig,
|
||||
plugins: list[PluginRequirement],
|
||||
workspace_prefix: str,
|
||||
) -> tuple[ActionExecutionServerInfo, str]:
|
||||
logger.info('Creating a server')
|
||||
|
||||
# Set up workspace directory
|
||||
temp_workspace = tempfile.mkdtemp(
|
||||
prefix=f'openhands_workspace_{workspace_prefix}',
|
||||
)
|
||||
workspace_mount_path = temp_workspace
|
||||
|
||||
# Find available ports
|
||||
execution_server_port = find_available_tcp_port(*EXECUTION_SERVER_PORT_RANGE)
|
||||
vscode_port = int(
|
||||
os.getenv('VSCODE_PORT') or str(find_available_tcp_port(*VSCODE_PORT_RANGE))
|
||||
)
|
||||
app_ports = [
|
||||
int(
|
||||
os.getenv('WORK_PORT_1')
|
||||
or os.getenv('APP_PORT_1')
|
||||
or str(find_available_tcp_port(*APP_PORT_RANGE_1))
|
||||
),
|
||||
int(
|
||||
os.getenv('WORK_PORT_2')
|
||||
or os.getenv('APP_PORT_2')
|
||||
or str(find_available_tcp_port(*APP_PORT_RANGE_2))
|
||||
),
|
||||
]
|
||||
|
||||
# Get user info
|
||||
user_id, username = get_user_info()
|
||||
|
||||
# Start the server process
|
||||
cmd = get_action_execution_server_startup_command(
|
||||
server_port=execution_server_port,
|
||||
plugins=plugins,
|
||||
app_config=config,
|
||||
python_prefix=[],
|
||||
python_executable=sys.executable,
|
||||
override_user_id=user_id,
|
||||
override_username=username,
|
||||
)
|
||||
|
||||
logger.info(f'Starting server with command: {cmd}')
|
||||
|
||||
env = os.environ.copy()
|
||||
# Get the code repo path
|
||||
code_repo_path = os.path.dirname(os.path.dirname(openhands.__file__))
|
||||
env['PYTHONPATH'] = os.pathsep.join([code_repo_path, env.get('PYTHONPATH', '')])
|
||||
env['OPENHANDS_REPO_PATH'] = code_repo_path
|
||||
env['LOCAL_RUNTIME_MODE'] = '1'
|
||||
env['VSCODE_PORT'] = str(vscode_port)
|
||||
|
||||
# Prepend the interpreter's bin directory to PATH for subprocesses
|
||||
env['PATH'] = f'{_python_bin_path()}{os.pathsep}{env.get("PATH", "")}'
|
||||
|
||||
logger.debug(f'Updated PATH for subprocesses: {env["PATH"]}')
|
||||
|
||||
server_process = subprocess.Popen( # noqa: S603
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
universal_newlines=True,
|
||||
bufsize=1,
|
||||
env=env,
|
||||
cwd=code_repo_path,
|
||||
)
|
||||
|
||||
log_thread_exit_event = threading.Event()
|
||||
|
||||
# Start a thread to read and log server output
|
||||
def log_output() -> None:
|
||||
if not server_process or not server_process.stdout:
|
||||
logger.error('server process or stdout not available for logging.')
|
||||
return
|
||||
|
||||
try:
|
||||
# Read lines while the process is running and stdout is available
|
||||
while server_process.poll() is None:
|
||||
if log_thread_exit_event.is_set():
|
||||
logger.info('server log thread received exit signal.')
|
||||
break
|
||||
line = server_process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
logger.info(f'server: {line.strip()}')
|
||||
|
||||
# Capture any remaining output
|
||||
if not log_thread_exit_event.is_set():
|
||||
logger.info('server process exited, reading remaining output.')
|
||||
for line in server_process.stdout:
|
||||
if log_thread_exit_event.is_set():
|
||||
break
|
||||
logger.info(f'server (remaining): {line.strip()}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error reading server output: {e}')
|
||||
finally:
|
||||
logger.info('server log output thread finished.')
|
||||
|
||||
log_thread = threading.Thread(target=log_output, daemon=True)
|
||||
log_thread.start()
|
||||
|
||||
# Create server info object
|
||||
server_info = ActionExecutionServerInfo(
|
||||
process=server_process,
|
||||
execution_server_port=execution_server_port,
|
||||
vscode_port=vscode_port,
|
||||
app_ports=app_ports,
|
||||
log_thread=log_thread,
|
||||
log_thread_exit_event=log_thread_exit_event,
|
||||
temp_workspace=temp_workspace,
|
||||
workspace_mount_path=workspace_mount_path,
|
||||
)
|
||||
|
||||
# API URL for the server
|
||||
api_url = f'{config.sandbox.local_runtime_url}:{execution_server_port}'
|
||||
|
||||
return server_info, api_url
|
||||
|
||||
|
||||
def _create_warm_server(
|
||||
config: OpenHandsConfig,
|
||||
plugins: list[PluginRequirement],
|
||||
) -> None:
|
||||
"""Create a warm server in the background."""
|
||||
try:
|
||||
server_info, api_url = _create_server(
|
||||
config=config,
|
||||
plugins=plugins,
|
||||
workspace_prefix='warm',
|
||||
)
|
||||
|
||||
# Wait for the server to be ready
|
||||
session = httpx.Client(timeout=30, verify=httpx_verify_option())
|
||||
|
||||
# Use tenacity to retry the connection
|
||||
@tenacity.retry(
|
||||
wait=tenacity.wait_fixed(2),
|
||||
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
||||
before_sleep=lambda retry_state: logger.debug(
|
||||
f'Waiting for warm server to be ready... (attempt {retry_state.attempt_number})'
|
||||
),
|
||||
)
|
||||
def wait_until_alive() -> bool:
|
||||
if server_info.process.poll() is not None:
|
||||
raise RuntimeError('Warm server process died')
|
||||
|
||||
try:
|
||||
response = session.get(f'{api_url}/alive')
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f'Warm server not ready yet: {e}')
|
||||
raise
|
||||
|
||||
wait_until_alive()
|
||||
logger.info(f'Warm server ready at port {server_info.execution_server_port}')
|
||||
|
||||
# Add to the warm servers list
|
||||
_WARM_SERVERS.append(server_info)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to create warm server: {e}')
|
||||
# Clean up resources
|
||||
if 'server_info' in locals():
|
||||
server_info.log_thread_exit_event.set()
|
||||
if server_info.process:
|
||||
server_info.process.terminate()
|
||||
try:
|
||||
server_info.process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
server_info.process.kill()
|
||||
server_info.log_thread.join(timeout=5)
|
||||
if server_info.temp_workspace:
|
||||
shutil.rmtree(server_info.temp_workspace)
|
||||
|
||||
|
||||
def _create_warm_server_in_background(
|
||||
config: OpenHandsConfig,
|
||||
plugins: list[PluginRequirement],
|
||||
) -> None:
|
||||
"""Start a new thread to create a warm server."""
|
||||
thread = threading.Thread(
|
||||
target=_create_warm_server, daemon=True, args=(config, plugins)
|
||||
)
|
||||
thread.start()
|
||||
@@ -0,0 +1,640 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import tenacity
|
||||
from tenacity import RetryCallState
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.exceptions import (
|
||||
AgentRuntimeDisconnectedError,
|
||||
AgentRuntimeError,
|
||||
AgentRuntimeNotFoundError,
|
||||
AgentRuntimeNotReadyError,
|
||||
AgentRuntimeUnavailableError,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime.builder.remote import RemoteRuntimeBuilder
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.runtime.utils.command import (
|
||||
DEFAULT_MAIN_MODULE,
|
||||
get_action_execution_server_startup_command,
|
||||
)
|
||||
from openhands.runtime.utils.request import send_request
|
||||
from openhands.runtime.utils.runtime_build import build_runtime_image
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.tenacity_stop import stop_base, stop_if_should_exit
|
||||
|
||||
|
||||
class _StopIfClosed(stop_base):
|
||||
def __init__(self, runtime: ActionExecutionClient):
|
||||
self.runtime = runtime
|
||||
|
||||
def __call__(self, retry_state):
|
||||
return self.runtime._runtime_closed
|
||||
|
||||
|
||||
class RemoteRuntime(ActionExecutionClient):
|
||||
"""This runtime will connect to a remote oh-runtime-client."""
|
||||
|
||||
port: int = 60000 # default port for the remote runtime client
|
||||
runtime_id: str | None = None
|
||||
runtime_url: str | None = None
|
||||
_runtime_initialized: bool = False
|
||||
runtime_builder: RemoteRuntimeBuilder
|
||||
container_image: str
|
||||
available_hosts: dict[str, int]
|
||||
main_module: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
llm_registry: LLMRegistry,
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable[..., None] | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
main_module: str = DEFAULT_MAIN_MODULE,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
llm_registry,
|
||||
sid,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
attach_to_existing,
|
||||
headless_mode,
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
logger.debug(f'RemoteRuntime.init user_id {user_id}')
|
||||
if self.config.sandbox.api_key is None:
|
||||
raise ValueError(
|
||||
'API key is required to use the remote runtime. '
|
||||
'Please set the API key in the config (config.toml) or as an environment variable (SANDBOX_API_KEY).'
|
||||
)
|
||||
self.session.headers.update({'X-API-Key': self.config.sandbox.api_key})
|
||||
|
||||
if self.config.workspace_base is not None:
|
||||
self.log(
|
||||
'debug',
|
||||
'Setting workspace_base is not supported in the remote runtime.',
|
||||
)
|
||||
if self.config.sandbox.remote_runtime_api_url is None:
|
||||
raise ValueError(
|
||||
'remote_runtime_api_url is required in the remote runtime.'
|
||||
)
|
||||
|
||||
assert self.config.sandbox.remote_runtime_class in (None, 'sysbox', 'gvisor')
|
||||
self.main_module = main_module
|
||||
|
||||
self.runtime_builder = RemoteRuntimeBuilder(
|
||||
self.config.sandbox.remote_runtime_api_url,
|
||||
self.config.sandbox.api_key,
|
||||
self.session,
|
||||
)
|
||||
self.available_hosts: dict[str, int] = {}
|
||||
self._session_api_key: str | None = None
|
||||
|
||||
def log(self, level: str, message: str, exc_info: bool | None = None) -> None:
|
||||
getattr(logger, level)(
|
||||
message,
|
||||
stacklevel=2,
|
||||
exc_info=exc_info,
|
||||
extra={
|
||||
'session_id': self.sid,
|
||||
'runtime_id': self.runtime_id,
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def action_execution_server_url(self) -> str:
|
||||
if self.runtime_url is None:
|
||||
raise NotImplementedError('Runtime URL is not initialized')
|
||||
return self.runtime_url
|
||||
|
||||
async def connect(self) -> None:
|
||||
try:
|
||||
await call_sync_from_async(self._start_or_attach_to_runtime)
|
||||
except Exception:
|
||||
self.close()
|
||||
self.log('error', 'Runtime failed to start', exc_info=True)
|
||||
raise
|
||||
await call_sync_from_async(self.setup_initial_env)
|
||||
self._runtime_initialized = True
|
||||
|
||||
def _start_or_attach_to_runtime(self) -> None:
|
||||
self.log('info', 'Starting or attaching to runtime')
|
||||
existing_runtime = self._check_existing_runtime()
|
||||
if existing_runtime:
|
||||
self.log('info', f'Using existing runtime with ID: {self.runtime_id}')
|
||||
elif self.attach_to_existing:
|
||||
self.log('info', f'Failed to find existing runtime for SID: {self.sid}')
|
||||
raise AgentRuntimeNotFoundError(
|
||||
f'Could not find existing runtime for SID: {self.sid}'
|
||||
)
|
||||
else:
|
||||
self.log('info', 'No existing runtime found, starting a new one')
|
||||
if self.config.sandbox.runtime_container_image is None:
|
||||
self.log(
|
||||
'info',
|
||||
f'Building remote runtime with base image: {self.config.sandbox.base_container_image}',
|
||||
)
|
||||
self._build_runtime()
|
||||
else:
|
||||
self.log(
|
||||
'info',
|
||||
f'Starting remote runtime with image: {self.config.sandbox.runtime_container_image}',
|
||||
)
|
||||
self.container_image = self.config.sandbox.runtime_container_image
|
||||
self._start_runtime()
|
||||
assert self.runtime_id is not None, (
|
||||
'Runtime ID is not set. This should never happen.'
|
||||
)
|
||||
assert self.runtime_url is not None, (
|
||||
'Runtime URL is not set. This should never happen.'
|
||||
)
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', 'Waiting for runtime to be alive...')
|
||||
self._wait_until_alive()
|
||||
if not self.attach_to_existing:
|
||||
self.log('info', 'Runtime is ready.')
|
||||
self.set_runtime_status(RuntimeStatus.READY)
|
||||
|
||||
def _check_existing_runtime(self) -> bool:
|
||||
self.log('info', f'Checking for existing runtime with session ID: {self.sid}')
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
|
||||
)
|
||||
data = response.json()
|
||||
status = data.get('status')
|
||||
self.log('info', f'Found runtime with status: {status}')
|
||||
if status == 'running' or status == 'paused':
|
||||
self._parse_runtime_response(response)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
self.log(
|
||||
'info', f'No existing runtime found for session ID: {self.sid}'
|
||||
)
|
||||
return False
|
||||
self.log(
|
||||
'error', f'Error while looking for remote runtime: {e}', exc_info=True
|
||||
)
|
||||
raise
|
||||
except httpx.HTTPError as e:
|
||||
self.log(
|
||||
'error', f'Error while looking for remote runtime: {e}', exc_info=True
|
||||
)
|
||||
raise
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Invalid JSON response from runtime API: {e}. URL: {self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}. Response: {response}',
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
if status == 'running':
|
||||
self.log('info', 'Found existing runtime in running state')
|
||||
return True
|
||||
elif status == 'stopped':
|
||||
self.log('info', 'Found existing runtime, but it is stopped')
|
||||
return False
|
||||
elif status == 'paused':
|
||||
self.log(
|
||||
'info', 'Found existing runtime in paused state, attempting to resume'
|
||||
)
|
||||
try:
|
||||
self._resume_runtime()
|
||||
self.log('info', 'Successfully resumed paused runtime')
|
||||
return True
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error', f'Failed to resume paused runtime: {e}', exc_info=True
|
||||
)
|
||||
# Return false to indicate we couldn't use the existing runtime
|
||||
return False
|
||||
else:
|
||||
self.log('error', f'Invalid response from runtime API: {data}')
|
||||
return False
|
||||
|
||||
def _build_runtime(self) -> None:
|
||||
self.log('debug', f'Building RemoteRuntime config:\n{self.config}')
|
||||
self.set_runtime_status(RuntimeStatus.BUILDING_RUNTIME)
|
||||
response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/registry_prefix',
|
||||
)
|
||||
response_json = response.json()
|
||||
registry_prefix = response_json['registry_prefix']
|
||||
os.environ['OH_RUNTIME_RUNTIME_IMAGE_REPO'] = (
|
||||
registry_prefix.rstrip('/') + '/runtime'
|
||||
)
|
||||
self.log(
|
||||
'debug',
|
||||
f'Runtime image repo: {os.environ["OH_RUNTIME_RUNTIME_IMAGE_REPO"]}',
|
||||
)
|
||||
if self.config.sandbox.base_container_image is None:
|
||||
raise ValueError(
|
||||
'base_container_image is required to build the runtime image. '
|
||||
)
|
||||
if self.config.sandbox.runtime_extra_deps:
|
||||
self.log(
|
||||
'debug',
|
||||
f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
|
||||
)
|
||||
|
||||
# Build the container image
|
||||
self.container_image = build_runtime_image(
|
||||
self.config.sandbox.base_container_image,
|
||||
self.runtime_builder,
|
||||
platform=self.config.sandbox.platform,
|
||||
extra_deps=self.config.sandbox.runtime_extra_deps,
|
||||
force_rebuild=self.config.sandbox.force_rebuild_runtime,
|
||||
enable_browser=self.config.enable_browser,
|
||||
)
|
||||
|
||||
response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/image_exists',
|
||||
params={'image': self.container_image},
|
||||
)
|
||||
if not response.json()['exists']:
|
||||
raise AgentRuntimeError(
|
||||
f'Container image {self.container_image} does not exist'
|
||||
)
|
||||
|
||||
def _start_runtime(self) -> None:
|
||||
# Prepare the request body for the /start endpoint
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
command = self.get_action_execution_server_startup_command()
|
||||
environment: dict[str, str] = {}
|
||||
if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true':
|
||||
environment['DEBUG'] = 'true'
|
||||
environment.update(self.config.sandbox.runtime_startup_env_vars)
|
||||
start_request: dict[str, Any] = {
|
||||
'image': self.container_image,
|
||||
'command': command,
|
||||
'working_dir': '/openhands/code/',
|
||||
'environment': environment,
|
||||
'session_id': self.sid,
|
||||
'resource_factor': self.config.sandbox.remote_runtime_resource_factor,
|
||||
}
|
||||
if self.config.sandbox.remote_runtime_class == 'sysbox':
|
||||
start_request['runtime_class'] = 'sysbox-runc'
|
||||
# We ignore other runtime classes for now, because both None and 'gvisor' map to 'gvisor'
|
||||
|
||||
# Start the sandbox using the /start endpoint
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/start',
|
||||
json=start_request,
|
||||
)
|
||||
self._parse_runtime_response(response)
|
||||
self.log(
|
||||
'debug',
|
||||
f'Runtime started. URL: {self.runtime_url}',
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
self.log('error', f'Unable to start runtime: {str(e)}')
|
||||
raise AgentRuntimeUnavailableError() from e
|
||||
|
||||
def _resume_runtime(self) -> None:
|
||||
"""Resume a stopped runtime.
|
||||
|
||||
Steps:
|
||||
1. Show status update that runtime is being started.
|
||||
2. Send the runtime API a /resume request
|
||||
3. Poll for the runtime to be ready
|
||||
4. Update env vars
|
||||
"""
|
||||
self.log('info', f'Attempting to resume runtime with ID: {self.runtime_id}')
|
||||
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/resume',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
self.log(
|
||||
'info',
|
||||
f'Resume API call successful with status code: {response.status_code}',
|
||||
)
|
||||
except Exception as e:
|
||||
self.log('error', f'Failed to call /resume API: {e}', exc_info=True)
|
||||
raise
|
||||
|
||||
self.log(
|
||||
'info', 'Runtime resume API call completed, waiting for it to be alive...'
|
||||
)
|
||||
try:
|
||||
self._wait_until_alive()
|
||||
self.log('info', 'Runtime is now alive after resume')
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Runtime failed to become alive after resume: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
self.setup_initial_env()
|
||||
self.log('info', 'Successfully set up initial environment after resume')
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Failed to set up initial environment after resume: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
self.log('info', 'Runtime successfully resumed and alive.')
|
||||
|
||||
def _parse_runtime_response(self, response: httpx.Response) -> None:
|
||||
start_response = response.json()
|
||||
self.runtime_id = start_response['runtime_id']
|
||||
self.runtime_url = start_response['url']
|
||||
self.available_hosts = start_response.get('work_hosts', {})
|
||||
|
||||
if 'session_api_key' in start_response:
|
||||
self.session.headers.update(
|
||||
{'X-Session-API-Key': start_response['session_api_key']}
|
||||
)
|
||||
self._session_api_key = start_response['session_api_key']
|
||||
self.log(
|
||||
'debug',
|
||||
'Session API key set',
|
||||
)
|
||||
|
||||
@property
|
||||
def session_api_key(self) -> str | None:
|
||||
return self._session_api_key
|
||||
|
||||
@property
|
||||
def vscode_url(self) -> str | None:
|
||||
token = super().get_vscode_token()
|
||||
if not token:
|
||||
return None
|
||||
assert self.runtime_url is not None and self.runtime_id is not None
|
||||
self.log('debug', f'runtime_url: {self.runtime_url}')
|
||||
parsed = urlparse(self.runtime_url)
|
||||
scheme, netloc, path = parsed.scheme, parsed.netloc, parsed.path or '/'
|
||||
# Path mode if runtime_url path starts with /{id}
|
||||
path_mode = path.startswith(f'/{self.runtime_id}')
|
||||
if path_mode:
|
||||
vscode_url = f'{scheme}://{netloc}/{self.runtime_id}/vscode?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
else:
|
||||
vscode_url = f'{scheme}://vscode-{netloc}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
||||
self.log(
|
||||
'debug',
|
||||
f'VSCode URL: {vscode_url}',
|
||||
)
|
||||
return vscode_url
|
||||
|
||||
@property
|
||||
def web_hosts(self) -> dict[str, int]:
|
||||
return self.available_hosts
|
||||
|
||||
def _wait_until_alive(self) -> None:
|
||||
retry_decorator = tenacity.retry(
|
||||
stop=tenacity.stop_after_delay(
|
||||
self.config.sandbox.remote_runtime_init_timeout
|
||||
)
|
||||
| stop_if_should_exit()
|
||||
| _StopIfClosed(self),
|
||||
reraise=True,
|
||||
retry=tenacity.retry_if_exception_type(AgentRuntimeNotReadyError),
|
||||
wait=tenacity.wait_fixed(2),
|
||||
)
|
||||
retry_decorator(self._wait_until_alive_impl)()
|
||||
|
||||
def _wait_until_alive_impl(self) -> None:
|
||||
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
|
||||
self.log(
|
||||
'debug',
|
||||
f'Sending request to: {self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
|
||||
)
|
||||
runtime_info_response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}',
|
||||
)
|
||||
runtime_data = runtime_info_response.json()
|
||||
self.log(
|
||||
'debug',
|
||||
f'received response: {runtime_data}',
|
||||
)
|
||||
assert 'runtime_id' in runtime_data
|
||||
assert runtime_data['runtime_id'] == self.runtime_id
|
||||
assert 'pod_status' in runtime_data
|
||||
pod_status = runtime_data['pod_status'].lower()
|
||||
self.log('debug', f'Pod status: {pod_status}')
|
||||
restart_count = runtime_data.get('restart_count', 0)
|
||||
if restart_count != 0:
|
||||
restart_reasons = runtime_data.get('restart_reasons')
|
||||
self.log(
|
||||
'debug', f'Pod restarts: {restart_count}, reasons: {restart_reasons}'
|
||||
)
|
||||
|
||||
# FIXME: We should fix it at the backend of /start endpoint, make sure
|
||||
# the pod is created before returning the response.
|
||||
# Retry a period of time to give the cluster time to start the pod
|
||||
if pod_status == 'ready':
|
||||
try:
|
||||
self.check_if_alive()
|
||||
except httpx.HTTPError as e:
|
||||
self.log(
|
||||
'warning',
|
||||
f"Runtime /alive failed, but pod says it's ready: {str(e)}",
|
||||
)
|
||||
raise AgentRuntimeNotReadyError(
|
||||
f'Runtime /alive failed to respond with 200: {str(e)}'
|
||||
)
|
||||
return
|
||||
elif (
|
||||
pod_status == 'not found'
|
||||
or pod_status == 'pending'
|
||||
or pod_status == 'running'
|
||||
): # nb: Running is not yet Ready
|
||||
raise AgentRuntimeNotReadyError(
|
||||
f'Runtime (ID={self.runtime_id}) is not yet ready. Status: {pod_status}'
|
||||
)
|
||||
elif pod_status in ('failed', 'unknown', 'crashloopbackoff'):
|
||||
if pod_status == 'crashloopbackoff':
|
||||
raise AgentRuntimeUnavailableError(
|
||||
'Runtime crashed and is being restarted, potentially due to memory usage. Please try again.'
|
||||
)
|
||||
else:
|
||||
raise AgentRuntimeUnavailableError(
|
||||
f'Runtime is unavailable (status: {pod_status}). Please try again.'
|
||||
)
|
||||
else:
|
||||
# Maybe this should be a hard failure, but passing through in case the API changes
|
||||
self.log('warning', f'Unknown pod status: {pod_status}')
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'Waiting for runtime pod to be active. Current status: {pod_status}',
|
||||
)
|
||||
raise AgentRuntimeNotReadyError()
|
||||
|
||||
def close(self) -> None:
|
||||
if self.attach_to_existing:
|
||||
super().close()
|
||||
return
|
||||
if self.config.sandbox.keep_runtime_alive:
|
||||
if self.config.sandbox.pause_closed_runtimes:
|
||||
try:
|
||||
if not self._runtime_closed:
|
||||
self._send_runtime_api_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/pause',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
self.log('info', 'Runtime paused.')
|
||||
except Exception as e:
|
||||
self.log('error', f'Unable to pause runtime: {str(e)}')
|
||||
raise e
|
||||
super().close()
|
||||
return
|
||||
try:
|
||||
if not self._runtime_closed:
|
||||
self._send_runtime_api_request(
|
||||
'POST',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/stop',
|
||||
json={'runtime_id': self.runtime_id},
|
||||
)
|
||||
self.log('info', 'Runtime stopped.')
|
||||
except Exception as e:
|
||||
self.log('error', f'Unable to stop runtime: {str(e)}')
|
||||
raise e
|
||||
finally:
|
||||
super().close()
|
||||
|
||||
def _send_runtime_api_request(
|
||||
self, method: str, url: str, **kwargs: Any
|
||||
) -> httpx.Response:
|
||||
try:
|
||||
kwargs['timeout'] = self.config.sandbox.remote_runtime_api_timeout
|
||||
return send_request(self.session, method, url, **kwargs)
|
||||
except httpx.TimeoutException:
|
||||
self.log(
|
||||
'error',
|
||||
f'No response received within the timeout period for url: {url}',
|
||||
)
|
||||
raise
|
||||
|
||||
def _send_action_server_request(
|
||||
self, method: str, url: str, **kwargs: Any
|
||||
) -> httpx.Response:
|
||||
if not self.config.sandbox.remote_runtime_enable_retries:
|
||||
return self._send_action_server_request_impl(method, url, **kwargs)
|
||||
|
||||
retry_decorator = tenacity.retry(
|
||||
retry=tenacity.retry_if_exception_type(httpx.NetworkError),
|
||||
stop=tenacity.stop_after_attempt(3)
|
||||
| stop_if_should_exit()
|
||||
| _StopIfClosed(self),
|
||||
before_sleep=tenacity.before_sleep_log(
|
||||
logger, # type: ignore[arg-type]
|
||||
logging.WARNING,
|
||||
),
|
||||
wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
|
||||
)
|
||||
return retry_decorator(self._send_action_server_request_impl)(
|
||||
method, url, **kwargs
|
||||
)
|
||||
|
||||
def _send_action_server_request_impl(
|
||||
self, method: str, url: str, **kwargs: Any
|
||||
) -> httpx.Response:
|
||||
try:
|
||||
return super()._send_action_server_request(method, url, **kwargs)
|
||||
except httpx.TimeoutException:
|
||||
self.log(
|
||||
'error',
|
||||
f'No response received within the timeout period for url: {url}',
|
||||
)
|
||||
raise
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
if hasattr(e, 'response') and e.response.status_code in (404, 502, 504):
|
||||
if e.response.status_code == 404:
|
||||
raise AgentRuntimeDisconnectedError(
|
||||
f'Runtime is not responding. This may be temporary, please try again. Original error: {e}'
|
||||
) from e
|
||||
else: # 502, 504
|
||||
raise AgentRuntimeDisconnectedError(
|
||||
f'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again. Original error: {e}'
|
||||
) from e
|
||||
elif hasattr(e, 'response') and e.response.status_code == 503:
|
||||
if self.config.sandbox.keep_runtime_alive:
|
||||
self.log(
|
||||
'info',
|
||||
f'Runtime appears to be paused (503 response). Runtime ID: {self.runtime_id}, URL: {url}',
|
||||
)
|
||||
try:
|
||||
self._resume_runtime()
|
||||
self.log(
|
||||
'info', 'Successfully resumed runtime after 503 response'
|
||||
)
|
||||
return super()._send_action_server_request(
|
||||
method, url, **kwargs
|
||||
)
|
||||
except Exception as resume_error:
|
||||
self.log(
|
||||
'error',
|
||||
f'Failed to resume runtime after 503 response: {resume_error}',
|
||||
exc_info=True,
|
||||
)
|
||||
raise AgentRuntimeDisconnectedError(
|
||||
f'Runtime is paused and could not be resumed. Original error: {e}, Resume error: {resume_error}'
|
||||
) from resume_error
|
||||
else:
|
||||
self.log(
|
||||
'info',
|
||||
'Runtime appears to be paused (503 response) but keep_runtime_alive is False',
|
||||
)
|
||||
raise AgentRuntimeDisconnectedError(
|
||||
f'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again. Original error: {e}'
|
||||
) from e
|
||||
else:
|
||||
raise e
|
||||
|
||||
def _stop_if_closed(self, retry_state: RetryCallState) -> bool:
|
||||
return self._runtime_closed
|
||||
|
||||
def get_action_execution_server_startup_command(self):
|
||||
return get_action_execution_server_startup_command(
|
||||
server_port=self.port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
main_module=self.main_module,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"default": {}
|
||||
},
|
||||
"tools": []
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# MCP Proxy Manager
|
||||
|
||||
This module provides a manager class for handling FastMCP proxy instances in OpenHands, including initialization, configuration, and mounting to FastAPI applications.
|
||||
|
||||
## Overview
|
||||
|
||||
The `MCPProxyManager` class encapsulates all the functionality related to creating, configuring, and managing FastMCP proxy instances. It simplifies the process of:
|
||||
|
||||
1. Initializing a FastMCP proxy
|
||||
2. Configuring the proxy with tools
|
||||
3. Mounting the proxy to a FastAPI application
|
||||
4. Updating the proxy configuration
|
||||
5. Shutting down the proxy
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
from openhands.runtime.mcp.proxy import MCPProxyManager
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Create a FastAPI app
|
||||
app = FastAPI()
|
||||
|
||||
# Create a proxy manager
|
||||
proxy_manager = MCPProxyManager(
|
||||
name="MyProxyServer",
|
||||
auth_enabled=True,
|
||||
api_key="my-api-key"
|
||||
)
|
||||
|
||||
# Initialize the proxy
|
||||
proxy_manager.initialize()
|
||||
|
||||
# Mount the proxy to the app
|
||||
await proxy_manager.mount_to_app(app, allow_origins=["*"])
|
||||
|
||||
# Update the tools configuration
|
||||
tools = [
|
||||
{
|
||||
"name": "my_tool",
|
||||
"description": "My tool description",
|
||||
"parameters": {...}
|
||||
}
|
||||
]
|
||||
proxy_manager.update_tools(tools)
|
||||
|
||||
# Update and remount the proxy
|
||||
await proxy_manager.update_and_remount(app, tools, allow_origins=["*"])
|
||||
|
||||
# Shutdown the proxy
|
||||
await proxy_manager.shutdown()
|
||||
```
|
||||
|
||||
### In-Memory Configuration
|
||||
|
||||
The `MCPProxyManager` maintains the configuration in-memory, eliminating the need for file-based configuration. This makes it easier to update the configuration and reduces the complexity of the code.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Simplified API**: The `MCPProxyManager` provides a simple and intuitive API for managing FastMCP proxies.
|
||||
2. **In-Memory Configuration**: Configuration is maintained in-memory, eliminating the need for file I/O operations.
|
||||
3. **Improved Error Handling**: The manager provides better error handling and logging for proxy operations.
|
||||
4. **Cleaner Code**: By encapsulating proxy-related functionality in a dedicated class, the code is more maintainable and easier to understand.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The `MCPProxyManager` uses the `FastMCP.as_proxy()` method to create a proxy server. It manages the lifecycle of the proxy, including initialization, configuration updates, and shutdown.
|
||||
|
||||
When updating the tools configuration, the manager creates a new proxy with the updated configuration and remounts it to the FastAPI application, ensuring that the proxy is always up-to-date with the latest configuration.
|
||||
@@ -0,0 +1,12 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""MCP Proxy module for OpenHands."""
|
||||
|
||||
from openhands.runtime.mcp.proxy.manager import MCPProxyManager
|
||||
|
||||
__all__ = ['MCPProxyManager']
|
||||
@@ -0,0 +1,164 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""MCP Proxy Manager for OpenHands.
|
||||
|
||||
This module provides a manager class for handling FastMCP proxy instances,
|
||||
including initialization, configuration, and mounting to FastAPI applications.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from anyio import get_cancelled_exc_class
|
||||
from fastapi import FastAPI
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import StaticTokenVerifier
|
||||
from fastmcp.utilities.logging import get_logger as fastmcp_get_logger
|
||||
|
||||
from openhands.core.config.mcp_config import StdioMCPServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
fastmcp_logger = fastmcp_get_logger('fastmcp')
|
||||
|
||||
|
||||
class MCPProxyManager:
|
||||
"""Manager for FastMCP proxy instances.
|
||||
|
||||
This class encapsulates all the functionality related to creating, configuring,
|
||||
and managing FastMCP proxy instances, including mounting them to FastAPI applications.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
auth_enabled: bool = False,
|
||||
api_key: Optional[str] = None,
|
||||
logger_level: Optional[int] = None,
|
||||
):
|
||||
"""Initialize the MCP Proxy Manager.
|
||||
|
||||
Args:
|
||||
name: Name of the proxy server
|
||||
auth_enabled: Whether authentication is enabled
|
||||
api_key: API key for authentication (required if auth_enabled is True)
|
||||
logger_level: Logging level for the FastMCP logger
|
||||
"""
|
||||
self.auth_enabled = auth_enabled
|
||||
self.api_key = api_key
|
||||
self.proxy: Optional[FastMCP] = None
|
||||
# Initialize with a valid configuration format for FastMCP
|
||||
self.config: dict[str, Any] = {
|
||||
'mcpServers': {},
|
||||
}
|
||||
|
||||
# Configure FastMCP logger
|
||||
if logger_level is not None:
|
||||
fastmcp_logger.setLevel(logger_level)
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Initialize the FastMCP proxy with the current configuration."""
|
||||
if len(self.config['mcpServers']) == 0:
|
||||
logger.info(
|
||||
'No MCP servers configured for FastMCP Proxy, skipping initialization.'
|
||||
)
|
||||
return None
|
||||
|
||||
# Create authentication provider if auth is enabled
|
||||
auth_provider = None
|
||||
if self.auth_enabled and self.api_key:
|
||||
# Use StaticTokenVerifier for simple API key authentication
|
||||
auth_provider = StaticTokenVerifier(
|
||||
{self.api_key: {'client_id': 'openhands', 'scopes': []}}
|
||||
)
|
||||
logger.info('FastMCP Proxy authentication enabled')
|
||||
else:
|
||||
logger.info('FastMCP Proxy authentication disabled')
|
||||
|
||||
# Create a new proxy with the current configuration
|
||||
self.proxy = FastMCP.as_proxy(
|
||||
self.config,
|
||||
auth=auth_provider,
|
||||
)
|
||||
|
||||
logger.info('FastMCP Proxy initialized successfully')
|
||||
|
||||
async def mount_to_app(
|
||||
self, app: FastAPI, allow_origins: Optional[list[str]] = None
|
||||
) -> None:
|
||||
"""Mount the SSE server app to a FastAPI application.
|
||||
|
||||
Args:
|
||||
app: FastAPI application to mount to
|
||||
allow_origins: List of allowed origins for CORS
|
||||
"""
|
||||
if len(self.config['mcpServers']) == 0:
|
||||
logger.info('No MCP servers configured for FastMCP Proxy, skipping mount.')
|
||||
return
|
||||
|
||||
if not self.proxy:
|
||||
raise ValueError('FastMCP Proxy is not initialized')
|
||||
|
||||
def close_on_double_start(app):
|
||||
async def wrapped(scope, receive, send):
|
||||
start_sent = False
|
||||
|
||||
async def check_send(message):
|
||||
nonlocal start_sent
|
||||
if message['type'] == 'http.response.start':
|
||||
if start_sent:
|
||||
raise get_cancelled_exc_class()(
|
||||
'closed because of double http.response.start (mcp issue https://github.com/modelcontextprotocol/python-sdk/issues/883)'
|
||||
)
|
||||
start_sent = True
|
||||
await send(message)
|
||||
|
||||
await app(scope, receive, check_send)
|
||||
|
||||
return wrapped
|
||||
|
||||
# Get the SSE app
|
||||
# mcp_app = self.proxy.http_app(path='/shttp')
|
||||
mcp_app = close_on_double_start(
|
||||
self.proxy.http_app(path='/sse', transport='sse')
|
||||
)
|
||||
app.mount('/mcp', mcp_app)
|
||||
|
||||
# Remove any existing mounts at root path
|
||||
if '/mcp' in app.routes:
|
||||
app.routes.remove('/mcp')
|
||||
|
||||
app.mount('/', mcp_app)
|
||||
logger.info('Mounted FastMCP Proxy app at /mcp')
|
||||
|
||||
async def update_and_remount(
|
||||
self,
|
||||
app: FastAPI,
|
||||
stdio_servers: list[StdioMCPServer],
|
||||
allow_origins: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
"""Update the tools configuration and remount the proxy to the app.
|
||||
|
||||
Args:
|
||||
app: FastAPI application to mount to
|
||||
stdio_servers: List of stdio server configurations (with ``name`` as extra field)
|
||||
allow_origins: List of allowed origins for CORS
|
||||
"""
|
||||
tools = {}
|
||||
for t in stdio_servers:
|
||||
dump = t.model_dump()
|
||||
name = dump.pop('name', None) or t.command
|
||||
tools[name] = dump
|
||||
self.config['mcpServers'] = tools
|
||||
|
||||
del self.proxy
|
||||
self.proxy = None
|
||||
|
||||
# Initialize a new proxy
|
||||
self.initialize()
|
||||
|
||||
# Mount the new proxy to the app
|
||||
await self.mount_to_app(app, allow_origins)
|
||||
@@ -0,0 +1,32 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
# Requirements
|
||||
from openhands.runtime.plugins.agent_skills import (
|
||||
AgentSkillsPlugin,
|
||||
AgentSkillsRequirement,
|
||||
)
|
||||
from openhands.runtime.plugins.jupyter import JupyterPlugin, JupyterRequirement
|
||||
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.plugins.vscode import VSCodePlugin, VSCodeRequirement
|
||||
|
||||
__all__ = [
|
||||
'Plugin',
|
||||
'PluginRequirement',
|
||||
'AgentSkillsRequirement',
|
||||
'AgentSkillsPlugin',
|
||||
'JupyterRequirement',
|
||||
'JupyterPlugin',
|
||||
'VSCodeRequirement',
|
||||
'VSCodePlugin',
|
||||
]
|
||||
|
||||
ALL_PLUGINS = {
|
||||
'jupyter': JupyterPlugin,
|
||||
'agent_skills': AgentSkillsPlugin,
|
||||
'vscode': VSCodePlugin,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# OpenHands Skill Sets
|
||||
|
||||
This folder implements a skill/tool set `agentskills` for OpenHands.
|
||||
|
||||
It is intended to be used by the agent **inside sandbox**.
|
||||
The skill set will be exposed as a `pip` package that can be installed as a plugin inside the sandbox.
|
||||
|
||||
The skill set can contain a bunch of wrapped tools for agent ([many examples here](https://github.com/OpenHands/OpenHands/pull/1914)), for example:
|
||||
- Audio/Video to text (these are a temporary solution, and we should switch to multimodal models when they are sufficiently cheap
|
||||
- PDF to text
|
||||
- etc.
|
||||
|
||||
# Inclusion Criteria
|
||||
|
||||
We are walking a fine line here.
|
||||
We DON't want to *wrap* every possible python packages and re-teach agent their usage (e.g., LLM already knows `pandas` pretty well, so we don't really need create a skill that reads `csv` - it can just use `pandas`).
|
||||
|
||||
We ONLY want to add a new skill, when:
|
||||
- Such skill is not easily achievable for LLM to write code directly (e.g., edit code and replace certain line)
|
||||
- It involves calling an external model (e.g., you need to call a speech to text model, editor model for speculative editing)
|
||||
|
||||
# Intended functionality
|
||||
|
||||
- Tool/skill usage (through `IPythonRunAction`)
|
||||
|
||||
```python
|
||||
# In[1]
|
||||
from agentskills import open_file, edit_file
|
||||
open_file("/workspace/a.txt")
|
||||
# Out[1]
|
||||
[SWE-agent open output]
|
||||
|
||||
# In[2]
|
||||
edit_file(
|
||||
"/workspace/a.txt",
|
||||
start=1, end=3,
|
||||
content=(
|
||||
("REPLACE TEXT")
|
||||
))
|
||||
# Out[1]
|
||||
[SWE-agent edit output]
|
||||
```
|
||||
|
||||
- Tool/skill retrieval (through `IPythonRunAction`)
|
||||
|
||||
```python
|
||||
# In[1]
|
||||
from agentskills import help_me
|
||||
|
||||
help_me("I want to solve a task that involves reading a bunch of PDFs and reason about them")
|
||||
|
||||
# Out[1]
|
||||
"Here are the top skills that may be helpful to you:
|
||||
- `pdf_to_text`: [documentation about the tools]
|
||||
...
|
||||
"
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openhands.events.action import Action
|
||||
from openhands.events.observation import Observation
|
||||
from openhands.runtime.plugins.agent_skills import agentskills
|
||||
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSkillsRequirement(PluginRequirement):
|
||||
name: str = 'agent_skills'
|
||||
documentation: str = agentskills.DOCUMENTATION
|
||||
|
||||
|
||||
class AgentSkillsPlugin(Plugin):
|
||||
name: str = 'agent_skills'
|
||||
|
||||
async def initialize(self, username: str) -> None:
|
||||
"""Initialize the plugin."""
|
||||
pass
|
||||
|
||||
async def run(self, action: Action) -> Observation:
|
||||
"""Run the plugin for a given action."""
|
||||
raise NotImplementedError('AgentSkillsPlugin does not support run method')
|
||||
@@ -0,0 +1,52 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from inspect import signature
|
||||
|
||||
from openhands.runtime.plugins.agent_skills import file_ops, file_reader
|
||||
from openhands.runtime.plugins.agent_skills.utils.dependency import import_functions
|
||||
|
||||
import_functions(
|
||||
module=file_ops, function_names=file_ops.__all__, target_globals=globals()
|
||||
)
|
||||
import_functions(
|
||||
module=file_reader, function_names=file_reader.__all__, target_globals=globals()
|
||||
)
|
||||
|
||||
__all__ = file_ops.__all__ + file_reader.__all__
|
||||
|
||||
try:
|
||||
from openhands.runtime.plugins.agent_skills import repo_ops
|
||||
|
||||
import_functions(
|
||||
module=repo_ops, function_names=repo_ops.__all__, target_globals=globals()
|
||||
)
|
||||
|
||||
__all__ += repo_ops.__all__
|
||||
except ImportError:
|
||||
# If repo_ops is not available, we just skip importing it.
|
||||
pass
|
||||
|
||||
|
||||
DOCUMENTATION = ''
|
||||
for func_name in __all__:
|
||||
func = globals()[func_name]
|
||||
|
||||
cur_doc = func.__doc__
|
||||
# remove indentation from docstring and extra empty lines
|
||||
cur_doc = '\n'.join(filter(None, map(lambda x: x.strip(), cur_doc.split('\n'))))
|
||||
# now add a consistent 4 indentation
|
||||
cur_doc = '\n'.join(map(lambda x: ' ' * 4 + x, cur_doc.split('\n')))
|
||||
|
||||
fn_signature = f'{func.__name__}' + str(signature(func))
|
||||
DOCUMENTATION += f'{fn_signature}:\n{cur_doc}\n\n'
|
||||
|
||||
|
||||
# Add file_editor (a function)
|
||||
from openhands.runtime.plugins.agent_skills.file_editor import file_editor # noqa: E402
|
||||
|
||||
__all__ += ['file_editor']
|
||||
@@ -0,0 +1,3 @@
|
||||
# File Editor
|
||||
|
||||
This file editor is largely based on Anthorpic released [`str_replace_editor`](https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo/computer_use_demo/tools/edit.py). The original code was released under [MIT license](https://github.com/anthropics/anthropic-quickstarts/blob/e373524f07594d48c3f9563248ea282a4c306c0c/LICENSE).
|
||||
@@ -0,0 +1,15 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""This file imports a global singleton of the `EditTool` class as well as raw functions that expose
|
||||
its __call__.
|
||||
The implementation of the `EditTool` class can be found at: https://github.com/OpenHands/openhands-aci/.
|
||||
"""
|
||||
|
||||
from openhands_aci.editor import file_editor
|
||||
|
||||
__all__ = ['file_editor']
|
||||
@@ -0,0 +1,14 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from openhands.runtime.plugins.agent_skills.file_ops import file_ops
|
||||
from openhands.runtime.plugins.agent_skills.utils.dependency import import_functions
|
||||
|
||||
import_functions(
|
||||
module=file_ops, function_names=file_ops.__all__, target_globals=globals()
|
||||
)
|
||||
__all__ = file_ops.__all__
|
||||
@@ -0,0 +1,344 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""File operations module for OpenHands agent.
|
||||
|
||||
This module provides a collection of file manipulation skills that enable the OpenHands
|
||||
agent to perform various file operations such as opening, searching, and navigating
|
||||
through files and directories.
|
||||
|
||||
Functions:
|
||||
- open_file(path: str, line_number: int | None = 1, context_lines: int = 100): Opens a file and optionally moves to a specific line.
|
||||
- goto_line(line_number: int): Moves the window to show the specified line number.
|
||||
- scroll_down(): Moves the window down by the number of lines specified in WINDOW.
|
||||
- scroll_up(): Moves the window up by the number of lines specified in WINDOW.
|
||||
- search_dir(search_term: str, dir_path: str = './'): Searches for a term in all files in the specified directory.
|
||||
- search_file(search_term: str, file_path: str | None = None): Searches for a term in the specified file or the currently open file.
|
||||
- find_file(file_name: str, dir_path: str = './'): Finds all files with the given name in the specified directory.
|
||||
|
||||
Note:
|
||||
All functions return string representations of their results.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
CURRENT_FILE: str | None = None
|
||||
CURRENT_LINE = 1
|
||||
WINDOW = 100
|
||||
|
||||
# This is also used in unit tests!
|
||||
MSG_FILE_UPDATED = '[File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]'
|
||||
LINTER_ERROR_MSG = '[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n'
|
||||
|
||||
|
||||
# ==================================================================================================
|
||||
|
||||
|
||||
def _output_error(error_msg: str) -> bool:
|
||||
print(f'ERROR: {error_msg}')
|
||||
return False
|
||||
|
||||
|
||||
def _check_current_file(file_path: str | None = None) -> bool:
|
||||
global CURRENT_FILE
|
||||
if not file_path:
|
||||
file_path = CURRENT_FILE
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return _output_error('No file open. Use the open_file function first.')
|
||||
return True
|
||||
|
||||
|
||||
def _clamp(value: int, min_value: int, max_value: int) -> int:
|
||||
return max(min_value, min(value, max_value))
|
||||
|
||||
|
||||
def _print_window(
|
||||
file_path: str | None,
|
||||
targeted_line: int,
|
||||
window: int,
|
||||
return_str: bool = False,
|
||||
ignore_window: bool = False,
|
||||
) -> str:
|
||||
global CURRENT_LINE
|
||||
if not _check_current_file(file_path) or file_path is None:
|
||||
return ''
|
||||
with open(file_path) as file:
|
||||
content = file.read()
|
||||
|
||||
# Ensure the content ends with a newline character
|
||||
if not content.endswith('\n'):
|
||||
content += '\n'
|
||||
|
||||
lines = content.splitlines(True) # Keep all line ending characters
|
||||
total_lines = len(lines)
|
||||
|
||||
# cover edge cases
|
||||
CURRENT_LINE = _clamp(targeted_line, 1, total_lines)
|
||||
half_window = max(1, window // 2)
|
||||
if ignore_window:
|
||||
# Use CURRENT_LINE as starting line (for e.g. scroll_down)
|
||||
start = max(1, CURRENT_LINE)
|
||||
end = min(total_lines, CURRENT_LINE + window)
|
||||
else:
|
||||
# Ensure at least one line above and below the targeted line
|
||||
start = max(1, CURRENT_LINE - half_window)
|
||||
end = min(total_lines, CURRENT_LINE + half_window)
|
||||
|
||||
# Adjust start and end to ensure at least one line above and below
|
||||
if start == 1:
|
||||
end = min(total_lines, start + window - 1)
|
||||
if end == total_lines:
|
||||
start = max(1, end - window + 1)
|
||||
|
||||
output = ''
|
||||
|
||||
# only display this when there's at least one line above
|
||||
if start > 1:
|
||||
output += f'({start - 1} more lines above)\n'
|
||||
else:
|
||||
output += '(this is the beginning of the file)\n'
|
||||
for i in range(start, end + 1):
|
||||
_new_line = f'{i}|{lines[i - 1]}'
|
||||
if not _new_line.endswith('\n'):
|
||||
_new_line += '\n'
|
||||
output += _new_line
|
||||
if end < total_lines:
|
||||
output += f'({total_lines - end} more lines below)\n'
|
||||
else:
|
||||
output += '(this is the end of the file)\n'
|
||||
output = output.rstrip()
|
||||
|
||||
if return_str:
|
||||
return output
|
||||
else:
|
||||
print(output)
|
||||
return ''
|
||||
|
||||
|
||||
def _cur_file_header(current_file: str | None, total_lines: int) -> str:
|
||||
if not current_file:
|
||||
return ''
|
||||
return f'[File: {os.path.abspath(current_file)} ({total_lines} lines total)]\n'
|
||||
|
||||
|
||||
def open_file(
|
||||
path: str, line_number: int | None = 1, context_lines: int | None = WINDOW
|
||||
) -> None:
|
||||
"""Opens a file in the editor and optionally positions at a specific line.
|
||||
|
||||
The function displays a limited window of content, centered around the specified line
|
||||
number if provided. To view the complete file content, the agent should use scroll_down and scroll_up
|
||||
commands iteratively.
|
||||
|
||||
Args:
|
||||
path: The path to the file to open. Absolute path is recommended.
|
||||
line_number: The target line number to center the view on (if possible).
|
||||
Defaults to 1.
|
||||
context_lines: Maximum number of lines to display in the view window.
|
||||
Limited to 100 lines. Defaults to 100.
|
||||
"""
|
||||
global CURRENT_FILE, CURRENT_LINE, WINDOW
|
||||
|
||||
if not os.path.isfile(path):
|
||||
_output_error(f'File {path} not found.')
|
||||
return
|
||||
|
||||
CURRENT_FILE = os.path.abspath(path)
|
||||
with open(CURRENT_FILE) as file:
|
||||
total_lines = max(1, sum(1 for _ in file))
|
||||
|
||||
if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines:
|
||||
_output_error(f'Line number must be between 1 and {total_lines}')
|
||||
return
|
||||
CURRENT_LINE = line_number
|
||||
|
||||
# Override WINDOW with context_lines
|
||||
if context_lines is None or context_lines < 1:
|
||||
context_lines = WINDOW
|
||||
|
||||
output = _cur_file_header(CURRENT_FILE, total_lines)
|
||||
output += _print_window(
|
||||
CURRENT_FILE,
|
||||
CURRENT_LINE,
|
||||
_clamp(context_lines, 1, 100),
|
||||
return_str=True,
|
||||
ignore_window=False,
|
||||
)
|
||||
if output.strip().endswith('more lines below)'):
|
||||
output += '\n[Use `scroll_down` to view the next 100 lines of the file!]'
|
||||
print(output)
|
||||
|
||||
|
||||
def goto_line(line_number: int) -> None:
|
||||
"""Moves the window to show the specified line number.
|
||||
|
||||
Args:
|
||||
line_number: int: The line number to move to.
|
||||
"""
|
||||
global CURRENT_FILE, CURRENT_LINE, WINDOW
|
||||
if not _check_current_file():
|
||||
return
|
||||
|
||||
with open(str(CURRENT_FILE)) as file:
|
||||
total_lines = max(1, sum(1 for _ in file))
|
||||
if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines:
|
||||
_output_error(f'Line number must be between 1 and {total_lines}.')
|
||||
return
|
||||
|
||||
CURRENT_LINE = _clamp(line_number, 1, total_lines)
|
||||
output = _cur_file_header(CURRENT_FILE, total_lines)
|
||||
output += _print_window(
|
||||
CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=False
|
||||
)
|
||||
print(output)
|
||||
|
||||
|
||||
def scroll_down() -> None:
|
||||
"""Moves the window down by 100 lines.
|
||||
|
||||
Args:
|
||||
None
|
||||
"""
|
||||
global CURRENT_FILE, CURRENT_LINE, WINDOW
|
||||
if not _check_current_file():
|
||||
return
|
||||
with open(str(CURRENT_FILE)) as file:
|
||||
total_lines = max(1, sum(1 for _ in file))
|
||||
CURRENT_LINE = _clamp(CURRENT_LINE + WINDOW, 1, total_lines)
|
||||
output = _cur_file_header(CURRENT_FILE, total_lines)
|
||||
output += _print_window(
|
||||
CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True
|
||||
)
|
||||
print(output)
|
||||
|
||||
|
||||
def scroll_up() -> None:
|
||||
"""Moves the window up by 100 lines.
|
||||
|
||||
Args:
|
||||
None
|
||||
"""
|
||||
global CURRENT_FILE, CURRENT_LINE, WINDOW
|
||||
if not _check_current_file():
|
||||
return
|
||||
with open(str(CURRENT_FILE)) as file:
|
||||
total_lines = max(1, sum(1 for _ in file))
|
||||
CURRENT_LINE = _clamp(CURRENT_LINE - WINDOW, 1, total_lines)
|
||||
output = _cur_file_header(CURRENT_FILE, total_lines)
|
||||
output += _print_window(
|
||||
CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True
|
||||
)
|
||||
print(output)
|
||||
|
||||
|
||||
def search_dir(search_term: str, dir_path: str = './') -> None:
|
||||
"""Searches for search_term in all files in dir. If dir is not provided, searches in the current directory.
|
||||
|
||||
Args:
|
||||
search_term: str: The term to search for.
|
||||
dir_path: str: The path to the directory to search.
|
||||
"""
|
||||
if not os.path.isdir(dir_path):
|
||||
_output_error(f'Directory {dir_path} not found')
|
||||
return
|
||||
matches = []
|
||||
for root, _, files in os.walk(dir_path):
|
||||
for file in files:
|
||||
if file.startswith('.'):
|
||||
continue
|
||||
file_path = os.path.join(root, file)
|
||||
with open(file_path, 'r', errors='ignore') as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
if search_term in line:
|
||||
matches.append((file_path, line_num, line.strip()))
|
||||
|
||||
if not matches:
|
||||
print(f'No matches found for "{search_term}" in {dir_path}')
|
||||
return
|
||||
|
||||
num_matches = len(matches)
|
||||
num_files = len(set(match[0] for match in matches))
|
||||
|
||||
if num_files > 100:
|
||||
print(
|
||||
f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.'
|
||||
)
|
||||
return
|
||||
|
||||
print(f'[Found {num_matches} matches for "{search_term}" in {dir_path}]')
|
||||
for file_path, line_num, line in matches:
|
||||
print(f'{file_path} (Line {line_num}): {line}')
|
||||
print(f'[End of matches for "{search_term}" in {dir_path}]')
|
||||
|
||||
|
||||
def search_file(search_term: str, file_path: str | None = None) -> None:
|
||||
"""Searches for search_term in file. If file is not provided, searches in the current open file.
|
||||
|
||||
Args:
|
||||
search_term: The term to search for.
|
||||
file_path: The path to the file to search.
|
||||
"""
|
||||
global CURRENT_FILE
|
||||
if file_path is None:
|
||||
file_path = CURRENT_FILE
|
||||
if file_path is None:
|
||||
_output_error('No file specified or open. Use the open_file function first.')
|
||||
return
|
||||
if not os.path.isfile(file_path):
|
||||
_output_error(f'File {file_path} not found.')
|
||||
return
|
||||
|
||||
matches = []
|
||||
with open(file_path) as file:
|
||||
for i, line in enumerate(file, 1):
|
||||
if search_term in line:
|
||||
matches.append((i, line.strip()))
|
||||
|
||||
if matches:
|
||||
print(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]')
|
||||
for match in matches:
|
||||
print(f'Line {match[0]}: {match[1]}')
|
||||
print(f'[End of matches for "{search_term}" in {file_path}]')
|
||||
else:
|
||||
print(f'[No matches found for "{search_term}" in {file_path}]')
|
||||
|
||||
|
||||
def find_file(file_name: str, dir_path: str = './') -> None:
|
||||
"""Finds all files with the given name in the specified directory.
|
||||
|
||||
Args:
|
||||
file_name: str: The name of the file to find.
|
||||
dir_path: str: The path to the directory to search.
|
||||
"""
|
||||
if not os.path.isdir(dir_path):
|
||||
_output_error(f'Directory {dir_path} not found')
|
||||
return
|
||||
|
||||
matches = []
|
||||
for root, _, files in os.walk(dir_path):
|
||||
for file in files:
|
||||
if file_name in file:
|
||||
matches.append(os.path.join(root, file))
|
||||
|
||||
if matches:
|
||||
print(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]')
|
||||
for match in matches:
|
||||
print(f'{match}')
|
||||
print(f'[End of matches for "{file_name}" in {dir_path}]')
|
||||
else:
|
||||
print(f'[No matches found for "{file_name}" in {dir_path}]')
|
||||
|
||||
|
||||
__all__ = [
|
||||
'open_file',
|
||||
'goto_line',
|
||||
'scroll_down',
|
||||
'scroll_up',
|
||||
'search_dir',
|
||||
'search_file',
|
||||
'find_file',
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from openhands.runtime.plugins.agent_skills.file_reader import file_readers
|
||||
from openhands.runtime.plugins.agent_skills.utils.dependency import import_functions
|
||||
|
||||
import_functions(
|
||||
module=file_readers, function_names=file_readers.__all__, target_globals=globals()
|
||||
)
|
||||
__all__ = file_readers.__all__
|
||||
@@ -0,0 +1,252 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""File reader skills for the OpenHands agent.
|
||||
|
||||
This module provides various functions to parse and extract content from different file types,
|
||||
including PDF, DOCX, LaTeX, audio, image, video, and PowerPoint files. It utilizes different
|
||||
libraries and APIs to process these files and output their content or descriptions.
|
||||
|
||||
Functions:
|
||||
parse_pdf(file_path: str) -> None: Parse and print content of a PDF file.
|
||||
parse_docx(file_path: str) -> None: Parse and print content of a DOCX file.
|
||||
parse_latex(file_path: str) -> None: Parse and print content of a LaTeX file.
|
||||
parse_audio(file_path: str, model: str = 'whisper-1') -> None: Transcribe and print content of an audio file.
|
||||
parse_image(file_path: str, task: str = 'Describe this image as detail as possible.') -> None: Analyze and print description of an image file.
|
||||
parse_video(file_path: str, task: str = 'Describe this image as detail as possible.', frame_interval: int = 30) -> None: Analyze and print description of video frames.
|
||||
parse_pptx(file_path: str) -> None: Parse and print content of a PowerPoint file.
|
||||
|
||||
Note:
|
||||
Some functions (parse_audio, parse_video, parse_image) require OpenAI API credentials
|
||||
and are only available if the necessary environment variables are set.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
import docx
|
||||
import pypdf
|
||||
from pptx import Presentation
|
||||
from pylatexenc.latex2text import LatexNodes2Text
|
||||
|
||||
from openhands.runtime.plugins.agent_skills.utils.config import (
|
||||
_get_max_token,
|
||||
_get_openai_api_key,
|
||||
_get_openai_base_url,
|
||||
_get_openai_client,
|
||||
_get_openai_model,
|
||||
)
|
||||
|
||||
|
||||
def parse_pdf(file_path: str) -> None:
|
||||
"""Parses the content of a PDF file and prints it.
|
||||
|
||||
Args:
|
||||
file_path: str: The path to the file to open.
|
||||
"""
|
||||
print(f'[Reading PDF file from {file_path}]')
|
||||
content = pypdf.PdfReader(file_path)
|
||||
text = ''
|
||||
for page_idx in range(len(content.pages)):
|
||||
text += (
|
||||
f'@@ Page {page_idx + 1} @@\n'
|
||||
+ content.pages[page_idx].extract_text()
|
||||
+ '\n\n'
|
||||
)
|
||||
print(text.strip())
|
||||
|
||||
|
||||
def parse_docx(file_path: str) -> None:
|
||||
"""Parses the content of a DOCX file and prints it.
|
||||
|
||||
Args:
|
||||
file_path: str: The path to the file to open.
|
||||
"""
|
||||
print(f'[Reading DOCX file from {file_path}]')
|
||||
content = docx.Document(file_path)
|
||||
text = ''
|
||||
for i, para in enumerate(content.paragraphs):
|
||||
text += f'@@ Page {i + 1} @@\n' + para.text + '\n\n'
|
||||
print(text)
|
||||
|
||||
|
||||
def parse_latex(file_path: str) -> None:
|
||||
"""Parses the content of a LaTex file and prints it.
|
||||
|
||||
Args:
|
||||
file_path: str: The path to the file to open.
|
||||
"""
|
||||
print(f'[Reading LaTex file from {file_path}]')
|
||||
with open(file_path) as f:
|
||||
data = f.read()
|
||||
text = LatexNodes2Text().latex_to_text(data)
|
||||
print(text.strip())
|
||||
|
||||
|
||||
def _base64_img(file_path: str) -> str:
|
||||
with open(file_path, 'rb') as image_file:
|
||||
encoded_image = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
return encoded_image
|
||||
|
||||
|
||||
def _base64_video(file_path: str, frame_interval: int = 10) -> list[str]:
|
||||
import cv2
|
||||
|
||||
video = cv2.VideoCapture(file_path)
|
||||
base64_frames = []
|
||||
frame_count = 0
|
||||
while video.isOpened():
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
break
|
||||
if frame_count % frame_interval == 0:
|
||||
_, buffer = cv2.imencode('.jpg', frame)
|
||||
base64_frames.append(base64.b64encode(buffer).decode('utf-8'))
|
||||
frame_count += 1
|
||||
video.release()
|
||||
return base64_frames
|
||||
|
||||
|
||||
def _prepare_image_messages(task: str, base64_image: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{'type': 'text', 'text': task},
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': f'data:image/jpeg;base64,{base64_image}'},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def parse_audio(file_path: str, model: str = 'whisper-1') -> None:
|
||||
"""Parses the content of an audio file and prints it.
|
||||
|
||||
Args:
|
||||
file_path: str: The path to the audio file to transcribe.
|
||||
model: str: The audio model to use for transcription. Defaults to 'whisper-1'.
|
||||
"""
|
||||
print(f'[Transcribing audio file from {file_path}]')
|
||||
try:
|
||||
# TODO: record the COST of the API call
|
||||
with open(file_path, 'rb') as audio_file:
|
||||
transcript = _get_openai_client().audio.translations.create(
|
||||
model=model, file=audio_file
|
||||
)
|
||||
print(transcript.text)
|
||||
|
||||
except Exception as e:
|
||||
print(f'Error transcribing audio file: {e}')
|
||||
|
||||
|
||||
def parse_image(
|
||||
file_path: str, task: str = 'Describe this image as detail as possible.'
|
||||
) -> None:
|
||||
"""Parses the content of an image file and prints the description.
|
||||
|
||||
Args:
|
||||
file_path: str: The path to the file to open.
|
||||
task: str: The task description for the API call. Defaults to 'Describe this image as detail as possible.'.
|
||||
"""
|
||||
print(f'[Reading image file from {file_path}]')
|
||||
# TODO: record the COST of the API call
|
||||
try:
|
||||
base64_image = _base64_img(file_path)
|
||||
response = _get_openai_client().chat.completions.create(
|
||||
model=_get_openai_model(),
|
||||
messages=_prepare_image_messages(task, base64_image),
|
||||
max_tokens=_get_max_token(),
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
print(content)
|
||||
|
||||
except Exception as error:
|
||||
print(f'Error with the request: {error}')
|
||||
|
||||
|
||||
def parse_video(
|
||||
file_path: str,
|
||||
task: str = 'Describe this image as detail as possible.',
|
||||
frame_interval: int = 30,
|
||||
) -> None:
|
||||
"""Parses the content of an image file and prints the description.
|
||||
|
||||
Args:
|
||||
file_path: str: The path to the video file to open.
|
||||
task: str: The task description for the API call. Defaults to 'Describe this image as detail as possible.'.
|
||||
frame_interval: int: The interval between frames to analyze. Defaults to 30.
|
||||
|
||||
"""
|
||||
print(
|
||||
f'[Processing video file from {file_path} with frame interval {frame_interval}]'
|
||||
)
|
||||
|
||||
task = task or 'This is one frame from a video, please summarize this frame.'
|
||||
base64_frames = _base64_video(file_path)
|
||||
selected_frames = base64_frames[::frame_interval]
|
||||
|
||||
if len(selected_frames) > 30:
|
||||
new_interval = len(base64_frames) // 30
|
||||
selected_frames = base64_frames[::new_interval]
|
||||
|
||||
print(f'Totally {len(selected_frames)} would be analyze...\n')
|
||||
|
||||
idx = 0
|
||||
for base64_frame in selected_frames:
|
||||
idx += 1
|
||||
print(f'Process the {file_path}, current No. {idx * frame_interval} frame...')
|
||||
# TODO: record the COST of the API call
|
||||
try:
|
||||
response = _get_openai_client().chat.completions.create(
|
||||
model=_get_openai_model(),
|
||||
messages=_prepare_image_messages(task, base64_frame),
|
||||
max_tokens=_get_max_token(),
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
current_frame_content = f"Frame {idx}'s content: {content}\n"
|
||||
print(current_frame_content)
|
||||
|
||||
except Exception as error:
|
||||
print(f'Error with the request: {error}')
|
||||
|
||||
|
||||
def parse_pptx(file_path: str) -> None:
|
||||
"""Parses the content of a pptx file and prints it.
|
||||
|
||||
Args:
|
||||
file_path: str: The path to the file to open.
|
||||
"""
|
||||
print(f'[Reading PowerPoint file from {file_path}]')
|
||||
try:
|
||||
pres = Presentation(str(file_path))
|
||||
text = []
|
||||
for slide_idx, slide in enumerate(pres.slides):
|
||||
text.append(f'@@ Slide {slide_idx + 1} @@')
|
||||
for shape in slide.shapes:
|
||||
if hasattr(shape, 'text'):
|
||||
text.append(shape.text)
|
||||
print('\n'.join(text))
|
||||
|
||||
except Exception as e:
|
||||
print(f'Error reading PowerPoint file: {e}')
|
||||
|
||||
|
||||
__all__ = [
|
||||
'parse_pdf',
|
||||
'parse_docx',
|
||||
'parse_latex',
|
||||
'parse_pptx',
|
||||
]
|
||||
|
||||
# This is called from OpenHands's side
|
||||
# If SANDBOX_ENV_OPENAI_API_KEY is set, we will be able to use these tools in the sandbox environment
|
||||
if _get_openai_api_key() and _get_openai_base_url():
|
||||
__all__ += ['parse_audio', 'parse_video', 'parse_image']
|
||||
@@ -0,0 +1,14 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from openhands.runtime.plugins.agent_skills.repo_ops import repo_ops
|
||||
from openhands.runtime.plugins.agent_skills.utils.dependency import import_functions
|
||||
|
||||
import_functions(
|
||||
module=repo_ops, function_names=repo_ops.__all__, target_globals=globals()
|
||||
)
|
||||
__all__ = repo_ops.__all__
|
||||
@@ -0,0 +1,18 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from openhands_aci.indexing.locagent.tools import (
|
||||
explore_tree_structure,
|
||||
get_entity_contents,
|
||||
search_code_snippets,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'get_entity_contents',
|
||||
'search_code_snippets',
|
||||
'explore_tree_structure',
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
|
||||
# ==================================================================================================
|
||||
# OPENAI
|
||||
# TODO: Move this to EventStream Actions when DockerRuntime is fully implemented
|
||||
# NOTE: we need to get env vars inside functions because they will be set in IPython
|
||||
# AFTER the agentskills is imported (the case for DockerRuntime)
|
||||
# ==================================================================================================
|
||||
def _get_openai_api_key() -> str:
|
||||
return os.getenv('OPENAI_API_KEY', os.getenv('SANDBOX_ENV_OPENAI_API_KEY', ''))
|
||||
|
||||
|
||||
def _get_openai_base_url() -> str:
|
||||
return os.getenv('OPENAI_BASE_URL', 'https://api.openai.com/v1')
|
||||
|
||||
|
||||
def _get_openai_model() -> str:
|
||||
return os.getenv('OPENAI_MODEL', 'gpt-4o')
|
||||
|
||||
|
||||
def _get_max_token() -> int:
|
||||
return int(os.getenv('MAX_TOKEN', '500'))
|
||||
|
||||
|
||||
def _get_openai_client() -> OpenAI:
|
||||
client = OpenAI(api_key=_get_openai_api_key(), base_url=_get_openai_base_url())
|
||||
return client
|
||||
@@ -0,0 +1,18 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
def import_functions(
|
||||
module: ModuleType, function_names: list[str], target_globals: dict[str, object]
|
||||
) -> None:
|
||||
for name in function_names:
|
||||
if hasattr(module, name):
|
||||
target_globals[name] = getattr(module, name)
|
||||
else:
|
||||
raise ValueError(f'Function {name} not found in {module.__name__}')
|
||||
@@ -0,0 +1,190 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import Action, IPythonRunCellAction
|
||||
from openhands.events.observation import IPythonRunCellObservation
|
||||
from openhands.runtime.plugins.jupyter.execute_server import JupyterKernel
|
||||
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
|
||||
'1',
|
||||
'true',
|
||||
't',
|
||||
'yes',
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JupyterRequirement(PluginRequirement):
|
||||
name: str = 'jupyter'
|
||||
|
||||
|
||||
class JupyterPlugin(Plugin):
|
||||
name: str = 'jupyter'
|
||||
kernel_gateway_port: int
|
||||
kernel_id: str
|
||||
gateway_process: asyncio.subprocess.Process | subprocess.Popen
|
||||
python_interpreter_path: str
|
||||
|
||||
async def initialize(
|
||||
self, username: str, kernel_id: str = 'openhands-default'
|
||||
) -> None:
|
||||
self.kernel_gateway_port = find_available_tcp_port(40000, 49999)
|
||||
self.kernel_id = kernel_id
|
||||
is_local_runtime = os.environ.get('LOCAL_RUNTIME_MODE') == '1'
|
||||
is_windows = sys.platform == 'win32'
|
||||
|
||||
if not is_local_runtime:
|
||||
# Non-LocalRuntime
|
||||
prefix = f'su - {username} -s ' if SU_TO_USER else ''
|
||||
# cd to code repo, setup all env vars and run micromamba
|
||||
poetry_prefix = (
|
||||
'cd /openhands/code\n'
|
||||
'export POETRY_VIRTUALENVS_PATH=/openhands/poetry;\n'
|
||||
'export PYTHONPATH=/openhands/code:$PYTHONPATH;\n'
|
||||
'export MAMBA_ROOT_PREFIX=/openhands/micromamba;\n'
|
||||
'/openhands/micromamba/bin/micromamba run -n openhands '
|
||||
)
|
||||
else:
|
||||
# LocalRuntime
|
||||
prefix = ''
|
||||
code_repo_path = os.environ.get('OPENHANDS_REPO_PATH')
|
||||
if not code_repo_path:
|
||||
raise ValueError(
|
||||
'OPENHANDS_REPO_PATH environment variable is not set. '
|
||||
'This is required for the jupyter plugin to work with LocalRuntime.'
|
||||
)
|
||||
# The correct environment is ensured by the PATH in LocalRuntime.
|
||||
poetry_prefix = f'cd {code_repo_path}\n'
|
||||
|
||||
if is_windows:
|
||||
# Windows-specific command format
|
||||
jupyter_launch_command = (
|
||||
f'cd /d "{code_repo_path}" && '
|
||||
f'"{sys.executable}" -m jupyter kernelgateway '
|
||||
'--KernelGatewayApp.ip=0.0.0.0 '
|
||||
f'--KernelGatewayApp.port={self.kernel_gateway_port}'
|
||||
)
|
||||
logger.debug(f'Jupyter launch command (Windows): {jupyter_launch_command}')
|
||||
|
||||
# Using synchronous subprocess.Popen for Windows as asyncio.create_subprocess_shell
|
||||
# has limitations on Windows platforms
|
||||
self.gateway_process = subprocess.Popen( # type: ignore[ASYNC101]
|
||||
jupyter_launch_command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
shell=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Windows-specific stdout handling with synchronous time.sleep
|
||||
# as asyncio has limitations on Windows for subprocess operations
|
||||
output = ''
|
||||
while should_continue():
|
||||
if self.gateway_process.stdout is None:
|
||||
time.sleep(1) # type: ignore[ASYNC101]
|
||||
continue
|
||||
|
||||
line = self.gateway_process.stdout.readline()
|
||||
if not line:
|
||||
time.sleep(1) # type: ignore[ASYNC101]
|
||||
continue
|
||||
|
||||
output += line
|
||||
if 'at' in line:
|
||||
break
|
||||
|
||||
time.sleep(1) # type: ignore[ASYNC101]
|
||||
logger.debug('Waiting for jupyter kernel gateway to start...')
|
||||
|
||||
logger.debug(
|
||||
f'Jupyter kernel gateway started at port {self.kernel_gateway_port}. Output: {output}'
|
||||
)
|
||||
else:
|
||||
# Unix systems (Linux/macOS)
|
||||
jupyter_launch_command = (
|
||||
f"{prefix}/bin/bash << 'EOF'\n"
|
||||
f'{poetry_prefix}'
|
||||
f'"{sys.executable}" -m jupyter kernelgateway '
|
||||
'--KernelGatewayApp.ip=0.0.0.0 '
|
||||
f'--KernelGatewayApp.port={self.kernel_gateway_port}\n'
|
||||
'EOF'
|
||||
)
|
||||
logger.debug(f'Jupyter launch command: {jupyter_launch_command}')
|
||||
|
||||
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
|
||||
# to avoid ASYNC101 linting error
|
||||
self.gateway_process = await asyncio.create_subprocess_shell(
|
||||
jupyter_launch_command,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# read stdout until the kernel gateway is ready
|
||||
output = ''
|
||||
while should_continue() and self.gateway_process.stdout is not None:
|
||||
line_bytes = await self.gateway_process.stdout.readline()
|
||||
line = line_bytes.decode('utf-8')
|
||||
output += line
|
||||
if 'at' in line:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
logger.debug('Waiting for jupyter kernel gateway to start...')
|
||||
|
||||
logger.debug(
|
||||
f'Jupyter kernel gateway started at port {self.kernel_gateway_port}. Output: {output}'
|
||||
)
|
||||
|
||||
_obs = await self.run(
|
||||
IPythonRunCellAction(code='import sys; print(sys.executable)')
|
||||
)
|
||||
self.python_interpreter_path = _obs.content.strip()
|
||||
|
||||
async def _run(self, action: Action) -> IPythonRunCellObservation:
|
||||
"""Internal method to run a code cell in the jupyter kernel."""
|
||||
if not isinstance(action, IPythonRunCellAction):
|
||||
raise ValueError(
|
||||
f'Jupyter plugin only supports IPythonRunCellAction, but got {action}'
|
||||
)
|
||||
|
||||
if not hasattr(self, 'kernel'):
|
||||
self.kernel = JupyterKernel(
|
||||
f'localhost:{self.kernel_gateway_port}', self.kernel_id
|
||||
)
|
||||
|
||||
if not self.kernel.initialized:
|
||||
await self.kernel.initialize()
|
||||
|
||||
# Execute the code and get structured output
|
||||
if action.timeout:
|
||||
output = await self.kernel.execute(action.code, timeout=int(action.timeout))
|
||||
else:
|
||||
output = await self.kernel.execute(action.code)
|
||||
# Extract text content and image URLs from the structured output
|
||||
text_content = output.get('text', '')
|
||||
image_urls = output.get('images', [])
|
||||
|
||||
return IPythonRunCellObservation(
|
||||
content=text_content, # type: ignore[arg-type]
|
||||
code=action.code,
|
||||
image_urls=image_urls if image_urls else None, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async def run(self, action: Action) -> IPythonRunCellObservation:
|
||||
obs = await self._run(action)
|
||||
return obs
|
||||
@@ -0,0 +1,316 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
import tornado
|
||||
import tornado.websocket
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
||||
from tornado.escape import json_decode, json_encode, url_escape
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||
from tornado.ioloop import PeriodicCallback
|
||||
from tornado.websocket import websocket_connect
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def strip_ansi(o: str) -> str:
|
||||
"""Removes ANSI escape sequences from `o`, as defined by ECMA-048 in
|
||||
http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-048.pdf
|
||||
|
||||
# https://github.com/ewen-lbh/python-strip-ansi/blob/master/strip_ansi/__init__.py
|
||||
|
||||
>>> strip_ansi("\\033[33mLorem ipsum\\033[0m")
|
||||
'Lorem ipsum'
|
||||
|
||||
>>> strip_ansi("Lorem \\033[38;25mIpsum\\033[0m sit\\namet.")
|
||||
'Lorem Ipsum sit\\namet.'
|
||||
|
||||
>>> strip_ansi("")
|
||||
''
|
||||
|
||||
>>> strip_ansi("\\x1b[0m")
|
||||
''
|
||||
|
||||
>>> strip_ansi("Lorem")
|
||||
'Lorem'
|
||||
|
||||
>>> strip_ansi('\\x1b[38;5;32mLorem ipsum\\x1b[0m')
|
||||
'Lorem ipsum'
|
||||
|
||||
>>> strip_ansi('\\x1b[1m\\x1b[46m\\x1b[31mLorem dolor sit ipsum\\x1b[0m')
|
||||
'Lorem dolor sit ipsum'
|
||||
"""
|
||||
# pattern = re.compile(r'/(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]/')
|
||||
pattern = re.compile(r'\x1B\[\d+(;\d+){0,2}m')
|
||||
stripped = pattern.sub('', o)
|
||||
return stripped
|
||||
|
||||
|
||||
class JupyterKernel:
|
||||
def __init__(self, url_suffix: str, convid: str, lang: str = 'python') -> None:
|
||||
self.base_url = f'http://{url_suffix}'
|
||||
self.base_ws_url = f'ws://{url_suffix}'
|
||||
self.lang = lang
|
||||
self.kernel_id: str | None = None
|
||||
self.ws: tornado.websocket.WebSocketClientConnection | None = None
|
||||
self.convid = convid
|
||||
logging.info(
|
||||
f'Jupyter kernel created for conversation {convid} at {url_suffix}'
|
||||
)
|
||||
|
||||
self.heartbeat_interval = 10000 # 10 seconds
|
||||
self.heartbeat_callback: PeriodicCallback | None = None
|
||||
self.initialized = False
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await self.execute(r'%colors nocolor')
|
||||
# pre-defined tools
|
||||
self.tools_to_run: list[str] = [
|
||||
# TODO: You can add code for your pre-defined tools here
|
||||
]
|
||||
for tool in self.tools_to_run:
|
||||
res = await self.execute(tool)
|
||||
logging.info(f'Tool [{tool}] initialized:\n{res}')
|
||||
self.initialized = True
|
||||
|
||||
async def _send_heartbeat(self) -> None:
|
||||
if not self.ws:
|
||||
return
|
||||
try:
|
||||
self.ws.ping()
|
||||
# logging.info('Heartbeat sent...')
|
||||
except tornado.iostream.StreamClosedError:
|
||||
# logging.info('Heartbeat failed, reconnecting...')
|
||||
try:
|
||||
await self._connect()
|
||||
except ConnectionRefusedError:
|
||||
logging.info(
|
||||
'ConnectionRefusedError: Failed to reconnect to kernel websocket - Is the kernel still running?'
|
||||
)
|
||||
|
||||
async def _connect(self) -> None:
|
||||
if self.ws:
|
||||
self.ws.close()
|
||||
self.ws = None
|
||||
|
||||
client = AsyncHTTPClient()
|
||||
if not self.kernel_id:
|
||||
n_tries = 5
|
||||
while n_tries > 0:
|
||||
try:
|
||||
response = await client.fetch(
|
||||
'{}/api/kernels'.format(self.base_url),
|
||||
method='POST',
|
||||
body=json_encode({'name': self.lang}),
|
||||
)
|
||||
kernel = json_decode(response.body)
|
||||
self.kernel_id = kernel['id']
|
||||
break
|
||||
except Exception:
|
||||
# kernels are not ready yet
|
||||
n_tries -= 1
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if n_tries == 0:
|
||||
raise ConnectionRefusedError('Failed to connect to kernel')
|
||||
|
||||
ws_req = HTTPRequest(
|
||||
url='{}/api/kernels/{}/channels'.format(
|
||||
self.base_ws_url, url_escape(self.kernel_id)
|
||||
)
|
||||
)
|
||||
self.ws = await websocket_connect(ws_req)
|
||||
logging.info('Connected to kernel websocket')
|
||||
|
||||
# Setup heartbeat
|
||||
if self.heartbeat_callback:
|
||||
self.heartbeat_callback.stop()
|
||||
self.heartbeat_callback = PeriodicCallback(
|
||||
self._send_heartbeat, self.heartbeat_interval
|
||||
)
|
||||
self.heartbeat_callback.start()
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception_type(ConnectionRefusedError),
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_fixed(2),
|
||||
) # type: ignore
|
||||
async def execute(
|
||||
self, code: str, timeout: int = 120
|
||||
) -> dict[str, list[str] | str]:
|
||||
if not self.ws or self.ws.stream.closed():
|
||||
await self._connect()
|
||||
|
||||
msg_id = uuid4().hex
|
||||
assert self.ws is not None
|
||||
res = await self.ws.write_message(
|
||||
json_encode(
|
||||
{
|
||||
'header': {
|
||||
'username': '',
|
||||
'version': '5.0',
|
||||
'session': '',
|
||||
'msg_id': msg_id,
|
||||
'msg_type': 'execute_request',
|
||||
},
|
||||
'parent_header': {},
|
||||
'channel': 'shell',
|
||||
'content': {
|
||||
'code': code,
|
||||
'silent': False,
|
||||
'store_history': False,
|
||||
'user_expressions': {},
|
||||
'allow_stdin': False,
|
||||
},
|
||||
'metadata': {},
|
||||
'buffers': {},
|
||||
}
|
||||
)
|
||||
)
|
||||
logging.info(f'Executed code in jupyter kernel:\n{res}')
|
||||
|
||||
outputs: list[dict] = []
|
||||
|
||||
async def wait_for_messages() -> bool:
|
||||
execution_done = False
|
||||
while not execution_done:
|
||||
assert self.ws is not None
|
||||
msg = await self.ws.read_message()
|
||||
if msg is None:
|
||||
continue
|
||||
msg_dict = json_decode(msg)
|
||||
msg_type = msg_dict['msg_type']
|
||||
parent_msg_id = msg_dict['parent_header'].get('msg_id', None)
|
||||
|
||||
if parent_msg_id != msg_id:
|
||||
continue
|
||||
|
||||
if os.environ.get('DEBUG'):
|
||||
logging.info(
|
||||
f'MSG TYPE: {msg_type.upper()} DONE:{execution_done}\nCONTENT: {msg_dict["content"]}'
|
||||
)
|
||||
|
||||
if msg_type == 'error':
|
||||
traceback = '\n'.join(msg_dict['content']['traceback'])
|
||||
outputs.append({'type': 'text', 'content': traceback})
|
||||
execution_done = True
|
||||
elif msg_type == 'stream':
|
||||
outputs.append(
|
||||
{'type': 'text', 'content': msg_dict['content']['text']}
|
||||
)
|
||||
elif msg_type in ['execute_result', 'display_data']:
|
||||
outputs.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'content': msg_dict['content']['data']['text/plain'],
|
||||
}
|
||||
)
|
||||
if 'image/png' in msg_dict['content']['data']:
|
||||
# Store image data in structured format
|
||||
image_url = f'data:image/png;base64,{msg_dict["content"]["data"]["image/png"]}'
|
||||
outputs.append({'type': 'image', 'content': image_url})
|
||||
|
||||
elif msg_type == 'execute_reply':
|
||||
execution_done = True
|
||||
return execution_done
|
||||
|
||||
async def interrupt_kernel() -> None:
|
||||
client = AsyncHTTPClient()
|
||||
if self.kernel_id is None:
|
||||
return
|
||||
interrupt_response = await client.fetch(
|
||||
f'{self.base_url}/api/kernels/{self.kernel_id}/interrupt',
|
||||
method='POST',
|
||||
body=json_encode({'kernel_id': self.kernel_id}),
|
||||
)
|
||||
logging.info(f'Kernel interrupted: {interrupt_response}')
|
||||
|
||||
try:
|
||||
execution_done = await asyncio.wait_for(wait_for_messages(), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
await interrupt_kernel()
|
||||
return {'text': f'[Execution timed out ({timeout} seconds).]', 'images': []}
|
||||
|
||||
# Process structured outputs
|
||||
text_outputs = []
|
||||
image_outputs = []
|
||||
|
||||
for output in outputs:
|
||||
if output['type'] == 'text':
|
||||
text_outputs.append(output['content'])
|
||||
elif output['type'] == 'image':
|
||||
image_outputs.append(output['content'])
|
||||
|
||||
if not text_outputs and execution_done:
|
||||
text_content = '[Code executed successfully with no output]'
|
||||
else:
|
||||
text_content = ''.join(text_outputs)
|
||||
|
||||
# Remove ANSI from text content
|
||||
text_content = strip_ansi(text_content)
|
||||
|
||||
# Return a dictionary with text content and image URLs
|
||||
return {'text': text_content, 'images': image_outputs}
|
||||
|
||||
async def shutdown_async(self) -> None:
|
||||
if self.kernel_id:
|
||||
client = AsyncHTTPClient()
|
||||
await client.fetch(
|
||||
'{}/api/kernels/{}'.format(self.base_url, self.kernel_id),
|
||||
method='DELETE',
|
||||
)
|
||||
self.kernel_id = None
|
||||
if self.ws:
|
||||
self.ws.close()
|
||||
self.ws = None
|
||||
|
||||
|
||||
class ExecuteHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, jupyter_kernel: JupyterKernel) -> None:
|
||||
self.jupyter_kernel = jupyter_kernel
|
||||
|
||||
async def post(self) -> None:
|
||||
data = json_decode(self.request.body)
|
||||
code = data.get('code')
|
||||
|
||||
if not code:
|
||||
self.set_status(400)
|
||||
self.write('Missing code')
|
||||
return
|
||||
|
||||
output = await self.jupyter_kernel.execute(code)
|
||||
|
||||
# Set content type to JSON and return the structured output
|
||||
self.set_header('Content-Type', 'application/json')
|
||||
self.write(json_encode(output))
|
||||
|
||||
|
||||
def make_app() -> tornado.web.Application:
|
||||
jupyter_kernel = JupyterKernel(
|
||||
f'localhost:{os.environ.get("JUPYTER_GATEWAY_PORT", "8888")}',
|
||||
os.environ.get('JUPYTER_GATEWAY_KERNEL_ID', 'default'),
|
||||
)
|
||||
asyncio.get_event_loop().run_until_complete(jupyter_kernel.initialize())
|
||||
|
||||
return tornado.web.Application(
|
||||
[
|
||||
(r'/execute', ExecuteHandler, {'jupyter_kernel': jupyter_kernel}),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = make_app()
|
||||
app.listen(os.environ.get('JUPYTER_EXEC_SERVER_PORT'))
|
||||
tornado.ioloop.IOLoop.current().start()
|
||||
@@ -0,0 +1,38 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openhands.events.action import Action
|
||||
from openhands.events.observation import Observation
|
||||
|
||||
|
||||
class Plugin:
|
||||
"""Base class for a plugin.
|
||||
|
||||
This will be initialized by the runtime client, which will run inside docker.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
async def initialize(self, username: str) -> None:
|
||||
"""Initialize the plugin."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def run(self, action: Action) -> Observation:
|
||||
"""Run the plugin for a given action."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginRequirement:
|
||||
"""Requirement for a plugin."""
|
||||
|
||||
name: str
|
||||
@@ -0,0 +1,164 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import Action
|
||||
from openhands.events.observation import Observation
|
||||
from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
|
||||
from openhands.runtime.utils.system import check_port_available
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
|
||||
'1',
|
||||
'true',
|
||||
't',
|
||||
'yes',
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VSCodeRequirement(PluginRequirement):
|
||||
name: str = 'vscode'
|
||||
|
||||
|
||||
class VSCodePlugin(Plugin):
|
||||
name: str = 'vscode'
|
||||
vscode_port: Optional[int] = None
|
||||
vscode_connection_token: Optional[str] = None
|
||||
gateway_process: asyncio.subprocess.Process
|
||||
|
||||
async def initialize(self, username: str, runtime_id: str | None = None) -> None:
|
||||
# Check if we're on Windows - VSCode plugin is not supported on Windows
|
||||
if os.name == 'nt' or sys.platform == 'win32':
|
||||
self.vscode_port = None
|
||||
self.vscode_connection_token = None
|
||||
logger.warning(
|
||||
'VSCode plugin is not supported on Windows. Plugin will be disabled.'
|
||||
)
|
||||
return
|
||||
|
||||
if username not in filter(None, [RUNTIME_USERNAME, 'root', 'openhands']):
|
||||
self.vscode_port = None
|
||||
self.vscode_connection_token = None
|
||||
logger.warning(
|
||||
'VSCodePlugin is only supported for root or openhands user. '
|
||||
'It is not yet supported for other users (i.e., when running LocalRuntime).'
|
||||
)
|
||||
return
|
||||
|
||||
# Set up VSCode settings.json
|
||||
self._setup_vscode_settings()
|
||||
|
||||
try:
|
||||
self.vscode_port = int(os.environ['VSCODE_PORT'])
|
||||
except (KeyError, ValueError):
|
||||
logger.warning(
|
||||
'VSCODE_PORT environment variable not set or invalid. VSCode plugin will be disabled.'
|
||||
)
|
||||
return
|
||||
|
||||
self.vscode_connection_token = str(uuid.uuid4())
|
||||
if not check_port_available(self.vscode_port):
|
||||
logger.warning(
|
||||
f'Port {self.vscode_port} is not available. VSCode plugin will be disabled.'
|
||||
)
|
||||
return
|
||||
workspace_path = os.getenv('WORKSPACE_MOUNT_PATH_IN_SANDBOX', '/workspace')
|
||||
# Compute base path for OpenVSCode Server when running behind a path-based router
|
||||
base_path_flag = ''
|
||||
# Allow explicit override via environment
|
||||
explicit_base = os.getenv('OPENVSCODE_SERVER_BASE_PATH')
|
||||
if explicit_base:
|
||||
explicit_base = (
|
||||
explicit_base if explicit_base.startswith('/') else f'/{explicit_base}'
|
||||
)
|
||||
base_path_flag = f' --server-base-path {explicit_base.rstrip("/")}'
|
||||
else:
|
||||
# If runtime_id passed explicitly (preferred), use it
|
||||
runtime_url = os.getenv('RUNTIME_URL', '')
|
||||
if runtime_url and runtime_id:
|
||||
parsed = urlparse(runtime_url)
|
||||
path = parsed.path or '/'
|
||||
path_mode = path.startswith(f'/{runtime_id}')
|
||||
if path_mode:
|
||||
base_path_flag = f' --server-base-path /{runtime_id}/vscode'
|
||||
|
||||
cmd = (
|
||||
(
|
||||
f"su - {username} -s /bin/bash << 'EOF'\n"
|
||||
if SU_TO_USER
|
||||
else "/bin/bash << 'EOF'\n"
|
||||
)
|
||||
+ f'sudo chown -R {username}:{username} /openhands/.openvscode-server\n'
|
||||
+ f'cd {workspace_path}\n'
|
||||
+ 'exec /openhands/.openvscode-server/bin/openvscode-server '
|
||||
+ f'--host 0.0.0.0 --connection-token {self.vscode_connection_token} '
|
||||
+ f'--port {self.vscode_port} --disable-workspace-trust{base_path_flag}\n'
|
||||
+ 'EOF'
|
||||
)
|
||||
|
||||
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
|
||||
# to avoid ASYNC101 linting error
|
||||
self.gateway_process = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# read stdout until the kernel gateway is ready
|
||||
output = ''
|
||||
while should_continue() and self.gateway_process.stdout is not None:
|
||||
line_bytes = await self.gateway_process.stdout.readline()
|
||||
line = line_bytes.decode('utf-8')
|
||||
print(line)
|
||||
output += line
|
||||
if 'at' in line:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
logger.debug('Waiting for VSCode server to start...')
|
||||
|
||||
logger.debug(
|
||||
f'VSCode server started at port {self.vscode_port}. Output: {output}'
|
||||
)
|
||||
|
||||
def _setup_vscode_settings(self) -> None:
|
||||
"""Set up VSCode settings by creating the .vscode directory in the workspace
|
||||
and copying the settings.json file there.
|
||||
"""
|
||||
# Get the path to the settings.json file in the plugin directory
|
||||
current_dir = Path(__file__).parent
|
||||
settings_path = current_dir / 'settings.json'
|
||||
|
||||
# Create the .vscode directory in the workspace if it doesn't exist
|
||||
workspace_dir = Path(os.getenv('WORKSPACE_BASE', '/workspace'))
|
||||
vscode_dir = workspace_dir / '.vscode'
|
||||
vscode_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy the settings.json file to the .vscode directory
|
||||
target_path = vscode_dir / 'settings.json'
|
||||
shutil.copy(settings_path, target_path)
|
||||
|
||||
# Make sure the settings file is readable and writable by all users
|
||||
os.chmod(target_path, 0o666)
|
||||
|
||||
logger.debug(f'VSCode settings copied to {target_path}')
|
||||
|
||||
async def run(self, action: Action) -> Observation:
|
||||
"""Run the plugin for a given action."""
|
||||
raise NotImplementedError('VSCodePlugin does not support run method')
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
"workbench.startupEditor": "none",
|
||||
"chat.commandCenter.enabled": false
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class RuntimeStatus(Enum):
|
||||
STOPPED = 'STATUS$STOPPED'
|
||||
BUILDING_RUNTIME = 'STATUS$BUILDING_RUNTIME'
|
||||
STARTING_RUNTIME = 'STATUS$STARTING_RUNTIME'
|
||||
RUNTIME_STARTED = 'STATUS$RUNTIME_STARTED'
|
||||
SETTING_UP_WORKSPACE = 'STATUS$SETTING_UP_WORKSPACE'
|
||||
SETTING_UP_GIT_HOOKS = 'STATUS$SETTING_UP_GIT_HOOKS'
|
||||
READY = 'STATUS$READY'
|
||||
ERROR = 'STATUS$ERROR'
|
||||
ERROR_RUNTIME_DISCONNECTED = 'STATUS$ERROR_RUNTIME_DISCONNECTED'
|
||||
ERROR_LLM_AUTHENTICATION = 'STATUS$ERROR_LLM_AUTHENTICATION'
|
||||
ERROR_LLM_SERVICE_UNAVAILABLE = 'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE'
|
||||
ERROR_LLM_INTERNAL_SERVER_ERROR = 'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR'
|
||||
ERROR_LLM_OUT_OF_CREDITS = 'STATUS$ERROR_LLM_OUT_OF_CREDITS'
|
||||
ERROR_LLM_CONTENT_POLICY_VIOLATION = 'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION'
|
||||
AGENT_RATE_LIMITED_STOPPED_MESSAGE = (
|
||||
'CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE'
|
||||
)
|
||||
GIT_PROVIDER_AUTHENTICATION_ERROR = 'STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR'
|
||||
LLM_RETRY = 'STATUS$LLM_RETRY'
|
||||
ERROR_MEMORY = 'STATUS$ERROR_MEMORY'
|
||||
@@ -0,0 +1,13 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from openhands.runtime.utils.system import (
|
||||
display_number_matrix,
|
||||
find_available_tcp_port,
|
||||
)
|
||||
|
||||
__all__ = ['display_number_matrix', 'find_available_tcp_port']
|
||||
@@ -0,0 +1,692 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import bashlex
|
||||
import libtmux
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import CmdRunAction
|
||||
from openhands.events.observation import ErrorObservation
|
||||
from openhands.events.observation.commands import (
|
||||
CMD_OUTPUT_PS1_END,
|
||||
CmdOutputMetadata,
|
||||
CmdOutputObservation,
|
||||
)
|
||||
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
SU_TO_USER = os.getenv('SU_TO_USER', 'true').lower() in (
|
||||
'1',
|
||||
'true',
|
||||
't',
|
||||
'yes',
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
|
||||
def split_bash_commands(commands: str) -> list[str]:
|
||||
if not commands.strip():
|
||||
return ['']
|
||||
try:
|
||||
parsed = bashlex.parse(commands)
|
||||
except (
|
||||
bashlex.errors.ParsingError,
|
||||
NotImplementedError,
|
||||
TypeError,
|
||||
AttributeError,
|
||||
):
|
||||
# Added AttributeError to catch 'str' object has no attribute 'kind' error (issue #8369)
|
||||
logger.debug(
|
||||
f'Failed to parse bash commands\n'
|
||||
f'[input]: {commands}\n'
|
||||
f'The original command will be returned as is.',
|
||||
exc_info=True,
|
||||
)
|
||||
# If parsing fails, return the original commands
|
||||
return [commands]
|
||||
|
||||
result: list[str] = []
|
||||
last_end = 0
|
||||
|
||||
for node in parsed:
|
||||
start, end = node.pos
|
||||
|
||||
# Include any text between the last command and this one
|
||||
if start > last_end:
|
||||
between = commands[last_end:start]
|
||||
logger.debug(f'BASH PARSING between: {between}')
|
||||
if result:
|
||||
result[-1] += between.rstrip()
|
||||
elif between.strip():
|
||||
# THIS SHOULD NOT HAPPEN
|
||||
result.append(between.rstrip())
|
||||
|
||||
# Extract the command, preserving original formatting
|
||||
command = commands[start:end].rstrip()
|
||||
logger.debug(f'BASH PARSING command: {command}')
|
||||
result.append(command)
|
||||
|
||||
last_end = end
|
||||
|
||||
# Add any remaining text after the last command to the last command
|
||||
remaining = commands[last_end:].rstrip()
|
||||
logger.debug(f'BASH PARSING remaining: {remaining}')
|
||||
if last_end < len(commands) and result:
|
||||
result[-1] += remaining
|
||||
logger.debug(f'BASH PARSING result[-1] += remaining: {result[-1]}')
|
||||
elif last_end < len(commands):
|
||||
if remaining:
|
||||
result.append(remaining)
|
||||
logger.debug(f'BASH PARSING result.append(remaining): {result[-1]}')
|
||||
return result
|
||||
|
||||
|
||||
def escape_bash_special_chars(command: str) -> str:
|
||||
r"""Escapes characters that have different interpretations in bash vs python.
|
||||
Specifically handles escape sequences like \;, \|, \&, etc.
|
||||
"""
|
||||
if command.strip() == '':
|
||||
return ''
|
||||
|
||||
try:
|
||||
parts = []
|
||||
last_pos = 0
|
||||
|
||||
def visit_node(node: Any) -> None:
|
||||
nonlocal last_pos
|
||||
if (
|
||||
node.kind == 'redirect'
|
||||
and hasattr(node, 'heredoc')
|
||||
and node.heredoc is not None
|
||||
):
|
||||
# We're entering a heredoc - preserve everything as-is until we see EOF
|
||||
# Store the heredoc end marker (usually 'EOF' but could be different)
|
||||
between = command[last_pos : node.pos[0]]
|
||||
parts.append(between)
|
||||
# Add the heredoc start marker
|
||||
parts.append(command[node.pos[0] : node.heredoc.pos[0]])
|
||||
# Add the heredoc content as-is
|
||||
parts.append(command[node.heredoc.pos[0] : node.heredoc.pos[1]])
|
||||
last_pos = node.pos[1]
|
||||
return
|
||||
|
||||
if node.kind == 'word':
|
||||
# Get the raw text between the last position and current word
|
||||
between = command[last_pos : node.pos[0]]
|
||||
word_text = command[node.pos[0] : node.pos[1]]
|
||||
|
||||
# Add the between text, escaping special characters
|
||||
between = re.sub(r'\\([;&|><])', r'\\\\\1', between)
|
||||
parts.append(between)
|
||||
|
||||
# Check if word_text is a quoted string or command substitution
|
||||
if (
|
||||
(word_text.startswith('"') and word_text.endswith('"'))
|
||||
or (word_text.startswith("'") and word_text.endswith("'"))
|
||||
or (word_text.startswith('$(') and word_text.endswith(')'))
|
||||
or (word_text.startswith('`') and word_text.endswith('`'))
|
||||
):
|
||||
# Preserve quoted strings, command substitutions, and heredoc content as-is
|
||||
parts.append(word_text)
|
||||
else:
|
||||
# Escape special chars in unquoted text
|
||||
word_text = re.sub(r'\\([;&|><])', r'\\\\\1', word_text)
|
||||
parts.append(word_text)
|
||||
|
||||
last_pos = node.pos[1]
|
||||
return
|
||||
|
||||
# Visit child nodes
|
||||
if hasattr(node, 'parts'):
|
||||
for part in node.parts:
|
||||
visit_node(part)
|
||||
|
||||
# Process all nodes in the AST
|
||||
nodes = list(bashlex.parse(command))
|
||||
for node in nodes:
|
||||
between = command[last_pos : node.pos[0]]
|
||||
between = re.sub(r'\\([;&|><])', r'\\\\\1', between)
|
||||
parts.append(between)
|
||||
last_pos = node.pos[0]
|
||||
visit_node(node)
|
||||
|
||||
# Handle any remaining text after the last word
|
||||
remaining = command[last_pos:]
|
||||
parts.append(remaining)
|
||||
return ''.join(parts)
|
||||
except (bashlex.errors.ParsingError, NotImplementedError, TypeError):
|
||||
logger.debug(
|
||||
f'Failed to parse bash commands for special characters escape\n'
|
||||
f'[input]: {command}\n'
|
||||
f'The original command will be returned as is.',
|
||||
exc_info=True,
|
||||
)
|
||||
return command
|
||||
|
||||
|
||||
class BashCommandStatus(Enum):
|
||||
CONTINUE = 'continue'
|
||||
COMPLETED = 'completed'
|
||||
NO_CHANGE_TIMEOUT = 'no_change_timeout'
|
||||
HARD_TIMEOUT = 'hard_timeout'
|
||||
|
||||
|
||||
def _remove_command_prefix(command_output: str, command: str) -> str:
|
||||
return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
|
||||
|
||||
|
||||
class BashSession:
|
||||
POLL_INTERVAL = 0.5
|
||||
HISTORY_LIMIT = 10_000
|
||||
PS1 = CmdOutputMetadata.to_ps1_prompt()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
work_dir: str,
|
||||
username: str | None = None,
|
||||
no_change_timeout_seconds: int = 30,
|
||||
max_memory_mb: int | None = None,
|
||||
):
|
||||
self.NO_CHANGE_TIMEOUT_SECONDS = no_change_timeout_seconds
|
||||
self.work_dir = work_dir
|
||||
self.username = username
|
||||
self._initialized = False
|
||||
self.max_memory_mb = max_memory_mb
|
||||
|
||||
def initialize(self) -> None:
|
||||
self.server = libtmux.Server()
|
||||
_shell_command = '/bin/bash'
|
||||
if SU_TO_USER and self.username in list(
|
||||
filter(None, [RUNTIME_USERNAME, 'root', 'openhands'])
|
||||
):
|
||||
# This starts a non-login (new) shell for the given user
|
||||
_shell_command = f'su {self.username} -'
|
||||
|
||||
# FIXME: we will introduce memory limit using sysbox-runc in coming PR
|
||||
# # otherwise, we are running as the CURRENT USER (e.g., when running LocalRuntime)
|
||||
# if self.max_memory_mb is not None:
|
||||
# window_command = (
|
||||
# f'prlimit --as={self.max_memory_mb * 1024 * 1024} {_shell_command}'
|
||||
# )
|
||||
# else:
|
||||
window_command = _shell_command
|
||||
|
||||
logger.debug(
|
||||
f'Initializing bash session in {self.work_dir} with command: {window_command}'
|
||||
)
|
||||
session_name = f'openhands-{self.username}-{uuid.uuid4()}'
|
||||
self.session = self.server.new_session(
|
||||
session_name=session_name,
|
||||
start_directory=self.work_dir, # This parameter is supported by libtmux
|
||||
kill_session=True,
|
||||
x=1000,
|
||||
y=1000,
|
||||
)
|
||||
|
||||
# Set history limit to a large number to avoid losing history
|
||||
# https://unix.stackexchange.com/questions/43414/unlimited-history-in-tmux
|
||||
self.session.set_option('history-limit', str(self.HISTORY_LIMIT), global_=True)
|
||||
self.session.history_limit = str(self.HISTORY_LIMIT)
|
||||
# We need to create a new pane because the initial pane's history limit is (default) 2000
|
||||
_initial_window = self.session.active_window
|
||||
self.window = self.session.new_window(
|
||||
window_name='bash',
|
||||
window_shell=window_command,
|
||||
start_directory=self.work_dir, # This parameter is supported by libtmux
|
||||
)
|
||||
self.pane = self.window.active_pane
|
||||
logger.debug(f'pane: {self.pane}; history_limit: {self.session.history_limit}')
|
||||
_initial_window.kill()
|
||||
|
||||
# Configure bash to use simple PS1 and disable PS2
|
||||
if self.pane:
|
||||
self.pane.send_keys(
|
||||
f'export PROMPT_COMMAND=\'export PS1="{self.PS1}"\'; export PS2=""'
|
||||
)
|
||||
time.sleep(0.1) # Wait for command to take effect
|
||||
self._clear_screen()
|
||||
|
||||
# Store the last command for interactive input handling
|
||||
self.prev_status: BashCommandStatus | None = None
|
||||
self.prev_output: str = ''
|
||||
self._closed: bool = False
|
||||
logger.debug(f'Bash session initialized with work dir: {self.work_dir}')
|
||||
|
||||
# Maintain the current working directory
|
||||
self._cwd = os.path.abspath(self.work_dir)
|
||||
self._initialized = True
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Ensure the session is closed when the object is destroyed."""
|
||||
self.close()
|
||||
|
||||
def _get_pane_content(self) -> str:
|
||||
"""Capture the current pane content and update the buffer."""
|
||||
if not self.pane:
|
||||
return ''
|
||||
content = '\n'.join(
|
||||
map(
|
||||
# avoid double newlines
|
||||
lambda line: line.rstrip(),
|
||||
self.pane.cmd('capture-pane', '-J', '-pS', '-').stdout,
|
||||
)
|
||||
)
|
||||
return content
|
||||
|
||||
def close(self) -> None:
|
||||
"""Clean up the session."""
|
||||
if self._closed:
|
||||
return
|
||||
self.session.kill()
|
||||
self._closed = True
|
||||
|
||||
@property
|
||||
def cwd(self) -> str:
|
||||
return self._cwd
|
||||
|
||||
def _is_special_key(self, command: str) -> bool:
|
||||
"""Check if the command is a special key."""
|
||||
# Special keys are of the form C-<key>
|
||||
_command = command.strip()
|
||||
return _command.startswith('C-') and len(_command) == 3
|
||||
|
||||
def _clear_screen(self) -> None:
|
||||
"""Clear the tmux pane screen and history."""
|
||||
if self.pane:
|
||||
self.pane.send_keys('C-l', enter=False)
|
||||
time.sleep(0.1)
|
||||
self.pane.cmd('clear-history')
|
||||
|
||||
def _get_command_output(
|
||||
self,
|
||||
command: str,
|
||||
raw_command_output: str,
|
||||
metadata: CmdOutputMetadata,
|
||||
continue_prefix: str = '',
|
||||
) -> str:
|
||||
"""Get the command output with the previous command output removed.
|
||||
|
||||
Args:
|
||||
command: The command that was executed.
|
||||
raw_command_output: The raw output from the command.
|
||||
metadata: The metadata object to store prefix/suffix in.
|
||||
continue_prefix: The prefix to add to the command output if it's a continuation of the previous command.
|
||||
"""
|
||||
# remove the previous command output from the new output if any
|
||||
if self.prev_output:
|
||||
command_output = raw_command_output.removeprefix(self.prev_output)
|
||||
metadata.prefix = continue_prefix
|
||||
else:
|
||||
command_output = raw_command_output
|
||||
self.prev_output = raw_command_output # update current command output anyway
|
||||
command_output = _remove_command_prefix(command_output, command)
|
||||
return command_output.rstrip()
|
||||
|
||||
def _handle_completed_command(
|
||||
self,
|
||||
command: str,
|
||||
pane_content: str,
|
||||
ps1_matches: list[re.Match],
|
||||
hidden: bool,
|
||||
) -> CmdOutputObservation:
|
||||
is_special_key = self._is_special_key(command)
|
||||
assert len(ps1_matches) >= 1, (
|
||||
f'Expected at least one PS1 metadata block, but got {len(ps1_matches)}.\n'
|
||||
f'---FULL OUTPUT---\n{pane_content!r}\n---END OF OUTPUT---'
|
||||
)
|
||||
metadata = CmdOutputMetadata.from_ps1_match(ps1_matches[-1])
|
||||
|
||||
# Special case where the previous command output is truncated due to history limit
|
||||
# We should get the content BEFORE the last PS1 prompt
|
||||
get_content_before_last_match = bool(len(ps1_matches) == 1)
|
||||
|
||||
# Update the current working directory if it has changed
|
||||
if metadata.working_dir != self._cwd and metadata.working_dir:
|
||||
logger.debug(
|
||||
f'directory_changed: {self._cwd}; {metadata.working_dir}; {command}'
|
||||
)
|
||||
self._cwd = metadata.working_dir
|
||||
|
||||
logger.debug(f'COMMAND OUTPUT: {pane_content}')
|
||||
# Extract the command output between the two PS1 prompts
|
||||
raw_command_output = self._combine_outputs_between_matches(
|
||||
pane_content,
|
||||
ps1_matches,
|
||||
get_content_before_last_match=get_content_before_last_match,
|
||||
)
|
||||
|
||||
if get_content_before_last_match:
|
||||
# Count the number of lines in the truncated output
|
||||
num_lines = len(raw_command_output.splitlines())
|
||||
metadata.prefix = f'[Previous command outputs are truncated. Showing the last {num_lines} lines of the output below.]\n'
|
||||
|
||||
metadata.suffix = (
|
||||
f'\n[The command completed with exit code {metadata.exit_code}.]'
|
||||
if not is_special_key
|
||||
else f'\n[The command completed with exit code {metadata.exit_code}. CTRL+{command[-1].upper()} was sent.]'
|
||||
)
|
||||
command_output = self._get_command_output(
|
||||
command,
|
||||
raw_command_output,
|
||||
metadata,
|
||||
)
|
||||
self.prev_status = BashCommandStatus.COMPLETED
|
||||
self.prev_output = '' # Reset previous command output
|
||||
self._ready_for_next_command()
|
||||
return CmdOutputObservation(
|
||||
content=command_output,
|
||||
command=command,
|
||||
metadata=metadata,
|
||||
hidden=hidden,
|
||||
)
|
||||
|
||||
def _handle_nochange_timeout_command(
|
||||
self,
|
||||
command: str,
|
||||
pane_content: str,
|
||||
ps1_matches: list[re.Match],
|
||||
) -> CmdOutputObservation:
|
||||
self.prev_status = BashCommandStatus.NO_CHANGE_TIMEOUT
|
||||
if len(ps1_matches) != 1:
|
||||
logger.warning(
|
||||
'Expected exactly one PS1 metadata block BEFORE the execution of a command, '
|
||||
f'but got {len(ps1_matches)} PS1 metadata blocks:\n---\n{pane_content!r}\n---'
|
||||
)
|
||||
raw_command_output = self._combine_outputs_between_matches(
|
||||
pane_content, ps1_matches
|
||||
)
|
||||
metadata = CmdOutputMetadata() # No metadata available
|
||||
metadata.suffix = (
|
||||
f'\n[The command has no new output after {self.NO_CHANGE_TIMEOUT_SECONDS} seconds. '
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
command_output = self._get_command_output(
|
||||
command,
|
||||
raw_command_output,
|
||||
metadata,
|
||||
continue_prefix='[Below is the output of the previous command.]\n',
|
||||
)
|
||||
return CmdOutputObservation(
|
||||
content=command_output,
|
||||
command=command,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _handle_hard_timeout_command(
|
||||
self,
|
||||
command: str,
|
||||
pane_content: str,
|
||||
ps1_matches: list[re.Match],
|
||||
timeout: float,
|
||||
) -> CmdOutputObservation:
|
||||
self.prev_status = BashCommandStatus.HARD_TIMEOUT
|
||||
if len(ps1_matches) != 1:
|
||||
logger.warning(
|
||||
'Expected exactly one PS1 metadata block BEFORE the execution of a command, '
|
||||
f'but got {len(ps1_matches)} PS1 metadata blocks:\n---\n{pane_content!r}\n---'
|
||||
)
|
||||
raw_command_output = self._combine_outputs_between_matches(
|
||||
pane_content, ps1_matches
|
||||
)
|
||||
metadata = CmdOutputMetadata() # No metadata available
|
||||
metadata.suffix = (
|
||||
f'\n[The command timed out after {timeout} seconds. '
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
command_output = self._get_command_output(
|
||||
command,
|
||||
raw_command_output,
|
||||
metadata,
|
||||
continue_prefix='[Below is the output of the previous command.]\n',
|
||||
)
|
||||
|
||||
return CmdOutputObservation(
|
||||
command=command,
|
||||
content=command_output,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _ready_for_next_command(self) -> None:
|
||||
"""Reset the content buffer for a new command."""
|
||||
# Clear the current content
|
||||
self._clear_screen()
|
||||
|
||||
def _combine_outputs_between_matches(
|
||||
self,
|
||||
pane_content: str,
|
||||
ps1_matches: list[re.Match],
|
||||
get_content_before_last_match: bool = False,
|
||||
) -> str:
|
||||
"""Combine all outputs between PS1 matches.
|
||||
|
||||
Args:
|
||||
pane_content: The full pane content containing PS1 prompts and command outputs
|
||||
ps1_matches: List of regex matches for PS1 prompts
|
||||
get_content_before_last_match: when there's only one PS1 match, whether to get
|
||||
the content before the last PS1 prompt (True) or after the last PS1 prompt (False)
|
||||
|
||||
Returns:
|
||||
Combined string of all outputs between matches
|
||||
"""
|
||||
if len(ps1_matches) == 1:
|
||||
if get_content_before_last_match:
|
||||
# The command output is the content before the last PS1 prompt
|
||||
return pane_content[: ps1_matches[0].start()]
|
||||
else:
|
||||
# The command output is the content after the last PS1 prompt
|
||||
return pane_content[ps1_matches[0].end() + 1 :]
|
||||
elif len(ps1_matches) == 0:
|
||||
return pane_content
|
||||
combined_output = ''
|
||||
for i in range(len(ps1_matches) - 1):
|
||||
# Extract content between current and next PS1 prompt
|
||||
output_segment = pane_content[
|
||||
ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()
|
||||
]
|
||||
combined_output += output_segment + '\n'
|
||||
# Add the content after the last PS1 prompt
|
||||
combined_output += pane_content[ps1_matches[-1].end() + 1 :]
|
||||
logger.debug(f'COMBINED OUTPUT: {combined_output}')
|
||||
return combined_output
|
||||
|
||||
def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservation:
|
||||
"""Execute a command in the bash session."""
|
||||
if not self._initialized:
|
||||
raise RuntimeError('Bash session is not initialized')
|
||||
|
||||
# Strip the command of any leading/trailing whitespace
|
||||
logger.debug(f'RECEIVED ACTION: {action}')
|
||||
command = action.command.strip()
|
||||
is_input: bool = action.is_input
|
||||
|
||||
# If the previous command is not completed, we need to check if the command is empty
|
||||
if self.prev_status not in {
|
||||
BashCommandStatus.CONTINUE,
|
||||
BashCommandStatus.NO_CHANGE_TIMEOUT,
|
||||
BashCommandStatus.HARD_TIMEOUT,
|
||||
}:
|
||||
if command == '':
|
||||
return CmdOutputObservation(
|
||||
content='ERROR: No previous running command to retrieve logs from.',
|
||||
command='',
|
||||
metadata=CmdOutputMetadata(),
|
||||
)
|
||||
if is_input:
|
||||
return CmdOutputObservation(
|
||||
content='ERROR: No previous running command to interact with.',
|
||||
command='',
|
||||
metadata=CmdOutputMetadata(),
|
||||
)
|
||||
|
||||
# Check if the command is a single command or multiple commands
|
||||
splited_commands = split_bash_commands(command)
|
||||
if len(splited_commands) > 1:
|
||||
return ErrorObservation(
|
||||
content=(
|
||||
f'ERROR: Cannot execute multiple commands at once.\n'
|
||||
f'Please run each command separately OR chain them into a single command via && or ;\n'
|
||||
f'Provided commands:\n{"\n".join(f"({i + 1}) {cmd}" for i, cmd in enumerate(splited_commands))}'
|
||||
)
|
||||
)
|
||||
|
||||
# Get initial state before sending command
|
||||
initial_pane_output = self._get_pane_content()
|
||||
initial_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(
|
||||
initial_pane_output
|
||||
)
|
||||
initial_ps1_count = len(initial_ps1_matches)
|
||||
logger.debug(f'Initial PS1 count: {initial_ps1_count}')
|
||||
|
||||
start_time = time.time()
|
||||
last_change_time = start_time
|
||||
last_pane_output = (
|
||||
initial_pane_output # Use initial output as the starting point
|
||||
)
|
||||
|
||||
# When prev command is still running, and we are trying to send a new command
|
||||
if (
|
||||
self.prev_status
|
||||
in {
|
||||
BashCommandStatus.HARD_TIMEOUT,
|
||||
BashCommandStatus.NO_CHANGE_TIMEOUT,
|
||||
}
|
||||
and not last_pane_output.rstrip().endswith(
|
||||
CMD_OUTPUT_PS1_END.rstrip()
|
||||
) # prev command is not completed
|
||||
and not is_input
|
||||
and command != '' # not input and not empty command
|
||||
):
|
||||
_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
|
||||
# Use initial_ps1_matches if _ps1_matches is empty, otherwise use _ps1_matches
|
||||
# This handles the case where the prompt might be scrolled off screen but existed before
|
||||
current_matches_for_output = (
|
||||
_ps1_matches if _ps1_matches else initial_ps1_matches
|
||||
)
|
||||
raw_command_output = self._combine_outputs_between_matches(
|
||||
last_pane_output, current_matches_for_output
|
||||
)
|
||||
metadata = CmdOutputMetadata() # No metadata available
|
||||
metadata.suffix = (
|
||||
f'\n[Your command "{command}" is NOT executed. '
|
||||
'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
|
||||
'By setting `is_input` to `true`, you can interact with the current process: '
|
||||
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
|
||||
)
|
||||
logger.debug(f'PREVIOUS COMMAND OUTPUT: {raw_command_output}')
|
||||
command_output = self._get_command_output(
|
||||
command,
|
||||
raw_command_output,
|
||||
metadata,
|
||||
continue_prefix='[Below is the output of the previous command.]\n',
|
||||
)
|
||||
return CmdOutputObservation(
|
||||
command=command,
|
||||
content=command_output,
|
||||
metadata=metadata,
|
||||
hidden=getattr(action, 'hidden', False),
|
||||
)
|
||||
|
||||
# Send actual command/inputs to the pane
|
||||
if command != '' and self.pane:
|
||||
is_special_key = self._is_special_key(command)
|
||||
if is_input:
|
||||
logger.debug(f'SENDING INPUT TO RUNNING PROCESS: {command!r}')
|
||||
self.pane.send_keys(
|
||||
command,
|
||||
enter=not is_special_key,
|
||||
)
|
||||
else:
|
||||
# convert command to raw string
|
||||
command = escape_bash_special_chars(command)
|
||||
logger.debug(f'SENDING COMMAND: {command!r}')
|
||||
self.pane.send_keys(
|
||||
command,
|
||||
enter=not is_special_key,
|
||||
)
|
||||
|
||||
# Loop until the command completes or times out
|
||||
while should_continue():
|
||||
_start_time = time.time()
|
||||
logger.debug(f'GETTING PANE CONTENT at {_start_time}')
|
||||
cur_pane_output = self._get_pane_content()
|
||||
logger.debug(
|
||||
f'PANE CONTENT GOT after {time.time() - _start_time:.2f} seconds'
|
||||
)
|
||||
cur_pane_lines = cur_pane_output.split('\n')
|
||||
if len(cur_pane_lines) <= 20:
|
||||
logger.debug('PANE_CONTENT: {cur_pane_output}')
|
||||
else:
|
||||
logger.debug(f'BEGIN OF PANE CONTENT: {cur_pane_lines[:10]}')
|
||||
logger.debug(f'END OF PANE CONTENT: {cur_pane_lines[-10:]}')
|
||||
ps1_matches = CmdOutputMetadata.matches_ps1_metadata(cur_pane_output)
|
||||
current_ps1_count = len(ps1_matches)
|
||||
|
||||
if cur_pane_output != last_pane_output:
|
||||
last_pane_output = cur_pane_output
|
||||
last_change_time = time.time()
|
||||
logger.debug(f'CONTENT UPDATED DETECTED at {last_change_time}')
|
||||
|
||||
# 1) Execution completed:
|
||||
# Condition 1: A new prompt has appeared since the command started.
|
||||
# Condition 2: The prompt count hasn't increased (potentially because the initial one scrolled off),
|
||||
# BUT the *current* visible pane ends with a prompt, indicating completion.
|
||||
if (
|
||||
current_ps1_count > initial_ps1_count
|
||||
or cur_pane_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
|
||||
):
|
||||
return self._handle_completed_command(
|
||||
command,
|
||||
pane_content=cur_pane_output,
|
||||
ps1_matches=ps1_matches,
|
||||
hidden=getattr(action, 'hidden', False),
|
||||
)
|
||||
|
||||
# Timeout checks should only trigger if a new prompt hasn't appeared yet.
|
||||
|
||||
# 2) Execution timed out since there's no change in output
|
||||
# for a while (self.NO_CHANGE_TIMEOUT_SECONDS)
|
||||
# We ignore this if the command is *blocking*
|
||||
time_since_last_change = time.time() - last_change_time
|
||||
logger.debug(
|
||||
f'CHECKING NO CHANGE TIMEOUT ({self.NO_CHANGE_TIMEOUT_SECONDS}s): elapsed {time_since_last_change}. Action blocking: {action.blocking}'
|
||||
)
|
||||
if (
|
||||
not action.blocking
|
||||
and time_since_last_change >= self.NO_CHANGE_TIMEOUT_SECONDS
|
||||
):
|
||||
return self._handle_nochange_timeout_command(
|
||||
command,
|
||||
pane_content=cur_pane_output,
|
||||
ps1_matches=ps1_matches,
|
||||
)
|
||||
|
||||
# 3) Execution timed out due to hard timeout
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.debug(
|
||||
f'CHECKING HARD TIMEOUT ({action.timeout}s): elapsed {elapsed_time:.2f}'
|
||||
)
|
||||
if action.timeout and elapsed_time >= action.timeout:
|
||||
logger.debug('Hard timeout triggered.')
|
||||
return self._handle_hard_timeout_command(
|
||||
command,
|
||||
pane_content=cur_pane_output,
|
||||
ps1_matches=ps1_matches,
|
||||
timeout=action.timeout,
|
||||
)
|
||||
|
||||
logger.debug(f'SLEEPING for {self.POLL_INTERVAL} seconds for next poll')
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
raise RuntimeError('Bash session was likely interrupted...')
|
||||
@@ -0,0 +1,14 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
# Common timeout message that can be used across different timeout scenarios
|
||||
TIMEOUT_MESSAGE_TEMPLATE = (
|
||||
"You may wait longer to see additional output by sending empty command '', "
|
||||
'send other commands to interact with the current process, '
|
||||
'send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command, '
|
||||
'or use the timeout parameter in execute_bash for future commands.'
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import os
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
|
||||
DEFAULT_PYTHON_PREFIX = [
|
||||
'/openhands/micromamba/bin/micromamba',
|
||||
'run',
|
||||
'-n',
|
||||
'openhands',
|
||||
'poetry',
|
||||
'run',
|
||||
]
|
||||
DEFAULT_MAIN_MODULE = 'openhands.runtime.action_execution_server'
|
||||
|
||||
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
|
||||
RUNTIME_UID = os.getenv('RUNTIME_UID')
|
||||
|
||||
|
||||
def get_action_execution_server_startup_command(
|
||||
server_port: int,
|
||||
plugins: list[PluginRequirement],
|
||||
app_config: OpenHandsConfig,
|
||||
python_prefix: list[str] = DEFAULT_PYTHON_PREFIX,
|
||||
override_user_id: int | None = None,
|
||||
override_username: str | None = None,
|
||||
main_module: str = DEFAULT_MAIN_MODULE,
|
||||
python_executable: str = 'python',
|
||||
) -> list[str]:
|
||||
sandbox_config = app_config.sandbox
|
||||
logger.debug(f'app_config {vars(app_config)}')
|
||||
logger.debug(f'sandbox_config {vars(sandbox_config)}')
|
||||
logger.debug(f'RUNTIME_USERNAME {RUNTIME_USERNAME}, RUNTIME_UID {RUNTIME_UID}')
|
||||
logger.debug(
|
||||
f'override_username {override_username}, override_user_id {override_user_id}'
|
||||
)
|
||||
|
||||
# Plugin args
|
||||
plugin_args = []
|
||||
if plugins is not None and len(plugins) > 0:
|
||||
plugin_args = ['--plugins'] + [plugin.name for plugin in plugins]
|
||||
|
||||
# Browsergym stuffs
|
||||
browsergym_args = []
|
||||
if sandbox_config.browsergym_eval_env is not None:
|
||||
browsergym_args = [
|
||||
'--browsergym-eval-env'
|
||||
] + sandbox_config.browsergym_eval_env.split(' ')
|
||||
|
||||
username = (
|
||||
override_username
|
||||
or RUNTIME_USERNAME
|
||||
or ('openhands' if app_config.run_as_openhands else 'root')
|
||||
)
|
||||
user_id = (
|
||||
override_user_id or RUNTIME_UID or (1000 if app_config.run_as_openhands else 0)
|
||||
)
|
||||
logger.debug(f'username {username}, user_id {user_id}')
|
||||
|
||||
base_cmd = [
|
||||
*python_prefix,
|
||||
python_executable,
|
||||
'-u',
|
||||
'-m',
|
||||
main_module,
|
||||
str(server_port),
|
||||
'--working-dir',
|
||||
app_config.workspace_mount_path_in_sandbox,
|
||||
*plugin_args,
|
||||
'--username',
|
||||
username,
|
||||
'--user-id',
|
||||
str(user_id),
|
||||
*browsergym_args,
|
||||
]
|
||||
|
||||
if not app_config.enable_browser:
|
||||
base_cmd.append('--no-enable-browser')
|
||||
logger.debug(f'get_action_execution_server_startup_command: {base_cmd}')
|
||||
|
||||
return base_cmd
|
||||
@@ -0,0 +1,321 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from openhands_aci.utils.diff import get_diff # type: ignore
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
FileEditAction,
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
IPythonRunCellAction,
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
ErrorObservation,
|
||||
FileEditObservation,
|
||||
FileReadObservation,
|
||||
FileWriteObservation,
|
||||
Observation,
|
||||
)
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.utils.chunk_localizer import Chunk, get_top_k_chunk_matches
|
||||
|
||||
USER_MSG = """
|
||||
Code changes will be provided in the form of a draft. You will need to apply the draft to the original code.
|
||||
The original code will be enclosed within `<original_code>` tags.
|
||||
The draft will be enclosed within `<update_snippet>` tags.
|
||||
You need to output the update code within `<updated_code>` tags.
|
||||
|
||||
Within the `<updated_code>` tag, include only the final code after updation. Do not include any explanations or other content within these tags.
|
||||
|
||||
<original_code>{old_contents}</original_code>
|
||||
|
||||
<update_snippet>{draft_changes}</update_snippet>
|
||||
"""
|
||||
|
||||
CORRECT_SYS_MSG = """You are a code repair assistant. Now you have an original file content and error information from a static code checking tool (lint tool). Your task is to automatically modify and return the repaired complete code based on these error messages and refer to the current file content.
|
||||
|
||||
The following are the specific task steps you need to complete:
|
||||
|
||||
Carefully read the current file content to ensure that you fully understand its code structure.
|
||||
|
||||
According to the lint error prompt, accurately locate and analyze the cause of the problem.
|
||||
|
||||
Modify the original file content and fix all errors prompted by the lint tool.
|
||||
|
||||
Return complete, runnable, and error-fixed code, paying attention to maintaining the overall style and specifications of the original code.
|
||||
|
||||
Please note:
|
||||
|
||||
Please strictly follow the lint error prompts to make modifications and do not miss any problems.
|
||||
|
||||
The modified code must be complete and cannot introduce new errors or bugs.
|
||||
|
||||
The modified code must maintain the original code function and logic, and no changes unrelated to error repair should be made."""
|
||||
|
||||
CORRECT_USER_MSG = """
|
||||
THE FOLLOWING ARE THE ORIGINAL FILE CONTENTS AND THE ERROR INFORMATION REPORTED BY THE LINT TOOL
|
||||
|
||||
# CURRENT FILE CONTENT:
|
||||
```
|
||||
{file_content}
|
||||
```
|
||||
|
||||
# ERROR MESSAGE FROM STATIC CODE CHECKING TOOL:
|
||||
```
|
||||
{lint_error}
|
||||
```
|
||||
""".strip()
|
||||
|
||||
|
||||
def _extract_code(string: str) -> str | None:
|
||||
pattern = r'<updated_code>(.*?)</updated_code>'
|
||||
matches = re.findall(pattern, string, re.DOTALL)
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
content = str(matches[0])
|
||||
if content.startswith('#EDIT:'):
|
||||
# Remove first line
|
||||
content = content[content.find('\n') + 1 :]
|
||||
return content
|
||||
|
||||
|
||||
def get_new_file_contents(
|
||||
llm: LLM, old_contents: str, draft_changes: str, num_retries: int = 3
|
||||
) -> str | None:
|
||||
while num_retries > 0:
|
||||
messages = [
|
||||
{
|
||||
'role': 'user',
|
||||
'content': USER_MSG.format(
|
||||
old_contents=old_contents, draft_changes=draft_changes
|
||||
),
|
||||
},
|
||||
]
|
||||
resp = llm.completion(messages=messages)
|
||||
new_contents = _extract_code(resp['choices'][0]['message']['content'])
|
||||
if new_contents is not None:
|
||||
return new_contents
|
||||
num_retries -= 1
|
||||
return None
|
||||
|
||||
|
||||
class FileEditRuntimeInterface(ABC):
|
||||
config: OpenHandsConfig
|
||||
|
||||
@abstractmethod
|
||||
def read(self, action: FileReadAction) -> Observation:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def write(self, action: FileWriteAction) -> Observation:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run_ipython(self, action: IPythonRunCellAction) -> Observation:
|
||||
pass
|
||||
|
||||
|
||||
class FileEditRuntimeMixin(FileEditRuntimeInterface):
|
||||
# Most LLMs have output token limit of 4k tokens.
|
||||
# This restricts the number of lines we can edit to avoid exceeding the token limit.
|
||||
MAX_LINES_TO_EDIT = 300
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enable_llm_editor: bool,
|
||||
llm_registry: LLMRegistry,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.enable_llm_editor = enable_llm_editor
|
||||
|
||||
if not self.enable_llm_editor:
|
||||
return
|
||||
|
||||
draft_editor_config = self.config.get_llm_config('draft_editor')
|
||||
|
||||
# manually set the model name for the draft editor LLM to distinguish token costs
|
||||
if draft_editor_config.caching_prompt:
|
||||
logger.debug(
|
||||
'It is not recommended to cache draft editor LLM prompts as it may incur high costs for the same prompt. '
|
||||
'Automatically setting caching_prompt=false.'
|
||||
)
|
||||
draft_editor_config.caching_prompt = False
|
||||
|
||||
self.draft_editor_llm = llm_registry.get_llm(
|
||||
'draft_editor_llm', draft_editor_config
|
||||
)
|
||||
logger.debug(
|
||||
f'[Draft edit functionality] enabled with LLM: {self.draft_editor_llm}'
|
||||
)
|
||||
|
||||
def _validate_range(
|
||||
self, start: int, end: int, total_lines: int
|
||||
) -> Observation | None:
|
||||
# start and end are 1-indexed and inclusive
|
||||
if (
|
||||
(start < 1 and start != -1)
|
||||
or start > total_lines
|
||||
or (start > end and end != -1 and start != -1)
|
||||
):
|
||||
return ErrorObservation(
|
||||
f'Invalid range for editing: start={start}, end={end}, total lines={total_lines}. start must be >= 1 and <={total_lines} (total lines of the edited file), start <= end, or start == -1 (append to the end of the file).'
|
||||
)
|
||||
if (
|
||||
(end < 1 and end != -1)
|
||||
or end > total_lines
|
||||
or (end < start and start != -1 and end != -1)
|
||||
):
|
||||
return ErrorObservation(
|
||||
f'Invalid range for editing: start={start}, end={end}, total lines={total_lines}. end must be >= 1 and <= {total_lines} (total lines of the edited file), end >= start, or end == -1 (to edit till the end of the file).'
|
||||
)
|
||||
return None
|
||||
|
||||
def llm_based_edit(self, action: FileEditAction, retry_num: int = 0) -> Observation:
|
||||
obs = self.read(FileReadAction(path=action.path))
|
||||
if (
|
||||
isinstance(obs, ErrorObservation)
|
||||
and 'File not found'.lower() in obs.content.lower()
|
||||
):
|
||||
logger.debug(
|
||||
f'Agent attempted to edit a file that does not exist. Creating the file. Error msg: {obs.content}'
|
||||
)
|
||||
# directly write the new content
|
||||
obs = self.write(
|
||||
FileWriteAction(path=action.path, content=action.content.strip())
|
||||
)
|
||||
if isinstance(obs, ErrorObservation):
|
||||
return obs
|
||||
if not isinstance(obs, FileWriteObservation):
|
||||
raise ValueError(
|
||||
f'Expected FileWriteObservation, got {type(obs)}: {str(obs)}'
|
||||
)
|
||||
return FileEditObservation(
|
||||
content=get_diff('', action.content, action.path),
|
||||
path=action.path,
|
||||
prev_exist=False,
|
||||
old_content='',
|
||||
new_content=action.content,
|
||||
)
|
||||
if not isinstance(obs, FileReadObservation):
|
||||
raise ValueError(
|
||||
f'Expected FileReadObservation, got {type(obs)}: {str(obs)}'
|
||||
)
|
||||
|
||||
original_file_content = obs.content
|
||||
old_file_lines = original_file_content.split('\n')
|
||||
# NOTE: start and end are 1-indexed
|
||||
start = action.start
|
||||
end = action.end
|
||||
# validate the range
|
||||
error = self._validate_range(start, end, len(old_file_lines))
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
# append to the end of the file
|
||||
if start == -1:
|
||||
updated_content = '\n'.join(old_file_lines + action.content.split('\n'))
|
||||
diff = get_diff(original_file_content, updated_content, action.path)
|
||||
obs = self.write(FileWriteAction(path=action.path, content=updated_content))
|
||||
return FileEditObservation(
|
||||
content=diff,
|
||||
path=action.path,
|
||||
prev_exist=True,
|
||||
old_content=original_file_content,
|
||||
new_content=updated_content,
|
||||
)
|
||||
|
||||
# Get the 0-indexed start and end
|
||||
start_idx = start - 1
|
||||
if end != -1:
|
||||
# remove 1 to make it 0-indexed
|
||||
# then add 1 since the `end` is inclusive
|
||||
end_idx = end - 1 + 1
|
||||
else:
|
||||
# end == -1 means the user wants to edit till the end of the file
|
||||
end_idx = len(old_file_lines)
|
||||
|
||||
# Get the range of lines to edit - reject if too long
|
||||
length_of_range = end_idx - start_idx
|
||||
if length_of_range > self.MAX_LINES_TO_EDIT + 1:
|
||||
error_msg = (
|
||||
f'[Edit error: The range of lines to edit is too long.]\n'
|
||||
f'[The maximum number of lines allowed to edit at once is {self.MAX_LINES_TO_EDIT}. '
|
||||
f'Got (L{start_idx + 1}-L{end_idx}) {length_of_range} lines.]\n'
|
||||
# [start_idx, end_idx), so no need to + 1
|
||||
)
|
||||
# search for relevant ranges to hint the agent
|
||||
topk_chunks: list[Chunk] = get_top_k_chunk_matches(
|
||||
text=original_file_content,
|
||||
query=action.content, # edit draft as query
|
||||
k=3,
|
||||
max_chunk_size=20, # lines
|
||||
)
|
||||
error_msg += (
|
||||
'Here are some snippets that maybe relevant to the provided edit.\n'
|
||||
)
|
||||
for i, chunk in enumerate(topk_chunks):
|
||||
error_msg += f'[begin relevant snippet {i + 1}. Line range: L{chunk.line_range[0]}-L{chunk.line_range[1]}. Similarity: {chunk.normalized_lcs}]\n'
|
||||
error_msg += f'[Browse around it via `open_file("{action.path}", {(chunk.line_range[0] + chunk.line_range[1]) // 2})`]\n'
|
||||
error_msg += chunk.visualize() + '\n'
|
||||
error_msg += f'[end relevant snippet {i + 1}]\n'
|
||||
error_msg += '-' * 40 + '\n'
|
||||
|
||||
error_msg += 'Consider using `open_file` to explore around the relevant snippets if needed.\n'
|
||||
error_msg += f'**IMPORTANT**: Please REDUCE the range of edits to less than {self.MAX_LINES_TO_EDIT} lines by setting `start` and `end` in the edit action (e.g. `<file_edit path="{action.path}" start=[PUT LINE NUMBER HERE] end=[PUT LINE NUMBER HERE] />`). '
|
||||
|
||||
return ErrorObservation(error_msg)
|
||||
|
||||
content_to_edit = '\n'.join(old_file_lines[start_idx:end_idx])
|
||||
_edited_content = get_new_file_contents(
|
||||
self.draft_editor_llm, content_to_edit, action.content
|
||||
)
|
||||
if _edited_content is None:
|
||||
ret_err = ErrorObservation(
|
||||
'Failed to get new file contents. '
|
||||
'Please try to reduce the number of edits and try again.'
|
||||
)
|
||||
ret_err.llm_metrics = self.draft_editor_llm.metrics
|
||||
return ret_err
|
||||
|
||||
# piece the updated content with the unchanged content
|
||||
updated_lines = (
|
||||
old_file_lines[:start_idx]
|
||||
+ _edited_content.split('\n')
|
||||
+ old_file_lines[end_idx:]
|
||||
)
|
||||
updated_content = '\n'.join(updated_lines)
|
||||
diff = get_diff(original_file_content, updated_content, action.path)
|
||||
|
||||
obs = self.write(FileWriteAction(path=action.path, content=updated_content))
|
||||
ret_obs = FileEditObservation(
|
||||
content=diff,
|
||||
path=action.path,
|
||||
prev_exist=True,
|
||||
old_content=original_file_content,
|
||||
new_content=updated_content,
|
||||
)
|
||||
ret_obs.llm_metrics = self.draft_editor_llm.metrics
|
||||
return ret_obs
|
||||
|
||||
def check_retry_num(self, retry_num: int) -> bool:
|
||||
correct_num = self.draft_editor_llm.config.correct_num
|
||||
return correct_num < retry_num
|
||||
|
||||
def correct_edit(
|
||||
self, file_content: str, error_obs: ErrorObservation, retry_num: int = 0
|
||||
) -> Observation:
|
||||
return error_obs
|
||||
@@ -0,0 +1,150 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""Utility module for generating file viewer HTML content."""
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
|
||||
def generate_file_viewer_html(file_path: str) -> str:
|
||||
"""Generate HTML content for viewing different file types.
|
||||
|
||||
Args:
|
||||
file_path: The absolute path to the file
|
||||
|
||||
Returns:
|
||||
str: HTML content for viewing the file
|
||||
|
||||
Raises:
|
||||
ValueError: If the file extension is not supported
|
||||
"""
|
||||
file_extension = os.path.splitext(file_path)[1].lower()
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
# Define supported file extensions
|
||||
supported_extensions = [
|
||||
'.pdf',
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
]
|
||||
|
||||
# Check if the file extension is supported
|
||||
if file_extension not in supported_extensions:
|
||||
raise ValueError(
|
||||
f'Unsupported file extension: {file_extension}. '
|
||||
f'Supported extensions are: {", ".join(supported_extensions)}'
|
||||
)
|
||||
|
||||
# Check if the file exists
|
||||
if not os.path.exists(file_path):
|
||||
raise ValueError(
|
||||
f'File not found locally: {file_path}. Please download the file to the local machine and try again.'
|
||||
)
|
||||
|
||||
# Read file content directly
|
||||
file_content = None
|
||||
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
|
||||
|
||||
# For binary files (images, PDFs), encode as base64
|
||||
if file_extension in ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp']:
|
||||
with open(file_path, 'rb') as file:
|
||||
file_content = base64.b64encode(file.read()).decode('utf-8')
|
||||
# For text files, read as text
|
||||
else:
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
file_content = file.read()
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>File Viewer - {file_name}</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<style>
|
||||
body, html {{ margin: 0; padding: 0; height: 100%; overflow: hidden; font-family: Arial, sans-serif; }}
|
||||
#viewer-container {{ width: 100%; height: 100vh; overflow: auto; }}
|
||||
.page {{ margin: 10px auto; box-shadow: 0 0 10px rgba(0,0,0,0.3); }}
|
||||
.text-content {{ margin: 20px; white-space: pre-wrap; font-family: monospace; line-height: 1.5; }}
|
||||
.error {{ color: red; margin: 20px; }}
|
||||
img {{ max-width: 100%; margin: 20px auto; display: block; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewer-container"></div>
|
||||
<script>
|
||||
const filePath = "{file_path}";
|
||||
const fileExtension = "{file_extension}";
|
||||
const fileContent = `{file_content if file_extension not in ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp'] else ''}`;
|
||||
const fileBase64 = "{file_content if file_extension in ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp'] else ''}";
|
||||
const mimeType = "{mime_type}";
|
||||
const container = document.getElementById('viewer-container');
|
||||
|
||||
async function loadContent() {{
|
||||
try {{
|
||||
if (fileExtension === '.pdf') {{
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
const binaryString = atob(fileBase64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {{
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}}
|
||||
|
||||
const loadingTask = pdfjsLib.getDocument({{data: bytes.buffer}});
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
// Get total number of pages
|
||||
const numPages = pdf.numPages;
|
||||
|
||||
// Render each page
|
||||
for (let pageNum = 1; pageNum <= numPages; pageNum++) {{
|
||||
const page = await pdf.getPage(pageNum);
|
||||
|
||||
// Set scale for rendering
|
||||
const viewport = page.getViewport({{ scale: 1.5 }});
|
||||
|
||||
// Create canvas for rendering
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'page';
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
container.appendChild(canvas);
|
||||
|
||||
// Render PDF page into canvas context
|
||||
const context = canvas.getContext('2d');
|
||||
const renderContext = {{
|
||||
canvasContext: context,
|
||||
viewport: viewport
|
||||
}};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
}}
|
||||
}} else if (['.png', '.jpg', '.jpeg', '.gif', '.bmp'].includes(fileExtension)) {{
|
||||
const img = document.createElement('img');
|
||||
img.src = `data:${{mimeType}};base64,${{fileBase64}}`;
|
||||
img.alt = filePath.split('/').pop();
|
||||
container.appendChild(img);
|
||||
}} else {{
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'text-content';
|
||||
pre.textContent = fileContent;
|
||||
container.appendChild(pre);
|
||||
}}
|
||||
}} catch (error) {{
|
||||
console.error('Error:', error);
|
||||
container.innerHTML = `<div class="error"><h2>Error loading file</h2><p>${{error.message}}</p></div>`;
|
||||
}}
|
||||
}}
|
||||
|
||||
window.onload = loadContent;
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -0,0 +1,157 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from openhands.events.observation import (
|
||||
ErrorObservation,
|
||||
FileReadObservation,
|
||||
FileWriteObservation,
|
||||
Observation,
|
||||
)
|
||||
|
||||
|
||||
def resolve_path(
|
||||
file_path: str,
|
||||
working_directory: str,
|
||||
workspace_base: str,
|
||||
workspace_mount_path_in_sandbox: str,
|
||||
) -> Path:
|
||||
"""Resolve a file path to a path on the host filesystem.
|
||||
|
||||
Args:
|
||||
file_path: The path to resolve.
|
||||
working_directory: The working directory of the agent.
|
||||
workspace_mount_path_in_sandbox: The path to the workspace inside the sandbox.
|
||||
workspace_base: The base path of the workspace on the host filesystem.
|
||||
|
||||
Returns:
|
||||
The resolved path on the host filesystem.
|
||||
"""
|
||||
path_in_sandbox = Path(file_path)
|
||||
|
||||
# Apply working directory
|
||||
if not path_in_sandbox.is_absolute():
|
||||
path_in_sandbox = Path(working_directory) / path_in_sandbox
|
||||
|
||||
# Sanitize the path with respect to the root of the full sandbox
|
||||
# (deny any .. path traversal to parent directories of the sandbox)
|
||||
abs_path_in_sandbox = path_in_sandbox.resolve()
|
||||
|
||||
# If the path is outside the workspace, deny it
|
||||
if not abs_path_in_sandbox.is_relative_to(workspace_mount_path_in_sandbox):
|
||||
raise PermissionError(f'File access not permitted: {file_path}')
|
||||
|
||||
# Get path relative to the root of the workspace inside the sandbox
|
||||
path_in_workspace = abs_path_in_sandbox.relative_to(
|
||||
Path(workspace_mount_path_in_sandbox)
|
||||
)
|
||||
|
||||
# Get path relative to host
|
||||
path_in_host_workspace = Path(workspace_base) / path_in_workspace
|
||||
|
||||
return path_in_host_workspace
|
||||
|
||||
|
||||
def read_lines(all_lines: list[str], start: int = 0, end: int = -1) -> list[str]:
|
||||
start = max(start, 0)
|
||||
start = min(start, len(all_lines))
|
||||
end = -1 if end == -1 else max(end, 0)
|
||||
end = min(end, len(all_lines))
|
||||
if end == -1:
|
||||
if start == 0:
|
||||
return all_lines
|
||||
else:
|
||||
return all_lines[start:]
|
||||
else:
|
||||
num_lines = len(all_lines)
|
||||
begin = max(0, min(start, num_lines - 2))
|
||||
end = -1 if end > num_lines else max(begin + 1, end)
|
||||
return all_lines[begin:end]
|
||||
|
||||
|
||||
async def read_file(
|
||||
path: str,
|
||||
workdir: str,
|
||||
workspace_base: str,
|
||||
workspace_mount_path_in_sandbox: str,
|
||||
start: int = 0,
|
||||
end: int = -1,
|
||||
) -> Observation:
|
||||
try:
|
||||
whole_path = resolve_path(
|
||||
path, workdir, workspace_base, workspace_mount_path_in_sandbox
|
||||
)
|
||||
except PermissionError:
|
||||
return ErrorObservation(
|
||||
f"You're not allowed to access this path: {path}. You can only access paths inside the workspace."
|
||||
)
|
||||
|
||||
try:
|
||||
with open(whole_path, 'r', encoding='utf-8') as file:
|
||||
lines = read_lines(file.readlines(), start, end)
|
||||
except FileNotFoundError:
|
||||
return ErrorObservation(f'File not found: {path}')
|
||||
except UnicodeDecodeError:
|
||||
return ErrorObservation(f'File could not be decoded as utf-8: {path}')
|
||||
except IsADirectoryError:
|
||||
return ErrorObservation(f'Path is a directory: {path}. You can only read files')
|
||||
code_view = ''.join(lines)
|
||||
return FileReadObservation(path=path, content=code_view)
|
||||
|
||||
|
||||
def insert_lines(
|
||||
to_insert: list[str], original: list[str], start: int = 0, end: int = -1
|
||||
) -> list[str]:
|
||||
"""Insert the new content to the original content based on start and end"""
|
||||
new_lines = [''] if start == 0 else original[:start]
|
||||
new_lines += [i + '\n' for i in to_insert]
|
||||
new_lines += [''] if end == -1 else original[end:]
|
||||
return new_lines
|
||||
|
||||
|
||||
async def write_file(
|
||||
path: str,
|
||||
workdir: str,
|
||||
workspace_base: str,
|
||||
workspace_mount_path_in_sandbox: str,
|
||||
content: str,
|
||||
start: int = 0,
|
||||
end: int = -1,
|
||||
) -> Observation:
|
||||
insert = content.split('\n')
|
||||
|
||||
try:
|
||||
whole_path = resolve_path(
|
||||
path, workdir, workspace_base, workspace_mount_path_in_sandbox
|
||||
)
|
||||
if not os.path.exists(os.path.dirname(whole_path)):
|
||||
os.makedirs(os.path.dirname(whole_path))
|
||||
mode = 'w' if not os.path.exists(whole_path) else 'r+'
|
||||
try:
|
||||
with open(whole_path, mode, encoding='utf-8') as file:
|
||||
if mode != 'w':
|
||||
all_lines = file.readlines()
|
||||
new_file = insert_lines(insert, all_lines, start, end)
|
||||
else:
|
||||
new_file = [i + '\n' for i in insert]
|
||||
|
||||
file.seek(0)
|
||||
file.writelines(new_file)
|
||||
file.truncate()
|
||||
except FileNotFoundError:
|
||||
return ErrorObservation(f'File not found: {path}')
|
||||
except IsADirectoryError:
|
||||
return ErrorObservation(
|
||||
f'Path is a directory: {path}. You can only write to files'
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
return ErrorObservation(f'File could not be decoded as utf-8: {path}')
|
||||
except PermissionError as e:
|
||||
return ErrorObservation(f'Permission error on {path}: {e}')
|
||||
return FileWriteObservation(content='', path=path)
|
||||
@@ -0,0 +1,200 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
#!/usr/bin/env python3
|
||||
"""Get git changes in the current working directory relative to the remote origin if possible.
|
||||
NOTE: Since this is run as a script, there should be no imports from project files!
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(cmd: str, cwd: str) -> str:
|
||||
result = subprocess.run(
|
||||
args=cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
|
||||
)
|
||||
byte_content = result.stderr or result.stdout or b''
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f'error_running_cmd:{result.returncode}:{byte_content.decode()}'
|
||||
)
|
||||
return byte_content.decode().strip()
|
||||
|
||||
|
||||
def get_valid_ref(repo_dir: str) -> str | None:
|
||||
refs = []
|
||||
try:
|
||||
current_branch = run('git --no-pager rev-parse --abbrev-ref HEAD', repo_dir)
|
||||
refs.append(f'origin/{current_branch}')
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
default_branch = (
|
||||
run('git --no-pager remote show origin | grep "HEAD branch"', repo_dir)
|
||||
.split()[-1]
|
||||
.strip()
|
||||
)
|
||||
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_default_branch = f'origin/{default_branch}'
|
||||
refs.append(ref_non_default_branch)
|
||||
refs.append(ref_default_branch)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
# compares with empty tree
|
||||
ref_new_repo = (
|
||||
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
|
||||
)
|
||||
refs.append(ref_new_repo)
|
||||
|
||||
# Find a ref that exists...
|
||||
for ref in refs:
|
||||
try:
|
||||
result = run(f'git --no-pager rev-parse --verify {ref}', repo_dir)
|
||||
return result
|
||||
except RuntimeError:
|
||||
# invalid ref - try next
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_changes_in_repo(repo_dir: str) -> list[dict[str, str]]:
|
||||
# Gets the status relative to the origin default branch - not the same as `git status`
|
||||
|
||||
ref = get_valid_ref(repo_dir)
|
||||
if not ref:
|
||||
return []
|
||||
|
||||
# Get changed files
|
||||
changed_files = run(
|
||||
f'git --no-pager diff --name-status {ref}', repo_dir
|
||||
).splitlines()
|
||||
changes = []
|
||||
for line in changed_files:
|
||||
if not line.strip():
|
||||
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
|
||||
|
||||
# Handle different output formats from git diff --name-status
|
||||
# Depending on git config, format can be either:
|
||||
# * "A file.txt"
|
||||
# * "A file.txt"
|
||||
# * "R100 old_file.txt new_file.txt" (rename with similarity percentage)
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
|
||||
|
||||
status = parts[0].strip()
|
||||
|
||||
# Handle rename operations (status starts with 'R' followed by similarity percentage)
|
||||
if status.startswith('R') and len(parts) == 3:
|
||||
# Rename: convert to delete (old path) + add (new path)
|
||||
old_path = parts[1].strip()
|
||||
new_path = parts[2].strip()
|
||||
changes.append(
|
||||
{
|
||||
'status': 'D',
|
||||
'path': old_path,
|
||||
}
|
||||
)
|
||||
changes.append(
|
||||
{
|
||||
'status': 'A',
|
||||
'path': new_path,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle copy operations (status starts with 'C' followed by similarity percentage)
|
||||
elif status.startswith('C') and len(parts) == 3:
|
||||
# Copy: only add the new path (original remains)
|
||||
new_path = parts[2].strip()
|
||||
changes.append(
|
||||
{
|
||||
'status': 'A',
|
||||
'path': new_path,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle regular operations (M, A, D, etc.)
|
||||
elif len(parts) == 2:
|
||||
path = parts[1].strip()
|
||||
else:
|
||||
raise RuntimeError(f'unexpected_value_in_git_diff:{changed_files}')
|
||||
|
||||
if status == '??':
|
||||
status = 'A'
|
||||
elif status == '*':
|
||||
status = 'M'
|
||||
|
||||
# Check for valid single-character status codes
|
||||
if status in {'M', 'A', 'D', 'U'}:
|
||||
changes.append(
|
||||
{
|
||||
'status': status,
|
||||
'path': path,
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f'unexpected_status_in_git_diff:{changed_files}')
|
||||
|
||||
# Get untracked files
|
||||
untracked_files = run(
|
||||
'git --no-pager ls-files --others --exclude-standard', repo_dir
|
||||
).splitlines()
|
||||
for path in untracked_files:
|
||||
if path:
|
||||
changes.append({'status': 'A', 'path': path})
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def get_git_changes(cwd: str) -> list[dict[str, str]]:
|
||||
git_dirs = {
|
||||
os.path.dirname(f)[2:]
|
||||
for f in glob.glob('./*/.git', root_dir=cwd, recursive=True)
|
||||
}
|
||||
|
||||
# First try the workspace directory
|
||||
changes = get_changes_in_repo(cwd)
|
||||
|
||||
# Filter out any changes which are in one of the git directories
|
||||
changes = [
|
||||
change
|
||||
for change in changes
|
||||
if next(
|
||||
iter(git_dir for git_dir in git_dirs if change['path'].startswith(git_dir)),
|
||||
None,
|
||||
)
|
||||
is None
|
||||
]
|
||||
|
||||
# Add changes from git directories
|
||||
for git_dir in git_dirs:
|
||||
git_dir_changes = get_changes_in_repo(str(Path(cwd, git_dir)))
|
||||
for change in git_dir_changes:
|
||||
change['path'] = git_dir + '/' + change['path']
|
||||
changes.append(change)
|
||||
|
||||
changes.sort(key=lambda change: change['path'])
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
changes = get_git_changes(os.getcwd())
|
||||
print(json.dumps(changes))
|
||||
except Exception as e:
|
||||
print(json.dumps({'error': str(e)}))
|
||||
@@ -0,0 +1,122 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
#!/usr/bin/env python3
|
||||
"""Get git diff in a single git file for the closest git repo in the file system
|
||||
NOTE: Since this is run as a script, there should be no imports from project files!
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MAX_FILE_SIZE_FOR_GIT_DIFF = 1024 * 1024 # 1 Mb
|
||||
|
||||
|
||||
def get_closest_git_repo(path: Path) -> Path | None:
|
||||
while True:
|
||||
path = path.parent
|
||||
git_path = Path(path, '.git')
|
||||
if git_path.is_dir():
|
||||
return path
|
||||
if path.parent == path:
|
||||
return None
|
||||
|
||||
|
||||
def run(cmd: str, cwd: str) -> str:
|
||||
result = subprocess.run(
|
||||
args=cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd
|
||||
)
|
||||
byte_content = result.stderr or result.stdout or b''
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f'error_running_cmd:{result.returncode}:{byte_content.decode()}'
|
||||
)
|
||||
return byte_content.decode().strip()
|
||||
|
||||
|
||||
def get_valid_ref(repo_dir: str) -> str | None:
|
||||
refs = []
|
||||
try:
|
||||
current_branch = run('git --no-pager rev-parse --abbrev-ref HEAD', repo_dir)
|
||||
refs.append(f'origin/{current_branch}')
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
default_branch = (
|
||||
run('git --no-pager remote show origin | grep "HEAD branch"', repo_dir)
|
||||
.split()[-1]
|
||||
.strip()
|
||||
)
|
||||
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_default_branch = f'origin/{default_branch}'
|
||||
refs.append(ref_non_default_branch)
|
||||
refs.append(ref_default_branch)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
# compares with empty tree
|
||||
ref_new_repo = (
|
||||
'$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)'
|
||||
)
|
||||
refs.append(ref_new_repo)
|
||||
|
||||
# Find a ref that exists...
|
||||
for ref in refs:
|
||||
try:
|
||||
result = run(f'git --no-pager rev-parse --verify {ref}', repo_dir)
|
||||
return result
|
||||
except RuntimeError:
|
||||
# invalid ref - try next
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _make_git_show_cmd(ref: str, repo_relative_path: str) -> str:
|
||||
"""Return a git-show shell command with the ref:path argument safely quoted."""
|
||||
return f'git show {shlex.quote(f"{ref}:{repo_relative_path}")}'
|
||||
|
||||
|
||||
def get_git_diff(relative_file_path: str) -> dict[str, str]:
|
||||
path = Path(os.getcwd(), relative_file_path).resolve()
|
||||
if os.path.getsize(path) > MAX_FILE_SIZE_FOR_GIT_DIFF:
|
||||
raise ValueError('file_to_large')
|
||||
closest_git_repo = get_closest_git_repo(path)
|
||||
if not closest_git_repo:
|
||||
raise ValueError('no_repository')
|
||||
current_rev = get_valid_ref(str(closest_git_repo))
|
||||
original = ''
|
||||
if current_rev is not None:
|
||||
try:
|
||||
original = run(
|
||||
_make_git_show_cmd(
|
||||
current_rev, str(path.relative_to(closest_git_repo))
|
||||
),
|
||||
str(closest_git_repo),
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
modified = '\n'.join(f.read().splitlines())
|
||||
except FileNotFoundError:
|
||||
modified = ''
|
||||
return {
|
||||
'modified': modified,
|
||||
'original': original,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
diff = get_git_diff(sys.argv[-1])
|
||||
print(json.dumps(diff))
|
||||
@@ -0,0 +1,157 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import json
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.utils import git_changes, git_diff
|
||||
|
||||
GIT_CHANGES_CMD = 'python3 /openhands/code/openhands/runtime/utils/git_changes.py'
|
||||
GIT_DIFF_CMD = 'python3 /openhands/code/openhands/runtime/utils/git_diff.py {file_path}'
|
||||
GIT_BRANCH_CMD = 'git branch --show-current'
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Represents the result of a shell command execution.
|
||||
|
||||
Attributes:
|
||||
content (str): The output content of the command.
|
||||
exit_code (int): The exit code of the command execution.
|
||||
"""
|
||||
|
||||
content: str
|
||||
exit_code: int
|
||||
|
||||
|
||||
class GitHandler:
|
||||
"""A handler for executing Git-related operations via shell commands."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
execute_shell_fn: Callable[[str, str | None], CommandResult],
|
||||
create_file_fn: Callable[[str, str], int],
|
||||
):
|
||||
self.execute = execute_shell_fn
|
||||
self.create_file_fn = create_file_fn
|
||||
self.cwd: str | None = None
|
||||
self.git_changes_cmd = GIT_CHANGES_CMD
|
||||
self.git_diff_cmd = GIT_DIFF_CMD
|
||||
self.git_branch_cmd = GIT_BRANCH_CMD
|
||||
|
||||
def set_cwd(self, cwd: str) -> None:
|
||||
"""Sets the current working directory for Git operations.
|
||||
|
||||
Args:
|
||||
cwd (str): The directory path.
|
||||
"""
|
||||
self.cwd = cwd
|
||||
|
||||
def _create_python_script_file(self, file: str):
|
||||
result = self.execute('mktemp -d', self.cwd)
|
||||
script_file = Path(result.content.strip(), Path(file).name)
|
||||
with open(file, 'r') as f:
|
||||
self.create_file_fn(str(script_file), f.read())
|
||||
result = self.execute(f'chmod +x "{script_file}"', self.cwd)
|
||||
return script_file
|
||||
|
||||
def get_current_branch(self) -> str | None:
|
||||
"""
|
||||
Retrieves the current branch name of the git repository.
|
||||
|
||||
Returns:
|
||||
str | None: The current branch name, or None if not a git repository or error occurs.
|
||||
"""
|
||||
# If cwd is not set, return None
|
||||
if not self.cwd:
|
||||
return None
|
||||
|
||||
result = self.execute(self.git_branch_cmd, self.cwd)
|
||||
if result.exit_code == 0:
|
||||
branch = result.content.strip()
|
||||
# git branch --show-current returns empty string if not on any branch (detached HEAD)
|
||||
if branch:
|
||||
return branch
|
||||
return None
|
||||
|
||||
# If not a git repository or other error, return None
|
||||
return None
|
||||
|
||||
def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
"""Retrieves the list of changed files in Git repositories.
|
||||
Examines each direct subdirectory of the workspace directory looking for git repositories
|
||||
and returns the changes for each of these directories.
|
||||
Optimized to use a single git command per repository for maximum performance.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]] | None: A list of dictionaries containing file paths and statuses. None if no git repositories found.
|
||||
"""
|
||||
# If cwd is not set, return None
|
||||
if not self.cwd:
|
||||
return None
|
||||
|
||||
result = self.execute(self.git_changes_cmd, self.cwd)
|
||||
if result.exit_code == 0:
|
||||
try:
|
||||
changes = json.loads(result.content)
|
||||
return changes
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'GitHandler:get_git_changes:error',
|
||||
extra={'content': result.content},
|
||||
)
|
||||
return None
|
||||
|
||||
if self.git_changes_cmd != GIT_CHANGES_CMD:
|
||||
# We have already tried to add a script to the workspace - it did not work
|
||||
return None
|
||||
|
||||
# We try to add a script for getting git changes to the runtime - legacy runtimes may be missing the script
|
||||
logger.info(
|
||||
'GitHandler:get_git_changes: adding git_changes script to runtime...'
|
||||
)
|
||||
script_file = self._create_python_script_file(git_changes.__file__)
|
||||
self.git_changes_cmd = f'python3 {script_file}'
|
||||
|
||||
# Try again with the new changes cmd
|
||||
return self.get_git_changes()
|
||||
|
||||
def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
"""Retrieves the original and modified content of a file in the repository.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the file.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: A dictionary containing the original and modified content.
|
||||
"""
|
||||
# If cwd is not set, return None
|
||||
if not self.cwd:
|
||||
raise ValueError('no_dir_in_git_diff')
|
||||
|
||||
result = self.execute(
|
||||
self.git_diff_cmd.format(file_path=shlex.quote(file_path)), self.cwd
|
||||
)
|
||||
if result.exit_code == 0:
|
||||
diff = json.loads(result.content, strict=False)
|
||||
return diff
|
||||
|
||||
if self.git_diff_cmd != GIT_DIFF_CMD:
|
||||
# We have already tried to add a script to the workspace - it did not work
|
||||
raise ValueError('error_in_git_diff')
|
||||
|
||||
# We try to add a script for getting git changes to the runtime - legacy runtimes may be missing the script
|
||||
logger.info('GitHandler:get_git_diff: adding git_diff script to runtime...')
|
||||
script_file = self._create_python_script_file(git_diff.__file__)
|
||||
self.git_diff_cmd = f'python3 {script_file} {{file_path}}'
|
||||
|
||||
# Try again with the new changes cmd
|
||||
return self.get_git_diff(file_path)
|
||||
@@ -0,0 +1,72 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
import docker
|
||||
|
||||
|
||||
class LogStreamer:
|
||||
"""Streams Docker container logs to stdout.
|
||||
|
||||
This class provides a way to stream logs from a Docker container directly to stdout
|
||||
through the provided logging function.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
container: docker.models.containers.Container,
|
||||
logFn: Callable[[str, str], None],
|
||||
):
|
||||
self.log = logFn
|
||||
# Initialize all attributes before starting the thread on this instance
|
||||
self.stdout_thread = None
|
||||
self.log_generator = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
try:
|
||||
self.log_generator = container.logs(stream=True, follow=True)
|
||||
# Start the stdout streaming thread
|
||||
self.stdout_thread = threading.Thread(target=self._stream_logs)
|
||||
self.stdout_thread.daemon = True
|
||||
self.stdout_thread.start()
|
||||
except Exception as e:
|
||||
self.log('error', f'Failed to initialize log streaming: {e}')
|
||||
|
||||
def _stream_logs(self) -> None:
|
||||
"""Stream logs from the Docker container to stdout."""
|
||||
if not self.log_generator:
|
||||
self.log('error', 'Log generator not initialized')
|
||||
return
|
||||
|
||||
try:
|
||||
for log_line in self.log_generator:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
if log_line:
|
||||
decoded_line = log_line.decode('utf-8').rstrip()
|
||||
self.log('debug', f'[inside container] {decoded_line}')
|
||||
except Exception as e:
|
||||
self.log('error', f'Error streaming docker logs to stdout: {e}')
|
||||
|
||||
def __del__(self) -> None:
|
||||
if (
|
||||
hasattr(self, 'stdout_thread')
|
||||
and self.stdout_thread
|
||||
and self.stdout_thread.is_alive()
|
||||
):
|
||||
self.close(timeout=5)
|
||||
|
||||
def close(self, timeout: float = 5.0) -> None:
|
||||
"""Clean shutdown of the log streaming."""
|
||||
self._stop_event.set()
|
||||
if self.stdout_thread and self.stdout_thread.is_alive():
|
||||
self.stdout_thread.join(timeout)
|
||||
# Close the log generator to release the file descriptor
|
||||
if self.log_generator is not None:
|
||||
self.log_generator.close()
|
||||
@@ -0,0 +1,73 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""Memory monitoring utilities for the runtime."""
|
||||
|
||||
import threading
|
||||
|
||||
from memory_profiler import memory_usage
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class LogStream:
|
||||
"""Stream-like object that redirects writes to a logger."""
|
||||
|
||||
def write(self, message: str) -> None:
|
||||
if message and not message.isspace():
|
||||
logger.info(f'[Memory usage] {message.strip()}')
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class MemoryMonitor:
|
||||
def __init__(self, enable: bool = False):
|
||||
"""Memory monitor for the runtime."""
|
||||
self._monitoring_thread: threading.Thread | None = None
|
||||
self._stop_monitoring = threading.Event()
|
||||
self.log_stream = LogStream()
|
||||
self.enable = enable
|
||||
|
||||
def start_monitoring(self) -> None:
|
||||
"""Start monitoring memory usage."""
|
||||
if not self.enable:
|
||||
return
|
||||
|
||||
if self._monitoring_thread is not None:
|
||||
return
|
||||
|
||||
def monitor_process() -> None:
|
||||
try:
|
||||
# Use memory_usage's built-in monitoring loop
|
||||
mem_usage = memory_usage(
|
||||
-1, # Monitor current process
|
||||
interval=0.1, # Check every second
|
||||
timeout=3600, # Run indefinitely
|
||||
max_usage=False, # Get continuous readings
|
||||
include_children=True, # Include child processes
|
||||
multiprocess=True, # Monitor all processes
|
||||
stream=self.log_stream, # Redirect output to logger
|
||||
backend='psutil_pss',
|
||||
)
|
||||
logger.info(f'Memory usage across time: {mem_usage}')
|
||||
except Exception as e:
|
||||
logger.error(f'Memory monitoring failed: {e}')
|
||||
|
||||
self._monitoring_thread = threading.Thread(target=monitor_process, daemon=True)
|
||||
self._monitoring_thread.start()
|
||||
logger.info('Memory monitoring started')
|
||||
|
||||
def stop_monitoring(self) -> None:
|
||||
"""Stop monitoring memory usage."""
|
||||
if not self.enable:
|
||||
return
|
||||
|
||||
if self._monitoring_thread is not None:
|
||||
self._stop_monitoring.set()
|
||||
self._monitoring_thread = None
|
||||
logger.info('Memory monitoring stopped')
|
||||
@@ -0,0 +1,234 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
"""File-based port locking system for preventing race conditions in port allocation."""
|
||||
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# Import fcntl only on Unix systems
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
HAS_FCNTL = True
|
||||
except ImportError:
|
||||
HAS_FCNTL = False
|
||||
|
||||
|
||||
class PortLock:
|
||||
"""File-based lock for a specific port to prevent race conditions."""
|
||||
|
||||
def __init__(self, port: int, lock_dir: Optional[str] = None):
|
||||
self.port = port
|
||||
self.lock_dir = lock_dir or os.path.join(
|
||||
tempfile.gettempdir(), 'openhands_port_locks'
|
||||
)
|
||||
self.lock_file_path = os.path.join(self.lock_dir, f'port_{port}.lock')
|
||||
self.lock_fd: Optional[int] = None
|
||||
self._locked = False
|
||||
|
||||
# Ensure lock directory exists
|
||||
os.makedirs(self.lock_dir, exist_ok=True)
|
||||
|
||||
def acquire(self, timeout: float = 1.0) -> bool:
|
||||
"""Acquire the lock for this port.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait for the lock
|
||||
|
||||
Returns:
|
||||
True if lock was acquired, False otherwise
|
||||
"""
|
||||
if self._locked:
|
||||
return True
|
||||
|
||||
try:
|
||||
if HAS_FCNTL:
|
||||
# Unix-style file locking with fcntl
|
||||
self.lock_fd = os.open(
|
||||
self.lock_file_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC
|
||||
)
|
||||
|
||||
# Try to acquire exclusive lock with timeout
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
self._locked = True
|
||||
|
||||
# Write port number to lock file for debugging
|
||||
os.write(self.lock_fd, f'{self.port}\n'.encode())
|
||||
os.fsync(self.lock_fd)
|
||||
|
||||
logger.debug(f'Acquired lock for port {self.port}')
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
# Lock is held by another process, wait a bit
|
||||
time.sleep(0.01)
|
||||
|
||||
# Timeout reached
|
||||
if self.lock_fd:
|
||||
os.close(self.lock_fd)
|
||||
self.lock_fd = None
|
||||
return False
|
||||
else:
|
||||
# Windows fallback: use atomic file creation
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
# Try to create lock file exclusively
|
||||
self.lock_fd = os.open(
|
||||
self.lock_file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
||||
)
|
||||
self._locked = True
|
||||
|
||||
# Write port number to lock file for debugging
|
||||
os.write(self.lock_fd, f'{self.port}\n'.encode())
|
||||
os.fsync(self.lock_fd)
|
||||
|
||||
logger.debug(f'Acquired lock for port {self.port}')
|
||||
return True
|
||||
except OSError:
|
||||
# Lock file already exists, wait a bit
|
||||
time.sleep(0.01)
|
||||
|
||||
# Timeout reached
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to acquire lock for port {self.port}: {e}')
|
||||
if self.lock_fd:
|
||||
try:
|
||||
os.close(self.lock_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self.lock_fd = None
|
||||
return False
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release the lock."""
|
||||
if self.lock_fd is not None:
|
||||
try:
|
||||
if HAS_FCNTL:
|
||||
# Unix: unlock and close
|
||||
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
|
||||
|
||||
os.close(self.lock_fd)
|
||||
|
||||
# Remove lock file (both Unix and Windows)
|
||||
try:
|
||||
os.unlink(self.lock_file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
logger.debug(f'Released lock for port {self.port}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Error releasing lock for port {self.port}: {e}')
|
||||
finally:
|
||||
self.lock_fd = None
|
||||
self._locked = False
|
||||
|
||||
def __enter__(self) -> 'PortLock':
|
||||
if not self.acquire():
|
||||
raise OSError(f'Could not acquire lock for port {self.port}')
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.release()
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool:
|
||||
return self._locked
|
||||
|
||||
|
||||
def find_available_port_with_lock(
|
||||
min_port: int = 30000,
|
||||
max_port: int = 39999,
|
||||
max_attempts: int = 20,
|
||||
bind_address: str = '0.0.0.0',
|
||||
lock_timeout: float = 1.0,
|
||||
) -> Optional[tuple[int, PortLock]]:
|
||||
"""Find an available port and acquire a lock for it.
|
||||
|
||||
This function combines file-based locking with port availability checking
|
||||
to prevent race conditions in multi-process scenarios.
|
||||
|
||||
Args:
|
||||
min_port: Minimum port number to try
|
||||
max_port: Maximum port number to try
|
||||
max_attempts: Maximum number of ports to try
|
||||
bind_address: Address to bind to when checking availability
|
||||
lock_timeout: Timeout for acquiring port lock
|
||||
|
||||
Returns:
|
||||
Tuple of (port, lock) if successful, None otherwise
|
||||
"""
|
||||
rng = random.SystemRandom()
|
||||
|
||||
# Try random ports first for better distribution
|
||||
random_attempts = min(max_attempts // 2, 10)
|
||||
for _ in range(random_attempts):
|
||||
port = rng.randint(min_port, max_port)
|
||||
|
||||
# Try to acquire lock first
|
||||
lock = PortLock(port)
|
||||
if lock.acquire(timeout=lock_timeout):
|
||||
# Check if port is actually available
|
||||
if _check_port_available(port, bind_address):
|
||||
logger.debug(f'Found and locked available port {port}')
|
||||
return port, lock
|
||||
else:
|
||||
# Port is locked but not available (maybe in TIME_WAIT state)
|
||||
lock.release()
|
||||
|
||||
# Small delay to reduce contention
|
||||
time.sleep(0.001)
|
||||
|
||||
# If random attempts failed, try sequential search
|
||||
remaining_attempts = max_attempts - random_attempts
|
||||
start_port = rng.randint(min_port, max_port - remaining_attempts)
|
||||
|
||||
for i in range(remaining_attempts):
|
||||
port = start_port + i
|
||||
if port > max_port:
|
||||
port = min_port + (port - max_port - 1)
|
||||
|
||||
# Try to acquire lock first
|
||||
lock = PortLock(port)
|
||||
if lock.acquire(timeout=lock_timeout):
|
||||
# Check if port is actually available
|
||||
if _check_port_available(port, bind_address):
|
||||
logger.debug(f'Found and locked available port {port}')
|
||||
return port, lock
|
||||
else:
|
||||
# Port is locked but not available
|
||||
lock.release()
|
||||
|
||||
# Small delay to reduce contention
|
||||
time.sleep(0.001)
|
||||
|
||||
logger.error(
|
||||
f'Could not find and lock available port in range {min_port}-{max_port} after {max_attempts} attempts'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _check_port_available(port: int, bind_address: str = '0.0.0.0') -> bool:
|
||||
"""Check if a port is available by trying to bind to it."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((bind_address, port))
|
||||
sock.close()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
@@ -0,0 +1,69 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx import HTTPStatusError
|
||||
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||
|
||||
from openhands.utils.http_session import HttpSession
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
|
||||
class RequestHTTPError(httpx.HTTPStatusError):
|
||||
"""Exception raised when an error occurs in a request with details."""
|
||||
|
||||
def __init__(self, *args: Any, detail: Any = None, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.detail = detail
|
||||
|
||||
def __str__(self) -> str:
|
||||
s = super().__str__()
|
||||
if self.detail is not None:
|
||||
s += f'\nDetails: {self.detail}'
|
||||
return str(s)
|
||||
|
||||
|
||||
def is_retryable_error(exception: Any) -> bool:
|
||||
return (
|
||||
isinstance(exception, httpx.HTTPStatusError)
|
||||
and exception.response.status_code == 429
|
||||
)
|
||||
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception(is_retryable_error),
|
||||
stop=stop_after_attempt(3) | stop_if_should_exit(),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=60),
|
||||
)
|
||||
def send_request(
|
||||
session: HttpSession,
|
||||
method: str,
|
||||
url: str,
|
||||
timeout: int = 60,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
response = session.request(method, url, timeout=timeout, **kwargs)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
try:
|
||||
_json = response.json()
|
||||
except json.decoder.JSONDecodeError:
|
||||
_json = None
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
raise RequestHTTPError(
|
||||
e,
|
||||
request=e.request,
|
||||
response=e.response if isinstance(e, HTTPStatusError) else None,
|
||||
detail=_json.get('detail') if _json is not None else None,
|
||||
) from e
|
||||
return response
|
||||
@@ -0,0 +1,489 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import string
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import docker
|
||||
from dirhash import dirhash
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
import openhands
|
||||
from openhands.core.exceptions import AgentRuntimeBuildError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.builder import DockerRuntimeBuilder, RuntimeBuilder
|
||||
from openhands.version import get_version
|
||||
|
||||
|
||||
class BuildFromImageType(Enum):
|
||||
SCRATCH = 'scratch' # Slowest: Build from base image (no dependencies are reused)
|
||||
VERSIONED = 'versioned' # Medium speed: Reuse the most recent image with the same base image & OH version (a lot of dependencies are already installed)
|
||||
LOCK = 'lock' # Fastest: Reuse the most recent image with the exact SAME dependencies (lock files)
|
||||
|
||||
|
||||
def get_runtime_image_repo() -> str:
|
||||
return os.getenv('OH_RUNTIME_RUNTIME_IMAGE_REPO', 'ghcr.io/openhands/runtime')
|
||||
|
||||
|
||||
def _generate_dockerfile(
|
||||
base_image: str,
|
||||
build_from: BuildFromImageType = BuildFromImageType.SCRATCH,
|
||||
extra_deps: str | None = None,
|
||||
enable_browser: bool = True,
|
||||
) -> str:
|
||||
"""Generate the Dockerfile content for the runtime image based on the base image.
|
||||
|
||||
Parameters:
|
||||
- base_image (str): The base image provided for the runtime image
|
||||
- build_from (BuildFromImageType): The build method for the runtime image.
|
||||
- extra_deps (str):
|
||||
- enable_browser (bool): Whether to enable browser support (install Playwright)
|
||||
|
||||
Returns:
|
||||
- str: The resulting Dockerfile content
|
||||
"""
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(
|
||||
searchpath=os.path.join(os.path.dirname(__file__), 'runtime_templates')
|
||||
)
|
||||
)
|
||||
template = env.get_template('Dockerfile.j2')
|
||||
|
||||
# Allow overriding conda/mamba channel alias (e.g., to avoid anaconda.org)
|
||||
channel_alias = os.getenv('OH_CONDA_CHANNEL_ALIAS', '').strip() or None
|
||||
|
||||
dockerfile_content = template.render(
|
||||
base_image=base_image,
|
||||
build_from_scratch=build_from == BuildFromImageType.SCRATCH,
|
||||
build_from_versioned=build_from == BuildFromImageType.VERSIONED,
|
||||
extra_deps=extra_deps if extra_deps is not None else '',
|
||||
enable_browser=enable_browser,
|
||||
channel_alias=channel_alias,
|
||||
)
|
||||
return dockerfile_content
|
||||
|
||||
|
||||
def get_runtime_image_repo_and_tag(base_image: str) -> tuple[str, str]:
|
||||
"""Retrieves the Docker repo and tag associated with the Docker image.
|
||||
|
||||
Parameters:
|
||||
- base_image (str): The name of the base Docker image
|
||||
|
||||
Returns:
|
||||
- tuple[str, str]: The Docker repo and tag of the Docker image
|
||||
"""
|
||||
if get_runtime_image_repo() in base_image:
|
||||
logger.debug(
|
||||
f'The provided image [{base_image}] is already a valid runtime image.\n'
|
||||
f'Will try to reuse it as is.'
|
||||
)
|
||||
|
||||
if ':' not in base_image:
|
||||
base_image = base_image + ':latest'
|
||||
repo, tag = base_image.split(':')
|
||||
return repo, tag
|
||||
else:
|
||||
if ':' not in base_image:
|
||||
base_image = base_image + ':latest'
|
||||
[repo, tag] = base_image.split(':')
|
||||
|
||||
# Hash the repo if it's too long
|
||||
if len(repo) > 32:
|
||||
repo_hash = hashlib.md5(repo[:-24].encode()).hexdigest()[:8]
|
||||
|
||||
repo = f'{repo_hash}_{repo[-24:]}' # Use 8 char hash + last 24 chars
|
||||
repo = repo.replace('/', '_s_')
|
||||
|
||||
new_tag = f'oh_v{get_version()}_image_{repo}_tag_{tag}'
|
||||
|
||||
# if it's still too long, hash the entire image name
|
||||
if len(new_tag) > 128:
|
||||
new_tag = f'oh_v{get_version()}_image_{hashlib.md5(new_tag.encode()).hexdigest()[:64]}'
|
||||
logger.warning(
|
||||
f'The new tag [{new_tag}] is still too long, so we use an hash of the entire image name: {new_tag}'
|
||||
)
|
||||
|
||||
return get_runtime_image_repo(), new_tag
|
||||
|
||||
|
||||
def build_runtime_image(
|
||||
base_image: str,
|
||||
runtime_builder: RuntimeBuilder,
|
||||
platform: str | None = None,
|
||||
extra_deps: str | None = None,
|
||||
build_folder: str | None = None,
|
||||
dry_run: bool = False,
|
||||
force_rebuild: bool = False,
|
||||
extra_build_args: list[str] | None = None,
|
||||
enable_browser: bool = True,
|
||||
) -> str:
|
||||
"""Prepares the final docker build folder.
|
||||
|
||||
If dry_run is False, it will also build the OpenHands runtime Docker image using the docker build folder.
|
||||
|
||||
Parameters:
|
||||
- base_image (str): The name of the base Docker image to use
|
||||
- runtime_builder (RuntimeBuilder): The runtime builder to use
|
||||
- platform (str): The target platform for the build (e.g. linux/amd64, linux/arm64)
|
||||
- extra_deps (str):
|
||||
- build_folder (str): The directory to use for the build. If not provided a temporary directory will be used
|
||||
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
|
||||
- force_rebuild (bool): if True, it will create the Dockerfile which uses the base_image
|
||||
- extra_build_args (List[str]): Additional build arguments to pass to the builder
|
||||
- enable_browser (bool): Whether to enable browser support (install Playwright)
|
||||
|
||||
Returns:
|
||||
- str: <image_repo>:<MD5 hash>. Where MD5 hash is the hash of the docker build folder
|
||||
|
||||
See https://docs.all-hands.dev/usage/architecture/runtime for more details.
|
||||
"""
|
||||
if build_folder is None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
result = build_runtime_image_in_folder(
|
||||
base_image=base_image,
|
||||
runtime_builder=runtime_builder,
|
||||
build_folder=Path(temp_dir),
|
||||
extra_deps=extra_deps,
|
||||
dry_run=dry_run,
|
||||
force_rebuild=force_rebuild,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
enable_browser=enable_browser,
|
||||
)
|
||||
return result
|
||||
|
||||
result = build_runtime_image_in_folder(
|
||||
base_image=base_image,
|
||||
runtime_builder=runtime_builder,
|
||||
build_folder=Path(build_folder),
|
||||
extra_deps=extra_deps,
|
||||
dry_run=dry_run,
|
||||
force_rebuild=force_rebuild,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
enable_browser=enable_browser,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_runtime_image_in_folder(
|
||||
base_image: str,
|
||||
runtime_builder: RuntimeBuilder,
|
||||
build_folder: Path,
|
||||
extra_deps: str | None,
|
||||
dry_run: bool,
|
||||
force_rebuild: bool,
|
||||
platform: str | None = None,
|
||||
extra_build_args: list[str] | None = None,
|
||||
enable_browser: bool = True,
|
||||
) -> str:
|
||||
runtime_image_repo, _ = get_runtime_image_repo_and_tag(base_image)
|
||||
lock_tag = (
|
||||
f'oh_v{get_version()}_{get_hash_for_lock_files(base_image, enable_browser)}'
|
||||
)
|
||||
versioned_tag = (
|
||||
# truncate the base image to 96 characters to fit in the tag max length (128 characters)
|
||||
f'oh_v{get_version()}_{get_tag_for_versioned_image(base_image)}'
|
||||
)
|
||||
versioned_image_name = f'{runtime_image_repo}:{versioned_tag}'
|
||||
source_tag = f'{lock_tag}_{get_hash_for_source_files()}'
|
||||
hash_image_name = f'{runtime_image_repo}:{source_tag}'
|
||||
|
||||
logger.info(f'Building image: {hash_image_name}')
|
||||
if force_rebuild:
|
||||
logger.debug(
|
||||
f'Force rebuild: [{runtime_image_repo}:{source_tag}] from scratch.'
|
||||
)
|
||||
prep_build_folder(
|
||||
build_folder,
|
||||
base_image,
|
||||
build_from=BuildFromImageType.SCRATCH,
|
||||
extra_deps=extra_deps,
|
||||
enable_browser=enable_browser,
|
||||
)
|
||||
if not dry_run:
|
||||
_build_sandbox_image(
|
||||
build_folder,
|
||||
runtime_builder,
|
||||
runtime_image_repo,
|
||||
source_tag,
|
||||
lock_tag,
|
||||
versioned_tag,
|
||||
platform,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
return hash_image_name
|
||||
|
||||
lock_image_name = f'{runtime_image_repo}:{lock_tag}'
|
||||
build_from = BuildFromImageType.SCRATCH
|
||||
|
||||
# If the exact image already exists, we do not need to build it
|
||||
if runtime_builder.image_exists(hash_image_name, False):
|
||||
logger.debug(f'Reusing Image [{hash_image_name}]')
|
||||
return hash_image_name
|
||||
|
||||
# We look for an existing image that shares the same lock_tag. If such an image exists, we
|
||||
# can use it as the base image for the build and just copy source files. This makes the build
|
||||
# much faster.
|
||||
if runtime_builder.image_exists(lock_image_name):
|
||||
logger.debug(f'Build [{hash_image_name}] from lock image [{lock_image_name}]')
|
||||
build_from = BuildFromImageType.LOCK
|
||||
base_image = lock_image_name
|
||||
elif runtime_builder.image_exists(versioned_image_name):
|
||||
logger.info(
|
||||
f'Build [{hash_image_name}] from versioned image [{versioned_image_name}]'
|
||||
)
|
||||
build_from = BuildFromImageType.VERSIONED
|
||||
base_image = versioned_image_name
|
||||
else:
|
||||
logger.debug(f'Build [{hash_image_name}] from scratch')
|
||||
|
||||
prep_build_folder(build_folder, base_image, build_from, extra_deps, enable_browser)
|
||||
if not dry_run:
|
||||
_build_sandbox_image(
|
||||
build_folder,
|
||||
runtime_builder,
|
||||
runtime_image_repo,
|
||||
source_tag=source_tag,
|
||||
lock_tag=lock_tag,
|
||||
# Only tag the versioned image if we are building from scratch.
|
||||
# This avoids too much layers when you lay one image on top of another multiple times
|
||||
versioned_tag=(
|
||||
versioned_tag if build_from == BuildFromImageType.SCRATCH else None
|
||||
),
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
|
||||
return hash_image_name
|
||||
|
||||
|
||||
def prep_build_folder(
|
||||
build_folder: Path,
|
||||
base_image: str,
|
||||
build_from: BuildFromImageType,
|
||||
extra_deps: str | None,
|
||||
enable_browser: bool = True,
|
||||
) -> None:
|
||||
# Copy the source code to directory. It will end up in build_folder/code
|
||||
# If package is not found, build from source code
|
||||
openhands_source_dir = Path(openhands.__file__).parent
|
||||
project_root = openhands_source_dir.parent
|
||||
logger.debug(f'Building source distribution using project root: {project_root}')
|
||||
|
||||
# Copy the 'openhands' directory (Source code)
|
||||
shutil.copytree(
|
||||
openhands_source_dir,
|
||||
Path(build_folder, 'code', 'openhands'),
|
||||
ignore=shutil.ignore_patterns(
|
||||
'.*/',
|
||||
'__pycache__/',
|
||||
'*.pyc',
|
||||
'*.md',
|
||||
),
|
||||
)
|
||||
|
||||
# Copy the 'skills' directory (Skills)
|
||||
shutil.copytree(Path(project_root, 'skills'), Path(build_folder, 'code', 'skills'))
|
||||
|
||||
# Copy pyproject.toml and poetry.lock files
|
||||
for file in ['pyproject.toml', 'poetry.lock']:
|
||||
src = Path(openhands_source_dir, file)
|
||||
if not src.exists():
|
||||
src = Path(project_root, file)
|
||||
shutil.copy2(src, Path(build_folder, 'code', file))
|
||||
|
||||
# Create a Dockerfile and write it to build_folder
|
||||
dockerfile_content = _generate_dockerfile(
|
||||
base_image,
|
||||
build_from=build_from,
|
||||
extra_deps=extra_deps,
|
||||
enable_browser=enable_browser,
|
||||
)
|
||||
dockerfile_path = Path(build_folder, 'Dockerfile')
|
||||
with open(str(dockerfile_path), 'w') as f:
|
||||
f.write(dockerfile_content)
|
||||
|
||||
|
||||
_ALPHABET = string.digits + string.ascii_lowercase
|
||||
|
||||
|
||||
def truncate_hash(hash: str) -> str:
|
||||
"""Convert the base16 hash to base36 and truncate at 16 characters."""
|
||||
value = int(hash, 16)
|
||||
result: list[str] = []
|
||||
while value > 0 and len(result) < 16:
|
||||
value, remainder = divmod(value, len(_ALPHABET))
|
||||
result.append(_ALPHABET[remainder])
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def get_hash_for_lock_files(base_image: str, enable_browser: bool = True) -> str:
|
||||
openhands_source_dir = Path(openhands.__file__).parent
|
||||
md5 = hashlib.md5()
|
||||
md5.update(base_image.encode())
|
||||
# Only include enable_browser in hash when it's False for backward compatibility
|
||||
if not enable_browser:
|
||||
md5.update(str(enable_browser).encode())
|
||||
for file in ['pyproject.toml', 'poetry.lock']:
|
||||
src = Path(openhands_source_dir, file)
|
||||
if not src.exists():
|
||||
src = Path(openhands_source_dir.parent, file)
|
||||
with open(src, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b''):
|
||||
md5.update(chunk)
|
||||
# We get away with truncation because we want something that is unique
|
||||
# rather than something that is cryptographically secure
|
||||
result = truncate_hash(md5.hexdigest())
|
||||
return result
|
||||
|
||||
|
||||
def get_tag_for_versioned_image(base_image: str) -> str:
|
||||
return base_image.replace('/', '_s_').replace(':', '_t_').lower()[-96:]
|
||||
|
||||
|
||||
def get_hash_for_source_files() -> str:
|
||||
openhands_source_dir = Path(openhands.__file__).parent
|
||||
dir_hash = dirhash(
|
||||
openhands_source_dir,
|
||||
'md5',
|
||||
ignore=[
|
||||
'.*/', # hidden directories
|
||||
'__pycache__/',
|
||||
'*.pyc',
|
||||
],
|
||||
)
|
||||
# We get away with truncation because we want something that is unique
|
||||
# rather than something that is cryptographically secure
|
||||
result = truncate_hash(dir_hash)
|
||||
return result
|
||||
|
||||
|
||||
def _build_sandbox_image(
|
||||
build_folder: Path,
|
||||
runtime_builder: RuntimeBuilder,
|
||||
runtime_image_repo: str,
|
||||
source_tag: str,
|
||||
lock_tag: str,
|
||||
versioned_tag: str | None,
|
||||
platform: str | None = None,
|
||||
extra_build_args: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Build and tag the sandbox image. The image will be tagged with all tags that do not yet exist."""
|
||||
names = [
|
||||
f'{runtime_image_repo}:{source_tag}',
|
||||
f'{runtime_image_repo}:{lock_tag}',
|
||||
]
|
||||
if versioned_tag is not None:
|
||||
names.append(f'{runtime_image_repo}:{versioned_tag}')
|
||||
names = [name for name in names if not runtime_builder.image_exists(name, False)]
|
||||
|
||||
image_name = runtime_builder.build(
|
||||
path=str(build_folder),
|
||||
tags=names,
|
||||
platform=platform,
|
||||
extra_build_args=extra_build_args,
|
||||
)
|
||||
if not image_name:
|
||||
raise AgentRuntimeBuildError(f'Build failed for image {names}')
|
||||
|
||||
return image_name
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
'--base_image',
|
||||
type=str,
|
||||
default='nikolaik/python-nodejs:python3.12-nodejs22-slim',
|
||||
)
|
||||
parser.add_argument('--build_folder', type=str, default=None)
|
||||
parser.add_argument('--force_rebuild', action='store_true', default=False)
|
||||
parser.add_argument('--platform', type=str, default=None)
|
||||
parser.add_argument('--enable_browser', action='store_true', default=True)
|
||||
parser.add_argument(
|
||||
'--no_enable_browser', dest='enable_browser', action='store_false'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.build_folder is not None:
|
||||
# If a build_folder is provided, we do not actually build the Docker image. We copy the necessary source code
|
||||
# and create a Dockerfile dynamically and place it in the build_folder only. This allows the Docker image to
|
||||
# then be created using the Dockerfile (most likely using the containers/build.sh script)
|
||||
build_folder = args.build_folder
|
||||
assert os.path.exists(build_folder), (
|
||||
f'Build folder {build_folder} does not exist'
|
||||
)
|
||||
logger.debug(
|
||||
f'Copying the source code and generating the Dockerfile in the build folder: {build_folder}'
|
||||
)
|
||||
|
||||
runtime_image_repo, runtime_image_tag = get_runtime_image_repo_and_tag(
|
||||
args.base_image
|
||||
)
|
||||
logger.debug(
|
||||
f'Runtime image repo: {runtime_image_repo} and runtime image tag: {runtime_image_tag}'
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# dry_run is true so we only prepare a temp_dir containing the required source code and the Dockerfile. We
|
||||
# then obtain the MD5 hash of the folder and return <image_repo>:<temp_dir_md5_hash>
|
||||
runtime_image_hash_name = build_runtime_image(
|
||||
args.base_image,
|
||||
runtime_builder=DockerRuntimeBuilder(docker.from_env()),
|
||||
build_folder=temp_dir,
|
||||
dry_run=True,
|
||||
force_rebuild=args.force_rebuild,
|
||||
platform=args.platform,
|
||||
enable_browser=args.enable_browser,
|
||||
)
|
||||
|
||||
_runtime_image_repo, runtime_image_source_tag = (
|
||||
runtime_image_hash_name.split(':')
|
||||
)
|
||||
|
||||
# Move contents of temp_dir to build_folder
|
||||
shutil.copytree(temp_dir, build_folder, dirs_exist_ok=True)
|
||||
logger.debug(
|
||||
f'Build folder [{build_folder}] is ready: {os.listdir(build_folder)}'
|
||||
)
|
||||
|
||||
# We now update the config.sh in the build_folder to contain the required values. This is used in the
|
||||
# containers/build.sh script which is called to actually build the Docker image
|
||||
with open(os.path.join(build_folder, 'config.sh'), 'a') as file:
|
||||
file.write(
|
||||
(
|
||||
f'\n'
|
||||
f'DOCKER_IMAGE_TAG={runtime_image_tag}\n'
|
||||
f'DOCKER_IMAGE_SOURCE_TAG={runtime_image_source_tag}\n'
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'`config.sh` is updated with the image repo[{runtime_image_repo}] and tags [{runtime_image_tag}, {runtime_image_source_tag}]'
|
||||
)
|
||||
logger.debug(
|
||||
f'Dockerfile, source code and config.sh are ready in {build_folder}'
|
||||
)
|
||||
else:
|
||||
# If a build_folder is not provided, after copying the required source code and dynamically creating the
|
||||
# Dockerfile, we actually build the Docker image
|
||||
logger.debug('Building image in a temporary folder')
|
||||
docker_builder = DockerRuntimeBuilder(docker.from_env())
|
||||
image_name = build_runtime_image(
|
||||
args.base_image,
|
||||
docker_builder,
|
||||
platform=args.platform,
|
||||
enable_browser=args.enable_browser,
|
||||
)
|
||||
logger.debug(f'\nBuilt image: {image_name}\n')
|
||||
@@ -0,0 +1,134 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def init_user_and_working_directory(
|
||||
username: str, user_id: int, initial_cwd: str
|
||||
) -> int | None:
|
||||
"""Create working directory and user if not exists.
|
||||
It performs the following steps effectively:
|
||||
* Creates the Working Directory:
|
||||
- Uses mkdir -p to create the directory.
|
||||
- Sets ownership to username:group (respects SANDBOX_GROUP_ID if set).
|
||||
- Adjusts permissions to be readable and writable by group and others.
|
||||
* User Verification and Creation:
|
||||
- Checks if the user exists using id -u.
|
||||
- If the user exists with the correct UID, it skips creation.
|
||||
- If the UID differs, it logs a warning and return an updated user_id.
|
||||
- If the user doesn't exist, it proceeds to create the user.
|
||||
* Sudo Configuration:
|
||||
- Appends %sudo ALL=(ALL) NOPASSWD:ALL to /etc/sudoers to grant
|
||||
passwordless sudo access to the sudo group.
|
||||
- Adds the user to the sudo group with the useradd command, handling
|
||||
UID conflicts by incrementing the UID if necessary.
|
||||
|
||||
Args:
|
||||
username (str): The username to create.
|
||||
user_id (int): The user ID to assign to the user.
|
||||
initial_cwd (str): The initial working directory to create.
|
||||
|
||||
Returns:
|
||||
int | None: The user ID if it was updated, None otherwise.
|
||||
"""
|
||||
# If running on Windows, just create the directory and return
|
||||
if sys.platform == 'win32':
|
||||
logger.debug('Running on Windows, skipping Unix-specific user setup')
|
||||
logger.debug(f'Client working directory: {initial_cwd}')
|
||||
|
||||
# Create the working directory if it doesn't exist
|
||||
os.makedirs(initial_cwd, exist_ok=True)
|
||||
logger.debug(f'Created working directory: {initial_cwd}')
|
||||
|
||||
return None
|
||||
|
||||
# if username is CURRENT_USER, then we don't need to do anything
|
||||
# This is specific to the local runtime
|
||||
if username == os.getenv('USER') and username not in ['root', 'openhands']:
|
||||
return None
|
||||
|
||||
# Skip root since it is already created
|
||||
existing_user_id = -1
|
||||
if username != 'root':
|
||||
# Check if the username already exists
|
||||
logger.debug(f'Attempting to create user `{username}` with UID {user_id}.')
|
||||
setup_user = True
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f'id -u {username}', shell=True, check=True, capture_output=True
|
||||
)
|
||||
existing_user_id = int(result.stdout.decode().strip())
|
||||
|
||||
# The user ID already exists, skip setup
|
||||
if existing_user_id == user_id:
|
||||
logger.debug(
|
||||
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
|
||||
)
|
||||
setup_user = False
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Returncode 1 indicates, that the user does not exist yet
|
||||
if e.returncode == 1:
|
||||
logger.debug(
|
||||
f'User `{username}` does not exist. Proceeding with user creation.'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'Error checking user `{username}`, skipping setup:\n{e}\n'
|
||||
)
|
||||
raise
|
||||
|
||||
if setup_user:
|
||||
# Add sudoer
|
||||
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
|
||||
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
|
||||
if output.returncode != 0:
|
||||
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
|
||||
logger.debug(
|
||||
f'Added sudoer successfully. Output: [{output.stdout.decode()}]'
|
||||
)
|
||||
|
||||
command = (
|
||||
f'useradd -rm -d /home/{username} -s /bin/bash '
|
||||
f'-g root -G sudo -u {user_id} {username}'
|
||||
)
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
if output.returncode == 0:
|
||||
logger.debug(
|
||||
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
|
||||
)
|
||||
|
||||
# First create the working directory, independent of the user
|
||||
logger.debug(f'Client working directory: {initial_cwd}')
|
||||
command = f'umask 002; mkdir -p {initial_cwd}'
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
out_str = output.stdout.decode()
|
||||
|
||||
# Get group ID from environment variable, default to 'root' for backward compatibility
|
||||
group_id = os.getenv('SANDBOX_GROUP_ID', 'root')
|
||||
command = f'chown -R {username}:{group_id} {initial_cwd}'
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
out_str += output.stdout.decode()
|
||||
|
||||
command = f'chmod g+rw {initial_cwd}'
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
out_str += output.stdout.decode()
|
||||
logger.debug(f'Created working directory. Output: [{out_str}]')
|
||||
|
||||
return None if existing_user_id == -1 else existing_user_id
|
||||
@@ -0,0 +1,400 @@
|
||||
FROM {{ base_image }}
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
# Shared environment variables
|
||||
ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
|
||||
MAMBA_ROOT_PREFIX=/openhands/micromamba \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
EDITOR=code \
|
||||
VISUAL=code \
|
||||
GIT_EDITOR="code --wait" \
|
||||
OPENVSCODE_SERVER_ROOT=/openhands/.openvscode-server
|
||||
|
||||
{% macro setup_base_system() %}
|
||||
|
||||
# Set PATH early to ensure system commands are available
|
||||
ENV PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
||||
|
||||
# Install base system dependencies
|
||||
|
||||
{% if (('ubuntu' in base_image) or ('mswebench' in base_image)) %}
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl ca-certificates sudo apt-utils git jq tmux build-essential ripgrep ffmpeg \
|
||||
coreutils util-linux procps findutils grep sed \
|
||||
libasound2-plugins libatomic1 && \
|
||||
(apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \
|
||||
# Install Docker dependencies
|
||||
apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
TZ=Etc/UTC DEBIAN_FRONTEND=noninteractive \
|
||||
{%- if ('mswebench' in base_image) -%}
|
||||
apt-get install -y --no-install-recommends nodejs python3 python-is-python3 python3-pip python3-venv
|
||||
{%- else %}
|
||||
apt-get install -y --no-install-recommends nodejs python3.12 python-is-python3 python3-pip python3.12-venv
|
||||
{% endif -%}
|
||||
{% endif %}
|
||||
|
||||
{% if (('ubuntu' not in base_image) and ('mswebench' not in base_image)) %}
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl ca-certificates sudo apt-utils git jq tmux build-essential ripgrep ffmpeg \
|
||||
coreutils util-linux procps findutils grep sed \
|
||||
libasound2-plugins libatomic1 && \
|
||||
(apt-get install -y --no-install-recommends libgl1 || apt-get install -y --no-install-recommends libgl1-mesa-glx) && \
|
||||
# Install Docker dependencies
|
||||
apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release && \
|
||||
# Security upgrade: patch GLib CVE-2025-14087 (buffer underflow in GVariant parser)
|
||||
(apt-get install -y --no-install-recommends --only-upgrade \
|
||||
libglib2.0-0t64 libglib2.0-bin libglib2.0-dev libglib2.0-dev-bin || true) && \
|
||||
# Security upgrade: patch OpenSSL CVEs (CVE-2025-15467, CVE-2025-69419, CVE-2025-69421, et al.)
|
||||
(apt-get install -y --no-install-recommends --only-upgrade \
|
||||
openssl openssl-provider-legacy libssl3t64 || true) && \
|
||||
# Security upgrade: patch ImageMagick CVEs (CVE-2026-25897, CVE-2026-25968, CVE-2026-26284, et al.)
|
||||
(apt-get install -y --no-install-recommends --only-upgrade \
|
||||
imagemagick imagemagick-7-common imagemagick-7.q16 \
|
||||
libmagickcore-7-arch-config libmagickcore-7-headers \
|
||||
libmagickcore-7.q16-10 libmagickcore-7.q16-10-extra \
|
||||
libmagickcore-7.q16-dev libmagickcore-dev \
|
||||
libmagickwand-7-headers libmagickwand-7.q16-10 \
|
||||
libmagickwand-7.q16-dev libmagickwand-dev || true)
|
||||
{% endif %}
|
||||
|
||||
{% if (('ubuntu' in base_image) or ('mswebench' in base_image)) %}
|
||||
RUN ln -s "$(dirname $(which node))/corepack" /usr/local/bin/corepack && \
|
||||
npm install -g corepack && corepack enable yarn && \
|
||||
curl -fsSL --compressed https://install.python-poetry.org | python -
|
||||
{% endif %}
|
||||
|
||||
# Install uv (required by MCP)
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/openhands/bin" sh
|
||||
# Add /openhands/bin to PATH
|
||||
ENV PATH="/openhands/bin:${PATH}"
|
||||
|
||||
# Remove UID 1000 and GID 1000 users/groups that might conflict with openhands user
|
||||
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi) && \
|
||||
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
|
||||
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
|
||||
|
||||
# Create openhands group and user (with fallback IDs if 1000 is taken)
|
||||
RUN (if getent group 1000 >/dev/null 2>&1; then \
|
||||
groupadd openhands; \
|
||||
else \
|
||||
groupadd -g 1000 openhands; \
|
||||
fi) && \
|
||||
(if getent passwd 1000 >/dev/null 2>&1; then \
|
||||
useradd -g openhands -m -s /bin/bash openhands; \
|
||||
else \
|
||||
useradd -u 1000 -g openhands -m -s /bin/bash openhands; \
|
||||
fi) && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers && \
|
||||
# Set empty password for openhands user to allow passwordless su
|
||||
passwd -d openhands && \
|
||||
# Set empty password for root user as well to ensure su works in both directions
|
||||
passwd -d root && \
|
||||
# Ensure root can su to openhands without password by configuring PAM
|
||||
sed -i '/pam_rootok.so/d' /etc/pam.d/su && \
|
||||
sed -i '1i auth sufficient pam_rootok.so' /etc/pam.d/su
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry && \
|
||||
chown -R openhands:openhands /openhands
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Define Docker installation macro
|
||||
{% macro install_docker() %}
|
||||
# Install Docker following official documentation
|
||||
# https://docs.docker.com/engine/install/ubuntu/
|
||||
# https://docs.docker.com/engine/install/debian/
|
||||
RUN \
|
||||
# Determine OS type and install accordingly
|
||||
if [[ "{{ base_image }}" == *"ubuntu"* || "{{ base_image }}" == *"betty1202"* ]]; then \
|
||||
# 'betty1202' for sweperf
|
||||
# Handle Ubuntu (following https://docs.docker.com/engine/install/ubuntu/)
|
||||
# Add Docker's official GPG key
|
||||
apt-get update && \
|
||||
apt-get install -y ca-certificates curl && \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc && \
|
||||
chmod a+r /etc/apt/keyrings/docker.asc && \
|
||||
# Add the repository to Apt sources
|
||||
# For Ubuntu 24.04 (noble), use jammy repository as noble isn't supported yet
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
|
||||
else \
|
||||
# Handle Debian (following https://docs.docker.com/engine/install/debian/)
|
||||
# Add Docker's official GPG key
|
||||
apt-get update && \
|
||||
apt-get install -y ca-certificates curl && \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
|
||||
chmod a+r /etc/apt/keyrings/docker.asc && \
|
||||
# Add the repository to Apt sources (default to bookworm for stability)
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null; \
|
||||
fi && \
|
||||
# Install Docker Engine, containerd, and Docker Compose
|
||||
apt-get update && \
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Configure Docker daemon with MTU 1450 to prevent packet fragmentation issues
|
||||
RUN mkdir -p /etc/docker && \
|
||||
echo '{"mtu": 1450}' > /etc/docker/daemon.json
|
||||
{% endmacro %}
|
||||
|
||||
# Install Docker only if not a swebench or mswebench image
|
||||
{% if not ('swebench' in base_image) and not ('mswebench' in base_image) %}
|
||||
{{ install_docker() }}
|
||||
{% endif %}
|
||||
# ================================================================
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% macro setup_vscode_server() %}
|
||||
# Reference:
|
||||
# 1. https://github.com/gitpod-io/openvscode-server
|
||||
# 2. https://github.com/gitpod-io/openvscode-releases
|
||||
|
||||
# Setup VSCode Server
|
||||
ARG RELEASE_TAG="openvscode-server-v1.98.2"
|
||||
ARG RELEASE_ORG="gitpod-io"
|
||||
# ARG USERNAME=openvscode-server
|
||||
# ARG USER_UID=1000
|
||||
# ARG USER_GID=1000
|
||||
|
||||
RUN if [ -z "${RELEASE_TAG}" ]; then \
|
||||
echo "The RELEASE_TAG build arg must be set." >&2 && \
|
||||
exit 1; \
|
||||
fi && \
|
||||
arch=$(uname -m) && \
|
||||
if [ "${arch}" = "x86_64" ]; then \
|
||||
arch="x64"; \
|
||||
elif [ "${arch}" = "aarch64" ]; then \
|
||||
arch="arm64"; \
|
||||
elif [ "${arch}" = "armv7l" ]; then \
|
||||
arch="armhf"; \
|
||||
fi && \
|
||||
wget https://github.com/${RELEASE_ORG}/openvscode-server/releases/download/${RELEASE_TAG}/${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
tar -xzf ${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
if [ -d "${OPENVSCODE_SERVER_ROOT}" ]; then rm -rf "${OPENVSCODE_SERVER_ROOT}"; fi && \
|
||||
mv ${RELEASE_TAG}-linux-${arch} ${OPENVSCODE_SERVER_ROOT} && \
|
||||
cp ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/openvscode-server ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/code && \
|
||||
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}
|
||||
|
||||
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% macro install_vscode_extensions() %}
|
||||
# Install our custom extensions as openhands user
|
||||
USER openhands
|
||||
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world && \
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/
|
||||
|
||||
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor && \
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/memory-monitor/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor/
|
||||
|
||||
# Some extension dirs are removed because they trigger false positives in vulnerability scans.
|
||||
RUN rm -rf ${OPENVSCODE_SERVER_ROOT}/extensions/{handlebars,pug,json,diff,grunt,ini,npm}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro install_dependencies_root() %}
|
||||
# Install system-level dependencies that require root
|
||||
USER root
|
||||
RUN \
|
||||
{% if enable_browser %}
|
||||
# Install system dependencies for Playwright (requires root)
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libnss3 libnspr4 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libxcomposite1 \
|
||||
libxdamage1 libxrandr2 libgbm1 libxss1 && \
|
||||
# Install libasound2 - try new package name first (Ubuntu 24.04+), fallback to old name
|
||||
(apt-get install -y --no-install-recommends libasound2t64 || apt-get install -y --no-install-recommends libasound2) && \
|
||||
# Install Playwright browsers in shared location accessible to all users
|
||||
export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers && \
|
||||
mkdir -p /opt/playwright-browsers && \
|
||||
umask 022 && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
|
||||
# Create cache directories and symlinks for both users
|
||||
mkdir -p /home/openhands/.cache && \
|
||||
mkdir -p /root/.cache && \
|
||||
ln -sf /opt/playwright-browsers /home/openhands/.cache/ms-playwright && \
|
||||
ln -sf /opt/playwright-browsers /root/.cache/ms-playwright && \
|
||||
chown -h openhands:openhands /home/openhands/.cache/ms-playwright && \
|
||||
# Set environment variable for all users
|
||||
echo 'export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers' >> /etc/environment && \
|
||||
{% endif %}
|
||||
# Set environment variables (requires root)
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
|
||||
# Permissions: poetry and micromamba are already world-readable thanks to
|
||||
# umask 022 set during install_dependencies_user and micromamba install.
|
||||
# No cross-layer chmod -R needed, which avoids expensive overlay2 copy-ups.
|
||||
mkdir -p /openhands/workspace && chmod -R g+rws,o+rw /openhands/workspace && \
|
||||
chown -R openhands:openhands /openhands/workspace && \
|
||||
# Ensure PATH includes system binaries early in startup
|
||||
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"' >> /etc/environment && \
|
||||
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"' >> /etc/bash.bashrc && \
|
||||
# Set up conda environment activation for all users
|
||||
echo 'eval "$(/openhands/micromamba/bin/micromamba shell hook --shell bash)"' >> /etc/bash.bashrc && \
|
||||
echo 'micromamba activate openhands 2>/dev/null || true' >> /etc/bash.bashrc && \
|
||||
# Set up environment for root user
|
||||
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/openhands/micromamba/bin:$PATH"' >> /root/.bashrc && \
|
||||
echo 'export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers' >> /root/.bashrc && \
|
||||
echo 'eval "$(/openhands/micromamba/bin/micromamba shell hook --shell bash)"' >> /root/.bashrc && \
|
||||
echo 'micromamba activate openhands 2>/dev/null || true' >> /root/.bashrc && \
|
||||
# Clean up system packages (requires root)
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% macro install_dependencies_user() %}
|
||||
# Install user-level dependencies as openhands user
|
||||
WORKDIR /openhands/code
|
||||
|
||||
USER openhands
|
||||
RUN \
|
||||
# Set umask so all files are created world-readable (dirs 755, files 644).
|
||||
# This avoids expensive cross-layer chmod -R 755 in install_dependencies_root.
|
||||
umask 022 && \
|
||||
/openhands/micromamba/bin/micromamba config set changeps1 False && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry env use python3.12 && \
|
||||
# Install project dependencies
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry install --only main,runtime --no-interaction --no-root && \
|
||||
# Clean up user caches
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry cache clear --all . -n && \
|
||||
/openhands/micromamba/bin/micromamba clean --all
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% if build_from_scratch %}
|
||||
# ================================================================
|
||||
# START: Build Runtime Image from Scratch
|
||||
# ================================================================
|
||||
# This is used in cases where the base image is something more generic like nikolaik/python-nodejs
|
||||
# rather than the current OpenHands release
|
||||
|
||||
{{ setup_base_system() }}
|
||||
|
||||
# Install micromamba
|
||||
RUN mkdir -p /openhands/micromamba/bin && \
|
||||
/bin/bash -c "PREFIX_LOCATION=/openhands/micromamba BIN_FOLDER=/openhands/micromamba/bin INIT_YES=no CONDA_FORGE_YES=yes $(curl -L https://micro.mamba.pm/install.sh)" && \
|
||||
/openhands/micromamba/bin/micromamba config remove channels defaults && \
|
||||
{%- if channel_alias %}
|
||||
/openhands/micromamba/bin/micromamba config set channel_alias '{{ channel_alias }}' && \
|
||||
{%- endif %}
|
||||
/openhands/micromamba/bin/micromamba config list && \
|
||||
chown -R openhands:openhands /openhands/micromamba && \
|
||||
# Create read-only shared access to micromamba for all users
|
||||
# This allows both root and openhands users to access the same packages
|
||||
# while maintaining security by keeping openhands as the owner
|
||||
chmod -R 755 /openhands/micromamba && \
|
||||
# Create a separate writable location for root's micromamba cache/config
|
||||
mkdir -p /root/.local/share/micromamba && \
|
||||
# Set up environment variables for system-wide access
|
||||
echo 'export PATH="/openhands/micromamba/bin:$PATH"' >> /etc/environment
|
||||
|
||||
# Create the openhands virtual environment and install poetry and python
|
||||
# Run as openhands user to avoid expensive chown -R operations later
|
||||
USER openhands
|
||||
RUN umask 022 && \
|
||||
/openhands/micromamba/bin/micromamba create -n openhands -y && \
|
||||
/openhands/micromamba/bin/micromamba install -n openhands -c conda-forge poetry python=3.12 -y
|
||||
USER root
|
||||
|
||||
# Create a clean openhands directory including only the pyproject.toml, poetry.lock and openhands/__init__.py
|
||||
RUN \
|
||||
if [ -d /openhands/code ]; then rm -rf /openhands/code; fi && \
|
||||
mkdir -p /openhands/code/openhands && \
|
||||
touch /openhands/code/openhands/__init__.py && \
|
||||
chown -R openhands:openhands /openhands/code && \
|
||||
# Set global git configuration to ensure proper author/committer information
|
||||
git config --global user.name "openhands" && \
|
||||
git config --global user.email "openhands@all-hands.dev"
|
||||
|
||||
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
|
||||
{{ install_dependencies_user() }}
|
||||
{{ install_dependencies_root() }}
|
||||
|
||||
# ================================================================
|
||||
# END: Build Runtime Image from Scratch
|
||||
# ================================================================
|
||||
{% endif %}
|
||||
|
||||
# Ensure openhands user/group and base dirs exist even when not building from scratch
|
||||
USER root
|
||||
RUN \
|
||||
# Ensure group exists (prefer GID 1000 if available)
|
||||
if ! getent group openhands >/dev/null 2>&1; then \
|
||||
if getent group 1000 >/dev/null 2>&1; then groupadd openhands; else groupadd -g 1000 openhands; fi; \
|
||||
fi && \
|
||||
# Ensure user exists (prefer UID 1000 if available)
|
||||
if ! id -u openhands >/dev/null 2>&1; then \
|
||||
if getent passwd 1000 >/dev/null 2>&1; then useradd -m -s /bin/bash -g openhands openhands; else useradd -u 1000 -g openhands -m -s /bin/bash openhands; fi; \
|
||||
fi && \
|
||||
# Ensure home and required directories exist before later steps
|
||||
mkdir -p /home/openhands && \
|
||||
mkdir -p /openhands && \
|
||||
mkdir -p $(dirname ${OPENVSCODE_SERVER_ROOT}) && \
|
||||
# Fix ownership only on files not already owned by openhands.
|
||||
# A blanket chown -R would trigger an overlay2 copy-up of every inode
|
||||
# from prior layers even when ownership is already correct.
|
||||
find /home/openhands /openhands \
|
||||
! \( -user openhands -group openhands \) \
|
||||
-exec chown openhands:openhands {} + 2>/dev/null || true
|
||||
|
||||
{{ setup_vscode_server() }}
|
||||
|
||||
# ================================================================
|
||||
# Copy Project source files
|
||||
# ================================================================
|
||||
RUN if [ -d /openhands/code/openhands ]; then rm -rf /openhands/code/openhands; fi
|
||||
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
RUN if [ -d /openhands/code/skills ]; then rm -rf /openhands/code/skills; fi
|
||||
COPY --chown=openhands:openhands ./code/skills /openhands/code/skills
|
||||
COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands
|
||||
RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
|
||||
chown -R openhands:openhands /openhands/code
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Install VSCode extensions for build_from_scratch
|
||||
# (must be after setup_vscode_server and source file copy)
|
||||
# ================================================================
|
||||
{% if build_from_scratch %}
|
||||
{{ install_vscode_extensions() }}
|
||||
{% endif %}
|
||||
|
||||
# ================================================================
|
||||
# END: Build from versioned image
|
||||
# ================================================================
|
||||
{% if build_from_versioned %}
|
||||
{{ install_dependencies_user() }}
|
||||
{{ install_dependencies_root() }}
|
||||
{{ install_vscode_extensions() }}
|
||||
{% endif %}
|
||||
|
||||
# Install extra dependencies if specified (as openhands user)
|
||||
{% if extra_deps %}
|
||||
USER openhands
|
||||
RUN {{ extra_deps }}
|
||||
{% endif %}
|
||||
|
||||
# Set up environment for openhands user
|
||||
USER root
|
||||
RUN \
|
||||
# Set up environment for openhands user
|
||||
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/openhands/micromamba/bin:$PATH"' >> /home/openhands/.bashrc && \
|
||||
echo 'export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers' >> /home/openhands/.bashrc && \
|
||||
echo 'eval "$(/openhands/micromamba/bin/micromamba shell hook --shell bash)"' >> /home/openhands/.bashrc && \
|
||||
echo 'micromamba activate openhands 2>/dev/null || true' >> /home/openhands/.bashrc && \
|
||||
chown openhands:openhands /home/openhands/.bashrc
|
||||
@@ -0,0 +1,7 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
@@ -0,0 +1,75 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import random
|
||||
import socket
|
||||
import time
|
||||
|
||||
|
||||
def check_port_available(port: int) -> bool:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(('0.0.0.0', port))
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(0.1) # Short delay to further reduce chance of collisions
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def find_available_tcp_port(
|
||||
min_port: int = 30000, max_port: int = 39999, max_attempts: int = 10
|
||||
) -> int:
|
||||
"""Find an available TCP port in a specified range.
|
||||
|
||||
Args:
|
||||
min_port (int): The lower bound of the port range (default: 30000)
|
||||
max_port (int): The upper bound of the port range (default: 39999)
|
||||
max_attempts (int): Maximum number of attempts to find an available port (default: 10)
|
||||
|
||||
Returns:
|
||||
int: An available port number, or -1 if none found after max_attempts
|
||||
"""
|
||||
rng = random.SystemRandom()
|
||||
ports = list(range(min_port, max_port + 1))
|
||||
rng.shuffle(ports)
|
||||
|
||||
for port in ports[:max_attempts]:
|
||||
if check_port_available(port):
|
||||
return port
|
||||
return -1
|
||||
|
||||
|
||||
def display_number_matrix(number: int) -> str | None:
|
||||
if not 0 <= number <= 999:
|
||||
return None
|
||||
|
||||
# Define the matrix representation for each digit
|
||||
digits = {
|
||||
'0': ['###', '# #', '# #', '# #', '###'],
|
||||
'1': [' #', ' #', ' #', ' #', ' #'],
|
||||
'2': ['###', ' #', '###', '# ', '###'],
|
||||
'3': ['###', ' #', '###', ' #', '###'],
|
||||
'4': ['# #', '# #', '###', ' #', ' #'],
|
||||
'5': ['###', '# ', '###', ' #', '###'],
|
||||
'6': ['###', '# ', '###', '# #', '###'],
|
||||
'7': ['###', ' #', ' #', ' #', ' #'],
|
||||
'8': ['###', '# #', '###', '# #', '###'],
|
||||
'9': ['###', '# #', '###', ' #', '###'],
|
||||
}
|
||||
|
||||
# alternatively, with leading zeros: num_str = f"{number:03d}"
|
||||
num_str = str(number) # Convert to string without padding
|
||||
|
||||
result = []
|
||||
for row in range(5):
|
||||
line = ' '.join(digits[digit][row] for digit in num_str)
|
||||
result.append(line)
|
||||
|
||||
matrix_display = '\n'.join(result)
|
||||
return f'\n{matrix_display}\n'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user